feat(ui): separate live data and lab surfaces

This commit is contained in:
DCCONSTRUCTIONS
2026-07-26 12:50:10 +03:00
parent 677dbdb776
commit 05413e3f5c
19 changed files with 1430 additions and 402 deletions
+5 -4
View File
@@ -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.
+59 -41
View File
@@ -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<RootId | null>(
polygonDatasetRoute.active ? "polygon" : null,
polygonDatasetRoute.active ? "data" : null,
);
const [sourceUrl, setSourceUrl] = useState("");
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(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<SceneSettings>) => 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() {
</StatusBadge>
) : activeDefinition.kind === "spatial" ? (
<div className="observation-header-tools">
<ObservationSessionSelect
disabled={runtime.pendingAction !== null || sourceSwitchBlocked}
blockedReason={sourceSwitchBlockedReason}
onReplayBegin={beginRecordedReplaySwitch}
onReplayAccepted={acceptRecordedReplay}
onReplaySettled={(_session, outcome) => settleRecordedReplaySwitch(outcome)}
/>
<StatusBadge tone={runtime.state?.sourceMode === "live" ? "success" : "neutral"}>
{runtime.state?.sourceMode === "live" ? "Эфир" : "Ожидание эфира"}
</StatusBadge>
{layoutSaveNotice || workspaceLayoutProfile.error ? (
<span
className="workspace-layout-feedback"
@@ -745,8 +751,12 @@ export default function App() {
</span>
) : null}
</div>
) : activeDefinition.kind === "polygon-datasets" ? (
) : activeDefinition.kind === "recordings" ? (
<StatusBadge tone="accent">Архив записей</StatusBadge>
) : activeDefinition.kind === "datasets" ? (
<StatusBadge tone="neutral">Offline evaluation</StatusBadge>
) : activeDefinition.kind === "lab-archive" ? (
<StatusBadge tone="accent">Лаборатория</StatusBadge>
) : (
<StatusBadge tone="warning">Интерфейс готов</StatusBadge>
)
@@ -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() {
/>
<Window
open={spatialWorkspaceActive && sourceWindowOpen}
open={sceneWorkspaceActive && sourceWindowOpen}
title="Визуальный движок"
subtitle="Rerun gRPC и записи пространственной сцены"
placement="end"
@@ -924,7 +942,7 @@ export default function App() {
</Window>
<Window
open={spatialWorkspaceActive && displayWindowOpen}
open={sceneWorkspaceActive && displayWindowOpen}
title="Отображение"
subtitle="Параметры пространственной сцены"
placement="end"
@@ -1003,7 +1021,7 @@ export default function App() {
</ControlRow>
</div>
) : null}
{replayActive ? (
{replayPresented ? (
<p className="scene-window-note">
Первый новый режим читает индекс архивных точек. Следующие палитры
переключаются из подготовленного цветового кэша без переэкспорта геометрии.
@@ -1068,7 +1086,7 @@ export default function App() {
</Window>
<Window
open={spatialWorkspaceActive && layerInspectorOpen}
open={sceneWorkspaceActive && layerInspectorOpen}
title="Слои сцены"
subtitle="Сущности пространственной сцены"
placement="end"
@@ -92,17 +92,7 @@ function sessionDescription(session: ObservationSessionSummary): string {
return `${formatStartedAt(session.startedAtUtc)} · ${formatDuration(session.durationSeconds)} · ${modalities}`;
}
export function ObservationSessionSelect({
limit = 100,
disabled = false,
blockedReason = null,
onReplayBegin,
onReplayAccepted,
onReplaySettled,
}: {
limit?: number;
disabled?: boolean;
blockedReason?: string | null;
export interface ObservationSessionReplayCallbacks {
onReplayBegin?: (
session: ObservationSessionSummary,
launch: ObservationSessionReplayLaunch,
@@ -115,6 +105,19 @@ export function ObservationSessionSelect({
session: ObservationSessionSummary,
outcome: ObservationReplayOutcome,
) => void | Promise<void>;
}
export function ObservationSessionSelect({
limit = 100,
disabled = false,
blockedReason = null,
onReplayBegin,
onReplayAccepted,
onReplaySettled,
}: ObservationSessionReplayCallbacks & {
limit?: number;
disabled?: boolean;
blockedReason?: string | null;
}) {
const [deleteTarget, setDeleteTarget] = useState<ObservationSessionSummary | null>(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<ObservationSessionSummary | null>(null);
const sessions = useObservationSessions({
limit,
replayEnabled: blockedReason === null,
onReplayBegin,
onReplayAccepted,
onReplaySettled,
});
const items = labsOnly
? sessions.items.filter((session) => session.lab !== null)
: sessions.items;
return <>
<section
className="observation-session-archive"
aria-label={labsOnly ? "Лабораторные записи" : "Сохранённые сессии"}
>
<header className="observation-session-archive__head">
<div>
<span className="section-eyebrow">
{labsOnly ? "ВОСПРОИЗВОДИМЫЕ ЛАБОРАТОРНЫЕ ЗАПИСИ" : "АРХИВ НАБЛЮДЕНИЯ"}
</span>
<h2>{labsOnly ? "Записанные лабораторные работы" : "Сохранённые сессии"}</h2>
<p>
{labsOnly
? "Каждая запись связана с LAB-конфигурацией и открывается как самостоятельный визуальный результат."
: "Выберите запись: сцена и синхронные каналы откроются ниже, не меняя эфир наблюдения."}
</p>
</div>
<button
type="button"
className="observation-session-archive__refresh"
aria-label="Обновить каталог сессий"
disabled={sessions.state === "loading"}
onClick={() => void sessions.refresh()}
>
<Icon name="refresh" size={14} />
<span>Обновить</span>
</button>
</header>
{items.length > 0 ? (
<div className="observation-session-archive__list">
{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 (
<article
key={session.id}
className="observation-session-archive__item"
data-deleting={deleting ? "true" : undefined}
>
<button
type="button"
className="observation-session-archive__open"
disabled={disabled || pending || deleting || !session.replayable}
title={blockedReason ?? undefined}
onClick={() => void sessions.replay(session.id)}
>
<i data-session-visual-state={visualState} aria-hidden="true" />
<span>
<strong>
{session.lab &&
!session.label.startsWith(`${session.lab.labId} ·`) ? (
<small>{session.lab.labId}</small>
) : null}
{session.label}
</strong>
<small>{sessionDescription(session)}</small>
</span>
<em>
{pending && sessions.replayProgress
? progressCopy(sessions.replayProgress.phase)
: observationSessionVisualLabel(visualState)}
</em>
</button>
<button
type="button"
className="observation-session-archive__delete"
aria-label={`Удалить сохранённую сессию ${session.label}`}
disabled={pending || deleting}
onClick={() => setDeleteTarget(session)}
>
<Icon name="trash" size={15} />
</button>
</article>
);
})}
</div>
) : sessions.state === "loading" ? (
<div className="observation-session-archive__empty" role="status">
<span className="busy-indicator" aria-hidden="true" />
<strong>Читаем каталог</strong>
</div>
) : (
<div className="observation-session-archive__empty">
<Icon name={sessions.error ? "alert" : "database"} size={18} />
<strong>
{sessions.error
? "Каталог недоступен"
: labsOnly
? "LAB-записей пока нет"
: "Сессий пока нет"}
</strong>
<span>
{sessions.error
?? (labsOnly
? "Зафиксированные лабораторные записи появятся здесь автоматически."
: "Завершённые записи появятся здесь автоматически.")}
</span>
</div>
)}
{sessions.error && items.length > 0 ? (
<footer className="observation-session-menu__error" role="alert">
<Icon name="alert" size={13} />
<span>{sessions.error}</span>
{sessions.failedSessionId ? (
<button type="button" onClick={() => void sessions.retry()}>
Повторить
</button>
) : null}
</footer>
) : null}
</section>
<ConfirmationModal
open={deleteTarget !== null}
title="Удалить сохранённую сессию?"
description={deleteTarget ? <>
<strong>{deleteTarget.label}</strong>
{deleteTarget.lab ? (
<p>
Будут удалены только LAB-запись из каталога и её подготовленный кэш.
Исходная запись {deleteTarget.lab.sourceSessionId} и зафиксированный
лабораторный результат останутся неизменными.
</p>
) : (
<p>
Сессия, исходные данные наблюдения, подготовленная Rerun-запись и
видеоматериалы будут удалены с этого сервера без возможности восстановления.
</p>
)}
</> : null}
confirmLabel="Удалить сессию"
pendingLabel="Удаление…"
danger
onClose={() => {
if (sessions.deletingSessionId === null) setDeleteTarget(null);
}}
onConfirm={async () => {
if (!deleteTarget) return;
if (await sessions.remove(deleteTarget.id)) setDeleteTarget(null);
}}
/>
</>;
}
+37 -110
View File
@@ -10,16 +10,17 @@ export type RootId =
| "system";
export type WorkspaceKind =
| "overview"
| "device"
| "spatial"
| "recordings"
| "cameras"
| "map"
| "timeline"
| "missions"
| "catalog"
| "lidar-quality"
| "polygon-datasets";
| "contour-health"
| "datasets"
| "lab-archive";
export type CapabilityStatus = "active" | "ready" | "contract" | "later";
@@ -128,15 +129,6 @@ export const roots: RootDefinition[] = [
statement: "Хранить живой контур и воспроизводимый эксперимент как одну модель данных.",
accent: "ПОТОКИ И ДОКАЗАТЕЛЬСТВА",
},
{
id: "polygon",
label: "Полигон",
title: "Полигон",
eyebrow: "СИМУЛЯЦИЯ И КВАЛИФИКАЦИЯ",
description: "Датасеты и виртуальные среды для воспроизводимой проверки алгоритмов.",
statement: "Проверять алгоритмы и поведение машины на версионированных входах.",
accent: "КВАЛИФИКАЦИЯ И ДОКАЗАТЕЛЬСТВА",
},
{
id: "system",
label: "Система",
@@ -146,92 +138,39 @@ export const roots: RootDefinition[] = [
statement: "Подключать новые возможности модульно, не связывая интерфейс с одним устройством.",
accent: "МОДУЛИ И ИНТЕГРАЦИИ",
},
{
id: "polygon",
label: "Тестировочный контур",
title: "Тестировочный контур",
eyebrow: "ЛАБОРАТОРНЫЕ ИССЛЕДОВАНИЯ",
description: "Воспроизводимые лабораторные работы, конфигурации и визуальные доказательства.",
statement: "Фиксировать каждый эксперимент как проверяемую лабораторную работу.",
accent: "ИССЛЕДОВАНИЯ И ДОКАЗАТЕЛЬСТВА",
},
];
export const workspaces: WorkspaceDefinition[] = [
{
id: "polygon-datasets",
id: "lab-archive",
root: "polygon",
label: "Датасеты",
title: "Датасеты",
eyebrow: "ПОЛИГОН / ДАТАСЕТЫ",
description: "Версионированные входы для replay-регрессии и независимой оценки алгоритмов.",
icon: "grid",
kind: "polygon-datasets",
label: "Лабораторные работы",
title: "Архив лабораторных работ",
eyebrow: "ТЕСТОВЫЙ КОНТУР / ЛАБОРАТОРИЯ",
description: "Конфигурации, ход эксперимента и визуальные результаты принятых лабораторных работ.",
icon: "clipboard",
kind: "lab-archive",
groups: [],
},
{
id: "command-overview",
root: "center",
label: "Оперативный обзор",
title: "Оперативный обзор",
eyebrow: "ЦЕНТР / СЕЙЧАС",
description: "Живое состояние локального контура и быстрые переходы к рабочим поверхностям.",
icon: "activity",
kind: "overview",
groups: [
{
title: "Оперативная картина",
description: "Состояние, которое уже приходит из локального адаптера.",
capabilities: [
active("Состояние устройства", "Фаза подключения, адрес и активный режим источника."),
active("Метрики потока", "Частота кадров, точки в кадре, задержка и пропуски предпросмотра."),
ready("Единый индикатор готовности", "Нормализованная готовность сенсоров и транспорта."),
],
},
{
title: "Будущий контур",
description: "Сводка нескольких аппаратов без изменения оболочки.",
capabilities: [
contract("Парк на карте", "Положение, связь и назначенная миссия каждого аппарата."),
contract("Очередь тревог", "Отклонения, потери связи и запросы оператора."),
later("Командные роли", "Разделение наблюдения, планирования и подтверждения действий."),
],
},
],
},
{
id: "contour-health",
root: "center",
label: "Состояние контура",
title: "Состояние контура",
eyebrow: "ЦЕНТР / СОСТОЯНИЕ",
description: "Сенсоры, транспорт, вычисления и внешние сервисы в одном диагностическом дереве.",
description: "Узлы, процессы, сеть и подключённые устройства по данным живого контура.",
icon: "shield",
kind: "catalog",
groups: [
{
title: "Критический путь",
description: "От физического устройства до операторского интерфейса.",
capabilities: [
active("Локальный API", "Проверка состояния, резервный REST-опрос и канал событий WebSocket."),
active("Приём данных", "Плагинский транспорт, нормализация облака точек и позы."),
ready("Визуальный движок", "Встроенный веб-визуализатор Rerun ожидает совместимый источник."),
contract("Бортовой шлюз", "Будущий транспортный адаптер между ROS 2/Zenoh и пунктом управления."),
],
},
],
},
{
id: "operator-activity",
root: "center",
label: "Активность",
title: "Активность оператора",
eyebrow: "ЦЕНТР / АКТИВНОСТЬ",
description: "Хронология подключений, запусков, остановок и изменений конфигурации.",
icon: "list",
kind: "timeline",
groups: [
{
title: "События",
description: "Операции интерфейса и ответы локального контура.",
capabilities: [
active("Текущие статусы", "REST и WebSocket уже передают фазу и текст операции."),
contract("Журнал действий", "Нормализованные события с пользователем, временем и результатом."),
later("Подпись подтверждений", "Аудит опасных команд и изменение полномочий."),
],
},
],
kind: "contour-health",
groups: [],
},
{
id: "vehicles",
@@ -548,21 +487,21 @@ export const workspaces: WorkspaceDefinition[] = [
label: "Сессии и записи",
title: "Сессии и записи",
eyebrow: "ДАННЫЕ / ЗАПИСИ",
description: "Живые и записанные эксперименты с воспроизводимым набором потоков.",
description: "Каталог сохранённых пространственных сцен и их воспроизведение.",
icon: "folder",
kind: "catalog",
groups: [
{
title: "Артефакты",
description: "Сохраняется политика «сначала исходные данные».",
capabilities: [
active("Нативные захваты", "Сырые конверты устройства и обезличенный манифест."),
active("Воспроизведение", "Повтор исходной записи через адаптер выбранной модели."),
ready("Запись RRD", "Совместимая запись Rerun после появления потокового адаптера."),
ready("Компоновка RBL", "Версионируемая компоновка визуализатора рядом с кодом."),
],
},
],
kind: "recordings",
groups: [],
},
{
id: "datasets",
root: "data",
label: "Датасеты",
title: "Датасеты",
eyebrow: "ДАННЫЕ / ДАТАСЕТЫ",
description: "Публичные источники для независимой replay-проверки алгоритмов.",
icon: "grid",
kind: "datasets",
groups: [],
},
{
id: "streams",
@@ -587,18 +526,6 @@ export const workspaces: WorkspaceDefinition[] = [
},
],
},
{
id: "lidar-quality",
root: "fleet",
label: "Диагностика LiDAR",
title: "Диагностика LiDAR",
eyebrow: "ПАРК / СЕНСОРЫ / LIDAR",
description:
"Состояние данных и качество записей выбранного LiDAR.",
icon: "activity",
kind: "lidar-quality",
groups: [],
},
{
id: "entities",
root: "data",
@@ -270,6 +270,158 @@
color: rgb(var(--nodedc-danger-rgb));
}
.observation-session-archive {
display: grid;
gap: 0.85rem;
padding: 1rem;
border-radius: 1rem;
background: rgb(255 255 255 / 0.025);
}
.observation-session-archive__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.observation-session-archive__head h2,
.observation-session-archive__head p {
margin: 0;
}
.observation-session-archive__head h2 {
margin-top: 0.25rem;
color: var(--nodedc-text-primary);
font-size: 1rem;
}
.observation-session-archive__head p {
max-width: 50rem;
margin-top: 0.35rem;
color: var(--nodedc-text-muted);
font-size: 0.68rem;
line-height: 1.5;
}
.observation-session-archive__refresh {
display: inline-flex;
align-items: center;
gap: 0.45rem;
border: 0;
border-radius: 999px;
background: rgb(255 255 255 / 0.055);
color: var(--nodedc-text-secondary);
padding: 0.55rem 0.75rem;
font: inherit;
font-size: 0.65rem;
cursor: pointer;
}
.observation-session-archive__list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.35rem;
max-height: 18rem;
overflow: auto;
}
.observation-session-archive__item {
display: grid;
grid-template-columns: minmax(0, 1fr) 2.5rem;
min-width: 0;
border-radius: 0.75rem;
background: rgb(255 255 255 / 0.025);
}
.observation-session-archive__open {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.7rem;
min-width: 0;
border: 0;
background: transparent;
color: inherit;
padding: 0.72rem;
text-align: left;
cursor: pointer;
}
.observation-session-archive__open > i {
width: 0.45rem;
height: 0.45rem;
border-radius: 50%;
background: var(--nodedc-text-muted);
}
.observation-session-archive__open > i[data-session-visual-state="ready"],
.observation-session-archive__open > i[data-session-visual-state="processing"] {
background: rgb(var(--nodedc-success-rgb));
}
.observation-session-archive__open > span {
display: grid;
min-width: 0;
gap: 0.28rem;
}
.observation-session-archive__open strong,
.observation-session-archive__open small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.observation-session-archive__open strong {
color: var(--nodedc-text-primary);
font-size: 0.7rem;
}
.observation-session-archive__open strong > small {
margin-right: 0.45rem;
color: var(--nodedc-accent);
font-size: 0.55rem;
letter-spacing: 0.05em;
}
.observation-session-archive__open > span > small,
.observation-session-archive__open em {
color: var(--nodedc-text-muted);
font-size: 0.58rem;
font-style: normal;
}
.observation-session-archive__delete {
display: grid;
place-items: center;
border: 0;
background: transparent;
color: var(--nodedc-text-muted);
cursor: pointer;
}
.observation-session-archive__delete:hover:not(:disabled) {
color: rgb(var(--nodedc-danger-rgb));
}
.observation-session-archive__empty {
display: grid;
min-height: 7rem;
place-items: center;
align-content: center;
gap: 0.45rem;
color: var(--nodedc-text-muted);
font-size: 0.65rem;
text-align: center;
}
@media (max-width: 1080px) {
.observation-session-archive__list {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 860px) {
.workspace-layout-feedback {
display: none;
@@ -287,4 +439,8 @@
.observation-session-select__trigger > svg:last-child {
display: none;
}
.observation-session-archive__head {
display: grid;
}
}
@@ -2157,6 +2157,349 @@
gap: 1rem;
}
.contour-health-dashboard {
display: grid;
gap: 1rem;
}
.contour-health-summary,
.contour-health-summary__status,
.contour-node header,
.contour-network-strip,
.contour-connected-device {
display: flex;
align-items: center;
}
.contour-health-summary {
justify-content: space-between;
gap: 1rem;
}
.contour-health-summary h2,
.contour-health-summary p,
.contour-node h3 {
margin: 0;
}
.contour-health-summary h2 {
margin-top: 0.25rem;
color: var(--nodedc-text-primary);
font-size: 1.1rem;
}
.contour-health-summary p {
max-width: 52rem;
margin-top: 0.35rem;
color: var(--nodedc-text-muted);
font-size: 0.68rem;
line-height: 1.5;
}
.contour-health-summary__status {
flex: 0 0 auto;
gap: 0.6rem;
}
.contour-health-kpis {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.45rem;
}
.contour-health-kpis > div {
display: grid;
gap: 0.28rem;
border-radius: 0.85rem;
background: rgb(255 255 255 / 0.03);
padding: 0.85rem;
}
.contour-health-kpis span,
.contour-health-kpis small,
.contour-node dt,
.contour-node small,
.contour-process-list small,
.contour-process-list em,
.contour-network-strip small,
.contour-connected-device small {
color: var(--nodedc-text-muted);
font-size: 0.58rem;
font-style: normal;
}
.contour-health-kpis strong {
color: var(--nodedc-text-primary);
font-size: 1.15rem;
}
.contour-node-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.65rem;
}
.contour-node {
display: grid;
align-content: start;
gap: 0.85rem;
min-width: 0;
border-radius: 1rem;
background: rgb(255 255 255 / 0.025);
padding: 1rem;
}
.contour-node header {
justify-content: space-between;
gap: 1rem;
}
.contour-node h3 {
margin-top: 0.25rem;
color: var(--nodedc-text-primary);
font-size: 0.95rem;
}
.contour-node dl {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
margin: 0;
border-radius: 0.75rem;
background: rgb(255 255 255 / 0.022);
}
.contour-node dl > div {
display: grid;
gap: 0.25rem;
min-width: 0;
padding: 0.7rem;
}
.contour-node dd {
margin: 0;
overflow: hidden;
color: var(--nodedc-text-secondary);
font-size: 0.62rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.contour-process-list {
display: grid;
gap: 0.3rem;
}
.contour-process-list > div {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.65rem;
min-width: 0;
padding: 0.6rem 0;
}
.contour-process-list > div + div {
border-top: 1px solid var(--station-hairline);
}
.contour-process-list i,
.contour-network-strip i {
width: 0.45rem;
height: 0.45rem;
border-radius: 50%;
background: var(--nodedc-text-muted);
}
.contour-process-list i[data-state="online"],
.contour-network-strip i[data-state="online"] {
background: rgb(var(--nodedc-success-rgb));
}
.contour-process-list i[data-state="idle"] {
background: rgb(var(--nodedc-warning-rgb));
}
.contour-process-list span {
display: grid;
min-width: 0;
gap: 0.22rem;
}
.contour-process-list strong {
overflow: hidden;
color: var(--nodedc-text-secondary);
font-size: 0.65rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.contour-network-strip {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1fr);
gap: 0.8rem;
border-radius: 1rem;
background: rgb(255 255 255 / 0.025);
padding: 0.85rem 1rem;
}
.contour-network-strip > div {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 0.25rem 0.55rem;
}
.contour-network-strip > div > .section-eyebrow,
.contour-network-strip > div > strong {
grid-column: 1 / -1;
}
.contour-network-strip strong,
.contour-connected-device strong {
color: var(--nodedc-text-secondary);
font-size: 0.68rem;
}
.contour-connected-device {
gap: 0.8rem;
border-radius: 0.8rem;
background: rgb(255 255 255 / 0.025);
padding: 0.75rem 1rem;
}
.contour-health-error {
margin: 0;
color: rgb(var(--nodedc-warning-rgb));
font-size: 0.62rem;
}
@media (max-width: 1100px) {
.contour-health-kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.contour-node-grid {
grid-template-columns: minmax(0, 1fr);
}
}
.recordings-workspace,
.lab-archive-workspace,
.recordings-workspace__viewer,
.lab-result-surface {
display: grid;
gap: 0.85rem;
}
.recordings-workspace__viewer,
.lab-result-surface {
min-height: 30rem;
}
.recordings-workspace__viewer > header,
.lab-result-surface > header {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0 0.2rem;
}
.recordings-workspace__viewer > header strong,
.lab-result-surface > header strong {
color: var(--nodedc-text-secondary);
font-size: 0.7rem;
}
.recordings-workspace__empty {
display: grid;
min-height: 27rem;
place-items: center;
align-content: center;
gap: 0.5rem;
border-radius: 1rem;
background: rgb(0 0 0 / 0.24);
color: var(--nodedc-text-muted);
text-align: center;
}
.recordings-workspace__empty strong {
color: var(--nodedc-text-secondary);
font-size: 0.75rem;
}
.recordings-workspace__empty p {
margin: 0;
font-size: 0.63rem;
}
.lab-configuration {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
border-radius: 1rem;
background: rgb(255 255 255 / 0.025);
padding: 1rem;
}
.lab-configuration h2,
.lab-configuration p {
margin: 0;
}
.lab-configuration h2 {
margin-top: 0.25rem;
color: var(--nodedc-text-primary);
font-size: 1rem;
}
.lab-configuration p {
max-width: 55rem;
margin-top: 0.35rem;
color: var(--nodedc-text-muted);
font-size: 0.66rem;
line-height: 1.5;
}
.lab-primary-result {
display: grid;
}
.lab-primary-result button {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.3rem 0.75rem;
border: 0;
border-radius: 0.85rem;
background: rgb(255 255 255 / 0.025);
color: var(--nodedc-text-secondary);
padding: 0.8rem 1rem;
text-align: left;
cursor: pointer;
}
.lab-primary-result button[data-active="true"] {
background: rgb(255 255 255 / 0.065);
}
.lab-primary-result button > span {
grid-row: 1 / 3;
align-self: center;
color: var(--nodedc-accent);
font-size: 0.65rem;
font-weight: 700;
}
.lab-primary-result button > strong {
color: var(--nodedc-text-primary);
font-size: 0.72rem;
}
.lab-primary-result button > small {
color: var(--nodedc-text-muted);
font-size: 0.58rem;
}
.dataset-entry {
display: grid;
grid-template-columns: minmax(20rem, 1.2fr) minmax(22rem, 0.8fr);
@@ -2257,6 +2600,18 @@
gap: 0.5rem;
}
.dataset-entry__expanded {
display: grid;
grid-column: 1 / -1;
gap: 0.8rem;
min-width: 0;
padding-top: 0.35rem;
}
.dataset-entry[data-open="true"] {
padding-bottom: 0.6rem;
}
.dataset-download {
position: relative;
grid-column: 1 / -1;
@@ -0,0 +1,341 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Icon, StatusBadge } from "@nodedc/ui-react";
import type { MissionRuntimeState } from "../core/runtime/contracts";
import {
fetchPolygonWorkerStatus,
type PolygonWorkerStatus,
} from "../core/polygon/liveWorker";
interface ControlPlaneHealth {
ok: boolean;
status: string;
service: string;
version: string;
plugin_runtimes: {
ready: number;
total: number;
};
}
interface PluginRuntimeHealth {
runtime_instance_id: string;
plugin_id: string;
plugin_version: string;
status: string;
observed_at: string;
detail_code: string | null;
}
interface ContourSnapshot {
controlPlane: ControlPlaneHealth | null;
pluginRuntimes: PluginRuntimeHealth[];
simulationWorker: PolygonWorkerStatus | null;
controlPlaneLatencyMs: number | null;
simulationGatewayLatencyMs: number | null;
observedAt: Date;
error: string | null;
}
function validControlPlaneHealth(value: unknown): value is ControlPlaneHealth {
if (!value || typeof value !== "object") return false;
const item = value as Partial<ControlPlaneHealth>;
return typeof item.ok === "boolean"
&& typeof item.status === "string"
&& typeof item.service === "string"
&& typeof item.version === "string"
&& Boolean(item.plugin_runtimes)
&& typeof item.plugin_runtimes?.ready === "number"
&& typeof item.plugin_runtimes?.total === "number";
}
function validPluginRuntime(value: unknown): value is PluginRuntimeHealth {
if (!value || typeof value !== "object") return false;
const item = value as Partial<PluginRuntimeHealth>;
return typeof item.runtime_instance_id === "string"
&& typeof item.plugin_id === "string"
&& typeof item.plugin_version === "string"
&& typeof item.status === "string"
&& typeof item.observed_at === "string";
}
async function timedJson(
url: string,
signal: AbortSignal,
): Promise<{ payload: unknown; latencyMs: number }> {
const started = performance.now();
const response = await fetch(url, {
method: "GET",
headers: { Accept: "application/json" },
signal,
});
const latencyMs = performance.now() - started;
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return { payload: await response.json(), latencyMs };
}
function formatLatency(value: number | null): string {
if (value === null) return "—";
return `${Math.max(1, Math.round(value))} мс`;
}
function formatObservedAt(value: Date | string | null): string {
if (!value) return "—";
const date = value instanceof Date ? value : new Date(value);
if (!Number.isFinite(date.getTime())) return "—";
return new Intl.DateTimeFormat("ru-RU", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).format(date);
}
export function ContourHealthWorkspace({
state,
}: {
state: MissionRuntimeState | null;
}) {
const [snapshot, setSnapshot] = useState<ContourSnapshot | null>(null);
const [loading, setLoading] = useState(false);
const [generation, setGeneration] = useState(0);
const refresh = useCallback(() => setGeneration((value) => value + 1), []);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
void Promise.allSettled([
timedJson("/api/health", controller.signal),
timedJson("/api/v1/device-plugin-runtimes", controller.signal),
(async () => {
const started = performance.now();
const worker = await fetchPolygonWorkerStatus({ signal: controller.signal });
return { worker, latencyMs: performance.now() - started };
})(),
]).then(([healthResult, runtimesResult, workerResult]) => {
if (controller.signal.aborted) return;
const healthPayload = healthResult.status === "fulfilled"
? healthResult.value.payload
: null;
const runtimesPayload = runtimesResult.status === "fulfilled"
? runtimesResult.value.payload
: null;
const runtimeItems = runtimesPayload
&& typeof runtimesPayload === "object"
&& Array.isArray((runtimesPayload as { items?: unknown }).items)
? (runtimesPayload as { items: unknown[] }).items.filter(validPluginRuntime)
: [];
const failures = [healthResult, runtimesResult, workerResult]
.filter((result) => result.status === "rejected").length;
setSnapshot({
controlPlane: validControlPlaneHealth(healthPayload) ? healthPayload : null,
pluginRuntimes: runtimeItems,
simulationWorker: workerResult.status === "fulfilled"
? workerResult.value.worker
: null,
controlPlaneLatencyMs: healthResult.status === "fulfilled"
? healthResult.value.latencyMs
: null,
simulationGatewayLatencyMs: workerResult.status === "fulfilled"
? workerResult.value.latencyMs
: null,
observedAt: new Date(),
error: failures === 0
? null
: `Не ответили ${failures} из 3 диагностических контрактов.`,
});
}).finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, [generation]);
useEffect(() => {
const timer = window.setInterval(refresh, 5_000);
return () => window.clearInterval(timer);
}, [refresh]);
const aiActive = Boolean(
state?.metrics?.aiFrameRateHz
&& state.metrics.aiFrameRateHz > 0,
);
const simulationWorker = snapshot?.simulationWorker ?? null;
const controlPlaneReady = Boolean(snapshot?.controlPlane?.ok);
const runtimeReady = snapshot?.pluginRuntimes.filter(
(runtime) => runtime.status === "ready",
).length ?? 0;
const connectedDevice = state?.activeDevice ?? null;
const processCount = (snapshot?.pluginRuntimes.length ?? 0) + 2;
const readyProcessCount = runtimeReady
+ (controlPlaneReady ? 1 : 0)
+ (simulationWorker?.available ? 1 : 0);
const nodeStatus = useMemo(() => {
if (!snapshot) return "Проверяем";
if (controlPlaneReady && snapshot.error === null) return "Контур отвечает";
if (controlPlaneReady) return "Частично доступен";
return "Нет связи";
}, [controlPlaneReady, snapshot]);
return (
<div className="contour-health-dashboard">
<section className="contour-health-summary">
<div>
<span className="section-eyebrow">ЖИВОЙ ДИАГНОСТИЧЕСКИЙ СРЕЗ</span>
<h2>Локальный вычислительный контур</h2>
<p>
Статусы читаются из Control Plane, plugin runtime и шлюза Simulation Worker.
Пустые значения не подменяются демонстрационными числами.
</p>
</div>
<div className="contour-health-summary__status">
<StatusBadge tone={controlPlaneReady ? "success" : "danger"}>
{nodeStatus}
</StatusBadge>
<Button
size="compact"
variant="secondary"
disabled={loading}
onClick={refresh}
>
<Icon name="refresh" size={14} />
{loading ? "Проверяем" : "Обновить"}
</Button>
</div>
</section>
<section className="contour-health-kpis" aria-label="Сводка состояния контура">
<div>
<span>Узлы</span>
<strong>{snapshot ? 2 : "—"}</strong>
<small>локальный + внешний worker</small>
</div>
<div>
<span>Процессы готовы</span>
<strong>{snapshot ? `${readyProcessCount} / ${processCount}` : "—"}</strong>
<small>по живым health-контрактам</small>
</div>
<div>
<span>Локальный API</span>
<strong>{formatLatency(snapshot?.controlPlaneLatencyMs ?? null)}</strong>
<small>браузер Control Plane</small>
</div>
<div>
<span>Последняя проверка</span>
<strong>{formatObservedAt(snapshot?.observedAt ?? null)}</strong>
<small>автообновление каждые 5 секунд</small>
</div>
</section>
<section className="contour-node-grid" aria-label="Вычислительные узлы">
<article className="contour-node" data-state={controlPlaneReady ? "online" : "offline"}>
<header>
<div>
<span className="section-eyebrow">УЗЕЛ 01 · ЛОКАЛЬНЫЙ</span>
<h3>Mission Core Control Plane</h3>
</div>
<StatusBadge tone={controlPlaneReady ? "success" : "danger"}>
{controlPlaneReady ? "Доступен" : "Недоступен"}
</StatusBadge>
</header>
<dl>
<div><dt>Версия</dt><dd>{snapshot?.controlPlane?.version ?? "—"}</dd></div>
<div><dt>API latency</dt><dd>{formatLatency(snapshot?.controlPlaneLatencyMs ?? null)}</dd></div>
<div><dt>Plugin runtimes</dt><dd>{snapshot ? `${runtimeReady} / ${snapshot.pluginRuntimes.length}` : "—"}</dd></div>
<div><dt>Активный режим</dt><dd>{state?.sourceMode ?? "idle"}</dd></div>
</dl>
<div className="contour-process-list">
{snapshot?.pluginRuntimes.map((runtime) => (
<div key={runtime.runtime_instance_id}>
<i data-state={runtime.status === "ready" ? "online" : "offline"} />
<span>
<strong>Device runtime · {runtime.plugin_id}</strong>
<small>v{runtime.plugin_version} · {formatObservedAt(runtime.observed_at)}</small>
</span>
<em>{runtime.status}</em>
</div>
))}
<div>
<i data-state={aiActive ? "online" : "idle"} />
<span>
<strong>AI perception</strong>
<small>
{aiActive
? `${Math.round(state?.metrics?.aiFrameRateHz ?? 0)} кадр/с`
: "Нет активного задания"}
</small>
</span>
<em>{aiActive ? "active" : "idle"}</em>
</div>
</div>
</article>
<article
className="contour-node"
data-state={simulationWorker?.available ? "online" : "offline"}
>
<header>
<div>
<span className="section-eyebrow">УЗЕЛ 02 · ВНЕШНИЙ</span>
<h3>{simulationWorker?.workerId ?? "Simulation Worker"}</h3>
</div>
<StatusBadge tone={simulationWorker?.available ? "success" : "neutral"}>
{simulationWorker?.available ? "Доступен" : "Offline"}
</StatusBadge>
</header>
<dl>
<div><dt>Gateway latency</dt><dd>{formatLatency(snapshot?.simulationGatewayLatencyMs ?? null)}</dd></div>
<div><dt>Сеть worker</dt><dd>{simulationWorker?.isolation.network ?? "unavailable"}</dd></div>
<div><dt>Политика данных</dt><dd>{simulationWorker?.isolation.artifactPolicy ?? "d-only"}</dd></div>
<div><dt>Активный прогон</dt><dd>{simulationWorker?.activeRunId ?? "нет"}</dd></div>
</dl>
<div className="contour-process-list">
<div>
<i data-state={simulationWorker?.available ? "online" : "offline"} />
<span>
<strong>Simulation orchestration</strong>
<small>
{simulationWorker?.runState
? `Прогон: ${simulationWorker.runState}`
: "Нет активного прогона"}
</small>
</span>
<em>{simulationWorker?.available ? "ready" : "offline"}</em>
</div>
</div>
</article>
</section>
<section className="contour-network-strip" aria-label="Состояние сети">
<div>
<span className="section-eyebrow">СЕТЬ</span>
<strong>Браузер</strong>
<small>127.0.0.1:8000</small>
</div>
<Icon name="chevron-right" />
<div>
<i data-state={controlPlaneReady ? "online" : "offline"} />
<strong>Control Plane</strong>
<small>{formatLatency(snapshot?.controlPlaneLatencyMs ?? null)}</small>
</div>
<Icon name="chevron-right" />
<div>
<i data-state={simulationWorker?.available ? "online" : "offline"} />
<strong>Worker gateway</strong>
<small>{formatLatency(snapshot?.simulationGatewayLatencyMs ?? null)}</small>
</div>
</section>
{connectedDevice ? (
<section className="contour-connected-device">
<span className="section-eyebrow">ПОДКЛЮЧЁННОЕ УСТРОЙСТВО</span>
<strong>{connectedDevice.displayName}</strong>
<small>{connectedDevice.endpointLabel ?? connectedDevice.modelId}</small>
</section>
) : null}
{snapshot?.error ? (
<p className="contour-health-error" role="status">{snapshot.error}</p>
) : null}
</div>
);
}
@@ -173,10 +173,6 @@ export function DatasetGatewayWorkspace() {
const gooseRuns = runCatalog?.items.filter(isGooseQualificationRun) ?? [];
const rellisRuns = runCatalog?.items.filter(isRellisQualificationRun) ?? [];
const openSource = catalog?.sources.find(
(source) => source.sourceId === openSourceId,
) ?? null;
return (
<div className="standard-workspace dataset-workspace">
<section className="dataset-purpose" aria-label="Назначение датасетов">
@@ -240,6 +236,7 @@ export function DatasetGatewayWorkspace() {
<article
className="dataset-entry"
data-source={source.sourceKind}
data-open={opened ? "true" : undefined}
key={source.sourceId}
>
<div className="dataset-entry__identity">
@@ -308,66 +305,69 @@ export function DatasetGatewayWorkspace() {
</Button>
</div>
</footer>
{opened ? (
<div className="dataset-entry__expanded">
{source.sourceKind === "goose" && selectedRunId ? (
<>
{gooseRuns.length > 1 ? (
<div className="dataset-result-select">
<div>
<span className="section-eyebrow">РЕЗУЛЬТАТЫ ОБРАБОТКИ</span>
<strong>Версия анализа</strong>
</div>
<select
aria-label="Выбрать версию анализа"
value={selectedRunId}
onChange={(event) => setSelectedRunId(event.target.value)}
>
{gooseRuns.map((item) => (
<option key={item.runId} value={item.runId}>
{item.profileGeneration}
</option>
))}
</select>
</div>
) : null}
<GooseDatasetReview runId={selectedRunId} />
</>
) : source.sourceKind === "goose" ? (
<section className="polygon-review-unavailable">
<StatusBadge tone="warning">Review недоступен</StatusBadge>
<h3>GOOSE принят, но анализ не опубликован</h3>
<p>{runError ?? "Нет совместимой версии покадрового анализа."}</p>
</section>
) : (
<>
{rellisRuns.length > 1 && selectedRellisRunId ? (
<div className="dataset-result-select">
<div>
<span className="section-eyebrow">РЕЗУЛЬТАТЫ ОБРАБОТКИ</span>
<strong>Версия анализа</strong>
</div>
<select
aria-label="Выбрать версию анализа RELLIS"
value={selectedRellisRunId}
onChange={(event) => setSelectedRellisRunId(event.target.value)}
>
{rellisRuns.map((item) => (
<option key={item.runId} value={item.runId}>
{item.profileGeneration}
</option>
))}
</select>
</div>
) : null}
<RellisDatasetReview runId={selectedRellisRunId} />
</>
)}
</div>
) : null}
</article>
);
})}
</div>
</GlassSurface>
{openSource?.sourceKind === "goose" && selectedRunId ? (
<>
{gooseRuns.length > 1 ? (
<div className="dataset-result-select">
<div>
<span className="section-eyebrow">РЕЗУЛЬТАТЫ ОБРАБОТКИ</span>
<strong>Версия анализа</strong>
</div>
<select
aria-label="Выбрать версию анализа"
value={selectedRunId}
onChange={(event) => setSelectedRunId(event.target.value)}
>
{gooseRuns.map((item) => (
<option key={item.runId} value={item.runId}>
{item.profileGeneration}
</option>
))}
</select>
</div>
) : null}
<GooseDatasetReview runId={selectedRunId} />
</>
) : openSource?.sourceKind === "goose" ? (
<section className="polygon-review-unavailable">
<StatusBadge tone="warning">Review недоступен</StatusBadge>
<h3>GOOSE принят, но анализ не опубликован</h3>
<p>{runError ?? "Нет совместимой версии покадрового анализа."}</p>
</section>
) : openSource?.sourceKind === "rellis" ? (
<>
{rellisRuns.length > 1 && selectedRellisRunId ? (
<div className="dataset-result-select">
<div>
<span className="section-eyebrow">РЕЗУЛЬТАТЫ ОБРАБОТКИ</span>
<strong>Версия анализа</strong>
</div>
<select
aria-label="Выбрать версию анализа RELLIS"
value={selectedRellisRunId}
onChange={(event) => setSelectedRellisRunId(event.target.value)}
>
{rellisRuns.map((item) => (
<option key={item.runId} value={item.runId}>
{item.profileGeneration}
</option>
))}
</select>
</div>
) : null}
<RellisDatasetReview runId={selectedRellisRunId} />
</>
) : null}
<details className="dataset-contract">
<summary>
<span>Технический контракт</span>
+102 -133
View File
@@ -13,7 +13,10 @@ import {
StatusBadge,
} from "@nodedc/ui-react";
import { MetricCard } from "../components/MetricCard";
import {
ObservationSessionArchive,
type ObservationSessionReplayCallbacks,
} from "../components/ObservationSessionSelect";
import {
ObservationMedia,
ObservationSourcePicker,
@@ -57,6 +60,7 @@ import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from "..
import type { SceneSettings } from "../sceneSettings";
import { LidarQualityWorkspace } from "./LidarQualityWorkspace";
import { DatasetGatewayWorkspace } from "./DatasetGatewayWorkspace";
import { ContourHealthWorkspace } from "./ContourHealthWorkspace";
function statusTone(status: CapabilityStatus): "success" | "accent" | "warning" | "neutral" {
if (status === "active") return "success";
@@ -144,131 +148,10 @@ export interface WorkspaceRendererProps {
View: ComponentType<DevicePluginConnectionProps>;
model: DeviceModelDefinition;
} | null;
}
function OverviewWorkspace({
definition,
state,
backendStatus,
navigation,
}: WorkspaceRendererProps) {
const streamActive = state?.sourceMode === "live" || state?.sourceMode === "replay";
const metrics = streamActive ? state?.metrics : undefined;
const latency = pipelineLatency(metrics);
const frameRate = finiteMetric(metrics?.frameRateHz);
const points = finiteMetric(metrics?.pointCount);
const adapterOnline = backendStatus === "online";
const rerunReady = Boolean(state?.spatialSource?.url);
return (
<div className="standard-workspace overview-workspace">
<WorkspaceLead
definition={definition}
note="Числа появляются только из реального локального контура"
/>
<section className="metrics-grid" aria-label="Оперативные показатели">
<MetricCard
featured
eyebrow="ДО ПУБЛИКАЦИИ"
value={formatNumber(latency)}
unit="мс"
detail="Вход адаптера → Scene Sink; без экрана"
/>
<MetricCard
eyebrow="ЧАСТОТА"
value={formatNumber(frameRate)}
unit="кадр/с"
detail="Последнее измерение потока"
/>
<MetricCard
eyebrow="ТОЧЕК В КАДРЕ"
value={points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}
detail="Без синтетического заполнения"
/>
<MetricCard
eyebrow="РЕЖИМ"
value={sourceModeLabel(state?.sourceMode)}
detail="Реальное время, повтор или ожидание"
/>
</section>
<div className="overview-grid">
<GlassSurface className="contour-panel" padding="lg">
<header className="panel-heading">
<div>
<span className="section-eyebrow">КРИТИЧЕСКИЙ ПУТЬ</span>
<h2>Контур наблюдения</h2>
</div>
<StatusBadge tone={adapterOnline ? "success" : "danger"}>
{adapterOnline ? "Контур доступен" : "Нет связи"}
</StatusBadge>
</header>
<div className="pipeline-strip" aria-label="Путь данных">
<div data-state={state?.activeDevice ? "ready" : "idle"}>
<span>01</span>
<strong>Устройство</strong>
<small>{state?.activeDevice?.endpointLabel || state?.activeDevice?.displayName || "не назначено"}</small>
</div>
<Icon name="chevron-right" />
<div data-state={streamActive ? "ready" : "idle"}>
<span>02</span>
<strong>Адаптер</strong>
<small>{sourceModeLabel(state?.sourceMode)}</small>
</div>
<Icon name="chevron-right" />
<div data-state={rerunReady ? "ready" : "idle"}>
<span>03</span>
<strong>Rerun</strong>
<small>{rerunReady ? "gRPC опубликован" : "ожидает поток"}</small>
</div>
<Icon name="chevron-right" />
<div data-state="ready">
<span>04</span>
<strong>Оператор</strong>
<small>интерфейс готов</small>
</div>
</div>
</GlassSurface>
<GlassSurface className="next-actions-panel" padding="lg">
<header className="panel-heading">
<div>
<span className="section-eyebrow">БЫСТРЫЙ ПЕРЕХОД</span>
<h2>Продолжить работу</h2>
</div>
</header>
<div className="action-list">
<button type="button" onClick={() => navigation.openView("local-device")}>
<span><Icon name="network" /></span>
<div>
<strong>Подключить устройство</strong>
<small>Сценарий установленного плагина и запуск потока</small>
</div>
<Icon name="chevron-right" />
</button>
<button type="button" onClick={() => navigation.openView("spatial-scene")}>
<span><Icon name="globe" /></span>
<div>
<strong>Открыть пространственную сцену</strong>
<small>Облако точек, траектория и слои</small>
</div>
<Icon name="chevron-right" />
</button>
<button type="button" onClick={() => navigation.openView("streams")}>
<span><Icon name="activity" /></span>
<div>
<strong>Проверить потоки</strong>
<small>Транспорт, схемы и готовность</small>
</div>
<Icon name="chevron-right" />
</button>
</div>
</GlassSurface>
</div>
<FeatureInventory definition={definition} />
</div>
);
sessionArchive: ObservationSessionReplayCallbacks & {
disabled: boolean;
blockedReason: string | null;
};
}
function EmptySpatialStage({ settings }: { settings: SceneSettings }) {
@@ -1290,12 +1173,97 @@ function CatalogWorkspace({ definition }: WorkspaceRendererProps) {
);
}
function RecordingsWorkspace(props: WorkspaceRendererProps) {
return (
<div className="recordings-workspace">
<ObservationSessionArchive {...props.sessionArchive} />
{props.recordedReplay ? (
<section className="recordings-workspace__viewer">
<header>
<span className="section-eyebrow">ВОСПРОИЗВЕДЕНИЕ</span>
<strong>Выбранная пространственная запись</strong>
</header>
<SpatialWorkspace {...props} />
</section>
) : (
<section className="recordings-workspace__empty">
<Icon name="database" size={20} />
<strong>Выберите сохранённую сессию</strong>
<p>Viewer появится здесь; эфир в разделе «Наблюдение» останется неизменным.</p>
</section>
)}
</div>
);
}
function LabArchiveWorkspace(props: WorkspaceRendererProps) {
const [surface, setSurface] = useState<"lidar-e29" | "replay">(
props.recordedReplay ? "replay" : "lidar-e29",
);
const replayActions: WorkspaceRendererProps["sessionArchive"] = {
...props.sessionArchive,
onReplayAccepted: async (session, launch) => {
await props.sessionArchive.onReplayAccepted?.(session, launch);
setSurface("replay");
},
};
return (
<div className="lab-archive-workspace">
<section className="lab-configuration">
<div>
<span className="section-eyebrow">ОБЪЕКТ И КОНФИГУРАЦИЯ ИССЛЕДОВАНИЯ</span>
<h2>Текущий профиль · camera + LiDAR</h2>
<p>
Объект и точная конфигурация берутся из LAB-provenance выбранной записи.
Исходный сенсор остаётся read-only, вычисление выполняется на worker D.
</p>
</div>
<StatusBadge tone="accent">Активная серия</StatusBadge>
</section>
<section className="lab-primary-result">
<button
type="button"
data-active={surface === "lidar-e29" ? "true" : undefined}
onClick={() => setSurface("lidar-e29")}
>
<span>LAB E29</span>
<strong>Локальная модель поверхности L2.6</strong>
<small>Диагностический replay всей записи и кадры для разбора</small>
</button>
</section>
<ObservationSessionArchive {...replayActions} labsOnly />
<section className="lab-result-surface">
<header>
<span className="section-eyebrow">ВИЗУАЛЬНОЕ ДОКАЗАТЕЛЬСТВО</span>
<strong>
{surface === "replay" && props.recordedReplay
? "Запись выбранной лабораторной работы"
: "LAB E29 · результат L2.6"}
</strong>
</header>
{surface === "replay" && props.recordedReplay ? (
<SpatialWorkspace {...props} />
) : (
<LidarQualityWorkspace
deviceLabel={props.deviceLabel}
onOpenObservation={() => props.navigation.openView("spatial-scene")}
/>
)}
</section>
</div>
);
}
export function WorkspaceRenderer(props: WorkspaceRendererProps) {
switch (props.definition.kind) {
case "overview":
return <OverviewWorkspace {...props} />;
case "spatial":
return <SpatialWorkspace {...props} />;
case "recordings":
return <RecordingsWorkspace {...props} />;
case "cameras":
return <CamerasWorkspace {...props} />;
case "map":
@@ -1306,15 +1274,16 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
return <MissionWorkspace {...props} />;
case "catalog":
return <CatalogWorkspace {...props} />;
case "lidar-quality":
case "contour-health":
return (
<LidarQualityWorkspace
deviceLabel={props.deviceLabel}
onOpenObservation={() => props.navigation.openView("spatial-scene")}
<ContourHealthWorkspace
state={props.state}
/>
);
case "polygon-datasets":
case "datasets":
return <DatasetGatewayWorkspace />;
case "lab-archive":
return <LabArchiveWorkspace {...props} />;
case "device":
return null;
}
@@ -610,6 +610,6 @@ test("LiDAR fetchers use read-only endpoints and workspace is registered", async
method: "GET",
},
]);
assert.equal(workspaceById("lidar-quality").root, "fleet");
assert.equal(workspaceById("lidar-quality").kind, "lidar-quality");
assert.equal(workspaceById("lab-archive").root, "polygon");
assert.equal(workspaceById("lab-archive").kind, "lab-archive");
});
@@ -319,17 +319,19 @@ test("Polygon exposes one dataset surface and keeps legacy links compatible", ()
workspacesForRoot("polygon").some(({ id }) => id === "polygon-run"),
false,
);
assert.equal(workspaceById("polygon-datasets").root, "polygon");
assert.equal(workspaceById("polygon-datasets").kind, "polygon-datasets");
assert.equal(workspaceById("polygon-datasets"), null);
assert.equal(workspaceById("datasets").root, "data");
assert.equal(workspaceById("datasets").kind, "datasets");
assert.deepEqual(
workspacesForRoot("polygon").map(({ id }) => id),
["polygon-datasets"],
["lab-archive"],
);
assert.equal(
workspacesForRoot("system").some(({ id }) => id === "polygon-run"),
false,
);
assert.equal(roots.some(({ id }) => id === "polygon"), true);
assert.equal(roots.at(-1)?.id, "polygon");
});
test("Polygon contracts decode path-free evidence and preserve the safety boundary", () => {
@@ -87,7 +87,7 @@ test("saved-session and manual source controls share the acquisition guard", asy
"utf8",
);
assert.match(appSource, /blockedReason=\{sourceSwitchBlockedReason\}/);
assert.match(appSource, /blockedReason: sourceSwitchBlockedReason/);
assert.match(appSource, /disabled=\{sourceSwitchBlocked \|\| !sourceDraft\.trim\(\)\}/);
assert.match(appSource, /if \(sourceSwitchBlockedRef\.current\) return;/);
assert.match(sessionSelectSource, /replayEnabled: blockedReason === null/);
@@ -262,12 +262,12 @@ test("scene tool windows are route-scoped and toolbar actions are not duplicated
const utilityEnd = appSource.indexOf("const header", utilityStart);
const utilitySource = appSource.slice(utilityStart, utilityEnd);
assert.match(appSource, /open=\{spatialWorkspaceActive && sourceWindowOpen\}/);
assert.match(appSource, /open=\{spatialWorkspaceActive && displayWindowOpen\}/);
assert.match(appSource, /open=\{spatialWorkspaceActive && layerInspectorOpen\}/);
assert.match(appSource, /open=\{sceneWorkspaceActive && sourceWindowOpen\}/);
assert.match(appSource, /open=\{sceneWorkspaceActive && displayWindowOpen\}/);
assert.match(appSource, /open=\{sceneWorkspaceActive && layerInspectorOpen\}/);
assert.match(
appSource,
/if \(spatialWorkspaceActive\) return;[\s\S]*setSceneWindowOrder\(\[\]\)/,
/if \(sceneWorkspaceActive\) return;[\s\S]*setSceneWindowOrder\(\[\]\)/,
);
assert.doesNotMatch(utilitySource, /Настроить визуальный движок/);
assert.doesNotMatch(utilitySource, /Настроить отображение/);