Files
NODEDC_MISSION_CORE/apps/control-station/src/App.tsx
T

1044 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {SceneDisplayControls} from "../../../packages/spatial-ui/src/SceneDisplayControls";
import {SceneLayerControls} from "../../../packages/spatial-ui/src/SceneLayerControls";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
AdminNavigationPanel,
AppHeader,
ApplicationPanel,
ApplicationShell,
Button,
ControlRow,
HeaderNavigation,
HeaderProfile,
Icon,
Inspector,
StatusBadge,
TextField,
UserProfileMenu,
Window,
WindowFooterActions,
useApplicationWorkspace,
} from "@nodedc/ui-react";
import { LandingStage } from "./components/LandingStage";
import { EnvironmentSettingsWindow } from "./components/EnvironmentSettingsWindow";
import { ObservationSessionSelect } from "./components/ObservationSessionSelect";
import { SystemNavigationPanel } from "./components/system/SystemNavigationPanel";
import { SystemWorkspaceSelector } from "./components/system/SystemWorkspaceSelector";
import { useComputeContourSettings } from "./components/system/useComputeContourSettings";
import { useApplicationPanelActions } from "./components/useApplicationPanelActions";
import { useEnvironmentSettings } from "./core/environment/useEnvironmentSettings";
import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost";
import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext";
import type { ViewerSettings } from "./core/runtime/contracts";
import {
SPATIAL_SOURCE_SWITCH_BLOCKED_REASON,
isSpatialSourceSwitchBlocked,
} from "./core/runtime/acquisitionGuard";
import {
createLatestAsyncCommitter,
type LatestAsyncCommitter,
} from "./core/runtime/latestAsyncCommitter";
import { useObservationLayout } from "./core/observation/useObservationLayout";
import { recordedObservationSources } from "./core/observation/recordedObservationSources";
import type {
ObservationSessionReplayLaunch,
ObservationSessionSummary,
} from "./core/observation/sessionArchive";
import { useRecordedSessionAdmission } from "./core/observation/useRecordedSessionAdmission";
import { useWorkspaceLayoutProfile } from "./core/observation/useWorkspaceLayoutProfile";
import { viewerSettingsTargetIdentity } from "./core/observation/viewerSettingsTarget";
import { resolvePolygonRunRoute } from "./core/polygon/runArchive";
import {
OBSERVATION_WORKSPACE_ID,
OBSERVATION_WORKSPACE_LAYOUT_VERSION,
type ObservationWorkspaceLayoutProfile,
} from "./core/observation/workspaceLayout";
import {
rootById,
roots,
workspaceById,
workspacesForRoot,
type RootId,
} from "./productModel";
import { backendLabel, localConnectionPhaseLabel, phaseTone } from "./presentation";
import {
defaultSceneSettings,
type SceneSettings,
} from "./sceneSettings";
import { DeviceWorkspace } from "./workspaces/DeviceWorkspace";
import { WorkspaceRenderer } from "./workspaces/Workspaces";
import { useLaboratoryAnnotationHeader } from "./components/laboratory/useLaboratoryAnnotationHeader";
import "./styles/scene-windows.css";
type SceneToolWindowId = "sources" | "display" | "layers";
const viewerSettingsQuietPeriodMs = 750;
interface LivePerceptionLayers {
detections2d: boolean;
segmentation: boolean;
cuboids3d: boolean;
}
const defaultLivePerceptionLayers: LivePerceptionLayers = {
detections2d: false,
segmentation: false,
cuboids3d: false,
};
function toViewerSettings(
settings: SceneSettings,
perception: LivePerceptionLayers = defaultLivePerceptionLayers,
): ViewerSettings {
return {
point_size: settings.pointSize,
color_mode: settings.colorMode,
palette: settings.palette,
custom_color: settings.customColor,
accumulation_seconds: settings.accumulationSeconds,
show_points: settings.showPoints,
show_trajectory: settings.showTrajectory,
show_grid: settings.showGrid,
show_detections_2d: perception.detections2d,
show_segmentation: perception.segmentation,
show_cuboids_3d: perception.cuboids3d,
};
}
function mergeViewerSettings(
current: SceneSettings,
remote: ViewerSettings,
): SceneSettings {
const next = {
...current,
pointSize: remote.point_size,
colorMode: remote.color_mode,
palette: remote.palette,
customColor: remote.custom_color,
accumulationSeconds: remote.accumulation_seconds,
showPoints: remote.show_points,
showTrajectory: remote.show_trajectory,
showGrid: remote.show_grid,
};
const unchanged =
current.pointSize === next.pointSize &&
current.colorMode === next.colorMode &&
current.palette === next.palette &&
current.customColor === next.customColor &&
current.accumulationSeconds === next.accumulationSeconds &&
current.showPoints === next.showPoints &&
current.showTrajectory === next.showTrajectory &&
current.showGrid === next.showGrid;
return unchanged ? current : next;
}
export default function App() {
const runtime = useMissionRuntime();
const environment = useEnvironmentSettings();
const computeContourSettings = useComputeContourSettings();
const { selection } = useDevicePluginHost();
const polygonDatasetRoute = useMemo(
() => resolvePolygonRunRoute(typeof window === "undefined" ? "" : window.location.search),
[],
);
const workspace = useApplicationWorkspace<string>({
navigationOpen: false,
contentExpanded: true,
});
const [activeRoot, setActiveRoot] = useState<RootId | null>(
polygonDatasetRoute.active ? "data" : null,
);
const [environmentSettingsOpen, setEnvironmentSettingsOpen] = useState(false);
const [sourceUrl, setSourceUrl] = useState("");
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
const [recordedReplayLabel, setRecordedReplayLabel] = useState<string | null>(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<SceneToolWindowId[]>([]);
const [layoutSaveNotice, setLayoutSaveNotice] = useState<string | null>(null);
const laboratoryAnnotation = useLaboratoryAnnotationHeader();
const [sceneSettings, setSceneSettings] = useState<SceneSettings>(defaultSceneSettings);
const [displayDraft, setDisplayDraft] = useState<SceneSettings>(defaultSceneSettings);
const [livePerceptionLayers, setLivePerceptionLayers] = useState<LivePerceptionLayers>(
defaultLivePerceptionLayers,
);
const sourceSwitchBlocked = isSpatialSourceSwitchBlocked(runtime.state);
const sourceSwitchBlockedReason = sourceSwitchBlocked
? SPATIAL_SOURCE_SWITCH_BLOCKED_REASON
: null;
const sourceSwitchBlockedRef = useRef(sourceSwitchBlocked);
sourceSwitchBlockedRef.current = sourceSwitchBlocked;
const appliedProfileKeyRef = useRef<string | null>(null);
const sceneSettingsRef = useRef<SceneSettings>(defaultSceneSettings);
const displayDraftRef = useRef<SceneSettings>(defaultSceneSettings);
const confirmedSceneSettingsRef = useRef<SceneSettings>(defaultSceneSettings);
const viewerSettingsCommitTimerRef = useRef<number | null>(null);
const runtimeUpdateViewerSettingsRef = useRef(runtime.updateViewerSettings);
const livePerceptionLayersRef = useRef<LivePerceptionLayers>(defaultLivePerceptionLayers);
const livePerceptionRevisionRef = useRef(0);
const replayActiveRef = useRef(false);
const selectedRecordedSessionIdRef = useRef<string | null>(null);
const sceneSettingsCommitterActiveRef = useRef(true);
const sceneSettingsCommitterRef = useRef<LatestAsyncCommitter<SceneSettings> | null>(null);
const polygonDatasetRouteOpenedRef = useRef(false);
const currentRoot = rootById(activeRoot);
const visibleRoots = roots;
const activeEnvironmentSurface = activeRoot ?? "home";
const landingPage = environment.settings.pages[activeEnvironmentSurface];
const landingQuickActions = [
landingPage.primaryWorkspaceId,
landingPage.secondaryWorkspaceId,
].flatMap((workspaceId) => {
const definition = workspaceById(workspaceId);
if (!definition) return [];
if (activeRoot !== null && definition.root !== activeRoot) return [];
return [definition];
});
const activeDefinition = workspaceById(workspace.activeView);
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 = archiveWorkspaceActive
? replayTransitioning ? "" : sourceUrl
: automaticSourceUrl;
const replayActive = Boolean(recordedReplay && sourceUrl === recordedReplay.sourceUrl);
const replayPresented = archiveWorkspaceActive && replayActive;
const recordedSessionAdmission = useRecordedSessionAdmission(
replayPresented ? recordedReplay : null,
);
runtimeUpdateViewerSettingsRef.current = runtime.updateViewerSettings;
replayActiveRef.current = replayPresented;
useEffect(() => {
if (!polygonDatasetRoute.active || polygonDatasetRouteOpenedRef.current) return;
polygonDatasetRouteOpenedRef.current = true;
workspace.openView("datasets");
}, [polygonDatasetRoute.active, workspace]);
if (!sceneSettingsCommitterRef.current) {
sceneSettingsCommitterRef.current = createLatestAsyncCommitter<SceneSettings>({
commit: (settings) => replayActiveRef.current
? Promise.resolve(true)
: runtimeUpdateViewerSettingsRef.current(
toViewerSettings(settings, livePerceptionLayersRef.current),
),
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 = replayPresented
? replaySources
: runtime.state?.observationSources ?? [];
const activeRuntimeState = useMemo(() => {
if (!replayPresented || !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, replayPresented, replaySources, runtime.state]);
const viewerSettingsTarget = viewerSettingsTargetIdentity(runtime.state);
const observationLayout = useObservationLayout(
activeObservationSources,
replayPresented ? undefined : runtime.setObservationSourceActive,
);
const workspaceLayoutProfile = useWorkspaceLayoutProfile();
const focusedObservationSource = activeObservationSources.find(
(source) => source.id === observationLayout.focusedSourceId,
);
const observationFullscreenActive = Boolean(
activeDefinition?.kind === "spatial"
? observationLayout.maximizedFloatingSourceId || focusedObservationSource?.modality === "point-cloud"
: activeDefinition?.kind === "lab-archive"
? observationLayout.maximizedFloatingSourceId || focusedObservationSource?.modality === "point-cloud"
: activeDefinition?.kind === "cameras" && focusedObservationSource,
);
useEffect(() => {
const remote = runtime.state?.viewerSettings;
if (
!remote ||
workspaceLayoutProfile.profile ||
sceneSettingsCommitterRef.current?.isBusy()
) return;
const merged = mergeViewerSettings(sceneSettingsRef.current, remote);
const remoteLayers = {
detections2d: remote.show_detections_2d ?? false,
segmentation: remote.show_segmentation ?? false,
cuboids3d: remote.show_cuboids_3d ?? false,
};
livePerceptionLayersRef.current = remoteLayers;
setLivePerceptionLayers(remoteLayers);
sceneSettingsRef.current = merged;
confirmedSceneSettingsRef.current = merged;
setSceneSettings(merged);
if (viewerSettingsCommitTimerRef.current === null) {
displayDraftRef.current = merged;
setDisplayDraft(merged);
}
}, [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);
observationLayout.restore(profile);
}, [observationLayout.restore, workspaceLayoutProfile.profile]);
useEffect(() => {
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([]);
}, [sceneWorkspaceActive]);
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}:${viewerSettingsTarget}`
: null;
if (
!profile ||
runtime.backendStatus !== "online" ||
!applicationKey ||
appliedProfileKeyRef.current === applicationKey
) {
return;
}
appliedProfileKeyRef.current = applicationKey;
void runtime.updateViewerSettings(
toViewerSettings(profile.sceneSettings, livePerceptionLayersRef.current),
).then((applied) => {
if (!applied && appliedProfileKeyRef.current === applicationKey) {
appliedProfileKeyRef.current = null;
}
});
}, [
runtime.backendStatus,
runtime.updateViewerSettings,
viewerSettingsTarget,
workspaceLayoutProfile.profile,
]);
const activateSceneWindow = useCallback((windowId: SceneToolWindowId) => {
setSceneWindowOrder((current) => [
...current.filter((candidate) => candidate !== windowId),
windowId,
]);
}, []);
const closeSceneWindow = useCallback((windowId: SceneToolWindowId) => {
if (windowId === "sources") setSourceWindowOpen(false);
if (windowId === "display") setDisplayWindowOpen(false);
if (windowId === "layers") setLayerInspectorOpen(false);
setSceneWindowOrder((current) => current.filter((candidate) => candidate !== windowId));
}, []);
useEffect(() => {
if (!sceneWorkspaceActive || !activeSceneWindow) return;
const closeActiveWindow = (event: KeyboardEvent) => {
if (event.key !== "Escape" || event.defaultPrevented) return;
if (document.querySelector(".nodedc-dropdown-surface")) return;
if (document.querySelector('.nodedc-overlay[data-placement="center"]')) return;
event.preventDefault();
closeSceneWindow(activeSceneWindow);
};
document.addEventListener("keydown", closeActiveWindow);
return () => document.removeEventListener("keydown", closeActiveWindow);
}, [activeSceneWindow, closeSceneWindow, sceneWorkspaceActive]);
const selectRoot = (rootId: RootId) => {
setActiveRoot(rootId);
workspace.openNavigation();
if (rootId === "system") {
workspace.openView(
activeDefinition?.root === "system" ? activeDefinition.id : "modules",
);
} else {
workspace.closeView();
}
};
const openView = (viewId: string) => {
const definition = workspaceById(viewId);
if (!definition) return;
setActiveRoot(definition.root);
workspace.openView(viewId);
};
const openSource = () => {
if (!sceneWorkspaceActive) return;
setSourceDraft(sourceUrl);
setSourceWindowOpen(true);
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<SceneSettings>) => {
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<SceneSettings>) => {
commitDisplaySettings({ ...displayDraftRef.current, ...patch });
}, [commitDisplaySettings]);
const openDisplay = () => {
if (!sceneWorkspaceActive) return;
setDisplayWindowOpen(true);
activateSceneWindow("display");
};
const openLayers = () => {
if (!sceneWorkspaceActive) return;
setLayerInspectorOpen(true);
activateSceneWindow("layers");
};
const changeLivePerceptionLayers = useCallback((next: LivePerceptionLayers) => {
const previous = livePerceptionLayersRef.current;
const revision = ++livePerceptionRevisionRef.current;
livePerceptionLayersRef.current = next;
setLivePerceptionLayers(next);
if (replayActiveRef.current) return;
void runtimeUpdateViewerSettingsRef.current(
toViewerSettings(sceneSettingsRef.current, next),
).then((applied) => {
if (applied || livePerceptionRevisionRef.current !== revision) return;
livePerceptionLayersRef.current = previous;
setLivePerceptionLayers(previous);
});
}, []);
const beginRecordedReplaySwitch = useCallback(async () => {
if (sourceSwitchBlockedRef.current) {
throw new Error(SPATIAL_SOURCE_SWITCH_BLOCKED_REASON);
}
// 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.
selectedRecordedSessionIdRef.current = null;
setReplayTransitioning(true);
setRecordedReplay(null);
setRecordedReplayLabel(null);
setSourceUrl("");
setSourceDraft("");
await new Promise<void>((resolve) => {
window.requestAnimationFrame(() => window.setTimeout(resolve, 0));
});
}, []);
const acceptRecordedReplay = useCallback((
session: ObservationSessionSummary,
launch: ObservationSessionReplayLaunch,
) => {
if (sourceSwitchBlockedRef.current) {
throw new Error(SPATIAL_SOURCE_SWITCH_BLOCKED_REASON);
}
selectedRecordedSessionIdRef.current = session.id;
setRecordedReplay(launch);
setRecordedReplayLabel(session.label);
setSourceUrl(launch.sourceUrl);
setSourceDraft(launch.sourceUrl);
setReplayTransitioning(false);
}, []);
const settleRecordedReplaySwitch = useCallback((outcome: "accepted" | "error" | "cancelled") => {
if (outcome !== "accepted") setReplayTransitioning(false);
}, []);
const releaseRecordedReplayForDelete = useCallback(async (sessionId: string) => {
if (selectedRecordedSessionIdRef.current !== sessionId) return;
// Deleting the selected archive is a source transition. Unmount Rerun and
// recorded media first so no admission request can outlive the evidence.
selectedRecordedSessionIdRef.current = null;
setReplayTransitioning(true);
setRecordedReplay(null);
setRecordedReplayLabel(null);
setSourceUrl("");
setSourceDraft("");
await new Promise<void>((resolve) => {
window.requestAnimationFrame(() => window.setTimeout(resolve, 0));
});
if (selectedRecordedSessionIdRef.current === null) setReplayTransitioning(false);
}, []);
const activateAutomaticSpatialSource = useCallback(() => {
// A plugin-owned live/file-replay start must release any host-selected
// archive or manual URL before the new acquisition becomes non-terminal.
// This transition is an internal start boundary, not an operator source
// switch, so it intentionally does not consult the acquisition guard.
selectedRecordedSessionIdRef.current = null;
setReplayTransitioning(false);
setRecordedReplay(null);
setRecordedReplayLabel(null);
setSourceUrl("");
setSourceDraft("");
observationLayout.activateAutomaticDefaults();
}, [observationLayout.activateAutomaticDefaults]);
useEffect(() => {
if (!["recordings", "lab-archive"].includes(activeDefinition?.kind ?? "")) {
setReplayTransitioning(false);
}
}, [activeDefinition?.kind]);
const applyScenePatch = (patch: Partial<SceneSettings>) => commitDisplayPatch(patch);
const saveWorkspaceLayout = useCallback(async () => {
flushDisplaySettings();
await sceneSettingsCommitterRef.current?.waitForIdle();
const layout = observationLayout.snapshot();
if (!layout) {
setLayoutSaveNotice("Сцена ещё не измерила рабочую область.");
return;
}
const draft: ObservationWorkspaceLayoutProfile = {
version: OBSERVATION_WORKSPACE_LAYOUT_VERSION,
revision: workspaceLayoutProfile.profile?.revision ?? 0,
workspaceId: OBSERVATION_WORKSPACE_ID,
sceneSettings: sceneSettingsRef.current,
...layout,
};
const saved = await workspaceLayoutProfile.save(draft);
setLayoutSaveNotice(saved ? "Компоновка сохранена" : workspaceLayoutProfile.error);
}, [
flushDisplaySettings,
observationLayout.snapshot,
workspaceLayoutProfile,
]);
useEffect(() => {
if (!layoutSaveNotice || layoutSaveNotice !== "Компоновка сохранена") return;
const timer = window.setTimeout(() => setLayoutSaveNotice(null), 2600);
return () => window.clearTimeout(timer);
}, [layoutSaveNotice]);
const [fleetCreateRequest, setFleetCreateRequest] = useState(0);
const onAddVehicle = useCallback(() => setFleetCreateRequest(value => value + 1), []);
const contentActions = useApplicationPanelActions({
onAddVehicle,
definition: activeDefinition,
refreshRuntime: runtime.refresh,
resetConnectionScenario: runtime.resetConnectionScenario,
connectionScenarioResetting: runtime.pendingAction === "mode",
saveWorkspaceLayout,
workspaceLayoutSaving: workspaceLayoutProfile.state === "saving",
systemUtilityActions: computeContourSettings.utilityActions,
});
const header = (
<AppHeader
brand={<img src="/nodedc-logo.svg" alt="NODE.DC" />}
brandHref="/"
brandLabel="NODEDC MISSION CORE"
center={
<HeaderNavigation
label="Архитектурные блоки пункта управления"
value={activeRoot ?? undefined}
items={visibleRoots.map((root) => ({
value: root.id,
label: environment.settings.pages[root.id].headerLabel,
}))}
onChange={selectRoot}
/>
}
right={
<HeaderProfile>
<UserProfileMenu
displayName="DC"
subtitle="Mission Core"
triggerLabel={null}
actions={[
{
id: "refresh",
label: "Обновить контур",
icon: "refresh",
onSelect: () => void runtime.refresh(),
},
{
id: "settings",
label: "Настройки",
icon: "settings",
onSelect: () => setEnvironmentSettingsOpen(true),
},
]}
/>
</HeaderProfile>
}
/>
);
return (
<>
<ApplicationShell
data-nodedc-ui
className="control-station"
data-observation-fullscreen={observationFullscreenActive ? "true" : undefined}
navigationOpen={
workspace.navigationOpen && activeRoot !== null
}
contentOpen={workspace.contentOpen && activeDefinition !== null}
contentExpanded={workspace.contentExpanded}
header={header}
stage={
<LandingStage
root={currentRoot}
backendStatus={runtime.backendStatus}
phase={runtime.state?.phase}
message={runtime.state?.message}
page={landingPage}
quickActions={landingQuickActions}
onOpenWorkspace={openView}
/>
}
navigation={currentRoot && activeRoot === "system" ? (
<SystemNavigationPanel
title={currentRoot.title}
onAdd={computeContourSettings.openCreate}
onClose={workspace.closeNavigation}
/>
) : currentRoot ? (
<AdminNavigationPanel
eyebrow="MISSION CORE"
title={currentRoot.title}
closeLabel={`Закрыть раздел «${currentRoot.label}»`}
navigationLabel={`Рабочие поверхности раздела «${currentRoot.label}»`}
onClose={workspace.closeNavigation}
contexts={[
{
id: "local-contour",
label: "Локальный контур",
description: selection ? "Подключение" : "Модель не выбрана",
icon: <Icon name="network" />,
active:
runtime.backendStatus !== "offline"
&& runtime.backendStatus !== "unconfigured",
},
]}
items={rootWorkspaces.map((item) => ({
id: item.id,
label: item.label,
icon: <Icon name={item.icon} />,
}))}
activeId={workspace.activeView ?? undefined}
onItemChange={openView}
footer={
<>
<span className="nodedc-admin-panel__nav-icon" aria-hidden="true">
<Icon name="activity" />
</span>
<span>
{rootWorkspaces.length}{" "}
{rootWorkspaces.length === 1
? "рабочая поверхность"
: "рабочих поверхностей"}
</span>
</>
}
/>
) : null}
content={activeDefinition ? (
<ApplicationPanel
key={activeDefinition.id}
eyebrow={activeDefinition.eyebrow}
title={
activeDefinition.kind === "recordings"
&& replayPresented
&& recordedReplayLabel
? `${activeDefinition.title}: ${recordedReplayLabel}`
: activeDefinition.title
}
description={activeDefinition.description}
expanded={workspace.contentExpanded}
onExpandedChange={workspace.setContentExpanded}
headerTools={
activeDefinition.kind === "device" ? (
<StatusBadge tone={phaseTone(runtime.state?.phase)}>
{localConnectionPhaseLabel(runtime.state?.phase)}
</StatusBadge>
) : activeDefinition.kind === "spatial" ? (
<div className="observation-header-tools">
<StatusBadge tone={["live", "replay"].includes(runtime.state?.sourceMode ?? "") ? "success" : "neutral"}>
{runtime.state?.sourceMode === "live" ? "Эфир" : runtime.state?.sourceMode === "replay" ? "Повтор записи" : "Ожидание эфира"}
</StatusBadge>
{layoutSaveNotice || workspaceLayoutProfile.error ? (
<span
className="workspace-layout-feedback"
data-error={workspaceLayoutProfile.error ? "true" : undefined}
role={workspaceLayoutProfile.error ? "alert" : "status"}
>
<i
className="api-dot"
data-status={workspaceLayoutProfile.error ? "error" : "online"}
aria-hidden="true"
/>
{layoutSaveNotice || workspaceLayoutProfile.error}
</span>
) : null}
</div>
) : activeDefinition.kind === "recordings" ? (
<ObservationSessionSelect
disabled={runtime.pendingAction !== null || sourceSwitchBlocked}
blockedReason={sourceSwitchBlockedReason}
onReplayBegin={beginRecordedReplaySwitch}
onReplayAccepted={acceptRecordedReplay}
onReplaySettled={(_session, outcome) =>
settleRecordedReplaySwitch(outcome)}
onDeleteBegin={releaseRecordedReplayForDelete}
/>
) : activeDefinition.kind === "vehicles" ? null : activeDefinition.kind === "datasets" ? (
<StatusBadge tone="neutral">Offline evaluation</StatusBadge>
) : activeDefinition.kind === "lab-archive" ? (
laboratoryAnnotation.control
) : activeDefinition.kind === "observatory" ? (
<StatusBadge tone="neutral">Только наблюдение</StatusBadge>
) : activeDefinition.root === "system" ? (
<SystemWorkspaceSelector
value={activeDefinition.id}
onChange={openView}
/>
) : (
<StatusBadge tone="warning">Интерфейс готов</StatusBadge>
)
}
utilityActions={contentActions}
onClose={workspace.closeView}
>
{activeDefinition.kind === "device" ? (
<DeviceWorkspace
onOpenSpatialScene={() => openView("spatial-scene")}
onActivateAutomaticSpatialSource={activateAutomaticSpatialSource}
/>
) : (
<WorkspaceRenderer
definition={activeDefinition}
fleetCreateRequest={fleetCreateRequest}
state={activeRuntimeState}
backendStatus={runtime.backendStatus}
sourceUrl={effectiveSourceUrl}
recordedReplay={replayPresented ? recordedReplay : null}
recordedSessionAdmission={recordedSessionAdmission}
sceneSettings={sceneSettings}
accumulationSeconds={displayDraft.accumulationSeconds}
onAccumulationChange={(accumulationSeconds) =>
stageDisplayPatch({ accumulationSeconds })}
onAccumulationCommit={flushDisplaySettings}
livePerceptionLayers={livePerceptionLayers}
onLivePerceptionLayersChange={changeLivePerceptionLayers}
observationLayout={observationLayout}
deviceLabel={selection?.model.displayName ?? null}
spatialControls={selection?.SpatialControlsView
? {
View: selection.SpatialControlsView,
model: selection.model,
}
: null}
sessionArchive={{
disabled: runtime.pendingAction !== null || sourceSwitchBlocked,
blockedReason: sourceSwitchBlockedReason,
onReplayBegin: beginRecordedReplaySwitch,
onReplayAccepted: acceptRecordedReplay,
onReplaySettled: (_session, outcome) =>
settleRecordedReplaySwitch(outcome),
onDeleteBegin: releaseRecordedReplayForDelete,
}}
onLaboratoryAnnotationActionChange={laboratoryAnnotation.setAction}
onLaboratoryViewActionChange={laboratoryAnnotation.setViewAction}
navigation={{
openView,
openSource,
openDisplay,
openLayers,
activateAutomaticSpatialSource,
}}
/>
)}
</ApplicationPanel>
) : null}
/>
<EnvironmentSettingsWindow
open={environmentSettingsOpen}
settings={environment.settings}
state={environment.state}
error={environment.error}
onClose={() => setEnvironmentSettingsOpen(false)}
onSave={environment.save}
onUpload={environment.upload}
/>
{computeContourSettings.window}
<Window
open={sceneWorkspaceActive && sourceWindowOpen}
title="Визуальный движок"
subtitle="Rerun gRPC и записи пространственной сцены"
placement="end"
draggable
closeOnBackdrop={false}
closeOnEscape={false}
lockBodyScroll={false}
trapFocus={false}
className="scene-tool-window scene-tool-window--sources"
data-scene-window="sources"
data-scene-active={activeSceneWindow === "sources" ? "true" : undefined}
onPointerDown={() => activateSceneWindow("sources")}
onClose={() => closeSceneWindow("sources")}
footer={
<WindowFooterActions>
<Button
variant="ghost"
disabled={sourceSwitchBlocked}
title={sourceSwitchBlockedReason ?? undefined}
onClick={() => {
if (sourceSwitchBlockedRef.current) return;
selectedRecordedSessionIdRef.current = null;
setSourceDraft("");
setSourceUrl("");
setRecordedReplay(null);
setRecordedReplayLabel(null);
}}
>
Сбросить адрес
</Button>
<Button
variant="primary"
shape="pill"
disabled={sourceSwitchBlocked || !sourceDraft.trim()}
title={sourceSwitchBlockedReason ?? undefined}
onClick={() => {
if (sourceSwitchBlockedRef.current) return;
selectedRecordedSessionIdRef.current = null;
setSourceUrl(sourceDraft.trim());
setRecordedReplay(null);
setRecordedReplayLabel(null);
}}
>
Применить адрес
</Button>
</WindowFooterActions>
}
>
<Inspector
defaultOpen={["connection"]}
singleOpen
sections={[
{
id: "connection",
label: "Подключение",
description: "Адрес потока или записи",
content: (
<div className="inspector-control-stack">
<ControlRow label="Состояние">
<span className="scene-window-state">
<i className="api-dot" data-status={effectiveSourceUrl ? "online" : "checking"} aria-hidden="true" />
{effectiveSourceUrl ? "Источник назначен" : "Источник не назначен"}
</span>
</ControlRow>
{effectiveSourceUrl ? (
<ControlRow label="Активный адрес" layout="stack">
<code className="scene-window-code">{effectiveSourceUrl}</code>
</ControlRow>
) : null}
<ControlRow label="Автоматический" layout="stack">
<code className="scene-window-code">
{automaticSourceUrl || "Локальный источник ещё не опубликован"}
</code>
</ControlRow>
<TextField
label="Ручной адрес"
hint="необязательно"
value={sourceDraft}
onChange={(event) => setSourceDraft(event.target.value)}
disabled={sourceSwitchBlocked}
spellCheck={false}
placeholder="rerun+http://127.0.0.1:9876/proxy"
description={sourceSwitchBlockedReason ?? "Пустое значение использует автоматический локальный источник. Ручной адрес нужен для другого gRPC-потока или записи RRD."}
/>
</div>
),
},
{
id: "formats",
label: "Поддерживаемые адреса",
description: "Rerun gRPC и RRD",
content: (
<div className="inspector-control-stack">
<ControlRow label="Живой поток" layout="stack">
<code className="scene-window-code">rerun+http://…/proxy</code>
</ControlRow>
<ControlRow label="Локальная запись" layout="stack">
<code className="scene-window-code">http://…/recording.rrd</code>
</ControlRow>
<ControlRow label="Удалённая запись" layout="stack">
<code className="scene-window-code">https://…/recording.rrd</code>
</ControlRow>
</div>
),
},
{
id: "adapter",
label: "Локальный адаптер",
description: "Транспорт устройства",
content: (
<div className="inspector-control-stack">
<ControlRow label="Контур">
<span className="scene-window-state">
<i className="api-dot" data-status={runtime.backendStatus} aria-hidden="true" />
{backendLabel(runtime.backendStatus)}
</span>
</ControlRow>
<p className="scene-window-note">
Встроенный адаптер публикует локальный Rerun gRPC автоматически после запуска
живого приёма или повтора записи. Ручной адрес для этого не требуется.
</p>
</div>
),
},
]}
/>
</Window>
<Window
open={sceneWorkspaceActive && displayWindowOpen}
title="Отображение"
subtitle="Параметры пространственной сцены"
placement="end"
draggable
closeOnBackdrop={false}
closeOnEscape={false}
lockBodyScroll={false}
trapFocus={false}
className="scene-tool-window scene-tool-window--display"
data-scene-window="display"
data-scene-active={activeSceneWindow === "display" ? "true" : undefined}
onPointerDown={() => activateSceneWindow("display")}
onClose={() => closeSceneWindow("display")}
>
<SceneDisplayControls displayDraft={displayDraft} stageDisplayPatch={stageDisplayPatch}
commitDisplayPatch={commitDisplayPatch} flushDisplaySettings={flushDisplaySettings} replayPresented={replayPresented}/>
</Window>
<Window
open={sceneWorkspaceActive && layerInspectorOpen}
title="Слои сцены"
subtitle="Сущности пространственной сцены"
placement="end"
draggable
closeOnBackdrop={false}
closeOnEscape={false}
lockBodyScroll={false}
trapFocus={false}
className="scene-tool-window scene-tool-window--layers"
data-scene-window="layers"
data-scene-active={activeSceneWindow === "layers" ? "true" : undefined}
onPointerDown={() => activateSceneWindow("layers")}
onClose={() => closeSceneWindow("layers")}
>
<div className="layer-inspector-intro">
<span className="scene-window-state">
<i className="api-dot" data-status={effectiveSourceUrl ? "online" : "checking"} aria-hidden="true" />
{effectiveSourceUrl ? "Источник назначен" : "Источник не назначен"}
</span>
<p>Структура повторяет продуктовые сущности, а не внутренние панели визуального движка.</p>
</div>
<SceneLayerControls sceneSettings={sceneSettings} pending={runtime.pendingAction === "viewer"} applyScenePatch={applyScenePatch}/>
</Window>
</>
);
}