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
@@ -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;
}