From fb8c15dfd3595b6e988202e14abd24f1773c529d Mon Sep 17 00:00:00 2001 From: chyoo Date: Tue, 29 Sep 2026 16:42:34 +0900 Subject: [PATCH] feat: add daily logging and improve workspace UI and ROS status --- electron.vite.config.js | 6 + electron.vite.config.ts | 6 + scripts/test-daily-log.cjs | 78 ++++++ scripts/test-i18n.cjs | 38 +++ scripts/test-ros-connections.cjs | 47 ++++ src/main/appLogging.ts | 29 ++ src/main/bootstrap.ts | 8 + src/main/dailyLog.ts | 51 ++++ src/main/index.ts | 18 +- src/renderer/src/App.tsx | 9 +- .../src/components/AppStatusModals.tsx | 2 +- .../features/workspace/ModuleListHeader.tsx | 2 +- .../src/features/workspace/ModuleListRow.tsx | 6 +- .../features/workspace/ModuleListToolbar.tsx | 4 +- .../src/features/workspace/ModuleListView.tsx | 2 +- .../workspace/WorkspaceEditorPathBar.tsx | 2 +- .../workspace/WorkspaceEditorTabs.tsx | 6 +- .../workspace/WorkspaceGuidePanel.tsx | 3 +- .../workspace/WorkspaceModuleTabs.tsx | 4 +- .../workspace/WorkspaceRosConnectionsView.tsx | 247 +++--------------- .../workspace/WorkspaceSourceControlView.tsx | 4 +- .../workspace/WorkspaceTerminalPanel.tsx | 4 +- .../src/features/workspace/studioDialogs.css | 2 +- .../workspace/useWorkspaceModuleControl.ts | 30 --- src/renderer/src/i18n/en.json | 3 + 25 files changed, 328 insertions(+), 283 deletions(-) create mode 100644 scripts/test-daily-log.cjs create mode 100644 scripts/test-ros-connections.cjs create mode 100644 src/main/appLogging.ts create mode 100644 src/main/bootstrap.ts create mode 100644 src/main/dailyLog.ts diff --git a/electron.vite.config.js b/electron.vite.config.js index 3b1571a..438e3bd 100644 --- a/electron.vite.config.js +++ b/electron.vite.config.js @@ -4,6 +4,12 @@ import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; export default defineConfig({ main: { + build: { + rollupOptions: { + input: { index: resolve(__dirname, 'src/main/bootstrap.ts') }, + output: { inlineDynamicImports: false, chunkFileNames: '[name]-[hash].js' } + } + }, plugins: [externalizeDepsPlugin()] }, preload: { diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 852599e..6a1c1e9 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -5,6 +5,12 @@ import tailwindcss from '@tailwindcss/vite' export default defineConfig({ main: { + build: { + rollupOptions: { + input: { index: resolve(__dirname, 'src/main/bootstrap.ts') }, + output: { inlineDynamicImports: false, chunkFileNames: '[name]-[hash].js' } + } + }, plugins: [externalizeDepsPlugin()] }, preload: { diff --git a/scripts/test-daily-log.cjs b/scripts/test-daily-log.cjs new file mode 100644 index 0000000..59e476d --- /dev/null +++ b/scripts/test-daily-log.cjs @@ -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]) +}) diff --git a/scripts/test-i18n.cjs b/scripts/test-i18n.cjs index 01d655a..3d327ca 100644 --- a/scripts/test-i18n.cjs +++ b/scripts/test-i18n.cjs @@ -9,6 +9,44 @@ const { createInstance } = require('i18next') const root = path.resolve(__dirname, '../src/renderer/src') 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, / { const source = fs.readFileSync(path.join(root, 'features/workspace/ModuleListView.tsx'), 'utf8') const titles = [...source.matchAll(/environment: '([^']+)'/g)].map(match => match[1]) diff --git a/scripts/test-ros-connections.cjs b/scripts/test-ros-connections.cjs new file mode 100644 index 0000000..2e3efa9 --- /dev/null +++ b/scripts/test-ros-connections.cjs @@ -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/) +}) diff --git a/src/main/appLogging.ts b/src/main/appLogging.ts new file mode 100644 index 0000000..44cacb1 --- /dev/null +++ b/src/main/appLogging.ts @@ -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}`) diff --git a/src/main/bootstrap.ts b/src/main/bootstrap.ts new file mode 100644 index 0000000..3930a63 --- /dev/null +++ b/src/main/bootstrap.ts @@ -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) +}) diff --git a/src/main/dailyLog.ts b/src/main/dailyLog.ts new file mode 100644 index 0000000..da01139 --- /dev/null +++ b/src/main/dailyLog.ts @@ -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. */ } + } + } +} diff --git a/src/main/index.ts b/src/main/index.ts index 683203e..1293ec5 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,5 @@ import { app, BrowserWindow, dialog, Menu, globalShortcut, ipcMain, screen, shell } from 'electron' +import { appLog } from './appLogging' import { checkDockerStatus } from './dockerStatus' import { cameraRuntimeArtifacts } from './cameraRuntime' import { remoteModuleFilesScript } from './remoteModuleFiles' @@ -12,7 +13,7 @@ import type { WebContents } from 'electron' import { spawn } from 'child_process' import * as pty 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 { homedir } from 'os' import { basename, dirname, join, relative, resolve } from 'path' @@ -51,21 +52,12 @@ const rosBridgePort = 8765 const exampleCodeRoot = resolve(homedir(), '다운로드', 'example-code') function writeRendererDiagnostic(kind: string, message: string): void { - try { - 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. - } + appLog.write(kind, message) } 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 { const content = await readFile(logPath, 'utf8') diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 5ea3831..13d4cc5 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -4951,6 +4951,9 @@ export default function App(): React.JSX.Element { const sessionPlatform = connectedPlatform ?? runningPlatforms[0] ?? localPcPlatform const workspacePlatform = connectedPlatform ?? runningPlatforms[0] ?? lastWorkspacePlatform const activePlatformName = workspacePlatform?.title ?? sessionPlatform?.title ?? '플랫폼' + const activePlatformDisplayName = (workspacePlatform ?? sessionPlatform)?.platformKey === 'futuredrive-local-pc' + ? t('로컬 PC') + : activePlatformName const sessionPlatformLabel = sessionRunning ? runningPlatforms.length === 1 ? runningPlatforms[0].title @@ -6208,7 +6211,7 @@ export default function App(): React.JSX.Element { setRunningPlatformCloseTarget(null)} onConfirm={() => { const target = runningPlatformCloseTarget @@ -6222,7 +6225,7 @@ export default function App(): React.JSX.Element { overlayClassName={fullAppModalOverlayClass} dark={appModalDark} action={sessionConfirmAction} - platformName={activePlatformName} + platformName={activePlatformDisplayName} onCancel={() => setSessionConfirmAction(null)} onConfirm={confirmManualSessionEnd} /> @@ -6257,7 +6260,7 @@ export default function App(): React.JSX.Element { setControlExtensionConfirmOpen(false)} onConfirm={() => void handleExtendControl()} diff --git a/src/renderer/src/components/AppStatusModals.tsx b/src/renderer/src/components/AppStatusModals.tsx index 1eb47ba..3c37e78 100644 --- a/src/renderer/src/components/AppStatusModals.tsx +++ b/src/renderer/src/components/AppStatusModals.tsx @@ -34,7 +34,7 @@ function strongClass(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 { diff --git a/src/renderer/src/features/workspace/ModuleListHeader.tsx b/src/renderer/src/features/workspace/ModuleListHeader.tsx index d56635a..304d9ad 100644 --- a/src/renderer/src/features/workspace/ModuleListHeader.tsx +++ b/src/renderer/src/features/workspace/ModuleListHeader.tsx @@ -8,7 +8,7 @@ type ModuleListHeaderProps = { export default function ModuleListHeader({ dark }: ModuleListHeaderProps): React.JSX.Element { useLocaleRender() return ( -
+
{t("상태")} {t("분류")} {t("모듈명")} diff --git a/src/renderer/src/features/workspace/ModuleListRow.tsx b/src/renderer/src/features/workspace/ModuleListRow.tsx index 5eaedf4..429829e 100644 --- a/src/renderer/src/features/workspace/ModuleListRow.tsx +++ b/src/renderer/src/features/workspace/ModuleListRow.tsx @@ -253,7 +253,7 @@ export default function ModuleListRow({ dropTarget ? (dark ? 'bg-[#2563eb]/14 outline outline-1 outline-[#3b82f6]/50' : 'bg-[#edf6ff] outline outline-1 outline-[#2563eb]/35') : '', selectable ? 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(' ')} > @@ -261,7 +261,7 @@ export default function ModuleListRow({ {t(stateLabel[module.state])} - {module.category} + {module.category} {module.name} @@ -279,7 +279,7 @@ export default function ModuleListRow({ className={[ 'mt-1 block h-[14px] max-w-full truncate text-left text-[10px] transition', '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(' ')} > {module.parameters.length ? module.parameters.join(' ') : t("파라미터 설정")} diff --git a/src/renderer/src/features/workspace/ModuleListToolbar.tsx b/src/renderer/src/features/workspace/ModuleListToolbar.tsx index 60a26d6..ce1b0c7 100644 --- a/src/renderer/src/features/workspace/ModuleListToolbar.tsx +++ b/src/renderer/src/features/workspace/ModuleListToolbar.tsx @@ -86,10 +86,10 @@ export default function ModuleListToolbar({ active ? dark ? 'border-white/10 bg-[#2b2b2b] text-white' - : 'border-[#e1e6ef] bg-white text-[#111520]' + : 'border-[#c4b5df] bg-[#e4d8f5] text-[#38205b]' : dark ? '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(' ')} > {t(label)} diff --git a/src/renderer/src/features/workspace/ModuleListView.tsx b/src/renderer/src/features/workspace/ModuleListView.tsx index c9b8138..2dd1458 100644 --- a/src/renderer/src/features/workspace/ModuleListView.tsx +++ b/src/renderer/src/features/workspace/ModuleListView.tsx @@ -499,7 +499,7 @@ const moduleGuidePages = [ slamModules: ['NAV2-Gazebo', 'NAV2-TurtleBot3Burger', 'NAV2-Perception', 'NAV2-SLAM'], navigationModules: ['NAV2-Gazebo', 'NAV2-TurtleBot3Burger', 'NAV2-Perception', 'NAV2-Map', 'NAV2-Localization', 'NAV2-Planning', 'NAV2-Control'], 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'], ['Localization', 'localization_mode:=amcl'] ] diff --git a/src/renderer/src/features/workspace/WorkspaceEditorPathBar.tsx b/src/renderer/src/features/workspace/WorkspaceEditorPathBar.tsx index 4c3ed2f..1175329 100644 --- a/src/renderer/src/features/workspace/WorkspaceEditorPathBar.tsx +++ b/src/renderer/src/features/workspace/WorkspaceEditorPathBar.tsx @@ -21,7 +21,7 @@ function WorkspaceEditorPathBar({ if (!currentActiveEditorFile) return null return ( -
+
{currentActiveEditorFile.split('/').map((part, index, parts) => ( diff --git a/src/renderer/src/features/workspace/WorkspaceEditorTabs.tsx b/src/renderer/src/features/workspace/WorkspaceEditorTabs.tsx index ab708db..71dcfe1 100644 --- a/src/renderer/src/features/workspace/WorkspaceEditorTabs.tsx +++ b/src/renderer/src/features/workspace/WorkspaceEditorTabs.tsx @@ -38,7 +38,7 @@ function WorkspaceEditorTabs({ if (files.length === 0) return null return ( -
+
{files.map((file, index) => (
-
- - -
-
- {modules.length ? modules.map(({ module, publishers, subscribers }) => { +
+ {modules.length ? modules.map((module) => { const moduleKey = module.folderName ?? module.name - const selected = selectedModuleKeys[moduleKey] !== false return ( -
- {(publishers.length > 0 || subscribers.length > 0) ? ( -
- {publishers.length > 0 && ( -
- {t("발행")} -
- {publishers.map((item) => {item.name})} -
-
- )} - {subscribers.length > 0 && ( -
- {t("구독")} -
- {subscribers.map((item) => {item.name})} -
-
- )} -
- ) : ( -

{t("토픽 정의가 아직 없습니다.")}

- )} - +
) }) : (
{t("실행 중인 모듈이 없습니다.")}
@@ -336,56 +210,11 @@ export default function WorkspaceRosConnectionsView({ loadRosGraph, }: WorkspaceRosConnectionsViewProps): React.JSX.Element { useLocaleRender() - const runningRosModules = useMemo(() => modules - .filter((module) => module.state === 'running') - .map((module) => ({ module, ...moduleTopics(module) })), [modules]) - const [selectedModuleKeys, setSelectedModuleKeys] = useState>({}) + const runningRosModules = useMemo(() => modules.filter((module) => module.state === 'running'), [modules]) const [rosGraph, setRosGraph] = useState({ topics: [] }) const [rosGraphLoading, setRosGraphLoading] = useState(false) - useEffect(() => { - const runningKeys = new Set(runningRosModules.map(({ module }) => module.folderName ?? module.name)) - setSelectedModuleKeys((current) => { - const next: Record = {} - 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(() => { - const selectedTopics = new Map() - 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 connections = useMemo(() => rosGraphConnections(rosGraph), [rosGraph]) const [selectedTopicName, setSelectedTopicName] = useState(null) const [topicDetailOpen, setTopicDetailOpen] = useState(false) const [topicDetailHeight, setTopicDetailHeight] = useState(320) @@ -401,19 +230,6 @@ export default function WorkspaceRosConnectionsView({ const runningModules = runningRosModules.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): void => { event.preventDefault() const startY = event.clientY @@ -431,6 +247,7 @@ export default function WorkspaceRosConnectionsView({ } const refreshRosGraph = async (refreshModules = false): Promise => { setRosGraphLoading(true) + setRosGraph({ topics: [] }) try { if (refreshModules) await onRefreshModules() setRosGraph(await loadRosGraph()) @@ -484,19 +301,15 @@ export default function WorkspaceRosConnectionsView({ className="grid min-h-0" style={{ gridTemplateRows: topicDetailOpen && selectedConnection ? `minmax(0, 1fr) ${topicDetailHeight}px` : 'minmax(0, 1fr)' }} > -
-
+
+
setWorkspaceModuleSelection(true)} - onClearSelection={() => setWorkspaceModuleSelection(false)} /> -
+
{t("토픽 연결")} @@ -504,7 +317,7 @@ export default function WorkspaceRosConnectionsView({
{activeConnections}/{connections.length}{t("활성")}
-
+
{connections.length ? connections.map((connection) => { const selected = topicDetailOpen && selectedConnection?.topic.name === connection.topic.name return ( @@ -541,7 +354,7 @@ export default function WorkspaceRosConnectionsView({ ) }) : ( -
{t("실행 모듈에서 확인된 토픽이 없습니다.")}
+
{rosGraphLoading ? t("조회 중...") : rosGraph.message ? t("ROS 그래프 조회에 실패했습니다.") : t("조회된 ROS 토픽이 없습니다.")}
)}
diff --git a/src/renderer/src/features/workspace/WorkspaceSourceControlView.tsx b/src/renderer/src/features/workspace/WorkspaceSourceControlView.tsx index 6cb0e74..9237721 100644 --- a/src/renderer/src/features/workspace/WorkspaceSourceControlView.tsx +++ b/src/renderer/src/features/workspace/WorkspaceSourceControlView.tsx @@ -163,10 +163,10 @@ function WorkspaceSourceControlView({ gitPanelTab === tab.key ? dark ? 'border-white/10 bg-[#2b2b2b] text-white' - : 'border-[#e1e6ef] bg-white text-[#111520]' + : 'border-[#c4b5df] bg-[#e4d8f5] text-[#38205b]' : dark ? '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(' ')} > {t(tab.label)} diff --git a/src/renderer/src/features/workspace/WorkspaceTerminalPanel.tsx b/src/renderer/src/features/workspace/WorkspaceTerminalPanel.tsx index 239b301..05e412b 100644 --- a/src/renderer/src/features/workspace/WorkspaceTerminalPanel.tsx +++ b/src/renderer/src/features/workspace/WorkspaceTerminalPanel.tsx @@ -38,7 +38,7 @@ function WorkspaceTerminalPanel({ >
-
+
{modes.map((mode) => ( @@ -48,7 +48,7 @@ function WorkspaceTerminalPanel({ aria-pressed={terminalMode === mode} onClick={() => onModeChange(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(' ')} > {mode === 'host' ? t("호스트") : t("컨테이너")} diff --git a/src/renderer/src/features/workspace/studioDialogs.css b/src/renderer/src/features/workspace/studioDialogs.css index b8c0016..3e78e7f 100644 --- a/src/renderer/src/features/workspace/studioDialogs.css +++ b/src/renderer/src/features/workspace/studioDialogs.css @@ -5,7 +5,7 @@ & .hover\:bg-white\/\[0\.045\]:hover { background-color: #edf2fa; } & .hover\:border-white\/25:hover { border-color: #9facbf; } & .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; } & .border-white\/10, & .border-white\/12 { border-color: #d8dee9; } & .text-\[\#78a4ff\] { color: #245bb8; } diff --git a/src/renderer/src/features/workspace/useWorkspaceModuleControl.ts b/src/renderer/src/features/workspace/useWorkspaceModuleControl.ts index 2500d03..116dce4 100644 --- a/src/renderer/src/features/workspace/useWorkspaceModuleControl.ts +++ b/src/renderer/src/features/workspace/useWorkspaceModuleControl.ts @@ -32,24 +32,6 @@ function waitForSimulatorStartup(): Promise { return new Promise((resolve) => window.setTimeout(resolve, 4000)) } -function duplicateCategorySummary(modules: ModuleRow[]): string | null { - const categories = new Map() - 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 = { availableModules: ModuleRow[] setAvailableModules: React.Dispatch> @@ -354,18 +336,6 @@ export function useWorkspaceModuleControl({ 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__') setModuleInstallError(null) try { diff --git a/src/renderer/src/i18n/en.json b/src/renderer/src/i18n/en.json index 1d5806f..c0143b2 100644 --- a/src/renderer/src/i18n/en.json +++ b/src/renderer/src/i18n/en.json @@ -1,4 +1,7 @@ { + "조회 중...": "Loading...", + "ROS 그래프 조회에 실패했습니다.": "Failed to retrieve the ROS graph.", + "조회된 ROS 토픽이 없습니다.": "No ROS topics found.", "공통": "General", "완료한 주차": "Completed weeks", "학습 시간": "Learning time",