feat(ui): separate live data and lab surfaces
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, /Настроить отображение/);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user