diff --git a/scripts/test-remote-latency.cjs b/scripts/test-remote-latency.cjs new file mode 100644 index 0000000..eb19b17 --- /dev/null +++ b/scripts/test-remote-latency.cjs @@ -0,0 +1,174 @@ +const { test } = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const vm = require('node:vm') +const ts = require('typescript') +const React = require('react') +const { renderToStaticMarkup } = require('react-dom/server') + +function load(file, overrides = {}, globals = {}) { + const exports = {} + vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve(file), 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } + }).outputText, { exports, process, require: name => overrides[name] ?? require(name), ...globals }) + return exports +} +const shared = load('../src/shared/remoteLatency.ts') +const main = load('../src/main/remoteLatency.ts') +const { createRemoteLatencyMonitor } = load('../src/renderer/src/features/platforms/remoteLatencyMonitor.ts', { '../../../../shared/remoteLatency': shared }) +const i18n = { t: key => key, useLocaleRender() {} } +const { default: RemoteLatencyLabel } = load('../src/renderer/src/components/RemoteLatencyLabel.tsx', { '../i18n': i18n, '../../../shared/remoteLatency': shared }) +const plain = value => JSON.parse(JSON.stringify(value)) +const tick = () => new Promise(resolve => setImmediate(resolve)) +const deferred = () => { let resolve; const promise = new Promise(done => { resolve = done }); return { promise, resolve } } + +test('ping targets cannot inject commands or options and accept real host formats', () => { + for (const host of ['127.0.0.1', '100.64.0.10', 'mk-mini.example.com', 'jetson', '::1', '[2001:db8::1]']) assert.ok(main.remoteLatencyHost(host)) + for (const host of [null, {}, '', '-f', '--help', 'host; echo BAD', 'host\nother', 'ssh://host:22', 'a'.repeat(254), 'user@host', 'a..b', '/tmp/file']) { + assert.throws(() => main.remoteLatencyHost(host), /Invalid/) + } + assert.equal(main.remoteLatencyHost('[::1]'), '::1') + assert.deepEqual(plain(main.remoteLatencyPingArgs('host', 'linux')), ['-n', '-c', '1', '-W', '2', '--', 'host']) + assert.deepEqual(plain(main.remoteLatencyPingArgs('host', 'win32')), ['-n', '1', '-w', '2000', 'host']) + assert.deepEqual(plain(main.remoteLatencyPingArgs('host', 'darwin')), ['-n', '-c', '1', '-W', '2000', '--', 'host']) +}) + +test('parses actual echo time, not process duration or packet loss', () => { + for (const [output, expected] of [ + ['64 bytes from device: icmp_seq=1 ttl=64 time=42.5 ms', 42.5], + ['Reply from host: bytes=32 time<1ms TTL=128', 0.5], + ['응답: 바이트=32 시간=12ms TTL=128', 12], + ['Reply: time=42,5 ms', 42.5], + ['100% packet loss, time 2000ms', null], ['Request timed out.', null] + ]) assert.equal(main.parseRemoteLatency(output), expected) +}) + +test('runs one bounded ping without a shell and returns null on missing binary or timeout', async () => { + for (const error of [null, Error('ENOENT'), Error('timeout')]) { + const mock = load('../src/main/remoteLatency.ts', { 'node:child_process': { execFile: (binary, args, options, callback) => { + assert.equal(binary, 'ping') + assert.equal(options.shell, undefined) + assert.equal(options.timeout, 2500) + assert.equal(options.maxBuffer, 8192) + assert.equal(options.windowsHide, true) + assert.equal(options.env.LC_ALL, 'C') + assert.equal(args.at(-1), '127.0.0.1') + callback(error, 'time=42 ms') + } } }) + assert.equal(await mock.measureRemoteLatency('127.0.0.1'), error ? null : 42) + } +}) + +function harness(read) { + const timers = new Map() + let id = 0 + const monitor = createRemoteLatencyMonitor(read, { + setInterval: (callback, interval) => { assert.equal(interval, 5000); timers.set(++id, callback); return id }, + clearInterval: id => timers.delete(id) + }) + return { monitor, timers, sample: async () => { [...timers.values()].forEach(callback => callback()); await tick() } } +} + +test('shares each host probe across displays and uses only the last three samples', async () => { + const values = [40, 1800, 42, 50] + let calls = 0 + const h = harness(async () => { calls++; return values.shift() }) + h.monitor.setHosts(['jetson', 'jetson']) + assert.equal(h.monitor.getSnapshot().jetson.status, 'measuring') + await tick() + assert.equal(h.timers.size, 1) + assert.equal(calls, 1) + assert.equal(h.monitor.getSnapshot().jetson.milliseconds, 40) + await h.sample() + await h.sample() + assert.equal(h.monitor.getSnapshot().jetson.milliseconds, 42) + await h.sample() + assert.equal(h.monitor.getSnapshot().jetson.milliseconds, 50) + h.monitor.stop() + assert.equal(h.timers.size, 0) +}) + +test('does not overlap slow probes and ignores completions after disconnect or host reuse', async () => { + const old = deferred(), next = deferred() + let calls = 0 + const h = harness(() => ++calls === 1 ? old.promise : next.promise) + h.monitor.setHosts(['jetson']) + await h.sample() + assert.equal(calls, 1) + h.monitor.setHosts([]) + h.monitor.setHosts(['jetson']) + assert.equal(calls, 2) + old.resolve(999) + await tick() + assert.equal(h.monitor.getSnapshot().jetson.status, 'measuring') + next.resolve(42) + await tick() + assert.equal(h.monitor.getSnapshot().jetson.milliseconds, 42) + h.monitor.stop() + assert.deepEqual(plain(h.monitor.getSnapshot()), {}) +}) + +test('clears stale success immediately after failure and starts a fresh sample window on recovery', async () => { + const values = [40, 50, null, 350] + const h = harness(async () => values.shift()) + h.monitor.setHosts(['jetson']) + await tick() + await h.sample() + await h.sample() + assert.deepEqual(plain(h.monitor.getSnapshot().jetson), { milliseconds: null, status: 'unavailable' }) + await h.sample() + assert.equal(h.monitor.getSnapshot().jetson.milliseconds, 350) + h.monitor.stop() +}) + +test('adding another target preserves existing samples and removing it stops its probe', async () => { + const h = harness(async host => host === 'a' ? 42 : 200) + h.monitor.setHosts(['a']) + await tick() + const before = h.monitor.getSnapshot().a + h.monitor.setHosts(['a', 'b']) + assert.equal(h.monitor.getSnapshot().a, before) + await tick() + assert.equal(h.timers.size, 2) + h.monitor.setHosts(['a']) + assert.equal(h.timers.size, 1) + assert.equal(h.monitor.getSnapshot().b, undefined) + h.monitor.stop() +}) + +test('shows only stable-width milliseconds with the requested colors in both themes', () => { + for (const dark of [false, true]) { + for (const [milliseconds, tone] of [[42, 'good'], [100, 'good'], [101, 'warning'], [250, 'warning'], [251, 'poor']]) { + const latency = { milliseconds, status: 'ok' } + assert.equal(shared.remoteLatencyTone(latency), tone) + const html = renderToStaticMarkup(React.createElement(RemoteLatencyLabel, { latency, dark })) + assert.ok(html.includes(`>${milliseconds} ms`)) + assert.match(html, /w-\[48px\]/) + assert.match(html, /tabular-nums/) + assert.ok(html.includes(tone === 'good' ? dark ? '#4ade80' : '#15803d' : tone === 'warning' ? dark ? '#facc15' : '#a16207' : dark ? '#f87171' : '#dc2626')) + } + } + assert.equal(renderToStaticMarkup(React.createElement(RemoteLatencyLabel)), '') + assert.equal(shared.remoteLatencyText({ milliseconds: 0.02, status: 'ok' }), '1 ms') + for (const status of ['measuring', 'unavailable']) { + const latency = { milliseconds: null, status } + assert.match(renderToStaticMarkup(React.createElement(RemoteLatencyLabel, { latency })), />-- ms<\/span>/) + assert.equal(shared.remoteLatencyTone(latency), status === 'measuring' ? 'unknown' : 'poor') + } +}) + +test('sidebar and countdown header use the same label while local platforms have no measurement', () => { + const overrides = { '../../i18n': i18n, '../i18n': i18n, '../../components/RemoteLatencyLabel': { default: RemoteLatencyLabel }, './RemoteLatencyLabel': { default: RemoteLatencyLabel }, '../../components/Tooltip': { default: ({ children }) => children } } + const { default: TopBar } = load('../src/renderer/src/features/workspace/WorkspaceTopBar.tsx', overrides) + const { default: Sidebar } = load('../src/renderer/src/components/Sidebar.tsx', { ...overrides, react: { ...React, useState: () => [false, () => {}], useRef: () => ({ current: null }), useEffect() {} } }) + const latency = { milliseconds: 42, status: 'ok' } + const header = renderToStaticMarkup(React.createElement(TopBar, { platformName: 'MK-mini', remoteLatency: latency, showControlCountdown: true, formatControlRemainingTime: () => '27:13', topToggleButtonClass: () => '', dark: true })) + const sidebar = renderToStaticMarkup(Sidebar({ collapsed: false, onToggle() {}, sessionStatus: 'running', runningSessions: [{ id: 'remote', title: 'MK-mini', latency }, { id: 'local', title: 'Local PC' }] })) + assert.match(header, /27:13/) + assert.equal((header.match(/>42 ms<\/span>/g) ?? []).length, 1) + assert.equal((sidebar.match(/>42 ms<\/span>/g) ?? []).length, 1) + const appSource = fs.readFileSync(require.resolve('../src/renderer/src/App.tsx'), 'utf8') + assert.match(appSource, /platform\.platformKey !== 'futuredrive-local-pc'/) + assert.match(appSource, /remoteLatency=\{getRemotePlatformLatency\(workspacePlatform\)\}/) + assert.match(appSource, /latency: getRemotePlatformLatency\(platform\)/) +}) diff --git a/src/main/index.ts b/src/main/index.ts index 0770d59..f825c4b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -10,6 +10,7 @@ import { inspectAgentRuntime } from './agentRuntimeTools' import { validateAgentTool, type AgentCheckedChange, type AgentToolRequest, type AgentToolResult } from '../shared/agentTools' import { resolveContainerTerminal } from './containerTerminal' import { remoteShellCommand, remoteShellEnvironment } from './remoteShell' +import { measureRemoteLatency } from './remoteLatency' import { listLocalGitModules, localGitModulePath, remoteBranches, repositoryAddress } from './gitModuleImport' import type { GitModuleSource, GitModuleImportInput } from '../shared/gitModuleImport' import { yahboomDevicePreflight, yahboomModuleCases, yahboomRuntimeImage, yahboomRuntimeArtifacts, yahboomSimulation } from './yahboomRuntime' @@ -6882,6 +6883,7 @@ function registerLocalWorkspaceHandlers(): void { ipcMain.handle('remote-workspace:modules:restart', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => restartRemoteWorkspaceModule(target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork)) ipcMain.handle('remote-workspace:modules:logs', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null) => readRemoteWorkspaceModuleLogs(target, moduleName, folderName)) ipcMain.handle('remote-workspace:agent-command:run', (_event, target: RemoteWorkspaceTarget, moduleName: string, command: string, folderName?: string | null) => runRemoteAgentCommand(target, moduleName, command, folderName)) + ipcMain.handle('remote-workspace:latency', (_event, host: string) => measureRemoteLatency(host)) ipcMain.handle('remote-workspace:visualization:status', (_event, target: RemoteWorkspaceTarget) => readRemoteVisualizationBridgeStatus(target)) ipcMain.handle('remote-workspace:visualization:start', (_event, target: RemoteWorkspaceTarget, stack?: string | null, rosDomainId?: string | null, environmentProfile?: EnvironmentProfileConfig | null, rosNetwork?: RosNetworkConfig | null, requestId?: string) => visualizationProgress.run((message) => { if (!_event.sender.isDestroyed()) _event.sender.send('visualization:progress', requestId, message) }, () => startRemoteVisualizationBridge(target, stack, rosDomainId, environmentProfile, rosNetwork))) ipcMain.handle('remote-workspace:visualization:stop', (_event, target: RemoteWorkspaceTarget) => stopRemoteVisualizationBridge(target)) diff --git a/src/main/remoteLatency.ts b/src/main/remoteLatency.ts new file mode 100644 index 0000000..9ea42e8 --- /dev/null +++ b/src/main/remoteLatency.ts @@ -0,0 +1,34 @@ +import { execFile } from 'node:child_process' +import { isIP } from 'node:net' + +export function remoteLatencyHost(value: unknown): string { + if (typeof value !== 'string') throw new Error('Invalid ping host') + const host = value.trim().replace(/^\[([^\]]+)\]$/, '$1') + const hostname = host.replace(/\.$/, '') + if (!host || host.length > 253 || (!isIP(host) && !hostname.split('.').every(label => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(label)))) { + throw new Error('Invalid ping host') + } + return host +} + +export function remoteLatencyPingArgs(host: string, platform: NodeJS.Platform): string[] { + if (platform === 'win32') return ['-n', '1', '-w', '2000', host] + return ['-n', '-c', '1', '-W', platform === 'darwin' ? '2000' : '2', '--', host] +} + +export function parseRemoteLatency(output: string): number | null { + const match = output.match(/([=<])\s*(\d+(?:[.,]\d+)?)\s*(?:ms|msec|мс|毫秒)/i) + if (!match) return null + const milliseconds = Number(match[2].replace(',', '.')) / (match[1] === '<' ? 2 : 1) + return Number.isFinite(milliseconds) && milliseconds >= 0 ? milliseconds : null +} + +export function measureRemoteLatency(value: unknown): Promise { + const host = remoteLatencyHost(value) + return new Promise(resolve => { + execFile('ping', remoteLatencyPingArgs(host, process.platform), { + encoding: 'utf8', timeout: 2500, maxBuffer: 8192, windowsHide: true, + env: { ...process.env, LC_ALL: 'C', LANG: 'C' } + }, (error, stdout) => resolve(error ? null : parseRemoteLatency(stdout))) + }) +} diff --git a/src/preload/index.ts b/src/preload/index.ts index e73e07d..8c9bdad 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -299,6 +299,7 @@ contextBridge.exposeInMainWorld('localWorkspace', { } }) contextBridge.exposeInMainWorld('remoteWorkspace', { + latency: (host: string) => ipcRenderer.invoke('remote-workspace:latency', host), moduleFiles: { read: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:read', target, moduleName, folderName), content: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:content', target, moduleName, path, folderName), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 9fffacc..9d5058d 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,5 +1,6 @@ import { t, useLocaleRender } from './i18n' import type { GitModuleImportInput } from '../../shared/gitModuleImport' +import type { RemoteLatency } from '../../shared/remoteLatency' import { useStudioTheme } from './features/workspace/studioTheme' import './features/workspace/studioPalette.css' import { type BeforeMount, type OnMount } from '@monaco-editor/react' @@ -31,6 +32,7 @@ import PlatformSelectModal from './features/platforms/PlatformSelectModal' import PlatformSelectionPanel from './features/platforms/PlatformSelectionPanel' import PlatformSettingsPanel from './features/platforms/PlatformSettingsPanel' import RecentSessionModal from './features/platforms/RecentSessionModal' +import { useRemoteLatency } from './features/platforms/useRemoteLatency' import type { RecentSessionItem } from './features/platforms/RecentSessionModal' import { connectPlatformControl, extendPlatformControl, fetchPlatformStatuses, getPlatformDotClass, isPlatformUnavailable, releasePlatformControl, requestOrCancelPlatformControl } from './features/platforms/platformControl' import type { PlatformControlGrantedPayload, PlatformControlTerminatedPayload, PlatformControlWarningPayload } from './features/platforms/platformControl' @@ -453,6 +455,7 @@ function LocalPcWorkspaceMockup({ canReleaseControl, canExtendControl, controlEndsAt, + remoteLatency, theme, title, platformName, @@ -503,6 +506,7 @@ function LocalPcWorkspaceMockup({ canReleaseControl: boolean canExtendControl: boolean controlEndsAt?: string | null + remoteLatency?: RemoteLatency theme: 'dark' | 'light' title: string platformName: string @@ -4090,6 +4094,7 @@ function LocalPcWorkspaceMockup({ textMid={textMid} hoverSoft={hoverSoft} platformName={platformKey === 'futuredrive-local-pc' ? t('로컬 PC') : platformName} + remoteLatency={remoteLatency} weekNumber={weekNumber} title={title} leftPanelMinimized={leftPanelMinimized} @@ -5730,6 +5735,19 @@ export default function App(): React.JSX.Element { const connectedPlatformEndsAt = connectedPlatform?.platformKey === 'futuredrive-local-pc' ? null : formatSessionClock(connectedPlatform?.control?.session?.endsAt) + const latencyTargets = authSession ? [...new Map([ + ...runningPlatforms, + ...(connectedPlatform ? [connectedPlatform] : []) + ].map(platform => [platform.id, platform])).values()] + .filter(platform => platform.platformKey !== 'futuredrive-local-pc') + .map(platform => ({ id: platform.id, host: makeRemoteWorkspaceTargetForPlatform(platform)?.host ?? null })) : [] + const remoteLatencies = useRemoteLatency(latencyTargets.flatMap(target => target.host ? [target.host] : [])) + const getRemotePlatformLatency = (platform: PlatformOption | null): RemoteLatency | undefined => { + const target = latencyTargets.find(item => item.id === platform?.id) + if (!target) return undefined + return target.host ? remoteLatencies[target.host] ?? { milliseconds: null, status: 'measuring' } + : { milliseconds: null, status: 'unavailable' } + } const sidebarRunningSessions = runningPlatforms.map((platform) => ({ id: platform.id, title: platform.platformKey === 'futuredrive-local-pc' ? t('로컬 PC') : platform.title, @@ -5738,7 +5756,8 @@ export default function App(): React.JSX.Element { ? sessionStartedAt : formatSessionClock(platform.control?.session?.startedAt), endsAt: platform.platformKey === 'futuredrive-local-pc' ? null : formatSessionClock(platform.control?.session?.endsAt), - dotClassName: getPlatformDotClass(platform) + dotClassName: getPlatformDotClass(platform), + latency: getRemotePlatformLatency(platform) })) const toggleSidebar = (): void => { setSidebarCollapsed((value) => { @@ -6664,6 +6683,7 @@ export default function App(): React.JSX.Element { canReleaseControl={canReleaseConnectedPlatform} canExtendControl={canExtendConnectedPlatform} controlEndsAt={connectedPlatformControlEndsAt} + remoteLatency={getRemotePlatformLatency(workspacePlatform)} theme={ideTheme} title={workspaceWeek.title} platformName={activePlatformName} diff --git a/src/renderer/src/components/RemoteLatencyLabel.tsx b/src/renderer/src/components/RemoteLatencyLabel.tsx new file mode 100644 index 0000000..a2e9cc3 --- /dev/null +++ b/src/renderer/src/components/RemoteLatencyLabel.tsx @@ -0,0 +1,19 @@ +import { t, useLocaleRender } from '../i18n' +import { remoteLatencyText, remoteLatencyTone, type RemoteLatency } from '../../../shared/remoteLatency' + +export default function RemoteLatencyLabel({ latency, dark = true }: { latency?: RemoteLatency; dark?: boolean }): React.JSX.Element | null { + useLocaleRender() + if (!latency) return null + const tone = remoteLatencyTone(latency) + const color = tone === 'good' ? dark ? 'text-[#4ade80]' : 'text-[#15803d]' + : tone === 'warning' ? dark ? 'text-[#facc15]' : 'text-[#a16207]' + : tone === 'poor' ? dark ? 'text-[#f87171]' : 'text-[#dc2626]' + : dark ? 'text-white/45' : 'text-[#64748b]' + const description = latency.status === 'measuring' ? t('지연시간 측정 중') + : latency.status === 'unavailable' ? t('Ping 응답 없음') : t('원격 플랫폼 왕복 지연시간') + return ( + + {remoteLatencyText(latency)} + + ) +} diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx index 203ea53..a69b17c 100644 --- a/src/renderer/src/components/Sidebar.tsx +++ b/src/renderer/src/components/Sidebar.tsx @@ -1,6 +1,8 @@ import { t, useLocaleRender } from '../i18n' import { Activity, BookOpen, Bot, LayoutDashboard, Menu, Power } from 'lucide-react' import { useEffect, useRef, useState } from 'react' +import RemoteLatencyLabel from './RemoteLatencyLabel' +import type { RemoteLatency } from '../../../shared/remoteLatency' const navItems = [ { id: 'curriculum', label: '커리큘럼', icon: BookOpen }, @@ -31,7 +33,7 @@ export default function Sidebar({ sessionStartedAt?: string | null sessionEndedAt?: string | null sessionEndsAt?: string | null - runningSessions?: Array<{ id: string; title: string; runningModuleCount?: number; startedAt?: string | null; endsAt?: string | null; dotClassName?: string }> + runningSessions?: Array<{ id: string; title: string; runningModuleCount?: number; startedAt?: string | null; endsAt?: string | null; dotClassName?: string; latency?: RemoteLatency }> canReleaseControl?: boolean onOpenSession?: (sessionId?: string) => void onDisconnectSession?: (sessionId?: string) => void @@ -134,18 +136,19 @@ export default function Sidebar({ className="relative mt-auto overflow-visible text-left transition-all duration-200" > {!collapsed && ( -
+
{visibleRunningSessions.map((session) => ( -
+