feat(observatory): add bounded M5.1 session review

This commit is contained in:
DCCONSTRUCTIONS
2026-08-30 17:29:39 +03:00
parent 81fdf6904a
commit e6a9846167
14 changed files with 1099 additions and 2 deletions
@@ -41,6 +41,7 @@ import { SimulationWorkspace } from "./simulation/SimulationWorkspace";
import { ComputeModulesWorkspace } from "./system/ComputeModulesWorkspace";
import { NetworkWorkspace } from "./system/NetworkWorkspace";
import { WorldMapWorkspace } from "./map/WorldMapWorkspace";
import { ObservatoryWorkspace } from "./observatory/ObservatoryWorkspace";
function statusTone(status: CapabilityStatus): "success" | "accent" | "warning" | "neutral" {
if (status === "active") return "success";
if (status === "ready") return "accent";
@@ -1191,6 +1192,8 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
);
case "simulations":
return <SimulationWorkspace />;
case "observatory":
return <ObservatoryWorkspace definition={props.definition} />;
case "device":
return null;
}
@@ -0,0 +1,291 @@
import { useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
Button,
GlassSurface,
Icon,
Select,
StatusBadge,
} from "@nodedc/ui-react";
import type { ObservationSessionStatus } from "../../core/observation/sessionArchive";
import { useObservatoryCatalog } from "../../core/observatory/useObservatoryCatalog";
import type { WorkspaceDefinition } from "../../productModel";
const MAX_PRESENTED_EVIDENCE = 6;
const statusLabel: Record<ObservationSessionStatus, string> = {
recording: "Запись идёт",
ready: "Готова",
degraded: "С деградацией",
interrupted: "Прервана",
failed: "Ошибка",
};
const modalityLabel: Record<string, string> = {
"point-cloud": "Облако точек",
pose: "Поза",
trajectory: "Траектория",
video: "Видео",
image: "Изображения",
depth: "Глубина",
telemetry: "Телеметрия",
};
function statusTone(
status: ObservationSessionStatus,
): "success" | "accent" | "warning" | "danger" | "neutral" {
if (status === "ready") return "success";
if (status === "recording") return "accent";
if (status === "degraded" || status === "interrupted") return "warning";
return "danger";
}
function formatTimestamp(value: string): string {
return new Intl.DateTimeFormat("ru-RU", {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}
function formatDuration(seconds: number): string {
const totalSeconds = Math.max(0, Math.round(seconds));
const hours = Math.floor(totalSeconds / 3_600);
const minutes = Math.floor((totalSeconds % 3_600) / 60);
const remainingSeconds = totalSeconds % 60;
return hours > 0
? `${hours}:${String(minutes).padStart(2, "0")}:${String(remainingSeconds).padStart(2, "0")}`
: `${minutes}:${String(remainingSeconds).padStart(2, "0")}`;
}
function catalogStateLabel(
state: ReturnType<typeof useObservatoryCatalog>["state"],
): string {
if (state === "ready") return "Срез актуален";
if (state === "refreshing") return "Обновляем каталог";
if (state === "error") return "Каталог недоступен";
return "Читаем каталог";
}
export function ObservatoryWorkspace({
definition,
}: {
definition: WorkspaceDefinition;
}) {
const controller = useObservatoryCatalog();
const [selectedSessionId, setSelectedSessionId] = useState("");
const items = controller.catalog?.items ?? [];
useEffect(() => {
if (items.some((item) => item.source.id === selectedSessionId)) return;
setSelectedSessionId(items[0]?.source.id ?? "");
}, [items, selectedSessionId]);
const selectedSession = items.find(
(item) => item.source.id === selectedSessionId,
) ?? null;
const presentedEvidence = selectedSession?.evidence.slice(
0,
MAX_PRESENTED_EVIDENCE,
) ?? [];
const options = useMemo(() => items.map(({ source, evidence }) => ({
value: source.id,
label: source.label,
description: `${formatTimestamp(source.startedAtUtc)} · ${formatDuration(source.durationSeconds)} · ${evidence.length} результатов`,
})), [items]);
const initialLoading = !controller.catalog
&& ["idle", "loading"].includes(controller.state);
const unavailable = !controller.catalog && controller.state === "error";
return (
<div
className="observatory-workspace"
data-observatory-authority="observation-only"
data-observatory-viewer="detached"
>
<section className="observatory-lead">
<div>
<span className="section-eyebrow">{definition.eyebrow}</span>
<h2>{definition.title}</h2>
<p>
Записанные источники и строго связанные результаты без запуска тяжёлого
визуализатора и без доступа к управлению аппаратом.
</p>
</div>
<StatusBadge
tone={controller.state === "error" ? "danger" : controller.state === "ready" ? "success" : "neutral"}
>
{catalogStateLabel(controller.state)}
</StatusBadge>
</section>
<GlassSurface className="observatory-catalog-bar" padding="lg">
<div className="observatory-catalog-bar__copy">
<span className="section-eyebrow">ИСТОЧНИК ДОКАЗАТЕЛЬСТВ</span>
<h3>Сохранённая сессия</h3>
<p>Выбор меняет только читаемую карточку и не готовит Rerun-запись в фоне.</p>
</div>
<div className="observatory-catalog-bar__controls">
<Select
label="Выбрать сохранённую сессию"
value={selectedSessionId}
options={options}
disabled={items.length === 0}
searchable
searchPlaceholder="Поиск по сессиям"
emptyLabel="Сессия не найдена"
minMenuWidth={360}
menuWidth={460}
onChange={setSelectedSessionId}
/>
<Button
size="compact"
variant="secondary"
disabled={controller.state === "loading" || controller.state === "refreshing"}
icon={<Icon name="refresh" />}
onClick={controller.refresh}
>
Обновить
</Button>
</div>
</GlassSurface>
{controller.error && controller.catalog ? (
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="alert">
<StatusBadge tone="warning">Показан последний срез</StatusBadge>
<span className="observatory-notice__copy">{controller.error}</span>
<Button size="compact" variant="ghost" onClick={controller.refresh}>Повторить</Button>
</GlassSurface>
) : null}
{controller.catalog
&& (controller.catalog.window.sourceLimitReached
|| controller.catalog.window.laboratoryLimitReached) ? (
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="status">
<StatusBadge tone="warning">Срез ограничен</StatusBadge>
<span className="observatory-notice__copy">
Загружены последние {controller.catalog.window.sourceCount} исходных сессий и{
" "
}{controller.catalog.window.laboratoryCount} LAB-результатов. Полнота исторических
связей не подтверждена.
</span>
</GlassSurface>
) : null}
{initialLoading ? (
<GlassSurface className="observatory-state" padding="lg">
<ActivityIndicator label="Читаем каталог сессий" />
<h3>Читаем источники и связи результатов</h3>
<p>Визуализатор и лабораторные сцены при этом не запускаются.</p>
</GlassSurface>
) : unavailable ? (
<GlassSurface className="observatory-state" padding="lg" role="alert">
<Icon name="alert" size={20} />
<StatusBadge tone="danger">Каталог недоступен</StatusBadge>
<h3>{controller.error}</h3>
<Button size="compact" variant="secondary" onClick={controller.refresh}>Повторить</Button>
</GlassSurface>
) : items.length === 0 ? (
<GlassSurface className="observatory-state" padding="lg">
<Icon name="database" size={20} />
<h3>Сохранённых сессий пока нет</h3>
<p>После завершения записи источник появится здесь без создания демонстрационных данных.</p>
</GlassSurface>
) : selectedSession ? (
<section className="observatory-session-grid" aria-label="Выбранная сессия и связанные результаты">
<GlassSurface className="observatory-session-summary" padding="lg">
<header>
<div>
<span className="section-eyebrow">ИСХОДНАЯ СЕССИЯ</span>
<h3>{selectedSession.source.label}</h3>
</div>
<StatusBadge tone={statusTone(selectedSession.source.status)}>
{statusLabel[selectedSession.source.status]}
</StatusBadge>
</header>
<dl>
<div>
<dt>Идентификатор</dt>
<dd><code>{selectedSession.source.id}</code></dd>
</div>
<div><dt>Начало</dt><dd>{formatTimestamp(selectedSession.source.startedAtUtc)}</dd></div>
<div><dt>Длительность</dt><dd>{formatDuration(selectedSession.source.durationSeconds)}</dd></div>
<div>
<dt>Чтение</dt>
<dd>{selectedSession.source.replayable ? "Доступна" : "Не подготовлена"}</dd>
</div>
</dl>
<div className="observatory-modalities" aria-label="Каналы сессии">
{selectedSession.source.modalities.length > 0
? selectedSession.source.modalities.map((modality) => (
<StatusBadge key={modality} tone="neutral">
{modalityLabel[modality] ?? modality}
</StatusBadge>
))
: <span>Каналы не зафиксированы</span>}
</div>
</GlassSurface>
<GlassSurface className="observatory-evidence" padding="lg">
<header>
<div>
<span className="section-eyebrow">СВЯЗАННЫЕ РЕЗУЛЬТАТЫ</span>
<h3>Лабораторные доказательства</h3>
</div>
<StatusBadge tone={selectedSession.evidence.length > 0 ? "accent" : "neutral"}>
{selectedSession.evidence.length}
</StatusBadge>
</header>
{selectedSession.evidence.length > 0 ? (
<ol className="observatory-evidence-list">
{presentedEvidence.map((evidence) => (
<li key={evidence.sessionId}>
<GlassSurface className="observatory-evidence-card" padding="md" tone="soft">
<div>
<strong>{evidence.lab.labId}</strong>
<span>{evidence.label}</span>
</div>
<dl>
<div><dt>Тип результата</dt><dd>{evidence.lab.resultKind}</dd></div>
<div>
<dt>Result ID</dt>
<dd><code>{evidence.lab.resultId}</code></dd>
</div>
<div><dt>Опубликован</dt><dd>{formatTimestamp(evidence.publishedAtUtc)}</dd></div>
</dl>
</GlassSurface>
</li>
))}
</ol>
) : (
<div className="observatory-evidence-empty">
<Icon name="clipboard" size={18} />
<strong>Связанных результатов нет</strong>
<p>
Наличие исходной записи само по себе не является выводом о качестве
компьютерного зрения или безопасности прохождения.
</p>
</div>
)}
{selectedSession.evidence.length > presentedEvidence.length ? (
<p className="observatory-evidence__bounded-note">
Показаны {presentedEvidence.length} последних из {selectedSession.evidence.length}
{" "}связанных результатов. Полный архив остаётся в legacy LAB.
</p>
) : null}
</GlassSurface>
</section>
) : null}
{(controller.catalog?.unresolvedEvidence.length ?? 0) > 0 ? (
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="status">
<StatusBadge tone="warning">Вне среза</StatusBadge>
<span className="observatory-notice__copy">
{controller.catalog?.unresolvedEvidence.length} результатов ссылаются на источники
вне текущего загруженного среза и не приписаны к квалификационным доказательствам.
</span>
</GlassSurface>
) : null}
</div>
);
}