From e35f81ac3430cf590425ebc6f9c79e19d2e4dc29 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Tue, 28 Jul 2026 03:18:27 +0300 Subject: [PATCH] fix(ui): align telemetry and laboratory navigation --- apps/control-station/src/App.tsx | 2 +- .../src/components/system/TelemetrySeries.tsx | 7 +- .../system/useComputeContourSettings.tsx | 28 +++++-- .../components/useApplicationPanelActions.ts | 8 +- .../src/core/system/ComputeContourContext.tsx | 11 +++ .../src/core/system/useWorkerTelemetry.ts | 3 +- apps/control-station/src/styles/spatial.css | 4 +- .../src/styles/system-telemetry.css | 18 ++++- .../laboratory/LaboratoryArchiveWorkspace.tsx | 36 ++++++++- .../system/ComputeModulesWorkspace.tsx | 79 ++++++++++++++----- .../workspaces/system/NetworkWorkspace.tsx | 19 ++--- .../test/observationSessions.test.mjs | 17 ++++ .../test/systemTelemetry.test.mjs | 18 ++++- 13 files changed, 195 insertions(+), 55 deletions(-) diff --git a/apps/control-station/src/App.tsx b/apps/control-station/src/App.tsx index 8c73acc..7b94d5e 100644 --- a/apps/control-station/src/App.tsx +++ b/apps/control-station/src/App.tsx @@ -619,7 +619,7 @@ export default function App() { refreshRuntime: runtime.refresh, saveWorkspaceLayout, workspaceLayoutSaving: workspaceLayoutProfile.state === "saving", - systemUtilityAction: computeContourSettings.utilityAction, + systemUtilityActions: computeContourSettings.utilityActions, }); const header = ( diff --git a/apps/control-station/src/components/system/TelemetrySeries.tsx b/apps/control-station/src/components/system/TelemetrySeries.tsx index fbbed41..99d86fc 100644 --- a/apps/control-station/src/components/system/TelemetrySeries.tsx +++ b/apps/control-station/src/components/system/TelemetrySeries.tsx @@ -2,6 +2,7 @@ interface TelemetrySeriesProps { label: string; values: Array; value: string; + resource?: string | null; ceiling?: number; } @@ -24,13 +25,17 @@ export function TelemetrySeries({ label, values, value, + resource, ceiling, }: TelemetrySeriesProps) { const points = linePoints(values, ceiling); return (
- {label} + + {label} + {resource ? {resource} : null} + {value}
void; openEdit: () => void; - utilityAction: ApplicationPanelUtilityAction; + utilityActions: readonly ApplicationPanelUtilityAction[]; window: ReactNode; } @@ -24,18 +24,30 @@ export function useComputeContourSettings(): ComputeContourSettingsController { setMode("edit"); setOpen(true); }, []); - const utilityAction = useMemo(() => ({ - label: "Настроить выбранный вычислительный контур", - icon: "settings", - disabled: contours.selectedContour === null, - onClick: openEdit, - }), [contours.selectedContour, openEdit]); + const utilityActions = useMemo(() => [ + { + label: "Обновить телеметрию выбранного контура", + icon: "refresh", + disabled: contours.selectedContour?.contour_id !== "worker-006", + onClick: contours.refreshTelemetry, + }, + { + label: "Настроить выбранный вычислительный контур", + icon: "settings", + disabled: contours.selectedContour === null, + onClick: openEdit, + }, + ], [ + contours.refreshTelemetry, + contours.selectedContour, + openEdit, + ]); return { canEdit: contours.selectedContour !== null, openCreate, openEdit, - utilityAction, + utilityActions, window: ( void; saveWorkspaceLayout: () => Promise; workspaceLayoutSaving: boolean; - systemUtilityAction: ApplicationPanelUtilityAction; + systemUtilityActions: readonly ApplicationPanelUtilityAction[]; } export function useApplicationPanelActions({ @@ -16,7 +16,7 @@ export function useApplicationPanelActions({ refreshRuntime, saveWorkspaceLayout, workspaceLayoutSaving, - systemUtilityAction, + systemUtilityActions, }: ApplicationPanelActionsOptions): ApplicationPanelUtilityAction[] { return useMemo(() => { const actions: ApplicationPanelUtilityAction[] = []; @@ -35,13 +35,13 @@ export function useApplicationPanelActions({ onClick: () => void saveWorkspaceLayout(), }); } - if (definition?.root === "system") actions.push(systemUtilityAction); + if (definition?.root === "system") actions.push(...systemUtilityActions); return actions; }, [ definition, refreshRuntime, saveWorkspaceLayout, - systemUtilityAction, + systemUtilityActions, workspaceLayoutSaving, ]); } diff --git a/apps/control-station/src/core/system/ComputeContourContext.tsx b/apps/control-station/src/core/system/ComputeContourContext.tsx index b413e36..1ae0677 100644 --- a/apps/control-station/src/core/system/ComputeContourContext.tsx +++ b/apps/control-station/src/core/system/ComputeContourContext.tsx @@ -23,6 +23,7 @@ interface ComputeContourContextValue { selectedContour: ComputeContour | null; loading: boolean; error: string | null; + telemetryRefreshGeneration: number; selectContour: (contourId: string) => void; createContour: (draft: ComputeContourDraft) => Promise; updateContour: ( @@ -30,6 +31,7 @@ interface ComputeContourContextValue { draft: ComputeContourDraft, ) => Promise; refresh: () => void; + refreshTelemetry: () => void; } const ComputeContourContext = createContext(null); @@ -42,7 +44,12 @@ export function ComputeContourProvider({ children }: { children: ReactNode }) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [generation, setGeneration] = useState(0); + const [telemetryRefreshGeneration, setTelemetryRefreshGeneration] = useState(0); const refresh = useCallback(() => setGeneration((value) => value + 1), []); + const refreshTelemetry = useCallback( + () => setTelemetryRefreshGeneration((value) => value + 1), + [], + ); useEffect(() => { const controller = new AbortController(); @@ -103,19 +110,23 @@ export function ComputeContourProvider({ children }: { children: ReactNode }) { selectedContour, loading, error, + telemetryRefreshGeneration, selectContour, createContour, updateContour, refresh, + refreshTelemetry, }), [ contours, selectedContour, loading, error, + telemetryRefreshGeneration, selectContour, createContour, updateContour, refresh, + refreshTelemetry, ]); return ( diff --git a/apps/control-station/src/core/system/useWorkerTelemetry.ts b/apps/control-station/src/core/system/useWorkerTelemetry.ts index d88acd1..c2fd19a 100644 --- a/apps/control-station/src/core/system/useWorkerTelemetry.ts +++ b/apps/control-station/src/core/system/useWorkerTelemetry.ts @@ -18,6 +18,7 @@ export interface WorkerTelemetryState { export function useWorkerTelemetry( pollMilliseconds = DEFAULT_WORKER_TELEMETRY_POLL_MILLISECONDS, enabled = true, + externalRefreshGeneration = 0, ): WorkerTelemetryState { const normalizedPollMilliseconds = normalizeWorkerTelemetryPollMilliseconds( pollMilliseconds, @@ -51,7 +52,7 @@ export function useWorkerTelemetry( if (!controller.signal.aborted) setLoading(false); }); return () => controller.abort(); - }, [enabled, generation]); + }, [enabled, externalRefreshGeneration, generation]); useEffect(() => { if (!enabled || loading) return; diff --git a/apps/control-station/src/styles/spatial.css b/apps/control-station/src/styles/spatial.css index cd68519..fcbee29 100644 --- a/apps/control-station/src/styles/spatial.css +++ b/apps/control-station/src/styles/spatial.css @@ -338,10 +338,10 @@ .scene-operation-status-stack { position: absolute; z-index: 11; - right: 0.85rem; + left: 0.85rem; bottom: 7.15rem; display: grid; - justify-items: end; + justify-items: start; gap: 0.3rem; } diff --git a/apps/control-station/src/styles/system-telemetry.css b/apps/control-station/src/styles/system-telemetry.css index b8fd12c..c510f68 100644 --- a/apps/control-station/src/styles/system-telemetry.css +++ b/apps/control-station/src/styles/system-telemetry.css @@ -142,7 +142,7 @@ .system-telemetry-series > div { display: flex; - align-items: baseline; + align-items: flex-start; justify-content: space-between; gap: 0.75rem; } @@ -153,6 +153,22 @@ font-size: 0.62rem; } +.system-telemetry-series__label { + display: grid; + min-width: 0; + gap: 0.16rem; +} + +.system-telemetry-series__label small { + overflow: hidden; + max-width: 11rem; + color: var(--nodedc-text-secondary); + font-size: 0.54rem; + line-height: 1.15; + text-overflow: ellipsis; + white-space: nowrap; +} + .system-telemetry-series strong, .network-stat-card strong { color: var(--nodedc-text-primary); diff --git a/apps/control-station/src/workspaces/laboratory/LaboratoryArchiveWorkspace.tsx b/apps/control-station/src/workspaces/laboratory/LaboratoryArchiveWorkspace.tsx index 1a5b7d9..0ca0019 100644 --- a/apps/control-station/src/workspaces/laboratory/LaboratoryArchiveWorkspace.tsx +++ b/apps/control-station/src/workspaces/laboratory/LaboratoryArchiveWorkspace.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, + useRef, useState, type ComponentType, } from "react"; @@ -71,6 +72,11 @@ const EMPTY_ADVANCED_RESULTS: AdvancedLaboratoryResults = { e37: null, }; +function laboratoryWorkOrdinal(value: string): number { + const match = value.match(/\bE(\d+)\b/i); + return match ? Number(match[1]) : -1; +} + function digestFromContentId(value: string | null | undefined): string | null { const digest = value?.split("-").at(-1) ?? ""; return /^[a-f0-9]{64}$/.test(digest) ? digest : null; @@ -559,6 +565,7 @@ function PublishedLaboratoryResult({ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) { const [profileId, setProfileId] = useState("sensor-fusion"); const [workId, setWorkId] = useState("e28-local-surface"); + const initialWorkSelectedRef = useRef(false); const [e28Model, setE28Model] = useState(null); const [e29Result, setE29Result] = useState(null); const [e30Result, setE30Result] = useState(null); @@ -580,7 +587,12 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) { && session.status === "ready" && session.replayable && session.modalities.includes("point-cloud") - )), + )).sort((left, right) => { + const ordinalDelta = laboratoryWorkOrdinal(right.lab?.labId ?? "") + - laboratoryWorkOrdinal(left.lab?.labId ?? ""); + if (ordinalDelta !== 0) return ordinalDelta; + return (right.startedAtUtc ?? "").localeCompare(left.startedAtUtc ?? ""); + }), [sessions.items], ); const sourceSessions = useMemo( @@ -661,7 +673,9 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) { advancedResults, sourceSessions, )); - return items; + return items.sort( + (left, right) => laboratoryWorkOrdinal(right.label) - laboratoryWorkOrdinal(left.label), + ); }, [ advancedResults, e28Model, @@ -717,13 +731,26 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) { setProfileId(firstProfile.id); if (firstProfile.id === "sensor-fusion") { const firstWork = sensorWorks[0]; - if (firstWork) setWorkId(firstWork.id); + if (firstWork) { + setWorkId(firstWork.id); + initialWorkSelectedRef.current = true; + } } else { const first = publishedWorks[0]; - if (first) setWorkId(`session:${first.id}`); + if (first) { + setWorkId(`session:${first.id}`); + initialWorkSelectedRef.current = true; + } } return; } + if (!initialWorkSelectedRef.current) { + const freshestWork = workOptions[0]; + if (!freshestWork) return; + setWorkId(freshestWork.id); + initialWorkSelectedRef.current = true; + return; + } if (!workOptions.some((work) => work.id === workId)) { const firstWork = workOptions[0]; if (firstWork) setWorkId(firstWork.id); @@ -741,6 +768,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) { const selectProfile = (next: LaboratoryProfileId) => { setProfileId(next); + initialWorkSelectedRef.current = true; if (next === "sensor-fusion") { const first = sensorWorks[0]; if (first) setWorkId(first.id); diff --git a/apps/control-station/src/workspaces/system/ComputeModulesWorkspace.tsx b/apps/control-station/src/workspaces/system/ComputeModulesWorkspace.tsx index 4d30e2a..dd882c8 100644 --- a/apps/control-station/src/workspaces/system/ComputeModulesWorkspace.tsx +++ b/apps/control-station/src/workspaces/system/ComputeModulesWorkspace.tsx @@ -1,7 +1,5 @@ import { - Button, GlassSurface, - Icon, StatusBadge, } from "@nodedc/ui-react"; @@ -24,14 +22,53 @@ function pipelineStateLabel(state: string): string { return "Нет live-состояния"; } +function formatResourcePair( + used: number | null | undefined, + total: number | null | undefined, +): string | null { + if ( + typeof used !== "number" + || !Number.isFinite(used) + || typeof total !== "number" + || !Number.isFinite(total) + || total <= 0 + ) { + return null; + } + return `${formatBytes(used)} / ${formatBytes(total)}`; +} + +function formatCpuCapacity( + percent: number | null | undefined, + logicalProcessors: number | null | undefined, +): string | null { + if ( + typeof percent !== "number" + || !Number.isFinite(percent) + || typeof logicalProcessors !== "number" + || !Number.isFinite(logicalProcessors) + || logicalProcessors <= 0 + ) { + return null; + } + const used = new Intl.NumberFormat("ru-RU", { + maximumFractionDigits: 1, + }).format(percent / 100 * logicalProcessors); + return `≈ ${used} / ${logicalProcessors} лог. ядра`; +} + export function ComputeModulesWorkspace() { - const { selectedContour } = useComputeContours(); + const { + selectedContour, + telemetryRefreshGeneration, + } = useComputeContours(); const legacyDiagnostic = selectedContour?.contour_id === "worker-006" && selectedContour.telemetry_mode === "legacy-ssh"; const supportsLiveTelemetry = selectedContour?.contour_id === "worker-006"; - const { telemetry, error, refresh } = useWorkerTelemetry( + const { telemetry, error } = useWorkerTelemetry( (selectedContour?.telemetry_poll_interval_seconds ?? 3) * 1_000, supportsLiveTelemetry, + telemetryRefreshGeneration, ); const node = telemetry?.node ?? null; const missionCoreRuntimes = telemetry?.runtimes.filter((runtime) => !runtime.external) ?? []; @@ -45,6 +82,12 @@ export function ComputeModulesWorkspace() { const memoryPercent = node?.memory.used_percent; const gpuPercent = node?.gpu?.utilization_percent; const gpuMemoryPercent = node?.gpu?.memory_used_percent; + const gpuMemoryUsedBytes = typeof node?.gpu?.memory_used_mib === "number" + ? node.gpu.memory_used_mib * 1024 * 1024 + : null; + const gpuMemoryTotalBytes = typeof node?.gpu?.memory_total_mib === "number" + ? node.gpu.memory_total_mib * 1024 * 1024 + : null; return (
@@ -63,15 +106,6 @@ export function ComputeModulesWorkspace() { ? agentTelemetry ? "Агент доступен" : "SSH-диагностика" : "Нет свежих данных"} -
@@ -92,24 +126,37 @@ export function ComputeModulesWorkspace() { item.cpu_percent)} /> item.memory_percent)} /> item.gpu_percent)} /> item.gpu_memory_percent)} /> @@ -135,11 +182,7 @@ export function ComputeModulesWorkspace() {
GPU
{node?.gpu?.name ?? "—"}
-
VRAM занята
{formatBytes( - typeof node?.gpu?.memory_used_mib === "number" - ? node.gpu.memory_used_mib * 1024 * 1024 - : null, - )}
+
VRAM занята
{formatBytes(gpuMemoryUsedBytes)}
Температура
{ typeof node?.gpu?.temperature_celsius === "number" ? `${node.gpu.temperature_celsius} °C` diff --git a/apps/control-station/src/workspaces/system/NetworkWorkspace.tsx b/apps/control-station/src/workspaces/system/NetworkWorkspace.tsx index b0fd0e0..da4fd62 100644 --- a/apps/control-station/src/workspaces/system/NetworkWorkspace.tsx +++ b/apps/control-station/src/workspaces/system/NetworkWorkspace.tsx @@ -1,7 +1,5 @@ import { - Button, GlassSurface, - Icon, StatusBadge, } from "@nodedc/ui-react"; @@ -18,13 +16,17 @@ import { } from "../../core/system/useWorkerTelemetry"; export function NetworkWorkspace() { - const { selectedContour } = useComputeContours(); + const { + selectedContour, + telemetryRefreshGeneration, + } = useComputeContours(); const legacyDiagnostic = selectedContour?.contour_id === "worker-006" && selectedContour.telemetry_mode === "legacy-ssh"; const supportsLiveTelemetry = selectedContour?.contour_id === "worker-006"; - const { telemetry, error, refresh } = useWorkerTelemetry( + const { telemetry, error } = useWorkerTelemetry( (selectedContour?.telemetry_poll_interval_seconds ?? 3) * 1_000, supportsLiveTelemetry, + telemetryRefreshGeneration, ); const aggregate = telemetry?.network.aggregate ?? null; const connected = Boolean( @@ -50,15 +52,6 @@ export function NetworkWorkspace() { {connected ? "Маршрут доступен" : "Нет свежих данных"} -
diff --git a/apps/control-station/test/observationSessions.test.mjs b/apps/control-station/test/observationSessions.test.mjs index d1d66d1..caa4239 100644 --- a/apps/control-station/test/observationSessions.test.mjs +++ b/apps/control-station/test/observationSessions.test.mjs @@ -202,6 +202,23 @@ test("data recordings keep the compact session dropdown and laboratory results s assert.match(laboratorySource, /ЛАБОРАТОРНАЯ РАБОТА/); assert.match(laboratorySource, /e28-local-surface/); assert.match(laboratorySource, /e29-camera-geometry/); + assert.match(laboratorySource, /laboratoryWorkOrdinal\(right\.label\)/); + assert.match(laboratorySource, /initialWorkSelectedRef/); +}); + +test("recording preparation statuses share the viewer's left alignment", async () => { + const spatialStyles = await readFile( + new URL("../src/styles/spatial.css", import.meta.url), + "utf8", + ); + const statusStack = spatialStyles.slice( + spatialStyles.indexOf(".scene-operation-status-stack"), + spatialStyles.indexOf(".scene-operation-status {"), + ); + + assert.match(statusStack, /left:\s*0\.85rem/); + assert.match(statusStack, /justify-items:\s*start/); + assert.doesNotMatch(statusStack, /right:/); }); test("source and laboratory catalogs are requested as disjoint backend projections", async () => { diff --git a/apps/control-station/test/systemTelemetry.test.mjs b/apps/control-station/test/systemTelemetry.test.mjs index 78ac357..619f00a 100644 --- a/apps/control-station/test/systemTelemetry.test.mjs +++ b/apps/control-station/test/systemTelemetry.test.mjs @@ -18,6 +18,9 @@ test("Worker 006 telemetry remains a bounded system feature slice", async () => pipelineStages, networkWorkspace, telemetryPolling, + telemetryContext, + panelActions, + contourSettingsHook, contourSettings, contourContract, pollIntervalContract, @@ -31,6 +34,9 @@ test("Worker 006 telemetry remains a bounded system feature slice", async () => read("components/system/WorkerPipelineStages.tsx"), read("workspaces/system/NetworkWorkspace.tsx"), read("core/system/useWorkerTelemetry.ts"), + read("core/system/ComputeContourContext.tsx"), + read("components/useApplicationPanelActions.ts"), + read("components/system/useComputeContourSettings.tsx"), read("components/system/ComputeContourSettingsWindow.tsx"), read("core/system/computeContours.ts"), read("core/system/telemetryPollInterval.ts"), @@ -49,7 +55,9 @@ test("Worker 006 telemetry remains a bounded system feature slice", async () => assert.doesNotMatch(core, /@nodedc\/ui-react/); assert.match(computeWorkspace, /useWorkerTelemetry/); assert.match(computeWorkspace, /telemetry_poll_interval_seconds/); - assert.match(computeWorkspace, />\s*Обновить\s*\s*Обновить\s* ); assert.match(networkWorkspace, /127\.0\.0\.1:8000/); assert.match(networkWorkspace, /telemetry_poll_interval_seconds/); - assert.match(networkWorkspace, />\s*Обновить\s*\s*Обновить\s*