Compare commits
4 Commits
0a15102f8c
...
9387ade537
| Author | SHA1 | Date | |
|---|---|---|---|
| 9387ade537 | |||
| 6b4ab4365e | |||
| a7fe81e658 | |||
| fb8c15dfd3 |
@@ -4,6 +4,12 @@ import react from '@vitejs/plugin-react';
|
|||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
main: {
|
main: {
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
input: { index: resolve(__dirname, 'src/main/bootstrap.ts') },
|
||||||
|
output: { inlineDynamicImports: false, chunkFileNames: '[name]-[hash].js' }
|
||||||
|
}
|
||||||
|
},
|
||||||
plugins: [externalizeDepsPlugin()]
|
plugins: [externalizeDepsPlugin()]
|
||||||
},
|
},
|
||||||
preload: {
|
preload: {
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ import tailwindcss from '@tailwindcss/vite'
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
main: {
|
main: {
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
input: { index: resolve(__dirname, 'src/main/bootstrap.ts') },
|
||||||
|
output: { inlineDynamicImports: false, chunkFileNames: '[name]-[hash].js' }
|
||||||
|
}
|
||||||
|
},
|
||||||
plugins: [externalizeDepsPlugin()]
|
plugins: [externalizeDepsPlugin()]
|
||||||
},
|
},
|
||||||
preload: {
|
preload: {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lms",
|
"name": "lms",
|
||||||
"version": "1.2.2",
|
"version": "1.2.4",
|
||||||
"description": "Autonomy Studio LMS Electron app",
|
"description": "Autonomy Studio LMS Electron app",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const os = require('node:os')
|
||||||
|
const path = require('node:path')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const exportsObject = {}
|
||||||
|
const source = fs.readFileSync(path.resolve(__dirname, '../src/main/dailyLog.ts'), 'utf8')
|
||||||
|
vm.runInNewContext(ts.transpileModule(source, {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||||
|
}).outputText, { exports: exportsObject, require })
|
||||||
|
const { createDailyLog, redactLog } = exportsObject
|
||||||
|
|
||||||
|
test('appends and rotates by local date, retaining unrelated files', (t) => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lms-log-test-'))
|
||||||
|
t.after(() => fs.rmSync(root, { recursive: true, force: true }))
|
||||||
|
fs.writeFileSync(path.join(root, 'renderer.log'), 'existing log')
|
||||||
|
let date = new Date(2026, 8, 29, 23, 59)
|
||||||
|
const log = createDailyLog([root], () => date)
|
||||||
|
log.write('test', 'first')
|
||||||
|
log.write('test', 'second')
|
||||||
|
const oldPath = log.path()
|
||||||
|
assert.match(oldPath, /lms-2026-09-29\.log$/)
|
||||||
|
assert.match(fs.readFileSync(oldPath, 'utf8'), /first[\s\S]*second/)
|
||||||
|
assert.equal(fs.statSync(oldPath).mode & 0o777, 0o600)
|
||||||
|
date = new Date(2026, 8, 30, 0, 1)
|
||||||
|
log.write('test', 'next day')
|
||||||
|
assert.match(log.path(), /lms-2026-09-30\.log$/)
|
||||||
|
assert.equal(fs.existsSync(oldPath), false)
|
||||||
|
assert.equal(fs.readFileSync(path.join(root, 'renderer.log'), 'utf8'), 'existing log')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('falls back when the primary directory is unusable', (t) => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lms-log-test-'))
|
||||||
|
t.after(() => fs.rmSync(root, { recursive: true, force: true }))
|
||||||
|
const blocked = path.join(root, 'file')
|
||||||
|
fs.writeFileSync(blocked, '')
|
||||||
|
const log = createDailyLog([blocked, path.join(root, 'fallback')])
|
||||||
|
log.write('test', 'fallback')
|
||||||
|
assert.equal(path.dirname(log.path()), path.join(root, 'fallback'))
|
||||||
|
const unavailable = createDailyLog([blocked])
|
||||||
|
assert.doesNotThrow(() => unavailable.write('test', 'ignored'))
|
||||||
|
assert.equal(unavailable.path(), '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('redacts common authentication fields and credential URLs', () => {
|
||||||
|
const result = redactLog('https://oauth2:secret123@example.com/repo.git Bearer secret456 --handoff-token=secret789 ?password=secret000 {"accessToken":"secret111"} password=secret222')
|
||||||
|
for (const secret of ['secret123', 'secret456', 'secret789', 'secret000', 'secret111', 'secret222']) {
|
||||||
|
assert.equal(result.includes(secret), false)
|
||||||
|
}
|
||||||
|
assert.match(result, /example.com\/repo.git/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('bootstrap logs dependency loading failures before exiting', async () => {
|
||||||
|
const events = []
|
||||||
|
const bootstrap = fs.readFileSync(path.resolve(__dirname, '../src/main/bootstrap.ts'), 'utf8')
|
||||||
|
const code = ts.transpileModule(bootstrap, {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||||
|
}).outputText
|
||||||
|
vm.runInNewContext(code, {
|
||||||
|
exports: {}, Error,
|
||||||
|
require: (name) => {
|
||||||
|
if (name === 'electron') return { app: { exit: (code) => events.push(['exit', code]) } }
|
||||||
|
if (name === './appLogging') {
|
||||||
|
events.push(['logger-ready'])
|
||||||
|
return { appLog: { write: (...args) => events.push(args) } }
|
||||||
|
}
|
||||||
|
if (name === './index') throw new Error('test native module failure')
|
||||||
|
throw new Error(`Unexpected dependency: ${name}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await new Promise((resolve) => setImmediate(resolve))
|
||||||
|
assert.equal(events[0][0], 'logger-ready')
|
||||||
|
assert.equal(events[1][0], 'startup-error')
|
||||||
|
assert.match(events[1][1], /test native module failure/)
|
||||||
|
assert.deepEqual(events[2], ['exit', 1])
|
||||||
|
})
|
||||||
@@ -9,6 +9,62 @@ const { createInstance } = require('i18next')
|
|||||||
const root = path.resolve(__dirname, '../src/renderer/src')
|
const root = path.resolve(__dirname, '../src/renderer/src')
|
||||||
const dictionary = JSON.parse(fs.readFileSync(path.join(root, 'i18n/en.json'), 'utf8'))
|
const dictionary = JSON.parse(fs.readFileSync(path.join(root, 'i18n/en.json'), 'utf8'))
|
||||||
|
|
||||||
|
test('close studio modal translates the built-in Local PC title but preserves vehicle names', () => {
|
||||||
|
const source = fs.readFileSync(path.join(root, 'App.tsx'), 'utf8')
|
||||||
|
const expression = source.match(/platformTitle=\{(runningPlatformCloseTarget\.platformKey[^\n]+)\}/)?.[1]
|
||||||
|
assert.ok(expression)
|
||||||
|
for (const language of ['en', 'ko']) {
|
||||||
|
const { api } = setup(language)
|
||||||
|
const title = vm.runInNewContext(expression, {
|
||||||
|
runningPlatformCloseTarget: { platformKey: 'futuredrive-local-pc', title: '로컬 PC' }, t: api.t
|
||||||
|
})
|
||||||
|
assert.equal(title, language === 'en' ? 'Local PC' : '로컬 PC')
|
||||||
|
if (language === 'en') assert.equal(api.t('{{v0}} 개발 스튜디오를 종료할까요?', { v0: title }), 'Close the Local PC studio?')
|
||||||
|
assert.equal(vm.runInNewContext(expression, {
|
||||||
|
runningPlatformCloseTarget: { platformKey: 'vehicle', title: '교육용 차량' }, t: api.t
|
||||||
|
}), '교육용 차량')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('session confirmation uses a localized platform display name', () => {
|
||||||
|
const source = fs.readFileSync(path.join(root, 'App.tsx'), 'utf8')
|
||||||
|
const expression = source.match(/const activePlatformDisplayName = ([\s\S]*?)\n const sessionPlatformLabel/)[1]
|
||||||
|
for (const language of ['en', 'ko']) {
|
||||||
|
const { api } = setup(language)
|
||||||
|
const name = vm.runInNewContext(expression, {
|
||||||
|
workspacePlatform: null,
|
||||||
|
sessionPlatform: { platformKey: 'futuredrive-local-pc' },
|
||||||
|
activePlatformName: '로컬 PC', t: api.t
|
||||||
|
})
|
||||||
|
assert.equal(name, language === 'en' ? 'Local PC' : '로컬 PC')
|
||||||
|
if (language === 'en') assert.equal(api.t('{{v0}} 개발 스튜디오 연결만 종료합니다. 플랫폼 제어권은 반납되지 않습니다.', { v0: name }),
|
||||||
|
'Disconnect from the Local PC studio only. Platform control is retained.')
|
||||||
|
assert.equal(vm.runInNewContext(expression, {
|
||||||
|
workspacePlatform: { platformKey: 'vehicle' }, sessionPlatform: null,
|
||||||
|
activePlatformName: '교육용 차량', t: api.t
|
||||||
|
}), '교육용 차량')
|
||||||
|
}
|
||||||
|
assert.match(source, /<SessionConfirmModal[\s\S]*?platformName=\{activePlatformDisplayName\}/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('close and disconnect modals localize raw Local PC names at render time', () => {
|
||||||
|
const source = fs.readFileSync(path.join(root, 'components/AppStatusModals.tsx'), 'utf8')
|
||||||
|
for (const [key, variable] of [
|
||||||
|
['{{v0}} 개발 스튜디오를 종료할까요?', 'platformTitle'],
|
||||||
|
['{{v0}} 개발 스튜디오 연결만 종료합니다. 플랫폼 제어권은 반납되지 않습니다.', 'platformName']
|
||||||
|
]) {
|
||||||
|
const line = source.split('\n').find(line => line.includes(key))
|
||||||
|
const expression = line.match(/v0: (.*?) \}/)[1]
|
||||||
|
for (const language of ['ko', 'en']) {
|
||||||
|
const { api } = setup(language)
|
||||||
|
const name = vm.runInNewContext(expression, { [variable]: '로컬 PC', t: api.t })
|
||||||
|
assert.equal(name, language === 'en' ? 'Local PC' : '로컬 PC')
|
||||||
|
if (language === 'en') assert.ok(!api.t(key, { v0: name }).includes('로컬'))
|
||||||
|
assert.equal(vm.runInNewContext(expression, { [variable]: 'Yahboom Micro #02', t: api.t }), 'Yahboom Micro #02')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test('all execution guide page titles have English translations', () => {
|
test('all execution guide page titles have English translations', () => {
|
||||||
const source = fs.readFileSync(path.join(root, 'features/workspace/ModuleListView.tsx'), 'utf8')
|
const source = fs.readFileSync(path.join(root, 'features/workspace/ModuleListView.tsx'), 'utf8')
|
||||||
const titles = [...source.matchAll(/environment: '([^']+)'/g)].map(match => match[1])
|
const titles = [...source.matchAll(/environment: '([^']+)'/g)].map(match => match[1])
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const exportsObject = {}
|
||||||
|
const source = fs.readFileSync(path.resolve(__dirname, '../src/renderer/src/features/workspace/WorkspaceRosConnectionsView.tsx'), 'utf8')
|
||||||
|
vm.runInNewContext(ts.transpileModule(source, {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
|
||||||
|
}).outputText, { exports: exportsObject, require: () => ({}) })
|
||||||
|
const { rosGraphConnections } = exportsObject
|
||||||
|
const endpoint = (node, type = 'sensor_msgs/msg/CompressedImage') => ({ node, type })
|
||||||
|
|
||||||
|
test('shows image and custom topics without module definitions', () => {
|
||||||
|
const connections = rosGraphConnections({ topics: [
|
||||||
|
{ name: '/camera/image/compressed', types: ['sensor_msgs/msg/CompressedImage'], publishers: [endpoint('/camera')], subscribers: [endpoint('/bridge')] },
|
||||||
|
{ name: '/custom/new_topic', types: ['custom_msgs/msg/Value'], publishers: [], subscribers: [endpoint('/custom', 'custom_msgs/msg/Value')] },
|
||||||
|
] })
|
||||||
|
assert.equal(connections.length, 2)
|
||||||
|
assert.equal(connections[0].topic.name, '/camera/image/compressed')
|
||||||
|
assert.equal(connections[0].topic.type, 'sensor_msgs/msg/CompressedImage')
|
||||||
|
assert.equal(connections[0].active, true)
|
||||||
|
assert.equal(connections[1].publishers.length, 0)
|
||||||
|
assert.equal(connections[1].active, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('empty and failed queries never synthesize topics or nodes', () => {
|
||||||
|
assert.equal(rosGraphConnections({ topics: [] }).length, 0)
|
||||||
|
assert.equal(rosGraphConnections({ topics: [], message: 'SSH timeout' }).length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps discovered topics without endpoints and deduplicates node entries', () => {
|
||||||
|
const connections = rosGraphConnections({ topics: [
|
||||||
|
{ name: '/z', types: [], publishers: [], subscribers: [] },
|
||||||
|
{ name: '/a', types: ['sensor_msgs/msg/CompressedImage'], publishers: [endpoint('/camera'), endpoint('/camera')], subscribers: [] },
|
||||||
|
] })
|
||||||
|
assert.equal(connections.length, 2)
|
||||||
|
assert.equal(connections[0].topic.name, '/a')
|
||||||
|
assert.equal(connections[0].publishers.length, 1)
|
||||||
|
assert.equal(connections[1].topic.name, '/z')
|
||||||
|
assert.equal(connections.every((connection) => !connection.active), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('module selection and guessed topic mappings are removed from the view', () => {
|
||||||
|
assert.doesNotMatch(source, /selectedModuleKeys|moduleTopics|fallbackPublishers|fallbackSubscribers|onToggleModule|ListChecks|ListX/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import { dirname, join } from 'path'
|
||||||
|
import { inspect } from 'util'
|
||||||
|
import { createDailyLog } from './dailyLog'
|
||||||
|
|
||||||
|
const fallback = app.getPath('logs')
|
||||||
|
export const appLog = createDailyLog([
|
||||||
|
process.env.APPIMAGE
|
||||||
|
? join(dirname(process.env.APPIMAGE), 'logs')
|
||||||
|
: app.isPackaged ? join(dirname(process.execPath), 'logs') : fallback,
|
||||||
|
fallback
|
||||||
|
])
|
||||||
|
|
||||||
|
for (const level of ['warn', 'error'] as const) {
|
||||||
|
const original = console[level].bind(console)
|
||||||
|
console[level] = (...args: unknown[]) => {
|
||||||
|
appLog.write(level, args.map((value) => typeof value === 'string' ? value : inspect(value, { depth: 4 })).join(' '))
|
||||||
|
original(...args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monitor fatal exceptions without changing Node's normal termination behavior.
|
||||||
|
process.on('uncaughtExceptionMonitor', (error, origin) => {
|
||||||
|
appLog.write(origin, error.stack ?? String(error))
|
||||||
|
})
|
||||||
|
process.on('warning', (warning) => appLog.write('warning', warning.stack ?? warning.message))
|
||||||
|
process.on('exit', (code) => appLog.write('exit', `code=${code}`))
|
||||||
|
app.on('child-process-gone', (_event, details) => appLog.write('child-process-gone', JSON.stringify(details)))
|
||||||
|
appLog.write('startup', `version=${app.getVersion()} platform=${process.platform} arch=${process.arch} electron=${process.versions.electron} executable=${process.env.APPIMAGE || process.execPath}`)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import { appLog } from './appLogging'
|
||||||
|
|
||||||
|
// Load native dependencies only after the startup logger is ready.
|
||||||
|
void import('./index').catch((error: unknown) => {
|
||||||
|
appLog.write('startup-error', error instanceof Error ? error.stack ?? error.message : String(error))
|
||||||
|
app.exit(1)
|
||||||
|
})
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { appendFileSync, closeSync, mkdirSync, openSync, readdirSync, unlinkSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
export function redactLog(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/(https?:\/\/)[^\s/@]+@/gi, '$1[REDACTED]@')
|
||||||
|
.replace(/\bBearer\s+[^\s"',;]+/gi, 'Bearer [REDACTED]')
|
||||||
|
.replace(/((?:[?&]|--)(?:[^\s=&]*token|password|secret|code|key)=)[^\s&#"']+/gi, '$1[REDACTED]')
|
||||||
|
.replace(/(["']?(?:[\w-]*token|password|secret|authorization|api[_-]?key)["']?\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;}]+)/gi, '$1[REDACTED]')
|
||||||
|
}
|
||||||
|
|
||||||
|
function localDate(now: Date): string {
|
||||||
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDailyLog(directories: string[], now: () => Date = () => new Date()) {
|
||||||
|
let activePath = ''
|
||||||
|
let activeDay = ''
|
||||||
|
|
||||||
|
function selectFile(): string {
|
||||||
|
const day = localDate(now())
|
||||||
|
if (activePath && activeDay === day) return activePath
|
||||||
|
for (const directory of [...new Set(directories)]) {
|
||||||
|
try {
|
||||||
|
mkdirSync(directory, { recursive: true })
|
||||||
|
const path = join(directory, `lms-${day}.log`)
|
||||||
|
closeSync(openSync(path, 'a', 0o600))
|
||||||
|
activePath = path
|
||||||
|
activeDay = day
|
||||||
|
for (const name of readdirSync(directory)) {
|
||||||
|
if (/^lms-\d{4}-\d{2}-\d{2}\.log$/.test(name) && name < `lms-${day}.log`) {
|
||||||
|
try { unlinkSync(join(directory, name)) } catch { /* Retention must not block startup. */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
} catch { /* Try the user-writable fallback directory. */ }
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: selectFile,
|
||||||
|
write(kind: string, message: string): void {
|
||||||
|
try {
|
||||||
|
const path = selectFile()
|
||||||
|
if (!path) return
|
||||||
|
appendFileSync(path, `[${now().toISOString()}] [${redactLog(kind)}] ${redactLog(message)}\n`, { mode: 0o600 })
|
||||||
|
} catch { /* Diagnostics must not interrupt the app. */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-13
@@ -1,4 +1,5 @@
|
|||||||
import { app, BrowserWindow, dialog, Menu, globalShortcut, ipcMain, screen, shell } from 'electron'
|
import { app, BrowserWindow, dialog, Menu, globalShortcut, ipcMain, screen, shell } from 'electron'
|
||||||
|
import { appLog } from './appLogging'
|
||||||
import { checkDockerStatus } from './dockerStatus'
|
import { checkDockerStatus } from './dockerStatus'
|
||||||
import { cameraRuntimeArtifacts } from './cameraRuntime'
|
import { cameraRuntimeArtifacts } from './cameraRuntime'
|
||||||
import { remoteModuleFilesScript } from './remoteModuleFiles'
|
import { remoteModuleFilesScript } from './remoteModuleFiles'
|
||||||
@@ -12,7 +13,7 @@ import type { WebContents } from 'electron'
|
|||||||
import { spawn } from 'child_process'
|
import { spawn } from 'child_process'
|
||||||
import * as pty from 'node-pty'
|
import * as pty from 'node-pty'
|
||||||
import type { IPty } from 'node-pty'
|
import type { IPty } from 'node-pty'
|
||||||
import { appendFileSync, createReadStream, createWriteStream, existsSync, mkdirSync } from 'fs'
|
import { createReadStream, createWriteStream, existsSync } from 'fs'
|
||||||
import { cp, mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'fs/promises'
|
import { cp, mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'fs/promises'
|
||||||
import { homedir } from 'os'
|
import { homedir } from 'os'
|
||||||
import { basename, dirname, join, relative, resolve } from 'path'
|
import { basename, dirname, join, relative, resolve } from 'path'
|
||||||
@@ -51,21 +52,12 @@ const rosBridgePort = 8765
|
|||||||
const exampleCodeRoot = resolve(homedir(), '다운로드', 'example-code')
|
const exampleCodeRoot = resolve(homedir(), '다운로드', 'example-code')
|
||||||
|
|
||||||
function writeRendererDiagnostic(kind: string, message: string): void {
|
function writeRendererDiagnostic(kind: string, message: string): void {
|
||||||
try {
|
appLog.write(kind, message)
|
||||||
const logDirectory = app.getPath('logs')
|
|
||||||
mkdirSync(logDirectory, { recursive: true })
|
|
||||||
appendFileSync(
|
|
||||||
join(logDirectory, 'renderer.log'),
|
|
||||||
`[${new Date().toISOString()}] [${kind}] ${message}\n`,
|
|
||||||
'utf8',
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
// Diagnostics must never interrupt the LMS window itself.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readAppDiagnostics(): Promise<{ path: string; content: string }> {
|
async function readAppDiagnostics(): Promise<{ path: string; content: string }> {
|
||||||
const logPath = join(app.getPath('logs'), 'renderer.log')
|
const logPath = appLog.path()
|
||||||
|
if (!logPath) return { path: '', content: '' }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = await readFile(logPath, 'utf8')
|
const content = await readFile(logPath, 'utf8')
|
||||||
|
|||||||
@@ -4951,6 +4951,9 @@ export default function App(): React.JSX.Element {
|
|||||||
const sessionPlatform = connectedPlatform ?? runningPlatforms[0] ?? localPcPlatform
|
const sessionPlatform = connectedPlatform ?? runningPlatforms[0] ?? localPcPlatform
|
||||||
const workspacePlatform = connectedPlatform ?? runningPlatforms[0] ?? lastWorkspacePlatform
|
const workspacePlatform = connectedPlatform ?? runningPlatforms[0] ?? lastWorkspacePlatform
|
||||||
const activePlatformName = workspacePlatform?.title ?? sessionPlatform?.title ?? '플랫폼'
|
const activePlatformName = workspacePlatform?.title ?? sessionPlatform?.title ?? '플랫폼'
|
||||||
|
const activePlatformDisplayName = (workspacePlatform ?? sessionPlatform)?.platformKey === 'futuredrive-local-pc'
|
||||||
|
? t('로컬 PC')
|
||||||
|
: activePlatformName
|
||||||
const sessionPlatformLabel = sessionRunning
|
const sessionPlatformLabel = sessionRunning
|
||||||
? runningPlatforms.length === 1
|
? runningPlatforms.length === 1
|
||||||
? runningPlatforms[0].title
|
? runningPlatforms[0].title
|
||||||
@@ -6208,7 +6211,7 @@ export default function App(): React.JSX.Element {
|
|||||||
<RunningPlatformCloseModal
|
<RunningPlatformCloseModal
|
||||||
overlayClassName={fullAppModalOverlayClass}
|
overlayClassName={fullAppModalOverlayClass}
|
||||||
dark={appModalDark}
|
dark={appModalDark}
|
||||||
platformTitle={runningPlatformCloseTarget.title}
|
platformTitle={runningPlatformCloseTarget.platformKey === 'futuredrive-local-pc' ? t('로컬 PC') : runningPlatformCloseTarget.title}
|
||||||
onCancel={() => setRunningPlatformCloseTarget(null)}
|
onCancel={() => setRunningPlatformCloseTarget(null)}
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
const target = runningPlatformCloseTarget
|
const target = runningPlatformCloseTarget
|
||||||
@@ -6222,7 +6225,7 @@ export default function App(): React.JSX.Element {
|
|||||||
overlayClassName={fullAppModalOverlayClass}
|
overlayClassName={fullAppModalOverlayClass}
|
||||||
dark={appModalDark}
|
dark={appModalDark}
|
||||||
action={sessionConfirmAction}
|
action={sessionConfirmAction}
|
||||||
platformName={activePlatformName}
|
platformName={activePlatformDisplayName}
|
||||||
onCancel={() => setSessionConfirmAction(null)}
|
onCancel={() => setSessionConfirmAction(null)}
|
||||||
onConfirm={confirmManualSessionEnd}
|
onConfirm={confirmManualSessionEnd}
|
||||||
/>
|
/>
|
||||||
@@ -6257,7 +6260,7 @@ export default function App(): React.JSX.Element {
|
|||||||
<ControlExtensionConfirmModal
|
<ControlExtensionConfirmModal
|
||||||
overlayClassName={fullAppModalOverlayClass}
|
overlayClassName={fullAppModalOverlayClass}
|
||||||
dark={appModalDark}
|
dark={appModalDark}
|
||||||
platformName={activePlatformName}
|
platformName={activePlatformDisplayName}
|
||||||
submitting={controlExtensionSubmitting}
|
submitting={controlExtensionSubmitting}
|
||||||
onCancel={() => setControlExtensionConfirmOpen(false)}
|
onCancel={() => setControlExtensionConfirmOpen(false)}
|
||||||
onConfirm={() => void handleExtendControl()}
|
onConfirm={() => void handleExtendControl()}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ function strongClass(dark: boolean): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function dimClass(dark: boolean): string {
|
function dimClass(dark: boolean): string {
|
||||||
return dark ? 'text-white/45' : 'text-[#727b8c]'
|
return dark ? 'text-white/45' : 'text-[#475569]'
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelButtonClass(dark: boolean): string {
|
function cancelButtonClass(dark: boolean): string {
|
||||||
@@ -164,7 +164,7 @@ export function RunningPlatformCloseModal({
|
|||||||
dark={dark}
|
dark={dark}
|
||||||
icon={<Power size={15} className="text-[#e87979]" />}
|
icon={<Power size={15} className="text-[#e87979]" />}
|
||||||
title={t("개발 스튜디오 종료")}
|
title={t("개발 스튜디오 종료")}
|
||||||
message={t("{{v0}} 개발 스튜디오를 종료할까요?", { v0: platformTitle })}
|
message={t("{{v0}} 개발 스튜디오를 종료할까요?", { v0: platformTitle === '로컬 PC' ? t('로컬 PC') : platformTitle })}
|
||||||
detail={t("현재 열린 개발 스튜디오 세션만 종료합니다. 플랫폼 제어권은 반납되지 않습니다.")}
|
detail={t("현재 열린 개발 스튜디오 세션만 종료합니다. 플랫폼 제어권은 반납되지 않습니다.")}
|
||||||
confirmLabel={t("종료")}
|
confirmLabel={t("종료")}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
@@ -197,7 +197,7 @@ export function SessionConfirmModal({
|
|||||||
message={release ? t("제어권을 반납할까요?") : t("연결을 종료할까요?")}
|
message={release ? t("제어권을 반납할까요?") : t("연결을 종료할까요?")}
|
||||||
detail={release
|
detail={release
|
||||||
? t("{{v0}} 제어권을 반납하면 대기 중인 다음 수강생에게 제어권이 넘어갈 수 있습니다.", { v0: platformName })
|
? t("{{v0}} 제어권을 반납하면 대기 중인 다음 수강생에게 제어권이 넘어갈 수 있습니다.", { v0: platformName })
|
||||||
: t("{{v0}} 개발 스튜디오 연결만 종료합니다. 플랫폼 제어권은 반납되지 않습니다.", { v0: platformName })}
|
: t("{{v0}} 개발 스튜디오 연결만 종료합니다. 플랫폼 제어권은 반납되지 않습니다.", { v0: platformName === '로컬 PC' ? t('로컬 PC') : platformName })}
|
||||||
confirmLabel={release ? t("반납") : t("연결종료")}
|
confirmLabel={release ? t("반납") : t("연결종료")}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
onConfirm={onConfirm}
|
onConfirm={onConfirm}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ type ModuleListHeaderProps = {
|
|||||||
export default function ModuleListHeader({ dark }: ModuleListHeaderProps): React.JSX.Element {
|
export default function ModuleListHeader({ dark }: ModuleListHeaderProps): React.JSX.Element {
|
||||||
useLocaleRender()
|
useLocaleRender()
|
||||||
return (
|
return (
|
||||||
<div className={['grid h-9 grid-cols-[78px_100px_minmax(150px,1fr)_minmax(104px,0.45fr)_minmax(104px,0.45fr)_84px_104px_62px] items-center border-b px-3 text-[11px] font-bold', dark ? 'border-white/10 bg-[#2b2b2b] text-white/45' : 'border-[#cbd5e1] bg-[#e2e8f0] text-[#334155]'].join(' ')}>
|
<div className={['grid h-9 grid-cols-[78px_100px_minmax(150px,1fr)_minmax(104px,0.45fr)_minmax(104px,0.45fr)_84px_104px_62px] items-center border-b px-3 text-[11px] font-bold', dark ? 'border-white/10 bg-[#2b2b2b] text-white/45' : 'border-[#c4b5df] bg-[#e4d8f5] text-[#38205b]'].join(' ')}>
|
||||||
<span className="text-center">{t("상태")}</span>
|
<span className="text-center">{t("상태")}</span>
|
||||||
<span className="text-center">{t("분류")}</span>
|
<span className="text-center">{t("분류")}</span>
|
||||||
<span>{t("모듈명")}</span>
|
<span>{t("모듈명")}</span>
|
||||||
|
|||||||
@@ -253,7 +253,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
|
|||||||
dropTarget ? (dark ? 'bg-[#2563eb]/14 outline outline-1 outline-[#3b82f6]/50' : 'bg-[#edf6ff] outline outline-1 outline-[#2563eb]/35') : '',
|
dropTarget ? (dark ? 'bg-[#2563eb]/14 outline outline-1 outline-[#3b82f6]/50' : 'bg-[#edf6ff] outline outline-1 outline-[#2563eb]/35') : '',
|
||||||
selectable
|
selectable
|
||||||
? dark ? 'text-white/70 hover:bg-white/[0.045]' : 'text-[#4f596b] hover:bg-[#f5f7fb]'
|
? dark ? 'text-white/70 hover:bg-white/[0.045]' : 'text-[#4f596b] hover:bg-[#f5f7fb]'
|
||||||
: dark ? 'text-white/42' : 'text-[#8a94a5]'
|
: dark ? 'text-white/42' : 'text-[#475569]'
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
<span className="text-center">
|
<span className="text-center">
|
||||||
@@ -261,7 +261,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
|
|||||||
{t(stateLabel[module.state])}
|
{t(stateLabel[module.state])}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className={['text-center', dark ? 'text-white/52' : 'text-[#687284]'].join(' ')}>{module.category}</span>
|
<span className={['text-center', dark ? 'text-white/52' : 'font-medium text-[#334155]'].join(' ')}>{module.category}</span>
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
<span className="flex min-w-0 items-center gap-2">
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
<span className={['truncate text-[12px] font-semibold', dark ? 'text-white/86' : 'text-[#111520]'].join(' ')}>{module.name}</span>
|
<span className={['truncate text-[12px] font-semibold', dark ? 'text-white/86' : 'text-[#111520]'].join(' ')}>{module.name}</span>
|
||||||
@@ -279,7 +279,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
|
|||||||
className={[
|
className={[
|
||||||
'mt-1 block h-[14px] max-w-full truncate text-left text-[10px] transition',
|
'mt-1 block h-[14px] max-w-full truncate text-left text-[10px] transition',
|
||||||
'cursor-pointer hover:underline',
|
'cursor-pointer hover:underline',
|
||||||
dark ? 'text-white/32 hover:text-white/55' : 'text-[#8a94a5] hover:text-[#4f596b]'
|
dark ? 'text-white/32 hover:text-white/55' : 'text-[#475569] hover:text-[#111520]'
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{module.parameters.length ? module.parameters.join(' ') : t("파라미터 설정")}
|
{module.parameters.length ? module.parameters.join(' ') : t("파라미터 설정")}
|
||||||
|
|||||||
@@ -86,10 +86,10 @@ export default function ModuleListToolbar({
|
|||||||
active
|
active
|
||||||
? dark
|
? dark
|
||||||
? 'border-white/10 bg-[#2b2b2b] text-white'
|
? 'border-white/10 bg-[#2b2b2b] text-white'
|
||||||
: 'border-[#e1e6ef] bg-white text-[#111520]'
|
: 'border-[#c4b5df] bg-[#e4d8f5] text-[#38205b]'
|
||||||
: dark
|
: dark
|
||||||
? 'border-white/8 bg-[#202020] text-white/45 hover:text-white/70'
|
? 'border-white/8 bg-[#202020] text-white/45 hover:text-white/70'
|
||||||
: 'border-[#e1e6ef] bg-[#eef1f6] text-[#687284] hover:text-[#111520]'
|
: 'border-[#d4c9e4] bg-[#eee8f6] text-[#475569] hover:bg-[#e8def3] hover:text-[#38205b]'
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{t(label)}
|
{t(label)}
|
||||||
|
|||||||
@@ -499,7 +499,7 @@ const moduleGuidePages = [
|
|||||||
slamModules: ['NAV2-Gazebo', 'NAV2-TurtleBot3Burger', 'NAV2-Perception', 'NAV2-SLAM'],
|
slamModules: ['NAV2-Gazebo', 'NAV2-TurtleBot3Burger', 'NAV2-Perception', 'NAV2-SLAM'],
|
||||||
navigationModules: ['NAV2-Gazebo', 'NAV2-TurtleBot3Burger', 'NAV2-Perception', 'NAV2-Map', 'NAV2-Localization', 'NAV2-Planning', 'NAV2-Control'],
|
navigationModules: ['NAV2-Gazebo', 'NAV2-TurtleBot3Burger', 'NAV2-Perception', 'NAV2-Map', 'NAV2-Localization', 'NAV2-Planning', 'NAV2-Control'],
|
||||||
parameters: [
|
parameters: [
|
||||||
['기본', 'sim_mode:=true, platform:=turtlebot3, robot:=turtlebot3, level:=2d'],
|
['기본', 'sim_mode:=true, platform:=turtlebot3 (mkmini, yahboom), robot:=turtlebot3 (mkmini, yahboom), level:=2d'],
|
||||||
['Perception', 'sim_mode:=true'],
|
['Perception', 'sim_mode:=true'],
|
||||||
['Localization', 'localization_mode:=amcl']
|
['Localization', 'localization_mode:=amcl']
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ function WorkspaceEditorPathBar({
|
|||||||
if (!currentActiveEditorFile) return null
|
if (!currentActiveEditorFile) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={['flex min-w-0 items-center gap-1 border-b px-4 text-[12px] font-medium', dark ? 'border-white/10 bg-[#1f1f1f] text-white/45' : 'border-[#d8dee9] bg-[#f7f8fb] text-[#687284]'].join(' ')}>
|
<div className={['flex min-w-0 items-center gap-1 border-b px-4 text-[12px] font-medium', dark ? 'border-white/10 bg-[#1f1f1f] text-white/45' : 'border-[#d4c9e4] bg-[#f0ebf7] text-[#475569]'].join(' ')}>
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
|
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
|
||||||
{currentActiveEditorFile.split('/').map((part, index, parts) => (
|
{currentActiveEditorFile.split('/').map((part, index, parts) => (
|
||||||
<span key={`${part}-${index}`} className="flex min-w-0 items-center gap-1">
|
<span key={`${part}-${index}`} className="flex min-w-0 items-center gap-1">
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ function WorkspaceEditorTabs({
|
|||||||
if (files.length === 0) return null
|
if (files.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={['min-w-0 overflow-hidden border-b pl-1 pr-2', dark ? 'border-white/10 bg-[#252525]' : 'border-[#d8dee9] bg-[#eef1f6]'].join(' ')}>
|
<div className={['min-w-0 overflow-hidden border-b pl-1 pr-2', dark ? 'border-white/10 bg-[#252525]' : 'border-[#d4c9e4] bg-[#eee8f6]'].join(' ')}>
|
||||||
<div className="thin-x-scrollbar flex h-full min-w-0 items-end gap-0.5 overflow-x-auto overflow-y-hidden [scrollbar-gutter:stable]">
|
<div className="thin-x-scrollbar flex h-full min-w-0 items-end gap-0.5 overflow-x-auto overflow-y-hidden [scrollbar-gutter:stable]">
|
||||||
{files.map((file, index) => (
|
{files.map((file, index) => (
|
||||||
<button
|
<button
|
||||||
@@ -65,10 +65,10 @@ function WorkspaceEditorTabs({
|
|||||||
activeFile === file
|
activeFile === file
|
||||||
? dark
|
? dark
|
||||||
? 'border-white/10 bg-[#1f1f1f] text-white'
|
? 'border-white/10 bg-[#1f1f1f] text-white'
|
||||||
: 'border-[#d8dee9] bg-[#f7f8fb] text-[#111520]'
|
: 'border-[#c4b5df] bg-[#e4d8f5] text-[#38205b]'
|
||||||
: dark
|
: dark
|
||||||
? 'border-white/10 bg-[#2b2b2b] text-white/45 hover:text-white/70'
|
? 'border-white/10 bg-[#2b2b2b] text-white/45 hover:text-white/70'
|
||||||
: 'border-[#d8dee9] bg-[#e8ebf2] text-[#687284] hover:text-[#111520]'
|
: 'border-[#d4c9e4] bg-[#eee8f6] text-[#475569] hover:bg-[#e8def3] hover:text-[#38205b]'
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{draggingFile && dropIndex === index && (
|
{draggingFile && dropIndex === index && (
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export default function WorkspaceGuidePanel<Resource extends WorkspaceGuideResou
|
|||||||
borderSoft,
|
borderSoft,
|
||||||
textStrong,
|
textStrong,
|
||||||
textMid,
|
textMid,
|
||||||
textDim,
|
textDim: inheritedTextDim,
|
||||||
iconButtonClass,
|
iconButtonClass,
|
||||||
resourceMode,
|
resourceMode,
|
||||||
materialItems,
|
materialItems,
|
||||||
@@ -97,6 +97,7 @@ export default function WorkspaceGuidePanel<Resource extends WorkspaceGuideResou
|
|||||||
onDownloadResource
|
onDownloadResource
|
||||||
}: WorkspaceGuidePanelProps<Resource>): React.JSX.Element {
|
}: WorkspaceGuidePanelProps<Resource>): React.JSX.Element {
|
||||||
useLocaleRender()
|
useLocaleRender()
|
||||||
|
const textDim = dark ? inheritedTextDim : 'text-[#475569]'
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
data-help="workspace-guide"
|
data-help="workspace-guide"
|
||||||
|
|||||||
@@ -61,8 +61,8 @@ export default function WorkspaceModuleTabs({
|
|||||||
className={[
|
className={[
|
||||||
'group flex h-full min-w-[170px] max-w-[210px] shrink-0 cursor-pointer items-center gap-2 rounded-none border-r px-3 text-[12px] font-bold transition',
|
'group flex h-full min-w-[170px] max-w-[210px] shrink-0 cursor-pointer items-center gap-2 rounded-none border-r px-3 text-[12px] font-bold transition',
|
||||||
active
|
active
|
||||||
? dark ? 'border-white/10 bg-[#1f1f1f] text-white/90' : 'border-[#d8dee9] bg-[#f9fafc] text-[#111520]'
|
? dark ? 'border-white/10 bg-[#1f1f1f] text-white/90' : 'border-[#c4b5df] bg-[#e4d8f5] text-[#38205b]'
|
||||||
: dark ? 'border-white/10 bg-[#252525] text-white/45 hover:bg-[#2b2b2b] hover:text-white/75' : 'border-[#d8dee9] bg-[#eef1f6] text-[#687284] hover:bg-[#e7ebf2] hover:text-[#111520]'
|
: dark ? 'border-white/10 bg-[#252525] text-white/45 hover:bg-[#2b2b2b] hover:text-white/75' : 'border-[#d4c9e4] bg-[#eee8f6] text-[#475569] hover:bg-[#e8def3] hover:text-[#38205b]'
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{moduleControlling ? (
|
{moduleControlling ? (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { t, useLocaleRender } from '../../i18n'
|
import { t, useLocaleRender } from '../../i18n'
|
||||||
import { Activity, ArrowRight, CircleDot, ListChecks, ListX, Radio, RefreshCw, Send, Waypoints, X } from 'lucide-react'
|
import { Activity, ArrowRight, CircleDot, Radio, RefreshCw, Send, Waypoints, X } from 'lucide-react'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import type React from 'react'
|
import type React from 'react'
|
||||||
import type { ModuleRow } from './types'
|
import type { ModuleRow } from './types'
|
||||||
@@ -9,12 +9,6 @@ type TopicRole = {
|
|||||||
type: string
|
type: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type RosModule = {
|
|
||||||
module: ModuleRow
|
|
||||||
publishers: TopicRole[]
|
|
||||||
subscribers: TopicRole[]
|
|
||||||
}
|
|
||||||
|
|
||||||
type RosConnection = {
|
type RosConnection = {
|
||||||
topic: TopicRole
|
topic: TopicRole
|
||||||
publishers: RosEndpoint[]
|
publishers: RosEndpoint[]
|
||||||
@@ -46,79 +40,6 @@ type WorkspaceRosConnectionsViewProps = {
|
|||||||
loadRosGraph: () => Promise<RosGraphSnapshot>
|
loadRosGraph: () => Promise<RosGraphSnapshot>
|
||||||
}
|
}
|
||||||
|
|
||||||
const TOPIC_TYPES: Record<string, string> = {
|
|
||||||
'/amcl_pose': 'geometry_msgs/PoseWithCovarianceStamped',
|
|
||||||
'/cloud_map': 'sensor_msgs/PointCloud2',
|
|
||||||
'/cmd_vel': 'geometry_msgs/Twist',
|
|
||||||
'/joint_states': 'sensor_msgs/JointState',
|
|
||||||
'/map': 'nav_msgs/OccupancyGrid',
|
|
||||||
'/odom': 'nav_msgs/Odometry',
|
|
||||||
'/plan': 'nav_msgs/Path',
|
|
||||||
'/scan': 'sensor_msgs/LaserScan',
|
|
||||||
'/tf': 'tf2_msgs/TFMessage',
|
|
||||||
'/tf_static': 'tf2_msgs/TFMessage',
|
|
||||||
}
|
|
||||||
|
|
||||||
function topic(name: string): TopicRole {
|
|
||||||
return { name, type: TOPIC_TYPES[name] ?? 'ROS message' }
|
|
||||||
}
|
|
||||||
|
|
||||||
function moduleTopics(module: ModuleRow): Pick<RosModule, 'publishers' | 'subscribers'> {
|
|
||||||
const name = module.name.toLowerCase()
|
|
||||||
const is3d = name.includes('3d')
|
|
||||||
|
|
||||||
if (name.includes('mkmini') || name.includes('turtlebot') || name.includes('vehicle')) {
|
|
||||||
return { publishers: ['/odom', '/tf', '/joint_states'].map(topic), subscribers: ['/cmd_vel'].map(topic) }
|
|
||||||
}
|
|
||||||
if (name.includes('gazebo')) {
|
|
||||||
return { publishers: ['/clock', '/tf', '/joint_states'].map(topic), subscribers: [] }
|
|
||||||
}
|
|
||||||
if (name.includes('perception')) {
|
|
||||||
return {
|
|
||||||
publishers: (is3d ? ['/cloud_map'] : ['/scan']).map(topic),
|
|
||||||
subscribers: ['/tf'].map(topic),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (name.includes('slam')) {
|
|
||||||
return {
|
|
||||||
publishers: (is3d ? ['/cloud_map', '/map', '/tf'] : ['/map', '/tf']).map(topic),
|
|
||||||
subscribers: (is3d ? ['/cloud_map', '/odom', '/tf'] : ['/scan', '/odom', '/tf']).map(topic),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (name.includes('localization')) {
|
|
||||||
return {
|
|
||||||
publishers: ['/amcl_pose', '/tf'].map(topic),
|
|
||||||
subscribers: ['/map', '/scan', '/odom', '/tf'].map(topic),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (name.includes('planning')) {
|
|
||||||
return {
|
|
||||||
publishers: ['/plan', '/cmd_vel'].map(topic),
|
|
||||||
subscribers: ['/map', '/amcl_pose', '/odom', '/tf'].map(topic),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (name.includes('control')) {
|
|
||||||
return {
|
|
||||||
publishers: ['/cmd_vel'].map(topic),
|
|
||||||
subscribers: ['/plan', '/odom', '/tf'].map(topic),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (name.includes('rviz') || name.includes('gui')) {
|
|
||||||
return {
|
|
||||||
publishers: [],
|
|
||||||
subscribers: ['/map', '/scan', '/cloud_map', '/plan', '/tf'].map(topic),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (name.includes('map')) {
|
|
||||||
return { publishers: ['/map'].map(topic), subscribers: [] }
|
|
||||||
}
|
|
||||||
return { publishers: [], subscribers: [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
function getModuleNodeName(module: ModuleRow): string {
|
|
||||||
return `/${module.name.toLowerCase().replace(/[^a-z0-9]+/g, '_')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarizeEndpoints(endpoints: RosEndpoint[]): string {
|
function summarizeEndpoints(endpoints: RosEndpoint[]): string {
|
||||||
if (!endpoints.length) return '대기 중'
|
if (!endpoints.length) return '대기 중'
|
||||||
const firstNode = endpoints[0].node
|
const firstNode = endpoints[0].node
|
||||||
@@ -135,28 +56,31 @@ function uniqueEndpoints(endpoints: RosEndpoint[]): RosEndpoint[] {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function rosGraphConnections(graph: RosGraphSnapshot): RosConnection[] {
|
||||||
|
return graph.topics.map((item) => {
|
||||||
|
const publishers = uniqueEndpoints(item.publishers)
|
||||||
|
const subscribers = uniqueEndpoints(item.subscribers)
|
||||||
|
return {
|
||||||
|
topic: { name: item.name, type: item.types.join(', ') },
|
||||||
|
publishers,
|
||||||
|
subscribers,
|
||||||
|
active: publishers.length > 0 && subscribers.length > 0,
|
||||||
|
}
|
||||||
|
}).sort((left, right) => Number(right.active) - Number(left.active) || left.topic.name.localeCompare(right.topic.name))
|
||||||
|
}
|
||||||
|
|
||||||
function RuntimeLane({
|
function RuntimeLane({
|
||||||
title,
|
title,
|
||||||
modules,
|
modules,
|
||||||
dark,
|
dark,
|
||||||
selectedModuleKeys,
|
|
||||||
onToggleModule,
|
|
||||||
onSelectAll,
|
|
||||||
onClearSelection,
|
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
modules: RosModule[]
|
modules: ModuleRow[]
|
||||||
dark: boolean
|
dark: boolean
|
||||||
selectedModuleKeys: Record<string, boolean>
|
|
||||||
onToggleModule: (module: ModuleRow) => void
|
|
||||||
onSelectAll: () => void
|
|
||||||
onClearSelection: () => void
|
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
useLocaleRender()
|
useLocaleRender()
|
||||||
const hasSelectedModules = modules.some(({ module }) => selectedModuleKeys[module.folderName ?? module.name] !== false)
|
|
||||||
const hasUnselectedModules = modules.some(({ module }) => selectedModuleKeys[module.folderName ?? module.name] === false)
|
|
||||||
return (
|
return (
|
||||||
<section className={['min-h-0 min-w-[200px] border-r last:border-r-0', dark ? 'border-white/10 bg-[#222222]' : 'border-[#dbe1eb] bg-[#f8f9fc]'].join(' ')}>
|
<section className={['grid min-h-0 min-w-[200px] grid-rows-[auto_minmax(0,1fr)] overflow-hidden border-r last:border-r-0', dark ? 'border-white/10 bg-[#222222]' : 'border-[#dbe1eb] bg-[#f8f9fc]'].join(' ')}>
|
||||||
<header className={['sticky top-0 z-10 flex items-center justify-between gap-2 border-b px-4 py-3', dark ? 'border-white/10 bg-[#252525]' : 'border-[#dbe1eb] bg-white'].join(' ')}>
|
<header className={['sticky top-0 z-10 flex items-center justify-between gap-2 border-b px-4 py-3', dark ? 'border-white/10 bg-[#252525]' : 'border-[#dbe1eb] bg-white'].join(' ')}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className={['grid h-6 w-6 place-items-center rounded-md', dark ? 'bg-[#0f766e]/25 text-[#7ee7d3]' : 'bg-[#e7f8f5] text-[#0f766e]'].join(' ')}>
|
<span className={['grid h-6 w-6 place-items-center rounded-md', dark ? 'bg-[#0f766e]/25 text-[#7ee7d3]' : 'bg-[#e7f8f5] text-[#0f766e]'].join(' ')}>
|
||||||
@@ -166,44 +90,16 @@ function RuntimeLane({
|
|||||||
<strong className={['block truncate text-[12px]', dark ? 'text-white/90' : 'text-[#182033]'].join(' ')}>{title}</strong>
|
<strong className={['block truncate text-[12px]', dark ? 'text-white/90' : 'text-[#182033]'].join(' ')}>{title}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-0.5">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={t("전체 선택")}
|
|
||||||
title={t("전체 선택")}
|
|
||||||
onClick={onSelectAll}
|
|
||||||
disabled={!hasUnselectedModules}
|
|
||||||
className={['grid h-6 w-6 place-items-center rounded transition', hasUnselectedModules ? 'cursor-pointer' : 'cursor-default opacity-35', dark ? 'text-white/52 hover:bg-white/10 hover:text-white disabled:hover:bg-transparent disabled:hover:text-white/52' : 'text-[#6c7890] hover:bg-[#edf2f8] hover:text-[#1e293b] disabled:hover:bg-transparent disabled:hover:text-[#6c7890]'].join(' ')}
|
|
||||||
>
|
|
||||||
<ListChecks size={15} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={t("전체 해제")}
|
|
||||||
title={t("전체 해제")}
|
|
||||||
onClick={onClearSelection}
|
|
||||||
disabled={!hasSelectedModules}
|
|
||||||
className={['grid h-6 w-6 place-items-center rounded transition', hasSelectedModules ? 'cursor-pointer' : 'cursor-default opacity-35', dark ? 'text-white/52 hover:bg-white/10 hover:text-white disabled:hover:bg-transparent disabled:hover:text-white/52' : 'text-[#6c7890] hover:bg-[#edf2f8] hover:text-[#1e293b] disabled:hover:bg-transparent disabled:hover:text-[#6c7890]'].join(' ')}
|
|
||||||
>
|
|
||||||
<ListX size={15} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
<div className="space-y-2 p-3">
|
<div className="thin-y-scrollbar min-h-0 space-y-2 overflow-y-auto overscroll-contain p-3">
|
||||||
{modules.length ? modules.map(({ module, publishers, subscribers }) => {
|
{modules.length ? modules.map((module) => {
|
||||||
const moduleKey = module.folderName ?? module.name
|
const moduleKey = module.folderName ?? module.name
|
||||||
const selected = selectedModuleKeys[moduleKey] !== false
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
key={moduleKey}
|
key={moduleKey}
|
||||||
type="button"
|
|
||||||
aria-pressed={selected}
|
|
||||||
onClick={() => onToggleModule(module)}
|
|
||||||
className={[
|
className={[
|
||||||
'w-full cursor-pointer rounded-md border p-3 text-left shadow-sm transition hover:-translate-y-px',
|
'w-full rounded-md border p-3 text-left shadow-sm',
|
||||||
selected
|
dark ? 'border-white/10 bg-[#2a2a2a]' : 'border-[#dbe1eb] bg-white'
|
||||||
? dark ? 'border-[#60a5fa]/45 bg-[#2563eb]/14 ring-1 ring-[#2563eb]/18 hover:bg-[#2563eb]/20' : 'border-[#82b3ff] bg-[#edf5ff] ring-1 ring-[#2563eb]/10 hover:bg-[#e3efff]'
|
|
||||||
: dark ? 'border-white/10 bg-[#2a2a2a] opacity-58 hover:border-white/24 hover:opacity-80' : 'border-[#dbe1eb] bg-white opacity-60 hover:border-[#9fb7db] hover:opacity-85'
|
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
@@ -212,29 +108,7 @@ function RuntimeLane({
|
|||||||
<strong className={['block truncate text-[12px]', dark ? 'text-white/88' : 'text-[#20293b]'].join(' ')}>{module.name}</strong>
|
<strong className={['block truncate text-[12px]', dark ? 'text-white/88' : 'text-[#20293b]'].join(' ')}>{module.name}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{(publishers.length > 0 || subscribers.length > 0) ? (
|
|
||||||
<div className="mt-3 space-y-2 text-[10px]">
|
|
||||||
{publishers.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<span className={dark ? 'text-[#7ee7d3]' : 'text-[#087f6a]'}>{t("발행")}</span>
|
|
||||||
<div className={['mt-1 flex flex-wrap gap-1', dark ? 'text-white/62' : 'text-[#556176]'].join(' ')}>
|
|
||||||
{publishers.map((item) => <code key={item.name} className={['rounded px-1.5 py-0.5', dark ? 'bg-[#ffffff0d]' : 'bg-[#eef2f7]'].join(' ')}>{item.name}</code>)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{subscribers.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<span className={dark ? 'text-[#9fc6ff]' : 'text-[#2563a8]'}>{t("구독")}</span>
|
|
||||||
<div className={['mt-1 flex flex-wrap gap-1', dark ? 'text-white/62' : 'text-[#556176]'].join(' ')}>
|
|
||||||
{subscribers.map((item) => <code key={item.name} className={['rounded px-1.5 py-0.5', dark ? 'bg-[#ffffff0d]' : 'bg-[#eef2f7]'].join(' ')}>{item.name}</code>)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className={['mt-3 text-[10px]', dark ? 'text-white/38' : 'text-[#8690a2]'].join(' ')}>{t("토픽 정의가 아직 없습니다.")}</p>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)
|
)
|
||||||
}) : (
|
}) : (
|
||||||
<div className={['rounded-md border border-dashed px-3 py-8 text-center text-[11px]', dark ? 'border-white/12 text-white/38' : 'border-[#cdd6e4] text-[#7b8799]'].join(' ')}>{t("실행 중인 모듈이 없습니다.")}</div>
|
<div className={['rounded-md border border-dashed px-3 py-8 text-center text-[11px]', dark ? 'border-white/12 text-white/38' : 'border-[#cdd6e4] text-[#7b8799]'].join(' ')}>{t("실행 중인 모듈이 없습니다.")}</div>
|
||||||
@@ -336,56 +210,11 @@ export default function WorkspaceRosConnectionsView({
|
|||||||
loadRosGraph,
|
loadRosGraph,
|
||||||
}: WorkspaceRosConnectionsViewProps): React.JSX.Element {
|
}: WorkspaceRosConnectionsViewProps): React.JSX.Element {
|
||||||
useLocaleRender()
|
useLocaleRender()
|
||||||
const runningRosModules = useMemo<RosModule[]>(() => modules
|
const runningRosModules = useMemo(() => modules.filter((module) => module.state === 'running'), [modules])
|
||||||
.filter((module) => module.state === 'running')
|
|
||||||
.map((module) => ({ module, ...moduleTopics(module) })), [modules])
|
|
||||||
const [selectedModuleKeys, setSelectedModuleKeys] = useState<Record<string, boolean>>({})
|
|
||||||
const [rosGraph, setRosGraph] = useState<RosGraphSnapshot>({ topics: [] })
|
const [rosGraph, setRosGraph] = useState<RosGraphSnapshot>({ topics: [] })
|
||||||
const [rosGraphLoading, setRosGraphLoading] = useState(false)
|
const [rosGraphLoading, setRosGraphLoading] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
const connections = useMemo(() => rosGraphConnections(rosGraph), [rosGraph])
|
||||||
const runningKeys = new Set(runningRosModules.map(({ module }) => module.folderName ?? module.name))
|
|
||||||
setSelectedModuleKeys((current) => {
|
|
||||||
const next: Record<string, boolean> = {}
|
|
||||||
runningKeys.forEach((key) => {
|
|
||||||
next[key] = key in current ? current[key] : true
|
|
||||||
})
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}, [runningRosModules])
|
|
||||||
|
|
||||||
const rosModules = useMemo(
|
|
||||||
() => runningRosModules.filter(({ module }) => selectedModuleKeys[module.folderName ?? module.name] !== false),
|
|
||||||
[runningRosModules, selectedModuleKeys],
|
|
||||||
)
|
|
||||||
const connections = useMemo<RosConnection[]>(() => {
|
|
||||||
const selectedTopics = new Map<string, TopicRole>()
|
|
||||||
rosModules.forEach((module) => {
|
|
||||||
module.publishers.forEach((item) => selectedTopics.set(item.name, item))
|
|
||||||
module.subscribers.forEach((item) => selectedTopics.set(item.name, item))
|
|
||||||
})
|
|
||||||
const graphTopics = new Map(rosGraph.topics.map((topic) => [topic.name, topic]))
|
|
||||||
return [...selectedTopics.values()]
|
|
||||||
.map((item) => {
|
|
||||||
const graphTopic = graphTopics.get(item.name)
|
|
||||||
const fallbackPublishers = rosModules
|
|
||||||
.filter((module) => module.publishers.some((entry) => entry.name === item.name))
|
|
||||||
.map(({ module }) => ({ node: getModuleNodeName(module), type: item.type }))
|
|
||||||
const fallbackSubscribers = rosModules
|
|
||||||
.filter((module) => module.subscribers.some((entry) => entry.name === item.name))
|
|
||||||
.map(({ module }) => ({ node: getModuleNodeName(module), type: item.type }))
|
|
||||||
const publishers = uniqueEndpoints(graphTopic?.publishers ?? fallbackPublishers)
|
|
||||||
const subscribers = uniqueEndpoints(graphTopic?.subscribers ?? fallbackSubscribers)
|
|
||||||
return {
|
|
||||||
topic: { name: item.name, type: graphTopic?.types[0] ?? item.type },
|
|
||||||
publishers,
|
|
||||||
subscribers,
|
|
||||||
active: publishers.length > 0 && subscribers.length > 0,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter((connection) => connection.publishers.length || connection.subscribers.length)
|
|
||||||
.sort((left, right) => Number(right.active) - Number(left.active) || left.topic.name.localeCompare(right.topic.name))
|
|
||||||
}, [rosGraph.topics, rosModules])
|
|
||||||
const [selectedTopicName, setSelectedTopicName] = useState<string | null>(null)
|
const [selectedTopicName, setSelectedTopicName] = useState<string | null>(null)
|
||||||
const [topicDetailOpen, setTopicDetailOpen] = useState(false)
|
const [topicDetailOpen, setTopicDetailOpen] = useState(false)
|
||||||
const [topicDetailHeight, setTopicDetailHeight] = useState(320)
|
const [topicDetailHeight, setTopicDetailHeight] = useState(320)
|
||||||
@@ -401,19 +230,6 @@ export default function WorkspaceRosConnectionsView({
|
|||||||
|
|
||||||
const runningModules = runningRosModules.length
|
const runningModules = runningRosModules.length
|
||||||
const activeConnections = connections.filter((connection) => connection.active).length
|
const activeConnections = connections.filter((connection) => connection.active).length
|
||||||
const toggleModuleConnection = (module: ModuleRow): void => {
|
|
||||||
const key = module.folderName ?? module.name
|
|
||||||
setSelectedModuleKeys((current) => ({ ...current, [key]: current[key] === false }))
|
|
||||||
}
|
|
||||||
const setWorkspaceModuleSelection = (selected: boolean): void => {
|
|
||||||
setSelectedModuleKeys((current) => {
|
|
||||||
const next = { ...current }
|
|
||||||
runningRosModules.forEach(({ module }) => {
|
|
||||||
next[module.folderName ?? module.name] = selected
|
|
||||||
})
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const startTopicDetailResize = (event: React.MouseEvent<HTMLDivElement>): void => {
|
const startTopicDetailResize = (event: React.MouseEvent<HTMLDivElement>): void => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const startY = event.clientY
|
const startY = event.clientY
|
||||||
@@ -431,6 +247,7 @@ export default function WorkspaceRosConnectionsView({
|
|||||||
}
|
}
|
||||||
const refreshRosGraph = async (refreshModules = false): Promise<void> => {
|
const refreshRosGraph = async (refreshModules = false): Promise<void> => {
|
||||||
setRosGraphLoading(true)
|
setRosGraphLoading(true)
|
||||||
|
setRosGraph({ topics: [] })
|
||||||
try {
|
try {
|
||||||
if (refreshModules) await onRefreshModules()
|
if (refreshModules) await onRefreshModules()
|
||||||
setRosGraph(await loadRosGraph())
|
setRosGraph(await loadRosGraph())
|
||||||
@@ -484,19 +301,15 @@ export default function WorkspaceRosConnectionsView({
|
|||||||
className="grid min-h-0"
|
className="grid min-h-0"
|
||||||
style={{ gridTemplateRows: topicDetailOpen && selectedConnection ? `minmax(0, 1fr) ${topicDetailHeight}px` : 'minmax(0, 1fr)' }}
|
style={{ gridTemplateRows: topicDetailOpen && selectedConnection ? `minmax(0, 1fr) ${topicDetailHeight}px` : 'minmax(0, 1fr)' }}
|
||||||
>
|
>
|
||||||
<div className="min-h-0 overflow-auto">
|
<div className="min-h-0 overflow-x-auto overflow-y-hidden">
|
||||||
<div className="grid min-h-full min-w-[520px] grid-cols-[minmax(200px,0.9fr)_minmax(300px,1.35fr)]">
|
<div className="grid h-full min-h-0 min-w-[520px] grid-cols-[minmax(200px,0.9fr)_minmax(300px,1.35fr)] grid-rows-[minmax(0,1fr)]">
|
||||||
<RuntimeLane
|
<RuntimeLane
|
||||||
dark={dark}
|
dark={dark}
|
||||||
title={remoteWorkspace ? t("원격 플랫폼") : t("로컬 PC")}
|
title={remoteWorkspace ? t("원격 플랫폼") : t("로컬 PC")}
|
||||||
modules={runningRosModules}
|
modules={runningRosModules}
|
||||||
selectedModuleKeys={selectedModuleKeys}
|
|
||||||
onToggleModule={toggleModuleConnection}
|
|
||||||
onSelectAll={() => setWorkspaceModuleSelection(true)}
|
|
||||||
onClearSelection={() => setWorkspaceModuleSelection(false)}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section className={['min-h-0 border-r p-3', dark ? 'border-white/10 bg-[#1f1f1f]' : 'border-[#dbe1eb] bg-[#f7f9fc]'].join(' ')}>
|
<section className={['grid min-h-0 min-w-0 grid-rows-[auto_minmax(0,1fr)] overflow-hidden border-r p-3', dark ? 'border-white/10 bg-[#1f1f1f]' : 'border-[#dbe1eb] bg-[#f7f9fc]'].join(' ')}>
|
||||||
<div className="mb-3 flex items-center justify-between px-1">
|
<div className="mb-3 flex items-center justify-between px-1">
|
||||||
<div>
|
<div>
|
||||||
<strong className={['block text-[12px]', dark ? 'text-white/88' : 'text-[#1f2937]'].join(' ')}>{t("토픽 연결")}</strong>
|
<strong className={['block text-[12px]', dark ? 'text-white/88' : 'text-[#1f2937]'].join(' ')}>{t("토픽 연결")}</strong>
|
||||||
@@ -504,7 +317,7 @@ export default function WorkspaceRosConnectionsView({
|
|||||||
</div>
|
</div>
|
||||||
<span className={['text-[10px] font-semibold', dark ? 'text-white/42' : 'text-[#7a8597]'].join(' ')}>{activeConnections}/{connections.length}{t("활성")}</span>
|
<span className={['text-[10px] font-semibold', dark ? 'text-white/42' : 'text-[#7a8597]'].join(' ')}>{activeConnections}/{connections.length}{t("활성")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="thin-y-scrollbar min-h-0 space-y-2 overflow-y-auto overscroll-contain">
|
||||||
{connections.length ? connections.map((connection) => {
|
{connections.length ? connections.map((connection) => {
|
||||||
const selected = topicDetailOpen && selectedConnection?.topic.name === connection.topic.name
|
const selected = topicDetailOpen && selectedConnection?.topic.name === connection.topic.name
|
||||||
return (
|
return (
|
||||||
@@ -541,7 +354,7 @@ export default function WorkspaceRosConnectionsView({
|
|||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}) : (
|
}) : (
|
||||||
<div className={['rounded-md border border-dashed px-3 py-8 text-center text-[11px]', dark ? 'border-white/12 text-white/38' : 'border-[#cdd6e4] text-[#7b8799]'].join(' ')}>{t("실행 모듈에서 확인된 토픽이 없습니다.")}</div>
|
<div className={['rounded-md border border-dashed px-3 py-8 text-center text-[11px]', dark ? 'border-white/12 text-white/38' : 'border-[#cdd6e4] text-[#7b8799]'].join(' ')}>{rosGraphLoading ? t("조회 중...") : rosGraph.message ? t("ROS 그래프 조회에 실패했습니다.") : t("조회된 ROS 토픽이 없습니다.")}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -163,10 +163,10 @@ function WorkspaceSourceControlView({
|
|||||||
gitPanelTab === tab.key
|
gitPanelTab === tab.key
|
||||||
? dark
|
? dark
|
||||||
? 'border-white/10 bg-[#2b2b2b] text-white'
|
? 'border-white/10 bg-[#2b2b2b] text-white'
|
||||||
: 'border-[#e1e6ef] bg-white text-[#111520]'
|
: 'border-[#c4b5df] bg-[#e4d8f5] text-[#38205b]'
|
||||||
: dark
|
: dark
|
||||||
? 'border-white/8 bg-[#202020] text-white/45 hover:text-white/70'
|
? 'border-white/8 bg-[#202020] text-white/45 hover:text-white/70'
|
||||||
: 'border-[#e1e6ef] bg-[#eef1f6] text-[#687284] hover:text-[#111520]'
|
: 'border-[#d4c9e4] bg-[#eee8f6] text-[#475569] hover:bg-[#e8def3] hover:text-[#38205b]'
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{t(tab.label)}
|
{t(tab.label)}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ function WorkspaceTerminalPanel({
|
|||||||
>
|
>
|
||||||
<span className={['h-px w-full transition', dark ? 'bg-white/10 group-hover:bg-white/45' : 'bg-[#c5cedb] group-hover:bg-[#8d98a8]'].join(' ')} />
|
<span className={['h-px w-full transition', dark ? 'bg-white/10 group-hover:bg-white/45' : 'bg-[#c5cedb] group-hover:bg-[#8d98a8]'].join(' ')} />
|
||||||
</div>
|
</div>
|
||||||
<div className={['flex h-10 items-center gap-2 border-b px-3', borderSoft].join(' ')}>
|
<div className={['flex h-10 items-center gap-2 border-b px-3', dark ? borderSoft : 'border-[#d4c9e4] bg-[#eee8f6]'].join(' ')}>
|
||||||
<Terminal size={14} className={textDim} />
|
<Terminal size={14} className={textDim} />
|
||||||
<div role="group" aria-label={t("터미널 환경")} className="flex items-center gap-1">
|
<div role="group" aria-label={t("터미널 환경")} className="flex items-center gap-1">
|
||||||
{modes.map((mode) => (
|
{modes.map((mode) => (
|
||||||
@@ -48,7 +48,7 @@ function WorkspaceTerminalPanel({
|
|||||||
aria-pressed={terminalMode === mode}
|
aria-pressed={terminalMode === mode}
|
||||||
onClick={() => onModeChange(mode)}
|
onClick={() => onModeChange(mode)}
|
||||||
className={['h-6 cursor-pointer rounded px-2 text-[12px] font-bold transition-colors', terminalMode === mode
|
className={['h-6 cursor-pointer rounded px-2 text-[12px] font-bold transition-colors', terminalMode === mode
|
||||||
? dark ? 'bg-white/15 text-white' : 'bg-white text-[#253044]'
|
? dark ? 'bg-white/15 text-white' : 'bg-[#e4d8f5] text-[#38205b]'
|
||||||
: `${textMid} ${dark ? 'hover:bg-white/5' : 'hover:bg-black/5'}`].join(' ')}
|
: `${textMid} ${dark ? 'hover:bg-white/5' : 'hover:bg-black/5'}`].join(' ')}
|
||||||
>
|
>
|
||||||
{mode === 'host' ? t("호스트") : t("컨테이너")}
|
{mode === 'host' ? t("호스트") : t("컨테이너")}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
& .hover\:bg-white\/\[0\.045\]:hover { background-color: #edf2fa; }
|
& .hover\:bg-white\/\[0\.045\]:hover { background-color: #edf2fa; }
|
||||||
& .hover\:border-white\/25:hover { border-color: #9facbf; }
|
& .hover\:border-white\/25:hover { border-color: #9facbf; }
|
||||||
& .text-white\/50, & .text-white\/55, & .text-white\/45,
|
& .text-white\/50, & .text-white\/55, & .text-white\/45,
|
||||||
& .text-white\/40, & .text-white\/35 { color: #687284; }
|
& .text-white\/40, & .text-white\/35 { color: #475569; }
|
||||||
& .text-white\/70 { color: #374151; }
|
& .text-white\/70 { color: #374151; }
|
||||||
& .border-white\/10, & .border-white\/12 { border-color: #d8dee9; }
|
& .border-white\/10, & .border-white\/12 { border-color: #d8dee9; }
|
||||||
& .text-\[\#78a4ff\] { color: #245bb8; }
|
& .text-\[\#78a4ff\] { color: #245bb8; }
|
||||||
|
|||||||
@@ -32,24 +32,6 @@ function waitForSimulatorStartup(): Promise<void> {
|
|||||||
return new Promise((resolve) => window.setTimeout(resolve, 4000))
|
return new Promise((resolve) => window.setTimeout(resolve, 4000))
|
||||||
}
|
}
|
||||||
|
|
||||||
function duplicateCategorySummary(modules: ModuleRow[]): string | null {
|
|
||||||
const categories = new Map<string, { label: string; modules: ModuleRow[] }>()
|
|
||||||
modules.forEach((module) => {
|
|
||||||
const label = module.category.trim()
|
|
||||||
if (!label) return
|
|
||||||
const key = label.toLowerCase()
|
|
||||||
const entry = categories.get(key) ?? { label, modules: [] }
|
|
||||||
entry.modules.push(module)
|
|
||||||
categories.set(key, entry)
|
|
||||||
})
|
|
||||||
|
|
||||||
const conflicts = Array.from(categories.values()).filter((entry) => entry.modules.length > 1)
|
|
||||||
if (!conflicts.length) return null
|
|
||||||
return conflicts
|
|
||||||
.map((entry) => `${entry.label}: ${entry.modules.map((module) => module.name).join(', ')}`)
|
|
||||||
.join(' / ')
|
|
||||||
}
|
|
||||||
|
|
||||||
type UseWorkspaceModuleControlOptions = {
|
type UseWorkspaceModuleControlOptions = {
|
||||||
availableModules: ModuleRow[]
|
availableModules: ModuleRow[]
|
||||||
setAvailableModules: React.Dispatch<React.SetStateAction<ModuleRow[]>>
|
setAvailableModules: React.Dispatch<React.SetStateAction<ModuleRow[]>>
|
||||||
@@ -354,18 +336,6 @@ export function useWorkspaceModuleControl({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeModules = availableModules.filter((module) => (
|
|
||||||
!isExcludedFromBulkControl(module)
|
|
||||||
&& (module.state === 'stopped' || module.state === 'running')
|
|
||||||
))
|
|
||||||
const duplicateCategories = duplicateCategorySummary(activeModules)
|
|
||||||
if (duplicateCategories) {
|
|
||||||
const message = `동일 카테고리 모듈이 중복되어 전체 실행을 중단했습니다. ${duplicateCategories}`
|
|
||||||
setModuleInstallError(message)
|
|
||||||
onTitlebarNotice('중복 카테고리 모듈을 확인하세요', 'warning')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setInstallingModuleName('__start_all__')
|
setInstallingModuleName('__start_all__')
|
||||||
setModuleInstallError(null)
|
setModuleInstallError(null)
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
|
"조회 중...": "Loading...",
|
||||||
|
"ROS 그래프 조회에 실패했습니다.": "Failed to retrieve the ROS graph.",
|
||||||
|
"조회된 ROS 토픽이 없습니다.": "No ROS topics found.",
|
||||||
"공통": "General",
|
"공통": "General",
|
||||||
"완료한 주차": "Completed weeks",
|
"완료한 주차": "Completed weeks",
|
||||||
"학습 시간": "Learning time",
|
"학습 시간": "Learning time",
|
||||||
|
|||||||
Reference in New Issue
Block a user