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, /Настроить отображение/);
+9 -7
View File
@@ -189,12 +189,14 @@ The archival surface consumes the same append-only run repository through
`GET /api/v1/polygon/runs` and
`GET /api/v1/polygon/runs/{run-id}`. `QualificationRunStore(read_only=True)`
does not create, chmod or mutate the configured repository and rejects every
write transition. When `/api/v1/polygon/worker` returns an admitted available
worker, the Control Station adds `Полигон` as a seventh header root. One header
click opens the single `polygon-datasets` workspace without a System launcher
panel. The old `?workspace=polygon-run[&run=<id>]` URL remains a compatibility
redirect into the dataset workspace; there is no separate Runs item in the
operator navigation.
write transition. The operator information architecture is now governed by
[ADR 0022](adr/0022-operator-surface-ownership.md): **Данные** owns saved
sessions and public datasets, **Наблюдение** is live-only, and the former
Polygon root is the last header item named **Тестировочный контур**. It opens
the laboratory archive even when Simulation Worker is offline. Old
`?workspace=polygon-run[&run=<id>]` and `?workspace=polygon-datasets` URLs remain
compatibility redirects into **Данные → Датасеты**; there is no separate Runs
item in the operator navigation.
Browser QA loaded the accepted archive and the S1C live worker, rotated and
zoomed the 3D rover, observed live run/provider status, Gazebo sim time and
ENU/FLU pose, then completed a clean stop. Start/stop stays behind the backend
@@ -206,7 +208,7 @@ supervision and an accepted routed worker transport.
The Dataset Gateway is now multi-source. RELLIS S0 pins official repository
frame `000104`, verifies `131,072` Ouster XYZI points against aligned labels and
publishes a bounded semantic/ground-target preview beside GOOSE in the same
`Полигон → Датасеты` workflow. This closes only reader, axes, ontology,
`Данные → Датасеты` workflow. This closes only reader, axes, ontology,
licensing and versioned ignore-policy compatibility. Full RELLIS Ouster scans,
labels and poses remain D-only admission work before the cross-dataset
Current/Patchwork++ decision.
+8 -5
View File
@@ -3,7 +3,8 @@
Status: implemented for native K1 point/pose evidence and host-side camera
archival. Valid K1 `ModelingReport` scan time, distance and speed are also
materialized as recorded Rerun time series. Recorded spatial playback is exposed
in the Mission Core observation workspace. The camera archive/player contract
in **Данные → Сессии и записи**; **Наблюдение → Пространственная сцена** is
live-only. The camera archive/player contract
is implemented and covered by tests. TEST007 physically accepted shared-timeline
playback of the saved cameras and point cloud. It also carries the first optional
external-perception result projected into the same Rerun recording; older
@@ -23,9 +24,10 @@ fully usable with only their available modalities.
3. Stop acquisition normally, or allow the local service to recover an
unexpected interruption on its next start. Session recording is automatic;
the disk action is not required.
4. Open **Сохранённые сессии** in the observation header. The menu shows up to
100 indexed runs, their date, duration, state, modalities and
background preparation state.
4. Open **Данные Сессии и записи**. The inline archive shows up to 100
indexed runs, their date, duration, state, modalities and background
preparation state. Opening an archive never replaces the live source owned
by **Наблюдение**.
5. Choose a replayable run. Opening a replay never performs conversion in the
request. A ready recording opens immediately; otherwise the client receives
HTTP 202, keeps the current scene mounted and polls the preparation status.
@@ -128,7 +130,8 @@ on deletion.
The visual result is resolved by the LAB session id, so `LAB E19`, `LAB E21`
and the derived temporal comparison `LAB E22` can coexist over `RAVNOVES00`
without “latest accepted result” replacing an earlier experiment. The operator
can open any row in **Сохранённые сессии** and use the same
can open any row in **Данные Сессии и записи** or the corresponding LAB row
in **Тестировочный контур** and use the same
**Объекты 2D / Сегментация / Кубы 3D** controls. First publication must also
warm the result-specific Rerun overlay; later opens reuse that verified cache
and do not rerun AI inference.
+12 -8
View File
@@ -90,11 +90,11 @@ Polygon is now a parallel product branch. As of this document:
Simulation Worker agent in a fresh loopback-only namespace, a bounded
Unix-socket Mission Core gateway and an explicit `internal-virtual-only`
lifecycle gate;
- UI-2 exact generation `60e7916` established `Полигон` as a dedicated header
root only while Mission Core confirms an available Simulation Worker. The
current product surface keeps that direct entry. The operator navigation has
one implemented item, `Датасеты`; immutable run evidence remains an internal
backend contract rather than a second page;
- UI-2 exact generation `60e7916` established the first dedicated `Полигон`
header root. ADR 0022 subsequently renamed the operator surface
**Тестировочный контур**, moved it last, and assigned it to laboratory work.
Public sources are now under **Данные → Датасеты**; immutable run evidence
remains an internal backend contract rather than a second page;
- the browser-native Three.js scene renders a recognisable procedural Ackermann
Rover, ground grid and ENU trajectory. Orbit supports 360° azimuth, bounded
vertical inspection from overhead to horizon, zoom, pan, follow and camera
@@ -860,7 +860,11 @@ Center | Fleet | Observation | Missions | Data | Polygon | System
```
The product surface and execution capability are intentionally distinct.
`Полигон` and its dataset catalog remain visible without a worker. Worker
The original `Полигон` implementation and its dataset catalog remain available
as historical evidence without a worker. Under
[ADR 0022](adr/0022-operator-surface-ownership.md), the operator root is now
named **Тестировочный контур**, owns laboratory work, and is last in the header.
Public evaluation sources moved to **Данные → Датасеты**. Worker
capability gates only live lifecycle and command actions, which fail closed and
show an explicit offline state.
@@ -946,8 +950,8 @@ fails closed. The review index exposes no paths or digests. A selected frame is
verified against the content-bound review manifest before its bounded point
preview is decoded. Responses never contain the configured root, raw dataset
bytes or command payloads. `?workspace=polygon-run[&run=<run-id>]` remains a
compatibility redirect into `polygon-datasets`; it does not restore a separate
Runs page.
compatibility redirect into **Данные → Датасеты**; it does not restore a
separate Runs page or the historical Polygon dataset surface.
UI-0 itself has no PX4 transport or lifecycle/command operations. It remains a
read-only evidence contract backed by the server-owned run repository and is
+2 -1
View File
@@ -432,7 +432,8 @@ Dataset expansion is no longer the next gate.
The implemented `missioncore.k1-local-surface/v1` derivative is reproducible
through `experiments/perception/run_k1_local_surface.py` and is exposed
read-only through `GET /api/v1/lidar/local-surfaces` plus the bound frame,
timeline and review endpoints. **Парк → Диагностика LiDAR** reuses the five
timeline and review endpoints. **Тестировочный контур → Лабораторные работы**
reuses the five
RAVNOVES00 scene selectors and also exposes a clickable timeline over the
complete recording. The selected source frame shows observed surface, observed
occupied-above-surface, negative outlier, unclassified evidence and yellow
+15 -14
View File
@@ -60,15 +60,15 @@ Implemented now:
`missioncore.k1-local-surface-frame/v2`: the prior-only plane, current
lower-cell coordinates, signed residual and derived inlier mask are
content-bound and visible as a read-only 3D overlay;
- a provider-neutral read-only local-surface view in
**Парк → Диагностика LiDAR**, synchronized to the five existing
- a provider-neutral read-only local-surface laboratory result in
**Тестировочный контур → Лабораторные работы**, synchronized to the five existing
RAVNOVES00 scene selectors and a clickable complete-recording timeline;
- a complete recorded-host-paced 1× worker replay through authenticated
ingress, K1 normalization, point/pose binding and a bounded latest-wins
surface estimator: `4,206` results at `9.1945 Hz` and `89.530 ms` p95 age;
- worker storage admission for `D:\NDC_MISSIONCORE\datasets` and
`/mnt/d/NDC_MISSIONCORE/datasets`;
- a dedicated, honest dataset catalog in **Полигон → Датасеты**;
- a dedicated, honest dataset catalog in **Данные → Датасеты**;
- live admission states (`downloading`, `verifying`, `frame-ready`) and a real
`Открыть` action;
- one admitted GOOSE validation frame with source colors, normalized remission
@@ -86,8 +86,8 @@ Implemented now:
- a reproducible Current/Patchwork++ A/B artifact with point-aligned masks,
disagreement views, independent-label metrics, latency and exact provider
identities;
- a separate **Парк → Диагностика LiDAR** surface containing only real sensor
recordings and their operational evidence.
- a separate laboratory surface containing only real sensor recordings and
their offline experimental evidence.
Not implemented:
@@ -106,17 +106,18 @@ Not implemented:
## Product surface boundary
The gateway is not part of device quality diagnostics.
The gateway and current local-surface work are not live device diagnostics.
- **Парк → Диагностика LiDAR** answers whether a selected real sensor recording
is present, reproducible and operationally usable. It never mixes public
dataset frames into the selected device evidence.
- **Наблюдение** opens a concrete live or recorded spatial scene.
- **Данные** owns source-of-record, retention, replay preparation and export.
- **Полигон → Датасеты** is the single operator surface for admitted evaluation
- **Наблюдение** opens only the current live spatial scene.
- **Данные → Сессии и записи** owns saved sensor records and replay.
- **Данные → Датасеты** is the single operator surface for admitted evaluation
inputs. Opening one dataset exposes its source fragments, sparse annotated
scans, visual overlays and useful analysis calculated only for the selected
fragment.
scans, visual overlays and useful analysis inside the expanded source card.
Opening another source closes the previous one.
- **Тестировочный контур → Лабораторные работы** owns immutable experiment
configurations, accepted LAB instances and visual conclusions such as E29.
- A future **Парк** diagnostics surface must require a connected device and a
dedicated live diagnostic contract. It cannot reuse an offline replay report.
- Internal replay-run identity, provenance and immutable artifacts remain a
backend evidence contract. They are not a second operator navigation item
and are not presented as vehicle motion.
@@ -0,0 +1,71 @@
# ADR 0022 — Operator surface ownership
Status: accepted, 2026-07-26
## Context
The first simulation and LiDAR qualification slices exposed implementation
results where they were produced. Saved observation sessions lived in the live
spatial workspace, public datasets lived under Polygon, and the local-surface
laboratory result was labelled as device diagnostics. This made the operator
infer backend history instead of following product concepts.
The same ambiguity affected Centre: separate overview, health and activity
pages repeated static capability inventories instead of showing the current
compute and network contour.
## Decision
Mission Core assigns one owner to each operator concept:
| Product area | Owns | Does not own |
| --- | --- | --- |
| **Наблюдение** | Current live sensor scene and live-only spatial controls | Saved-session selection or offline experiments |
| **Данные** | Saved sessions, replay, public datasets, streams, entities and export | Simulation execution or laboratory conclusions |
| **Тестировочный контур** | Versioned laboratory work, test configuration and visual evidence | Source-of-record retention or public dataset storage |
| **Центр → Состояние контура** | Live compute nodes, processes, network reachability and connected devices | Static feature inventory or a separate activity page |
| **Система** | Deeper module, integration, network, audit and configuration administration | Daily operational health summary |
| **Парк** | Devices, sensors and live device operation | Offline algorithm-development reports |
The existing internal root id `polygon` remains stable for compatibility, but
its operator label is **Тестировочный контур** and it is last in the header.
Legacy `?workspace=polygon-datasets` and `?workspace=polygon-run` links resolve
to **Данные → Датасеты**. They do not recreate the previous Polygon dataset
page.
Saved session replay is mounted only in **Данные → Сессии и записи** or from a
LAB entry. Leaving either archive returns **Наблюдение → Пространственная
сцена** to the runtime-published live source. A selected archive cannot leak
into the live observation surface.
Dataset sources use a single-open accordion. Expanding one source mounts its
viewer and analysis inside that source card, pushes later sources down, and
closes the previously expanded source.
The laboratory archive reads immutable LAB instances from the existing
observation-session catalog. The E29 local-surface result remains a read-only
laboratory surface and is no longer presented as live LiDAR diagnostics. A
future device-diagnostics workspace must require an attached device and its own
diagnostic contract.
Contour health reads only live contracts:
- `GET /api/health`;
- `GET /api/v1/device-plugin-runtimes`;
- `GET /api/v1/polygon/worker`;
- current Mission Runtime state for active device and perception metrics.
Unknown or unavailable values remain empty/offline. The UI does not invent
load, latency, activity or connected devices.
## Consequences
- Public datasets and private sensor records are both discoverable under Data,
but remain separate evidence classes.
- Laboratory evidence is inspectable without confusing it with live device
health or closed-loop simulation.
- The current Simulation Worker may be offline while its prior laboratory
evidence remains available.
- The generic Control Station remains device-neutral; device names and
implementation details come from plugin/runtime contracts.
- New operator pages must declare one product owner before implementation.