feat: display remote platform round-trip latency
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Vendored
+1
@@ -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">
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"지연시간 측정 중": "Measuring latency",
|
||||
"Ping 응답 없음": "No ping response",
|
||||
"원격 플랫폼 왕복 지연시간": "Remote platform round-trip latency",
|
||||
"에이전트 모델 선택": "Agent model",
|
||||
"사용 가능한 모델 없음": "No models available",
|
||||
"전송": "Send",
|
||||
|
||||
Reference in New Issue
Block a user