feat: display remote platform round-trip latency
This commit is contained in:
@@ -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</span>`))
|
||||||
|
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\)/)
|
||||||
|
})
|
||||||
@@ -10,6 +10,7 @@ import { inspectAgentRuntime } from './agentRuntimeTools'
|
|||||||
import { validateAgentTool, type AgentCheckedChange, type AgentToolRequest, type AgentToolResult } from '../shared/agentTools'
|
import { validateAgentTool, type AgentCheckedChange, type AgentToolRequest, type AgentToolResult } from '../shared/agentTools'
|
||||||
import { resolveContainerTerminal } from './containerTerminal'
|
import { resolveContainerTerminal } from './containerTerminal'
|
||||||
import { remoteShellCommand, remoteShellEnvironment } from './remoteShell'
|
import { remoteShellCommand, remoteShellEnvironment } from './remoteShell'
|
||||||
|
import { measureRemoteLatency } from './remoteLatency'
|
||||||
import { listLocalGitModules, localGitModulePath, remoteBranches, repositoryAddress } from './gitModuleImport'
|
import { listLocalGitModules, localGitModulePath, remoteBranches, repositoryAddress } from './gitModuleImport'
|
||||||
import type { GitModuleSource, GitModuleImportInput } from '../shared/gitModuleImport'
|
import type { GitModuleSource, GitModuleImportInput } from '../shared/gitModuleImport'
|
||||||
import { yahboomDevicePreflight, yahboomModuleCases, yahboomRuntimeImage, yahboomRuntimeArtifacts, yahboomSimulation } from './yahboomRuntime'
|
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: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: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: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: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: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))
|
ipcMain.handle('remote-workspace:visualization:stop', (_event, target: RemoteWorkspaceTarget) => stopRemoteVisualizationBridge(target))
|
||||||
|
|||||||
@@ -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<number | null> {
|
||||||
|
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)))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -299,6 +299,7 @@ contextBridge.exposeInMainWorld('localWorkspace', {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
contextBridge.exposeInMainWorld('remoteWorkspace', {
|
contextBridge.exposeInMainWorld('remoteWorkspace', {
|
||||||
|
latency: (host: string) => ipcRenderer.invoke('remote-workspace:latency', host),
|
||||||
moduleFiles: {
|
moduleFiles: {
|
||||||
read: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:read', target, moduleName, folderName),
|
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),
|
content: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:content', target, moduleName, path, folderName),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { t, useLocaleRender } from './i18n'
|
import { t, useLocaleRender } from './i18n'
|
||||||
import type { GitModuleImportInput } from '../../shared/gitModuleImport'
|
import type { GitModuleImportInput } from '../../shared/gitModuleImport'
|
||||||
|
import type { RemoteLatency } from '../../shared/remoteLatency'
|
||||||
import { useStudioTheme } from './features/workspace/studioTheme'
|
import { useStudioTheme } from './features/workspace/studioTheme'
|
||||||
import './features/workspace/studioPalette.css'
|
import './features/workspace/studioPalette.css'
|
||||||
import { type BeforeMount, type OnMount } from '@monaco-editor/react'
|
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 PlatformSelectionPanel from './features/platforms/PlatformSelectionPanel'
|
||||||
import PlatformSettingsPanel from './features/platforms/PlatformSettingsPanel'
|
import PlatformSettingsPanel from './features/platforms/PlatformSettingsPanel'
|
||||||
import RecentSessionModal from './features/platforms/RecentSessionModal'
|
import RecentSessionModal from './features/platforms/RecentSessionModal'
|
||||||
|
import { useRemoteLatency } from './features/platforms/useRemoteLatency'
|
||||||
import type { RecentSessionItem } from './features/platforms/RecentSessionModal'
|
import type { RecentSessionItem } from './features/platforms/RecentSessionModal'
|
||||||
import { connectPlatformControl, extendPlatformControl, fetchPlatformStatuses, getPlatformDotClass, isPlatformUnavailable, releasePlatformControl, requestOrCancelPlatformControl } from './features/platforms/platformControl'
|
import { connectPlatformControl, extendPlatformControl, fetchPlatformStatuses, getPlatformDotClass, isPlatformUnavailable, releasePlatformControl, requestOrCancelPlatformControl } from './features/platforms/platformControl'
|
||||||
import type { PlatformControlGrantedPayload, PlatformControlTerminatedPayload, PlatformControlWarningPayload } from './features/platforms/platformControl'
|
import type { PlatformControlGrantedPayload, PlatformControlTerminatedPayload, PlatformControlWarningPayload } from './features/platforms/platformControl'
|
||||||
@@ -453,6 +455,7 @@ function LocalPcWorkspaceMockup({
|
|||||||
canReleaseControl,
|
canReleaseControl,
|
||||||
canExtendControl,
|
canExtendControl,
|
||||||
controlEndsAt,
|
controlEndsAt,
|
||||||
|
remoteLatency,
|
||||||
theme,
|
theme,
|
||||||
title,
|
title,
|
||||||
platformName,
|
platformName,
|
||||||
@@ -503,6 +506,7 @@ function LocalPcWorkspaceMockup({
|
|||||||
canReleaseControl: boolean
|
canReleaseControl: boolean
|
||||||
canExtendControl: boolean
|
canExtendControl: boolean
|
||||||
controlEndsAt?: string | null
|
controlEndsAt?: string | null
|
||||||
|
remoteLatency?: RemoteLatency
|
||||||
theme: 'dark' | 'light'
|
theme: 'dark' | 'light'
|
||||||
title: string
|
title: string
|
||||||
platformName: string
|
platformName: string
|
||||||
@@ -4090,6 +4094,7 @@ function LocalPcWorkspaceMockup({
|
|||||||
textMid={textMid}
|
textMid={textMid}
|
||||||
hoverSoft={hoverSoft}
|
hoverSoft={hoverSoft}
|
||||||
platformName={platformKey === 'futuredrive-local-pc' ? t('로컬 PC') : platformName}
|
platformName={platformKey === 'futuredrive-local-pc' ? t('로컬 PC') : platformName}
|
||||||
|
remoteLatency={remoteLatency}
|
||||||
weekNumber={weekNumber}
|
weekNumber={weekNumber}
|
||||||
title={title}
|
title={title}
|
||||||
leftPanelMinimized={leftPanelMinimized}
|
leftPanelMinimized={leftPanelMinimized}
|
||||||
@@ -5730,6 +5735,19 @@ export default function App(): React.JSX.Element {
|
|||||||
const connectedPlatformEndsAt = connectedPlatform?.platformKey === 'futuredrive-local-pc'
|
const connectedPlatformEndsAt = connectedPlatform?.platformKey === 'futuredrive-local-pc'
|
||||||
? null
|
? null
|
||||||
: formatSessionClock(connectedPlatform?.control?.session?.endsAt)
|
: 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) => ({
|
const sidebarRunningSessions = runningPlatforms.map((platform) => ({
|
||||||
id: platform.id,
|
id: platform.id,
|
||||||
title: platform.platformKey === 'futuredrive-local-pc' ? t('로컬 PC') : platform.title,
|
title: platform.platformKey === 'futuredrive-local-pc' ? t('로컬 PC') : platform.title,
|
||||||
@@ -5738,7 +5756,8 @@ export default function App(): React.JSX.Element {
|
|||||||
? sessionStartedAt
|
? sessionStartedAt
|
||||||
: formatSessionClock(platform.control?.session?.startedAt),
|
: formatSessionClock(platform.control?.session?.startedAt),
|
||||||
endsAt: platform.platformKey === 'futuredrive-local-pc' ? null : formatSessionClock(platform.control?.session?.endsAt),
|
endsAt: platform.platformKey === 'futuredrive-local-pc' ? null : formatSessionClock(platform.control?.session?.endsAt),
|
||||||
dotClassName: getPlatformDotClass(platform)
|
dotClassName: getPlatformDotClass(platform),
|
||||||
|
latency: getRemotePlatformLatency(platform)
|
||||||
}))
|
}))
|
||||||
const toggleSidebar = (): void => {
|
const toggleSidebar = (): void => {
|
||||||
setSidebarCollapsed((value) => {
|
setSidebarCollapsed((value) => {
|
||||||
@@ -6664,6 +6683,7 @@ export default function App(): React.JSX.Element {
|
|||||||
canReleaseControl={canReleaseConnectedPlatform}
|
canReleaseControl={canReleaseConnectedPlatform}
|
||||||
canExtendControl={canExtendConnectedPlatform}
|
canExtendControl={canExtendConnectedPlatform}
|
||||||
controlEndsAt={connectedPlatformControlEndsAt}
|
controlEndsAt={connectedPlatformControlEndsAt}
|
||||||
|
remoteLatency={getRemotePlatformLatency(workspacePlatform)}
|
||||||
theme={ideTheme}
|
theme={ideTheme}
|
||||||
title={workspaceWeek.title}
|
title={workspaceWeek.title}
|
||||||
platformName={activePlatformName}
|
platformName={activePlatformName}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<span title={description} aria-label={`${description}: ${remoteLatencyText(latency)}`} className={`inline-block w-[48px] shrink-0 whitespace-nowrap text-right text-[11px] font-semibold tabular-nums leading-none ${color}`}>
|
||||||
|
{remoteLatencyText(latency)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { t, useLocaleRender } from '../i18n'
|
import { t, useLocaleRender } from '../i18n'
|
||||||
import { Activity, BookOpen, Bot, LayoutDashboard, Menu, Power } from 'lucide-react'
|
import { Activity, BookOpen, Bot, LayoutDashboard, Menu, Power } from 'lucide-react'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import RemoteLatencyLabel from './RemoteLatencyLabel'
|
||||||
|
import type { RemoteLatency } from '../../../shared/remoteLatency'
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ id: 'curriculum', label: '커리큘럼', icon: BookOpen },
|
{ id: 'curriculum', label: '커리큘럼', icon: BookOpen },
|
||||||
@@ -31,7 +33,7 @@ export default function Sidebar({
|
|||||||
sessionStartedAt?: string | null
|
sessionStartedAt?: string | null
|
||||||
sessionEndedAt?: string | null
|
sessionEndedAt?: string | null
|
||||||
sessionEndsAt?: 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
|
canReleaseControl?: boolean
|
||||||
onOpenSession?: (sessionId?: string) => void
|
onOpenSession?: (sessionId?: string) => void
|
||||||
onDisconnectSession?: (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"
|
className="relative mt-auto overflow-visible text-left transition-all duration-200"
|
||||||
>
|
>
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div className="session-scrollbar grid max-h-[286px] gap-2 overflow-y-auto pr-1">
|
<div className="session-scrollbar grid min-w-0 grid-cols-[minmax(0,1fr)] max-h-[286px] gap-2 overflow-y-auto pr-1">
|
||||||
{visibleRunningSessions.map((session) => (
|
{visibleRunningSessions.map((session) => (
|
||||||
<div key={session.id} className="relative">
|
<div key={session.id} className="relative min-w-0">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onOpenSession?.(session.id)}
|
onClick={() => onOpenSession?.(session.id)}
|
||||||
className="flex min-h-[68px] w-full cursor-pointer items-center gap-3 rounded-lg border border-white/10 bg-white/[0.05] py-3 pl-3 pr-11 text-left transition hover:border-white/20 hover:bg-white/[0.08]"
|
className="flex min-h-[68px] min-w-0 w-full cursor-pointer items-center gap-3 rounded-lg border border-white/10 bg-white/[0.05] py-3 pl-3 pr-11 text-left transition hover:border-white/20 hover:bg-white/[0.08]"
|
||||||
>
|
>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="flex min-w-0 items-center gap-2">
|
<span className="flex min-w-0 items-center gap-1.5">
|
||||||
<span className={['h-2.5 w-2.5 shrink-0 rounded-full', session.dotClassName ?? 'bg-[#14b8a6]'].join(' ')} />
|
<span className={['h-2.5 w-2.5 shrink-0 rounded-full', session.dotClassName ?? 'bg-[#14b8a6]'].join(' ')} />
|
||||||
<span className="block min-w-0 truncate text-[13px] font-bold text-white/85">{session.title}</span>
|
<span title={session.title} className="block min-w-0 truncate text-[13px] font-bold text-white/85">{session.title}</span>
|
||||||
|
<RemoteLatencyLabel latency={session.latency} />
|
||||||
</span>
|
</span>
|
||||||
{(session.startedAt || session.endsAt) && (
|
{(session.startedAt || session.endsAt) && (
|
||||||
<span className="mt-1 block truncate text-[11px] text-white/38">
|
<span className="mt-1 block truncate text-[11px] text-white/38">
|
||||||
@@ -202,9 +205,10 @@ export default function Sidebar({
|
|||||||
className="flex min-h-[68px] w-full cursor-pointer items-center gap-3 py-3 pl-3 pr-11 text-left transition hover:bg-white/[0.06]"
|
className="flex min-h-[68px] w-full cursor-pointer items-center gap-3 py-3 pl-3 pr-11 text-left transition hover:bg-white/[0.06]"
|
||||||
>
|
>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="flex min-w-0 items-center gap-2">
|
<span className="flex min-w-0 items-center gap-1.5">
|
||||||
<span className={['h-2.5 w-2.5 shrink-0 rounded-full', session.dotClassName ?? 'bg-[#14b8a6]'].join(' ')} />
|
<span className={['h-2.5 w-2.5 shrink-0 rounded-full', session.dotClassName ?? 'bg-[#14b8a6]'].join(' ')} />
|
||||||
<span className="block min-w-0 truncate text-[13px] font-bold text-white/85">{session.title}</span>
|
<span title={session.title} className="block min-w-0 truncate text-[13px] font-bold text-white/85">{session.title}</span>
|
||||||
|
<RemoteLatencyLabel latency={session.latency} />
|
||||||
</span>
|
</span>
|
||||||
{(session.startedAt || session.endsAt) && (
|
{(session.startedAt || session.endsAt) && (
|
||||||
<span className="mt-1 block truncate text-[11px] text-white/38">
|
<span className="mt-1 block truncate text-[11px] text-white/38">
|
||||||
|
|||||||
Vendored
+1
@@ -315,6 +315,7 @@ interface Window {
|
|||||||
terminal: Window['terminal']
|
terminal: Window['terminal']
|
||||||
}
|
}
|
||||||
remoteWorkspace: {
|
remoteWorkspace: {
|
||||||
|
latency: (host: string) => Promise<number | null>
|
||||||
moduleFiles: {
|
moduleFiles: {
|
||||||
read: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ReturnType<Window['moduleFiles']['read']>
|
read: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ReturnType<Window['moduleFiles']['read']>
|
||||||
content: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => Promise<{ content: string }>
|
content: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => Promise<{ content: string }>
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { REMOTE_LATENCY_INTERVAL_MS, type RemoteLatency } from '../../../../shared/remoteLatency'
|
||||||
|
|
||||||
|
type Timers = {
|
||||||
|
setInterval: (callback: () => void, interval: number) => unknown
|
||||||
|
clearInterval: (timer: unknown) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type Probe = { timer?: unknown; inFlight: boolean; samples: number[]; result: RemoteLatency }
|
||||||
|
|
||||||
|
export function createRemoteLatencyMonitor(read: (host: string) => Promise<number | null>, timers: Timers) {
|
||||||
|
const probes = new Map<string, Probe>()
|
||||||
|
const listeners = new Set<() => void>()
|
||||||
|
let snapshot: Record<string, RemoteLatency> = {}
|
||||||
|
const publish = (): void => {
|
||||||
|
snapshot = Object.fromEntries([...probes].map(([host, probe]) => [host, probe.result]))
|
||||||
|
listeners.forEach(listener => listener())
|
||||||
|
}
|
||||||
|
const sample = async (host: string, probe: Probe): Promise<void> => {
|
||||||
|
if (probe.inFlight || probes.get(host) !== probe) return
|
||||||
|
probe.inFlight = true
|
||||||
|
let milliseconds: number | null
|
||||||
|
try { milliseconds = await read(host) } catch { milliseconds = null }
|
||||||
|
if (probes.get(host) !== probe) return
|
||||||
|
probe.inFlight = false
|
||||||
|
if (typeof milliseconds === 'number' && Number.isFinite(milliseconds) && milliseconds >= 0 && milliseconds <= 60_000) {
|
||||||
|
probe.samples = [...probe.samples, milliseconds].slice(-3)
|
||||||
|
const sorted = [...probe.samples].sort((a, b) => a - b)
|
||||||
|
const median = (sorted[Math.floor((sorted.length - 1) / 2)] + sorted[Math.floor(sorted.length / 2)]) / 2
|
||||||
|
probe.result = { milliseconds: median, status: 'ok' }
|
||||||
|
} else {
|
||||||
|
// An outage must not keep displaying a previously healthy measurement.
|
||||||
|
probe.samples = []
|
||||||
|
probe.result = { milliseconds: null, status: 'unavailable' }
|
||||||
|
}
|
||||||
|
publish()
|
||||||
|
}
|
||||||
|
const setHosts = (hosts: string[]): void => {
|
||||||
|
const desired = new Set(hosts.filter(Boolean))
|
||||||
|
for (const [host, probe] of probes) {
|
||||||
|
if (desired.has(host)) continue
|
||||||
|
timers.clearInterval(probe.timer)
|
||||||
|
probes.delete(host)
|
||||||
|
}
|
||||||
|
for (const host of desired) {
|
||||||
|
if (probes.has(host)) continue
|
||||||
|
const probe: Probe = { inFlight: false, samples: [], result: { milliseconds: null, status: 'measuring' } }
|
||||||
|
probes.set(host, probe)
|
||||||
|
probe.timer = timers.setInterval(() => { void sample(host, probe) }, REMOTE_LATENCY_INTERVAL_MS)
|
||||||
|
void sample(host, probe)
|
||||||
|
}
|
||||||
|
publish()
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
setHosts,
|
||||||
|
stop: () => setHosts([]),
|
||||||
|
getSnapshot: () => snapshot,
|
||||||
|
subscribe: (listener: () => void) => { listeners.add(listener); return () => { listeners.delete(listener) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { useEffect, useMemo, useSyncExternalStore } from 'react'
|
||||||
|
import { createRemoteLatencyMonitor } from './remoteLatencyMonitor'
|
||||||
|
import type { RemoteLatency } from '../../../../shared/remoteLatency'
|
||||||
|
|
||||||
|
export function useRemoteLatency(hosts: string[]): Record<string, RemoteLatency> {
|
||||||
|
const key = JSON.stringify([...new Set(hosts)].sort())
|
||||||
|
const monitor = useMemo(() => createRemoteLatencyMonitor(host => window.remoteWorkspace.latency(host), {
|
||||||
|
setInterval: (callback, interval) => window.setInterval(callback, interval),
|
||||||
|
clearInterval: timer => window.clearInterval(timer as number)
|
||||||
|
}), [])
|
||||||
|
const snapshot = useSyncExternalStore(monitor.subscribe, monitor.getSnapshot)
|
||||||
|
useEffect(() => { monitor.setHosts(JSON.parse(key) as string[]) }, [key, monitor])
|
||||||
|
useEffect(() => () => monitor.stop(), [monitor])
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import { t, useLocaleRender } from '../../i18n'
|
|||||||
import { BookOpen, Camera, Clock3, MessageCircle, PanelLeft, Power, RefreshCw, Upload, X } from 'lucide-react'
|
import { BookOpen, Camera, Clock3, MessageCircle, PanelLeft, Power, RefreshCw, Upload, X } from 'lucide-react'
|
||||||
import type React from 'react'
|
import type React from 'react'
|
||||||
import Tooltip from '../../components/Tooltip'
|
import Tooltip from '../../components/Tooltip'
|
||||||
|
import RemoteLatencyLabel from '../../components/RemoteLatencyLabel'
|
||||||
|
import type { RemoteLatency } from '../../../../shared/remoteLatency'
|
||||||
|
|
||||||
type WorkspaceTopBarProps = {
|
type WorkspaceTopBarProps = {
|
||||||
cameraAvailable: boolean
|
cameraAvailable: boolean
|
||||||
@@ -12,6 +14,7 @@ type WorkspaceTopBarProps = {
|
|||||||
textMid: string
|
textMid: string
|
||||||
hoverSoft: string
|
hoverSoft: string
|
||||||
platformName: string
|
platformName: string
|
||||||
|
remoteLatency?: RemoteLatency
|
||||||
weekNumber: number
|
weekNumber: number
|
||||||
title: string
|
title: string
|
||||||
leftPanelMinimized: boolean
|
leftPanelMinimized: boolean
|
||||||
@@ -46,6 +49,7 @@ export default function WorkspaceTopBar({
|
|||||||
textMid,
|
textMid,
|
||||||
hoverSoft,
|
hoverSoft,
|
||||||
platformName,
|
platformName,
|
||||||
|
remoteLatency,
|
||||||
weekNumber,
|
weekNumber,
|
||||||
title,
|
title,
|
||||||
leftPanelMinimized,
|
leftPanelMinimized,
|
||||||
@@ -128,6 +132,7 @@ export default function WorkspaceTopBar({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<RemoteLatencyLabel latency={remoteLatency} dark={dark} />
|
||||||
<span className={['ml-1 mr-1 h-4 w-px', dark ? 'bg-white/16' : 'bg-[#cfd6e4]'].join(' ')} />
|
<span className={['ml-1 mr-1 h-4 w-px', dark ? 'bg-white/16' : 'bg-[#cfd6e4]'].join(' ')} />
|
||||||
{submitVisible && (
|
{submitVisible && (
|
||||||
<Tooltip label={t("학습 완료")} side="bottom">
|
<Tooltip label={t("학습 완료")} side="bottom">
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
|
"지연시간 측정 중": "Measuring latency",
|
||||||
|
"Ping 응답 없음": "No ping response",
|
||||||
|
"원격 플랫폼 왕복 지연시간": "Remote platform round-trip latency",
|
||||||
"에이전트 모델 선택": "Agent model",
|
"에이전트 모델 선택": "Agent model",
|
||||||
"사용 가능한 모델 없음": "No models available",
|
"사용 가능한 모델 없음": "No models available",
|
||||||
"전송": "Send",
|
"전송": "Send",
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export type RemoteLatency = {
|
||||||
|
milliseconds: number | null
|
||||||
|
status: 'measuring' | 'ok' | 'unavailable'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const REMOTE_LATENCY_INTERVAL_MS = 5000
|
||||||
|
export const REMOTE_LATENCY_GOOD_MAX_MS = 100
|
||||||
|
export const REMOTE_LATENCY_WARNING_MAX_MS = 250
|
||||||
|
|
||||||
|
export function remoteLatencyTone(latency: RemoteLatency): 'good' | 'warning' | 'poor' | 'unknown' {
|
||||||
|
if (latency.status === 'measuring') return 'unknown'
|
||||||
|
if (latency.status !== 'ok' || latency.milliseconds === null || !Number.isFinite(latency.milliseconds) || latency.milliseconds < 0) return 'poor'
|
||||||
|
const displayedMilliseconds = Math.round(latency.milliseconds)
|
||||||
|
if (displayedMilliseconds <= REMOTE_LATENCY_GOOD_MAX_MS) return 'good'
|
||||||
|
if (displayedMilliseconds <= REMOTE_LATENCY_WARNING_MAX_MS) return 'warning'
|
||||||
|
return 'poor'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function remoteLatencyText(latency: RemoteLatency): string {
|
||||||
|
return latency.status === 'ok' && latency.milliseconds !== null && Number.isFinite(latency.milliseconds) && latency.milliseconds >= 0
|
||||||
|
? `${Math.max(1, Math.round(latency.milliseconds))} ms`
|
||||||
|
: '-- ms'
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user