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
+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",