feat: display remote platform round-trip latency

This commit is contained in:
2026-11-02 09:51:35 +09:00
parent 017093077f
commit 0dc603e5df
13 changed files with 369 additions and 9 deletions
+174
View File
@@ -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\)/)
})
+2
View File
@@ -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))
+34
View File
@@ -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)))
})
}
+1
View File
@@ -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),
+21 -1
View File
@@ -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}
@@ -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>
)
}
+12 -8
View File
@@ -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 && (
<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) => (
<div key={session.id} className="relative">
<div key={session.id} className="relative min-w-0">
<button
type="button"
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="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="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>
{(session.startedAt || session.endsAt) && (
<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]"
>
<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="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>
{(session.startedAt || session.endsAt) && (
<span className="mt-1 block truncate text-[11px] text-white/38">
+1
View File
@@ -315,6 +315,7 @@ interface Window {
terminal: Window['terminal']
}
remoteWorkspace: {
latency: (host: string) => Promise<number | null>
moduleFiles: {
read: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ReturnType<Window['moduleFiles']['read']>
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 type React from 'react'
import Tooltip from '../../components/Tooltip'
import RemoteLatencyLabel from '../../components/RemoteLatencyLabel'
import type { RemoteLatency } from '../../../../shared/remoteLatency'
type WorkspaceTopBarProps = {
cameraAvailable: boolean
@@ -12,6 +14,7 @@ type WorkspaceTopBarProps = {
textMid: string
hoverSoft: string
platformName: string
remoteLatency?: RemoteLatency
weekNumber: number
title: string
leftPanelMinimized: boolean
@@ -46,6 +49,7 @@ export default function WorkspaceTopBar({
textMid,
hoverSoft,
platformName,
remoteLatency,
weekNumber,
title,
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(' ')} />
{submitVisible && (
<Tooltip label={t("학습 완료")} side="bottom">
+3
View File
@@ -1,4 +1,7 @@
{
"지연시간 측정 중": "Measuring latency",
"Ping 응답 없음": "No ping response",
"원격 플랫폼 왕복 지연시간": "Remote platform round-trip latency",
"에이전트 모델 선택": "Agent model",
"사용 가능한 모델 없음": "No models available",
"전송": "Send",
+23
View File
@@ -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'
}