feat(ui): separate live data and lab surfaces
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user