diff --git a/apps/control-station/README.md b/apps/control-station/README.md index d6f287f..2b2efd4 100644 --- a/apps/control-station/README.md +++ b/apps/control-station/README.md @@ -102,12 +102,13 @@ revisioned API. Смена 2D/3D/карты и семантические сло | Раздел | Назначение | | --- | --- | -| Центр | Оперативный обзор, состояние контура и активность оператора. | +| Центр | Живое состояние вычислительных узлов, процессов, сети и подключённых устройств. | | Парк | Аппараты, текущее устройство, сенсоры и конфигурации борта. | -| Наблюдение | Пространственная сцена, камеры, карта, объекты, телеметрия и время. | +| Наблюдение | Только live-пространственная сцена, камеры, карта, объекты, телеметрия и время. | | Миссии | Планировщик, маршруты, сценарии и исполнение. Командный backend отключён. | -| Данные | Сессии, потоки, сущности, playback и экспорт доказательств. | +| Данные | Сохранённые сессии, replay, публичные датасеты, потоки, сущности и экспорт. | | Система | Модули, интеграции, сеть, аудит и настройки платформы. | +| Тестировочный контур | Архив лабораторных работ, конфигурации экспериментов и визуальные результаты. | Карточки возможностей имеют четыре честных уровня: работает сейчас, готово к источнику, интерфейсный контракт и последующий этап. Каталог не следует читать @@ -192,7 +193,7 @@ production-сборки запускается командой `npm run preview только после реальных сообщений. 11. Физически остановить K1, дождаться steady green, затем остановить локальный приём из device workflow или spatial block, чтобы запечатать evidence. -12. После нормального stop или recovery открыть **Сохранённые сессии**. Дождаться +12. После нормального stop или recovery открыть **Данные → Сессии и записи**. Дождаться состояния **Готово**, выбрать запись и использовать host timeline. Evidence сохраняется автоматически; disk action сохраняет только workspace layout. diff --git a/apps/control-station/src/App.tsx b/apps/control-station/src/App.tsx index 98f8b91..85e09e5 100644 --- a/apps/control-station/src/App.tsx +++ b/apps/control-station/src/App.tsx @@ -26,7 +26,6 @@ import { } from "@nodedc/ui-react"; import { LandingStage } from "./components/LandingStage"; -import { ObservationSessionSelect } from "./components/ObservationSessionSelect"; import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost"; import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext"; import type { ViewerSettings } from "./core/runtime/contracts"; @@ -160,7 +159,7 @@ export default function App() { }); const [activeRoot, setActiveRoot] = useState( - polygonDatasetRoute.active ? "polygon" : null, + polygonDatasetRoute.active ? "data" : null, ); const [sourceUrl, setSourceUrl] = useState(""); const [recordedReplay, setRecordedReplay] = useState(null); @@ -199,24 +198,33 @@ export default function App() { const currentRoot = rootById(activeRoot); const visibleRoots = roots; const activeDefinition = workspaceById(workspace.activeView); - const spatialWorkspaceActive = Boolean( - workspace.contentOpen && activeDefinition?.kind === "spatial", + const sceneWorkspaceActive = Boolean( + workspace.contentOpen + && activeDefinition + && ["spatial", "recordings"].includes(activeDefinition.kind), + ); + const archiveWorkspaceActive = Boolean( + activeDefinition + && ["recordings", "lab-archive"].includes(activeDefinition.kind), ); const rootWorkspaces = workspacesForRoot(activeRoot); const activeSceneWindow = sceneWindowOrder[sceneWindowOrder.length - 1] ?? null; const automaticSourceUrl = runtime.state?.spatialSource?.url.trim() ?? ""; - const effectiveSourceUrl = replayTransitioning ? "" : sourceUrl || automaticSourceUrl; + const effectiveSourceUrl = archiveWorkspaceActive + ? replayTransitioning ? "" : sourceUrl + : automaticSourceUrl; const replayActive = Boolean(recordedReplay && sourceUrl === recordedReplay.sourceUrl); + const replayPresented = archiveWorkspaceActive && replayActive; const recordedSessionAdmission = useRecordedSessionAdmission( - replayActive ? recordedReplay : null, + replayPresented ? recordedReplay : null, ); runtimeUpdateViewerSettingsRef.current = runtime.updateViewerSettings; - replayActiveRef.current = replayActive; + replayActiveRef.current = replayPresented; useEffect(() => { if (!polygonDatasetRoute.active || polygonDatasetRouteOpenedRef.current) return; polygonDatasetRouteOpenedRef.current = true; - workspace.openView("polygon-datasets"); + workspace.openView("datasets"); }, [polygonDatasetRoute.active, workspace]); if (!sceneSettingsCommitterRef.current) { @@ -245,11 +253,11 @@ export default function App() { () => recordedObservationSources(recordedReplay), [recordedReplay], ); - const activeObservationSources = replayActive + const activeObservationSources = replayPresented ? replaySources : runtime.state?.observationSources ?? []; const activeRuntimeState = useMemo(() => { - if (!replayActive || !recordedReplay) return runtime.state; + if (!replayPresented || !recordedReplay) return runtime.state; return { ...(runtime.state ?? { phase: "replaying" as const }), phase: "replaying" as const, @@ -272,7 +280,7 @@ export default function App() { }, }, }; - }, [recordedReplay, replayActive, replaySources, runtime.state]); + }, [recordedReplay, replayPresented, replaySources, runtime.state]); const viewerSettingsTargetIdentity = [ runtime.state?.activeDevice?.pluginId, runtime.state?.activeDevice?.modelId, @@ -283,7 +291,7 @@ export default function App() { ].filter(Boolean).join(":") || "local-runtime"; const observationLayout = useObservationLayout( activeObservationSources, - replayActive ? undefined : runtime.setObservationSourceActive, + replayPresented ? undefined : runtime.setObservationSourceActive, ); const workspaceLayoutProfile = useWorkspaceLayoutProfile(); const focusedObservationSource = activeObservationSources.find( @@ -335,14 +343,14 @@ export default function App() { }, [observationLayout.restore, workspaceLayoutProfile.profile]); useEffect(() => { - if (spatialWorkspaceActive) return; + if (sceneWorkspaceActive) return; // Scene tools are transient children of the spatial workspace. They must // never survive a route/root/content close or appear over the landing page. setSourceWindowOpen(false); setDisplayWindowOpen(false); setLayerInspectorOpen(false); setSceneWindowOrder([]); - }, [spatialWorkspaceActive]); + }, [sceneWorkspaceActive]); useEffect(() => { sceneSettingsCommitterActiveRef.current = true; @@ -398,7 +406,7 @@ export default function App() { }, []); useEffect(() => { - if (!spatialWorkspaceActive || !activeSceneWindow) return; + if (!sceneWorkspaceActive || !activeSceneWindow) return; const closeActiveWindow = (event: KeyboardEvent) => { if (event.key !== "Escape" || event.defaultPrevented) return; @@ -410,12 +418,12 @@ export default function App() { document.addEventListener("keydown", closeActiveWindow); return () => document.removeEventListener("keydown", closeActiveWindow); - }, [activeSceneWindow, closeSceneWindow, spatialWorkspaceActive]); + }, [activeSceneWindow, closeSceneWindow, sceneWorkspaceActive]); const selectRoot = (rootId: RootId) => { setActiveRoot(rootId); if (rootId === "polygon") { - workspace.openView("polygon-datasets"); + workspace.openView("lab-archive"); workspace.openNavigation(); return; } @@ -431,7 +439,7 @@ export default function App() { }; const openSource = () => { - if (!spatialWorkspaceActive) return; + if (!sceneWorkspaceActive) return; setSourceDraft(sourceUrl); setSourceWindowOpen(true); activateSceneWindow("sources"); @@ -472,13 +480,13 @@ export default function App() { }, [commitDisplaySettings]); const openDisplay = () => { - if (!spatialWorkspaceActive) return; + if (!sceneWorkspaceActive) return; setDisplayWindowOpen(true); activateSceneWindow("display"); }; const openLayers = () => { - if (!spatialWorkspaceActive) return; + if (!sceneWorkspaceActive) return; setLayerInspectorOpen(true); activateSceneWindow("layers"); }; @@ -547,12 +555,9 @@ export default function App() { }, []); useEffect(() => { - // ObservationSessionSelect owns the cancellable request and unmounts when - // the operator leaves the spatial workspace. Its unmount cannot safely - // call back into this owner, so clear the transient blanking state here. - // Returning to Observation then starts from a deterministic idle source - // instead of an orphaned `replayTransitioning=true` state. - if (activeDefinition?.kind !== "spatial") setReplayTransitioning(false); + if (!["recordings", "lab-archive"].includes(activeDefinition?.kind ?? "")) { + setReplayTransitioning(false); + } }, [activeDefinition?.kind]); const applyScenePatch = (patch: Partial) => commitDisplayPatch(patch); @@ -598,7 +603,10 @@ export default function App() { }); } - if (activeDefinition?.kind === "spatial") { + if ( + activeDefinition + && ["spatial", "recordings"].includes(activeDefinition.kind) + ) { actions.push( { label: workspaceLayoutProfile.state === "saving" @@ -709,7 +717,9 @@ export default function App() { key={activeDefinition.id} eyebrow={activeDefinition.eyebrow} title={ - activeDefinition.kind === "spatial" && replayActive && recordedReplayLabel + activeDefinition.kind === "recordings" + && replayPresented + && recordedReplayLabel ? `${activeDefinition.title}: ${recordedReplayLabel}` : activeDefinition.title } @@ -723,13 +733,9 @@ export default function App() { ) : activeDefinition.kind === "spatial" ? (
- settleRecordedReplaySwitch(outcome)} - /> + + {runtime.state?.sourceMode === "live" ? "Эфир" : "Ожидание эфира"} + {layoutSaveNotice || workspaceLayoutProfile.error ? ( ) : null}
- ) : activeDefinition.kind === "polygon-datasets" ? ( + ) : activeDefinition.kind === "recordings" ? ( + Архив записей + ) : activeDefinition.kind === "datasets" ? ( Offline evaluation + ) : activeDefinition.kind === "lab-archive" ? ( + Лаборатория ) : ( Интерфейс готов ) @@ -765,7 +775,7 @@ export default function App() { state={activeRuntimeState} backendStatus={runtime.backendStatus} sourceUrl={effectiveSourceUrl} - recordedReplay={replayActive ? recordedReplay : null} + recordedReplay={replayPresented ? recordedReplay : null} recordedSessionAdmission={recordedSessionAdmission} sceneSettings={sceneSettings} accumulationSeconds={displayDraft.accumulationSeconds} @@ -782,6 +792,14 @@ export default function App() { model: selection.model, } : null} + sessionArchive={{ + disabled: runtime.pendingAction !== null || sourceSwitchBlocked, + blockedReason: sourceSwitchBlockedReason, + onReplayBegin: beginRecordedReplaySwitch, + onReplayAccepted: acceptRecordedReplay, + onReplaySettled: (_session, outcome) => + settleRecordedReplaySwitch(outcome), + }} navigation={{ openView, openSource, @@ -796,7 +814,7 @@ export default function App() { /> ) : null} - {replayActive ? ( + {replayPresented ? (

Первый новый режим читает индекс архивных точек. Следующие палитры переключаются из подготовленного цветового кэша без переэкспорта геометрии. @@ -1068,7 +1086,7 @@ export default function App() { void | Promise; +} + +export function ObservationSessionSelect({ + limit = 100, + disabled = false, + blockedReason = null, + onReplayBegin, + onReplayAccepted, + onReplaySettled, +}: ObservationSessionReplayCallbacks & { + limit?: number; + disabled?: boolean; + blockedReason?: string | null; }) { const [deleteTarget, setDeleteTarget] = useState(null); const sessions = useObservationSessions({ @@ -300,3 +303,177 @@ export function ObservationSessionSelect({ /> ; } + +export function ObservationSessionArchive({ + limit = 100, + labsOnly = false, + disabled = false, + blockedReason = null, + onReplayBegin, + onReplayAccepted, + onReplaySettled, +}: ObservationSessionReplayCallbacks & { + limit?: number; + labsOnly?: boolean; + disabled?: boolean; + blockedReason?: string | null; +}) { + const [deleteTarget, setDeleteTarget] = useState(null); + const sessions = useObservationSessions({ + limit, + replayEnabled: blockedReason === null, + onReplayBegin, + onReplayAccepted, + onReplaySettled, + }); + const items = labsOnly + ? sessions.items.filter((session) => session.lab !== null) + : sessions.items; + + return <> +

+
+
+ + {labsOnly ? "ВОСПРОИЗВОДИМЫЕ ЛАБОРАТОРНЫЕ ЗАПИСИ" : "АРХИВ НАБЛЮДЕНИЯ"} + +

{labsOnly ? "Записанные лабораторные работы" : "Сохранённые сессии"}

+

+ {labsOnly + ? "Каждая запись связана с LAB-конфигурацией и открывается как самостоятельный визуальный результат." + : "Выберите запись: сцена и синхронные каналы откроются ниже, не меняя эфир наблюдения."} +

+
+ +
+ + {items.length > 0 ? ( +
+ {items.map((session) => { + const pending = sessions.replayingSessionId === session.id; + const deleting = sessions.deletingSessionId === session.id; + const failed = sessions.failedSessionId === session.id; + const visualState = observationSessionVisualState(session, { pending, failed }); + return ( +
+