feat(observatory): add bounded M5.1 session review
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user