diff --git a/apps/control-station/package-lock.json b/apps/control-station/package-lock.json index bf469d0..4a680ed 100644 --- a/apps/control-station/package-lock.json +++ b/apps/control-station/package-lock.json @@ -7,7 +7,9 @@ "": { "name": "@nodedc/mission-core-control-station", "version": "0.1.0", + "hasInstallScript": true, "dependencies": { + "@noble/hashes": "^2.2.0", "@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens", "@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core", "@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react", @@ -834,6 +836,18 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodedc/tokens": { "resolved": "../../../NODEDC_DESIGN_GUIDELINE/packages/tokens", "link": true diff --git a/apps/control-station/package.json b/apps/control-station/package.json index 32220e3..3c5e022 100644 --- a/apps/control-station/package.json +++ b/apps/control-station/package.json @@ -4,6 +4,7 @@ "private": true, "type": "module", "scripts": { + "postinstall": "node scripts/patch-rerun-web-viewer.mjs", "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", @@ -11,6 +12,7 @@ "typecheck": "tsc -b --pretty false" }, "dependencies": { + "@noble/hashes": "^2.2.0", "@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens", "@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core", "@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react", diff --git a/apps/control-station/scripts/patch-rerun-web-viewer.mjs b/apps/control-station/scripts/patch-rerun-web-viewer.mjs new file mode 100644 index 0000000..9b780c4 --- /dev/null +++ b/apps/control-station/scripts/patch-rerun-web-viewer.mjs @@ -0,0 +1,61 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const packageRoot = resolve(root, "node_modules/@rerun-io/web-viewer"); +const manifest = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8")); + +if (manifest.version !== "0.34.1") { + throw new Error( + `Refusing to patch @rerun-io/web-viewer ${manifest.version}; audit the new vendor lifecycle first.`, + ); +} + +const patches = [ + { + label: "compiled watchdog", + path: resolve(packageRoot, "index.js"), + before: ` function check_for_panic() {\n if (self.#handle?.has_panicked()) {`, + after: ` function check_for_panic() {\n if (self.#state !== "ready" || !self.#handle) {\n return;\n }\n if (self.#handle.has_panicked()) {`, + }, + { + label: "source watchdog", + path: resolve(packageRoot, "index.ts"), + before: ` function check_for_panic() {\n if (self.#handle?.has_panicked()) {`, + after: ` function check_for_panic() {\n if (self.#state !== "ready" || !self.#handle) {\n return;\n }\n if (self.#handle.has_panicked()) {`, + }, + { + label: "compiled singleton global listener", + path: resolve(packageRoot, "index.js"), + before: `function setupGlobalEventListeners() {\n window.addEventListener("keyup", (e) => {`, + after: `let globalEventListenersInstalled = false;\nfunction setupGlobalEventListeners() {\n if (globalEventListenersInstalled) {\n return;\n }\n globalEventListenersInstalled = true;\n window.addEventListener("keyup", (e) => {`, + }, + { + label: "source singleton global listener", + path: resolve(packageRoot, "index.ts"), + before: `function setupGlobalEventListeners() {\n window.addEventListener("keyup", (e) => {`, + after: `let globalEventListenersInstalled = false;\nfunction setupGlobalEventListeners() {\n if (globalEventListenersInstalled) {\n return;\n }\n globalEventListenersInstalled = true;\n window.addEventListener("keyup", (e) => {`, + }, +]; + +for (const patch of patches) { + const source = readFileSync(patch.path, "utf8"); + if (source.includes(patch.after)) continue; + if (!source.includes(patch.before)) { + throw new Error(`Vendor ${patch.label} patch no longer matches ${patch.path}.`); + } + writeFileSync(patch.path, source.replace(patch.before, patch.after)); +} + +for (const path of [resolve(packageRoot, "index.js"), resolve(packageRoot, "index.ts")]) { + const source = readFileSync(path, "utf8"); + const keyupListenerCount = source.split('window.addEventListener("keyup"').length - 1; + if (!source.includes("let globalEventListenersInstalled = false;") || keyupListenerCount !== 1) { + throw new Error(`Vendor singleton global listener verification failed for ${path}.`); + } +} + +console.log( + "Patched @rerun-io/web-viewer 0.34.1 watchdog teardown and singleton global keyup listener.", +); diff --git a/apps/control-station/src/App.tsx b/apps/control-station/src/App.tsx index a0c5609..a087e61 100644 --- a/apps/control-station/src/App.tsx +++ b/apps/control-station/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AdminNavigationPanel, AppHeader, @@ -26,10 +26,25 @@ 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"; +import { + createLatestAsyncCommitter, + type LatestAsyncCommitter, +} from "./core/runtime/latestAsyncCommitter"; import { useObservationLayout } from "./core/observation/useObservationLayout"; +import { recordedObservationSources } from "./core/observation/recordedObservationSources"; +import type { ObservationSessionReplayLaunch } from "./core/observation/sessionArchive"; +import { useRecordedSessionAdmission } from "./core/observation/useRecordedSessionAdmission"; +import { useWorkspaceLayoutProfile } from "./core/observation/useWorkspaceLayoutProfile"; +import { + OBSERVATION_WORKSPACE_ID, + OBSERVATION_WORKSPACE_LAYOUT_VERSION, + type ObservationToolWindowId, + type ObservationWorkspaceLayoutProfile, +} from "./core/observation/workspaceLayout"; import { rootById, roots, @@ -48,7 +63,10 @@ import { DeviceWorkspace } from "./workspaces/DeviceWorkspace"; import { WorkspaceRenderer } from "./workspaces/Workspaces"; import "./styles/scene-windows.css"; -type SceneToolWindowId = "sources" | "display" | "layers" | "layout"; +type SceneToolWindowId = ObservationToolWindowId; + +const sceneToolWindowIds: readonly SceneToolWindowId[] = ["sources", "display", "layers"]; +const viewerSettingsQuietPeriodMs = 750; const colorModeOptions: Array<{ value: PointColorMode; label: string; description: string }> = [ { value: "intensity", label: "Интенсивность", description: "Значение отражённого сигнала" }, @@ -116,28 +134,117 @@ export default function App() { const [activeRoot, setActiveRoot] = useState(null); const [sourceUrl, setSourceUrl] = useState(""); + const [recordedReplay, setRecordedReplay] = useState(null); + const [replayTransitioning, setReplayTransitioning] = useState(false); const [sourceDraft, setSourceDraft] = useState(""); const [sourceWindowOpen, setSourceWindowOpen] = useState(false); const [displayWindowOpen, setDisplayWindowOpen] = useState(false); const [layerInspectorOpen, setLayerInspectorOpen] = useState(false); const [sceneWindowOrder, setSceneWindowOrder] = useState([]); - const [layoutWindowOpen, setLayoutWindowOpen] = useState(false); - const [layoutName, setLayoutName] = useState("Операторская сцена"); - const [layoutDraftSaved, setLayoutDraftSaved] = useState(false); + const [layoutSaveNotice, setLayoutSaveNotice] = useState(null); const [sceneSettings, setSceneSettings] = useState(defaultSceneSettings); const [displayDraft, setDisplayDraft] = useState(defaultSceneSettings); + const appliedProfileKeyRef = useRef(null); + const sceneSettingsRef = useRef(defaultSceneSettings); + const displayDraftRef = useRef(defaultSceneSettings); + const confirmedSceneSettingsRef = useRef(defaultSceneSettings); + const viewerSettingsCommitTimerRef = useRef(null); + const runtimeUpdateViewerSettingsRef = useRef(runtime.updateViewerSettings); + const replayActiveRef = useRef(false); + const sceneSettingsCommitterActiveRef = useRef(true); + const sceneSettingsCommitterRef = useRef | null>(null); const currentRoot = rootById(activeRoot); const activeDefinition = workspaceById(workspace.activeView); const rootWorkspaces = workspacesForRoot(activeRoot); const activeSceneWindow = sceneWindowOrder[sceneWindowOrder.length - 1] ?? null; const automaticSourceUrl = runtime.state?.spatialSource?.url.trim() ?? ""; - const effectiveSourceUrl = sourceUrl || automaticSourceUrl; - const observationLayout = useObservationLayout( - runtime.state?.observationSources ?? [], - runtime.setObservationSourceActive, + const effectiveSourceUrl = replayTransitioning ? "" : sourceUrl || automaticSourceUrl; + const replayActive = Boolean(recordedReplay && sourceUrl === recordedReplay.sourceUrl); + const recordedSessionAdmission = useRecordedSessionAdmission( + replayActive ? recordedReplay : null, ); - const focusedObservationSource = runtime.state?.observationSources?.find( + const displayPaletteOptions = replayActive + ? paletteOptions.map((option) => { + if (option.value === "turbo") { + return { + ...option, + label: "Цвет записи", + description: "Вернуть цвета, сохранённые внутри RRD", + }; + } + return option.value === "custom" ? option : { ...option, disabled: true }; + }) + : paletteOptions; + runtimeUpdateViewerSettingsRef.current = runtime.updateViewerSettings; + replayActiveRef.current = replayActive; + + if (!sceneSettingsCommitterRef.current) { + sceneSettingsCommitterRef.current = createLatestAsyncCommitter({ + commit: (settings) => replayActiveRef.current + ? Promise.resolve(true) + : runtimeUpdateViewerSettingsRef.current(toViewerSettings(settings)), + onSettled: ({ value, applied, superseded }) => { + if (!sceneSettingsCommitterActiveRef.current) return; + if (applied) confirmedSceneSettingsRef.current = value; + if (superseded) return; + + const next = applied ? value : confirmedSceneSettingsRef.current; + sceneSettingsRef.current = next; + setSceneSettings(next); + if (!applied && displayDraftRef.current === value) { + displayDraftRef.current = next; + setDisplayDraft(next); + } + }, + }); + } + const replaySources = useMemo( + () => recordedObservationSources(recordedReplay), + [recordedReplay], + ); + const activeObservationSources = replayActive + ? replaySources + : runtime.state?.observationSources ?? []; + const activeRuntimeState = useMemo(() => { + if (!replayActive || !recordedReplay) return runtime.state; + return { + ...(runtime.state ?? { phase: "replaying" as const }), + phase: "replaying" as const, + sourceMode: "replay" as const, + spatialSource: { + id: "recorded.spatial.primary", + url: recordedReplay.sourceUrl, + label: "Сохранённая пространственная сцена", + kind: "rrd" as const, + }, + observationSources: replaySources, + observationTimeline: { + mode: "recorded" as const, + seekable: true, + sessionRecording: true, + synchronization: "host-arrival-best-effort" as const, + range: { + startSeconds: recordedReplay.timelineStartSeconds, + endSeconds: recordedReplay.timelineEndSeconds, + }, + }, + }; + }, [recordedReplay, replayActive, replaySources, runtime.state]); + const viewerSettingsTargetIdentity = [ + runtime.state?.activeDevice?.pluginId, + runtime.state?.activeDevice?.modelId, + runtime.state?.activeDevice?.instanceId, + runtime.state?.deviceSession?.sessionId, + runtime.state?.deviceSession?.deviceId, + runtime.state?.acquisition?.acquisitionId, + ].filter(Boolean).join(":") || "local-runtime"; + const observationLayout = useObservationLayout( + activeObservationSources, + replayActive ? undefined : runtime.setObservationSourceActive, + ); + const workspaceLayoutProfile = useWorkspaceLayoutProfile(); + const focusedObservationSource = activeObservationSources.find( (source) => source.id === observationLayout.focusedSourceId, ); const observationFullscreenActive = Boolean( @@ -148,12 +255,80 @@ export default function App() { useEffect(() => { const remote = runtime.state?.viewerSettings; - if (!remote) return; - setSceneSettings((current) => mergeViewerSettings(current, remote)); - if (!displayWindowOpen) { - setDisplayDraft((current) => mergeViewerSettings(current, remote)); + if ( + !remote || + workspaceLayoutProfile.profile || + sceneSettingsCommitterRef.current?.isBusy() + ) return; + const merged = mergeViewerSettings(sceneSettingsRef.current, remote); + sceneSettingsRef.current = merged; + confirmedSceneSettingsRef.current = merged; + setSceneSettings(merged); + if (viewerSettingsCommitTimerRef.current === null) { + displayDraftRef.current = merged; + setDisplayDraft(merged); } - }, [runtime.state?.viewerSettings, displayWindowOpen]); + }, [runtime.state?.viewerSettings, workspaceLayoutProfile.profile]); + + useEffect(() => { + const profile = workspaceLayoutProfile.profile; + if (!profile) return; + if (viewerSettingsCommitTimerRef.current !== null) { + window.clearTimeout(viewerSettingsCommitTimerRef.current); + viewerSettingsCommitTimerRef.current = null; + } + sceneSettingsRef.current = profile.sceneSettings; + displayDraftRef.current = profile.sceneSettings; + confirmedSceneSettingsRef.current = profile.sceneSettings; + setSceneSettings(profile.sceneSettings); + setDisplayDraft(profile.sceneSettings); + setSourceWindowOpen(profile.toolWindows.sourcesOpen); + setDisplayWindowOpen(profile.toolWindows.displayOpen); + setLayerInspectorOpen(profile.toolWindows.layersOpen); + setSceneWindowOrder(profile.toolWindows.order.filter((windowId) => { + if (windowId === "sources") return profile.toolWindows.sourcesOpen; + if (windowId === "display") return profile.toolWindows.displayOpen; + return profile.toolWindows.layersOpen; + })); + observationLayout.restore(profile); + }, [observationLayout.restore, workspaceLayoutProfile.profile]); + + useEffect(() => { + sceneSettingsCommitterActiveRef.current = true; + return () => { + sceneSettingsCommitterActiveRef.current = false; + if (viewerSettingsCommitTimerRef.current !== null) { + window.clearTimeout(viewerSettingsCommitTimerRef.current); + viewerSettingsCommitTimerRef.current = null; + } + }; + }, []); + + useEffect(() => { + const profile = workspaceLayoutProfile.profile; + const applicationKey = profile + ? `${profile.revision}:${viewerSettingsTargetIdentity}` + : null; + if ( + !profile || + runtime.backendStatus !== "online" || + !applicationKey || + appliedProfileKeyRef.current === applicationKey + ) { + return; + } + appliedProfileKeyRef.current = applicationKey; + void runtime.updateViewerSettings(toViewerSettings(profile.sceneSettings)).then((applied) => { + if (!applied && appliedProfileKeyRef.current === applicationKey) { + appliedProfileKeyRef.current = null; + } + }); + }, [ + runtime.backendStatus, + runtime.updateViewerSettings, + viewerSettingsTargetIdentity, + workspaceLayoutProfile.profile, + ]); const activateSceneWindow = useCallback((windowId: SceneToolWindowId) => { setSceneWindowOrder((current) => [ @@ -166,7 +341,6 @@ export default function App() { if (windowId === "sources") setSourceWindowOpen(false); if (windowId === "display") setDisplayWindowOpen(false); if (windowId === "layers") setLayerInspectorOpen(false); - if (windowId === "layout") setLayoutWindowOpen(false); setSceneWindowOrder((current) => current.filter((candidate) => candidate !== windowId)); }, []); @@ -204,8 +378,41 @@ export default function App() { activateSceneWindow("sources"); }; + const commitDisplaySettings = useCallback((next: SceneSettings) => { + if (viewerSettingsCommitTimerRef.current !== null) { + window.clearTimeout(viewerSettingsCommitTimerRef.current); + viewerSettingsCommitTimerRef.current = null; + } + displayDraftRef.current = next; + setDisplayDraft(next); + sceneSettingsCommitterRef.current?.enqueue(next); + }, []); + + const stageDisplayPatch = useCallback((patch: Partial) => { + const next = { ...displayDraftRef.current, ...patch }; + displayDraftRef.current = next; + setDisplayDraft(next); + if (viewerSettingsCommitTimerRef.current !== null) { + window.clearTimeout(viewerSettingsCommitTimerRef.current); + } + viewerSettingsCommitTimerRef.current = window.setTimeout(() => { + viewerSettingsCommitTimerRef.current = null; + sceneSettingsCommitterRef.current?.enqueue(displayDraftRef.current); + }, viewerSettingsQuietPeriodMs); + }, []); + + const flushDisplaySettings = useCallback(() => { + if (viewerSettingsCommitTimerRef.current === null) return; + window.clearTimeout(viewerSettingsCommitTimerRef.current); + viewerSettingsCommitTimerRef.current = null; + sceneSettingsCommitterRef.current?.enqueue(displayDraftRef.current); + }, []); + + const commitDisplayPatch = useCallback((patch: Partial) => { + commitDisplaySettings({ ...displayDraftRef.current, ...patch }); + }, [commitDisplaySettings]); + const openDisplay = () => { - setDisplayDraft(sceneSettings); setDisplayWindowOpen(true); activateSceneWindow("display"); }; @@ -215,28 +422,90 @@ export default function App() { activateSceneWindow("layers"); }; - const openLayout = () => { - setLayoutDraftSaved(false); - setLayoutWindowOpen(true); - activateSceneWindow("layout"); - }; + const beginRecordedReplaySwitch = useCallback(async () => { + // useObservationSessions calls this only after backend preparation has + // produced a validated launch descriptor. Keep the old scene mounted + // before this point; now perform one controlled receiver teardown before + // accepting the already-ready archive. + setReplayTransitioning(true); + setRecordedReplay(null); + setSourceUrl(""); + setSourceDraft(""); + await new Promise((resolve) => { + window.requestAnimationFrame(() => window.setTimeout(resolve, 0)); + }); + }, []); - const applyDisplaySettings = async () => { - const applied = await runtime.updateViewerSettings(toViewerSettings(displayDraft)); - if (applied) setSceneSettings(displayDraft); - }; + const acceptRecordedReplay = useCallback((launch: ObservationSessionReplayLaunch) => { + setRecordedReplay(launch); + setSourceUrl(launch.sourceUrl); + setSourceDraft(launch.sourceUrl); + setReplayTransitioning(false); + }, []); - const applyScenePatch = async (patch: Partial) => { - const previous = sceneSettings; - const next = { ...sceneSettings, ...patch }; - setSceneSettings(next); - setDisplayDraft((current) => (displayWindowOpen ? { ...current, ...patch } : next)); - const applied = await runtime.updateViewerSettings(toViewerSettings(next)); - if (!applied) { - setSceneSettings(previous); - if (!displayWindowOpen) setDisplayDraft(previous); + const settleRecordedReplaySwitch = useCallback((outcome: "accepted" | "error" | "cancelled") => { + if (outcome !== "accepted") setReplayTransitioning(false); + }, []); + + 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); + }, [activeDefinition?.kind]); + + const applyScenePatch = (patch: Partial) => commitDisplayPatch(patch); + + const saveWorkspaceLayout = useCallback(async () => { + flushDisplaySettings(); + await sceneSettingsCommitterRef.current?.waitForIdle(); + + const layout = observationLayout.snapshot(); + if (!layout) { + setLayoutSaveNotice("Сцена ещё не измерила рабочую область."); + return; } - }; + const openWindowOrder = sceneWindowOrder.filter((windowId) => { + if (windowId === "sources") return sourceWindowOpen; + if (windowId === "display") return displayWindowOpen; + return layerInspectorOpen; + }); + const completeWindowOrder = [ + ...sceneToolWindowIds.filter((windowId) => !openWindowOrder.includes(windowId)), + ...openWindowOrder, + ]; + const draft: ObservationWorkspaceLayoutProfile = { + version: OBSERVATION_WORKSPACE_LAYOUT_VERSION, + revision: workspaceLayoutProfile.profile?.revision ?? 0, + workspaceId: OBSERVATION_WORKSPACE_ID, + sceneSettings: sceneSettingsRef.current, + toolWindows: { + sourcesOpen: sourceWindowOpen, + displayOpen: displayWindowOpen, + layersOpen: layerInspectorOpen, + order: completeWindowOrder, + }, + ...layout, + }; + const saved = await workspaceLayoutProfile.save(draft); + setLayoutSaveNotice(saved ? "Компоновка сохранена" : workspaceLayoutProfile.error); + }, [ + displayWindowOpen, + flushDisplaySettings, + layerInspectorOpen, + observationLayout.snapshot, + sceneWindowOrder, + sourceWindowOpen, + workspaceLayoutProfile, + ]); + + useEffect(() => { + if (!layoutSaveNotice || layoutSaveNotice !== "Компоновка сохранена") return; + const timer = window.setTimeout(() => setLayoutSaveNotice(null), 2600); + return () => window.clearTimeout(timer); + }, [layoutSaveNotice]); const contentActions = useMemo(() => { const actions: ApplicationPanelUtilityAction[] = []; @@ -251,25 +520,27 @@ export default function App() { if (activeDefinition?.kind === "spatial") { actions.push( + { + label: workspaceLayoutProfile.state === "saving" + ? "Сохраняем компоновку" + : "Сохранить компоновку", + icon: "save", + disabled: workspaceLayoutProfile.state === "saving", + onClick: () => void saveWorkspaceLayout(), + }, { label: "Настроить визуальный движок", icon: "network", onClick: openSource }, { label: "Настроить отображение", icon: "sliders", onClick: openDisplay }, { label: "Открыть слои", icon: "list", onClick: openLayers }, ); } - actions.push({ - label: "Сохранить компоновку", - icon: "save", - onClick: openLayout, - }); return actions; - }, [activeDefinition?.kind, runtime, sceneSettings, sourceUrl]); + }, [activeDefinition?.kind, runtime, saveWorkspaceLayout, workspaceLayoutProfile.state]); const header = ( } brandLabel="NODEDC MISSION CORE" - left={MISSION CORE} center={ <> @@ -363,9 +634,29 @@ export default function App() { {phaseLabel(runtime.state?.phase)} ) : activeDefinition.kind === "spatial" ? ( - - {effectiveSourceUrl ? "Источник назначен" : "Без источника"} - +
+ acceptRecordedReplay(launch)} + onReplaySettled={(_session, outcome) => settleRecordedReplaySwitch(outcome)} + /> + {layoutSaveNotice || workspaceLayoutProfile.error ? ( + +