fix(control-station): separate lab contours from recordings

This commit is contained in:
DCCONSTRUCTIONS
2026-07-26 13:34:12 +03:00
parent 05413e3f5c
commit 42041b37cd
10 changed files with 840 additions and 171 deletions
+9 -1
View File
@@ -26,6 +26,7 @@ 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";
@@ -752,7 +753,14 @@ export default function App() {
) : null}
</div>
) : activeDefinition.kind === "recordings" ? (
<StatusBadge tone="accent">Архив записей</StatusBadge>
<ObservationSessionSelect
disabled={runtime.pendingAction !== null || sourceSwitchBlocked}
blockedReason={sourceSwitchBlockedReason}
onReplayBegin={beginRecordedReplaySwitch}
onReplayAccepted={acceptRecordedReplay}
onReplaySettled={(_session, outcome) =>
settleRecordedReplaySwitch(outcome)}
/>
) : activeDefinition.kind === "datasets" ? (
<StatusBadge tone="neutral">Offline evaluation</StatusBadge>
) : activeDefinition.kind === "lab-archive" ? (
+3 -3
View File
@@ -153,10 +153,10 @@ export const workspaces: WorkspaceDefinition[] = [
{
id: "lab-archive",
root: "polygon",
label: "Лабораторные работы",
title: "Архив лабораторных работ",
label: "Лабораторные контуры",
title: "Лабораторные контуры",
eyebrow: "ТЕСТОВЫЙ КОНТУР / ЛАБОРАТОРИЯ",
description: "Конфигурации, ход эксперимента и визуальные результаты принятых лабораторных работ.",
description: "Профили конфигураций, лабораторные работы и их воспроизводимые результаты.",
icon: "clipboard",
kind: "lab-archive",
groups: [],
+312 -69
View File
@@ -2385,18 +2385,17 @@
.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 {
width: 100%;
height: 100%;
min-height: 34rem;
}
.recordings-workspace__viewer > header,
.lab-result-surface > header {
display: flex;
align-items: center;
@@ -2404,102 +2403,346 @@
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 {
.lab-archive-workspace {
padding-bottom: 1rem;
}
.laboratory-selector {
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;
grid-template-columns: minmax(0, 1fr) minmax(18rem, 0.38fr);
align-items: center;
justify-content: space-between;
gap: 1rem;
gap: 1.4rem;
border-radius: 1rem;
background: rgb(255 255 255 / 0.025);
padding: 1rem;
}
.lab-configuration h2,
.lab-configuration p {
.laboratory-selector h2,
.laboratory-selector p {
margin: 0;
}
.lab-configuration h2 {
.laboratory-selector h2 {
margin-top: 0.25rem;
color: var(--nodedc-text-primary);
font-size: 1rem;
}
.lab-configuration p {
max-width: 55rem;
.laboratory-selector p {
max-width: 60rem;
margin-top: 0.35rem;
color: var(--nodedc-text-muted);
font-size: 0.66rem;
font-size: 0.64rem;
line-height: 1.5;
}
.lab-primary-result {
.laboratory-selector label {
display: grid;
gap: 0.35rem;
}
.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 {
.laboratory-selector label > span {
color: var(--nodedc-text-muted);
font-size: 0.55rem;
font-weight: 650;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.laboratory-selector select {
width: 100%;
min-width: 0;
border: 0;
border-radius: 999px;
outline: 0;
background: rgb(255 255 255 / 0.075);
color: var(--nodedc-text-primary);
padding: 0.58rem 0.85rem;
font: inherit;
font-size: 0.65rem;
font-weight: 620;
}
.laboratory-selector select:focus-visible {
background: rgb(255 255 255 / 0.12);
}
.laboratory-selector select:disabled {
opacity: 0.45;
}
.laboratory-work-output {
display: grid;
gap: 0.85rem;
}
.laboratory-task,
.laboratory-result-summary,
.laboratory-visual-result {
border-radius: 1rem;
background: rgb(255 255 255 / 0.025);
padding: 1rem;
}
.laboratory-task > header,
.laboratory-result-summary > header,
.laboratory-visual-result > header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1.5rem;
}
.laboratory-task h2,
.laboratory-task p,
.laboratory-task dl,
.laboratory-result-summary h2,
.laboratory-result-summary p,
.laboratory-visual-result h2,
.laboratory-visual-result p {
margin: 0;
}
.laboratory-task h2,
.laboratory-result-summary h2,
.laboratory-visual-result h2 {
margin-top: 0.3rem;
color: var(--nodedc-text-primary);
font-size: 1rem;
letter-spacing: -0.025em;
}
.laboratory-task p,
.laboratory-result-summary > p,
.laboratory-visual-result header p {
max-width: 66rem;
margin-top: 0.38rem;
color: var(--nodedc-text-muted);
font-size: 0.63rem;
line-height: 1.55;
}
.laboratory-task dl {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.4rem;
margin-top: 0.9rem;
}
.laboratory-task dl > div,
.laboratory-result-metrics > div {
display: grid;
gap: 0.25rem;
border-radius: 0.75rem;
background: rgb(255 255 255 / 0.035);
padding: 0.7rem;
}
.laboratory-task dt,
.laboratory-task dd {
margin: 0;
}
.laboratory-task dt,
.laboratory-result-metrics span,
.laboratory-result-metrics small {
color: var(--nodedc-text-muted);
font-size: 0.54rem;
}
.laboratory-task dd,
.laboratory-result-metrics strong {
overflow: hidden;
color: var(--nodedc-text-primary);
font-size: 0.7rem;
font-weight: 660;
text-overflow: ellipsis;
white-space: nowrap;
}
.lab-result-surface {
min-height: 36rem;
}
.lab-result-surface .spatial-workspace {
min-height: 34rem;
}
.laboratory-result-pending {
display: grid;
min-height: 34rem;
place-items: center;
align-content: center;
gap: 0.5rem;
border-radius: 1rem;
background: #06070a;
color: var(--nodedc-text-muted);
text-align: center;
}
.laboratory-result-pending strong {
color: var(--nodedc-text-secondary);
font-size: 0.75rem;
}
.laboratory-result-pending p {
max-width: 34rem;
margin: 0;
font-size: 0.62rem;
}
.laboratory-visual-result {
display: grid;
min-height: 34rem;
align-content: start;
gap: 1rem;
background: #08090c;
}
.laboratory-e29-chart {
display: grid;
gap: 0.85rem;
border-radius: 0.9rem;
background: rgb(255 255 255 / 0.025);
padding: 1rem;
}
.laboratory-e29-chart__bar {
display: flex;
height: 1.1rem;
overflow: hidden;
border-radius: 999px;
background: rgb(255 255 255 / 0.04);
}
.laboratory-e29-chart__bar i[data-status="agree"],
.laboratory-e29-chart__legend i[data-status="agree"] {
background: rgb(var(--nodedc-success-rgb));
}
.laboratory-e29-chart__bar i[data-status="camera"],
.laboratory-e29-chart__legend i[data-status="camera"] {
background: rgb(var(--nodedc-accent-rgb));
}
.laboratory-e29-chart__bar i[data-status="conflict"],
.laboratory-e29-chart__legend i[data-status="conflict"] {
min-width: 0.25rem;
background: rgb(var(--nodedc-warning-rgb));
}
.laboratory-e29-chart__legend {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.5rem;
}
.laboratory-e29-chart__legend > div {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.25rem 0.45rem;
border-radius: 0.75rem;
background: rgb(255 255 255 / 0.025);
padding: 0.7rem;
}
.laboratory-e29-chart__legend i {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
}
.laboratory-e29-chart__legend span,
.laboratory-e29-chart__legend small {
color: var(--nodedc-text-muted);
font-size: 0.56rem;
}
.laboratory-e29-chart__legend strong {
color: var(--nodedc-text-primary);
font-size: 0.85rem;
}
.laboratory-e29-chart__legend small {
grid-column: 2 / 4;
}
.laboratory-e29-evidence {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.6rem;
}
.laboratory-e29-evidence article {
display: grid;
align-content: start;
gap: 0.45rem;
border-radius: 0.9rem;
background: rgb(255 255 255 / 0.025);
padding: 1rem;
}
.laboratory-e29-evidence strong {
color: var(--nodedc-text-primary);
font-size: 1.4rem;
}
.laboratory-e29-evidence p {
margin: 0;
color: var(--nodedc-text-muted);
font-size: 0.62rem;
line-height: 1.5;
}
.laboratory-e29-evidence article > div {
display: grid;
gap: 0.3rem;
}
.laboratory-e29-evidence article > div span {
border-radius: 0.6rem;
background: rgb(255 255 255 / 0.03);
color: var(--nodedc-text-secondary);
padding: 0.45rem 0.55rem;
font-size: 0.58rem;
}
.laboratory-result-summary {
display: grid;
gap: 0.85rem;
}
.laboratory-result-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.4rem;
}
.laboratory-result-metrics > div {
min-width: 0;
}
.laboratory-result-metrics strong {
font-size: 0.9rem;
}
@media (max-width: 1100px) {
.laboratory-selector {
grid-template-columns: minmax(0, 1fr);
}
.laboratory-task dl,
.laboratory-result-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.dataset-entry {
display: grid;
grid-template-columns: minmax(20rem, 1.2fr) minmax(22rem, 0.8fr);
@@ -61,9 +61,11 @@ function errorMessage(error: unknown): string {
export function LidarQualityWorkspace({
deviceLabel,
onOpenObservation,
embedded = false,
}: {
deviceLabel: string | null;
onOpenObservation: () => void;
embedded?: boolean;
}) {
const [catalog, setCatalog] = useState<LidarReplayCatalog | null>(null);
const [detail, setDetail] = useState<LidarReplayDetail | null>(null);
@@ -248,29 +250,31 @@ export function LidarQualityWorkspace({
</GlassSurface>
) : (
<>
<section className="lidar-device-context" aria-label="Выбранный LiDAR и запись">
<div className="lidar-device-context__identity">
<span className="section-eyebrow">ВЫБРАННЫЙ СЕНСОР</span>
<strong>{deviceLabel ?? "Архивный источник"} · LiDAR</strong>
<small>
Запись {fieldReview?.sessionId ?? detail.pack.sessionId}
</small>
</div>
<div className="lidar-device-context__verdict">
<StatusBadge tone="warning">Диагностическая запись</StatusBadge>
<p>
Данные воспроизводятся и доступны для визуального разбора.
Физическая точность сенсора этой записью не аттестована.
</p>
</div>
<Button
size="compact"
variant="secondary"
onClick={onOpenObservation}
>
Открыть наблюдение
</Button>
</section>
{!embedded ? (
<section className="lidar-device-context" aria-label="Выбранный LiDAR и запись">
<div className="lidar-device-context__identity">
<span className="section-eyebrow">ВЫБРАННЫЙ СЕНСОР</span>
<strong>{deviceLabel ?? "Архивный источник"} · LiDAR</strong>
<small>
Запись {fieldReview?.sessionId ?? detail.pack.sessionId}
</small>
</div>
<div className="lidar-device-context__verdict">
<StatusBadge tone="warning">Диагностическая запись</StatusBadge>
<p>
Данные воспроизводятся и доступны для визуального разбора.
Физическая точность сенсора этой записью не аттестована.
</p>
</div>
<Button
size="compact"
variant="secondary"
onClick={onOpenObservation}
>
Открыть наблюдение
</Button>
</section>
) : null}
<GlassSurface className="lidar-field-review" padding="lg">
<header className="panel-heading lidar-field-review__heading">
@@ -14,7 +14,6 @@ import {
} from "@nodedc/ui-react";
import {
ObservationSessionArchive,
type ObservationSessionReplayCallbacks,
} from "../components/ObservationSessionSelect";
import {
@@ -24,7 +23,11 @@ import {
} from "../components/ObservationSources";
import { ObservationTimeline } from "../components/ObservationTimeline";
import { FloatingObservationWindow } from "../components/FloatingObservationWindow";
import type { ObservationSessionReplayLaunch } from "../core/observation/sessionArchive";
import type {
ObservationSessionReplayLaunch,
ObservationSessionSummary,
} from "../core/observation/sessionArchive";
import { useObservationSessions } from "../core/observation/useObservationSessions";
import type { RecordedSessionAdmissionController } from "../core/observation/useRecordedSessionAdmission";
import type {
RecordedAdmissionPhase,
@@ -1176,84 +1179,447 @@ 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>
)}
<SpatialWorkspace {...props} />
</div>
);
}
function LabArchiveWorkspace(props: WorkspaceRendererProps) {
const [surface, setSurface] = useState<"lidar-e29" | "replay">(
props.recordedReplay ? "replay" : "lidar-e29",
type LaboratoryProfileId = "sensor-fusion" | "published-perception";
type LaboratoryWorkId = "e28-local-surface" | "e29-camera-geometry" | `session:${string}`;
interface LaboratoryOption<T extends string> {
id: T;
label: string;
}
function LaboratorySelector<T extends string>({
eyebrow,
title,
description,
label,
value,
options,
disabled = false,
onChange,
}: {
eyebrow: string;
title: string;
description: string;
label: string;
value: T;
options: readonly LaboratoryOption<T>[];
disabled?: boolean;
onChange: (value: T) => void;
}) {
return (
<section className="laboratory-selector">
<div>
<span className="section-eyebrow">{eyebrow}</span>
<h2>{title}</h2>
<p>{description}</p>
</div>
<label>
<span>{label}</span>
<select
value={value}
disabled={disabled}
onChange={(event) => onChange(event.currentTarget.value as T)}
>
{options.map((option) => (
<option key={option.id} value={option.id}>{option.label}</option>
))}
</select>
</label>
</section>
);
const replayActions: WorkspaceRendererProps["sessionArchive"] = {
...props.sessionArchive,
onReplayAccepted: async (session, launch) => {
await props.sessionArchive.onReplayAccepted?.(session, launch);
setSurface("replay");
},
};
}
function LaboratoryTask({
title,
description,
status,
facts,
}: {
title: string;
description: string;
status: string;
facts: readonly { label: string; value: string }[];
}) {
return (
<section className="laboratory-task">
<header>
<div>
<span className="section-eyebrow">ОПИСАНИЕ ЛАБОРАТОРНОЙ РАБОТЫ</span>
<h2>{title}</h2>
<p>{description}</p>
</div>
<StatusBadge tone="warning">{status}</StatusBadge>
</header>
<dl>
{facts.map((fact) => (
<div key={fact.label}>
<dt>{fact.label}</dt>
<dd>{fact.value}</dd>
</div>
))}
</dl>
</section>
);
}
function E29LaboratoryResult({ rigLabel }: { rigLabel: string }) {
const semanticTotal = 19_625;
const statuses = [
{ id: "agree", label: "Камера + геометрия", value: 6_341 },
{ id: "camera", label: "Только камера", value: 13_246 },
{ id: "conflict", label: "Конфликт", value: 38 },
] as const;
const conflictEpisodes = [
"track 115 · 69,79971,398 с",
"track 470 · 215,324218,708 с",
"track 679 · 282,569283,281 с",
"track 1011 · 354,937355,242 с",
"track 1276 · 402,606402,995 с",
];
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>
<>
<LaboratoryTask
title="LAB E29 · camera-first semantics + независимая геометрия"
description="Проверяется рабочая продуктовая граница: камера сохраняет семантический класс и идентичность объекта, LiDAR подтверждает метрическую дальность и занятую геометрию по локальной поверхности L2.6. Отсутствие точек не объявляется свободным пространством."
status="Диагностический replay"
facts={[
{ label: "Конфигурация", value: `${rigLabel} · камера + LiDAR · worker D` },
{ label: "Источник", value: "RAVNOVES00 · 4 489 кадров" },
{ label: "Объектов", value: "20 513 наблюдений" },
{ label: "Полномочия", value: "Только shadow · команды запрещены" },
]}
/>
<section className="laboratory-visual-result" aria-label="Визуальный результат LAB E29">
<header>
<div>
<span className="section-eyebrow">ВИЗУАЛЬНЫЙ РЕЗУЛЬТАТ</span>
<h2>Покрытие семантических наблюдений геометрией</h2>
<p>
Это покрытие одного воспроизводимого replay, а не accuracy и не допуск
планировщика. Конфликты сохранены для ручного покадрового разбора.
</p>
</div>
<StatusBadge tone="warning">16 конфликтных эпизодов</StatusBadge>
</header>
<div className="laboratory-e29-chart" role="img" aria-label="Распределение статусов геометрии">
<div className="laboratory-e29-chart__bar">
{statuses.map((status) => (
<i
key={status.id}
data-status={status.id}
style={{ width: `${(status.value / semanticTotal) * 100}%` }}
/>
))}
</div>
<div className="laboratory-e29-chart__legend">
{statuses.map((status) => (
<div key={status.id}>
<i data-status={status.id} />
<span>{status.label}</span>
<strong>{status.value.toLocaleString("ru-RU")}</strong>
<small>{((status.value / semanticTotal) * 100).toLocaleString("ru-RU", {
maximumFractionDigits: 2,
})}%</small>
</div>
))}
</div>
</div>
<div className="laboratory-e29-evidence">
<article>
<span className="section-eyebrow">НЕЗАВИСИМАЯ ГЕОМЕТРИЯ</span>
<strong>21 321 компонент</strong>
<p>
Незасемантизированные занятые компоненты сохранены отдельным слоем:
им не назначается выдуманный класс и они не считаются свободным местом.
</p>
</article>
<article>
<span className="section-eyebrow">КОНФЛИКТЫ ДЛЯ РАЗБОРА</span>
<div>
{conflictEpisodes.map((episode) => <span key={episode}>{episode}</span>)}
</div>
</article>
</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 className="laboratory-result-summary">
<header>
<div>
<span className="section-eyebrow">РЕЗУЛЬТАТ И ВЫВОД</span>
<h2>Архитектурная схема подтверждена, safety-gate не пройден</h2>
</div>
<StatusBadge tone="success">Replay завершён</StatusBadge>
</header>
<div className="laboratory-result-metrics">
<div><span>Поддержка геометрией</span><strong>6 341</strong><small>32,31% current observations</small></div>
<div><span>Camera-only</span><strong>13 246</strong><small>Семантика сохранена</small></div>
<div><span>Postprocess p95</span><strong>2,517 мс</strong><small>8,467 с полный build</small></div>
<div><span>Geometry-only</span><strong>21 321</strong><small>Занято / класс неизвестен</small></div>
</div>
<p>
Следующий gate покадровый разбор конфликтов, camera-only пробелов и
geometry-only компонентов, затем source-paced shadow с deadline и staleness.
</p>
</section>
</>
);
}
<ObservationSessionArchive {...replayActions} labsOnly />
function formatLaboratoryMetric(value: unknown): string {
if (typeof value === "boolean") return value ? "Да" : "Нет";
if (typeof value === "number") {
return value.toLocaleString("ru-RU", { maximumFractionDigits: 3 });
}
return String(value);
}
function laboratoryMetricLabel(key: string): string {
return key
.replace(/_p95_ms$/, " · p95 мс")
.replaceAll("_", " ")
.replace(/^./, (value) => value.toLocaleUpperCase("ru-RU"));
}
function laboratorySessionTitle(session: ObservationSessionSummary): string {
const labPrefix = session.lab ? `${session.lab.labId} · ` : "";
return labPrefix && session.label.startsWith(labPrefix)
? session.label.slice(labPrefix.length)
: session.label;
}
function PublishedLaboratoryResult({
props,
session,
loading,
error,
}: {
props: WorkspaceRendererProps;
session: ObservationSessionSummary;
loading: boolean;
error: string | null;
}) {
const lab = session.lab;
const metrics = Object.entries(lab?.provenance ?? {})
.filter(([key, value]) => (
(typeof value === "number" || typeof value === "boolean")
&& !key.includes("sha256")
&& !key.includes("authority")
))
.slice(0, 8);
const replayReady = props.recordedReplay?.sessionId === session.id;
return (
<>
<LaboratoryTask
title={`${lab?.labId ?? "LAB"} · ${laboratorySessionTitle(session)}`}
description="Опубликованная лабораторная работа открывается по своей неизменяемой записи. Viewer показывает исходные синхронные каналы; за ним приведены только метрики, зафиксированные в LAB-provenance этого результата."
status="Зафиксированный результат"
facts={[
{ label: "Тип результата", value: lab?.resultKind ?? "—" },
{ label: "Источник", value: lab?.sourceSessionId ?? session.id },
{ label: "Конфигурация", value: lab?.configSha256?.slice(0, 16) ?? "Не зафиксирована" },
{ label: "Полномочия", value: "Диагностика · без навигации и команд" },
]}
/>
<section className="lab-result-surface">
<header>
<span className="section-eyebrow">ВИЗУАЛЬНОЕ ДОКАЗАТЕЛЬСТВО</span>
<strong>
{surface === "replay" && props.recordedReplay
? "Запись выбранной лабораторной работы"
: "LAB E29 · результат L2.6"}
</strong>
<strong>Исходная запись выбранной лабораторной работы</strong>
</header>
{surface === "replay" && props.recordedReplay ? (
{replayReady ? (
<SpatialWorkspace {...props} />
) : (
<LidarQualityWorkspace
deviceLabel={props.deviceLabel}
onOpenObservation={() => props.navigation.openView("spatial-scene")}
/>
<div className="laboratory-result-pending" role="status">
{loading ? <span className="busy-indicator" aria-hidden="true" /> : <Icon name="database" size={20} />}
<strong>{loading ? "Подготавливаем лабораторную запись" : "Запись не открыта"}</strong>
<p>
{error ?? (loading
? "Связанное визуальное доказательство откроется после проверки записи."
: "Выберите работу ещё раз, чтобы открыть связанное визуальное доказательство.")}
</p>
</div>
)}
</section>
<section className="laboratory-result-summary">
<header>
<div>
<span className="section-eyebrow">ЗАФИКСИРОВАННЫЕ МЕТРИКИ</span>
<h2>Результат из LAB-provenance</h2>
</div>
<StatusBadge tone={
lab?.provenance.benchmark_passed === true ? "success" : "warning"
}>
{lab?.provenance.benchmark_passed === true ? "Benchmark passed" : "Требует разбора"}
</StatusBadge>
</header>
<div className="laboratory-result-metrics">
{metrics.length ? metrics.map(([key, value]) => (
<div key={key}>
<span>{laboratoryMetricLabel(key)}</span>
<strong>{formatLaboratoryMetric(value)}</strong>
<small>Immutable provenance</small>
</div>
)) : (
<div>
<span>Метрики</span>
<strong>Не опубликованы</strong>
<small>Доступна исходная запись</small>
</div>
)}
</div>
</section>
</>
);
}
function LabArchiveWorkspace(props: WorkspaceRendererProps) {
const [profileId, setProfileId] = useState<LaboratoryProfileId>("sensor-fusion");
const [workId, setWorkId] = useState<LaboratoryWorkId>("e28-local-surface");
const sessions = useObservationSessions({
limit: 100,
replayEnabled: props.sessionArchive.blockedReason === null,
onReplayBegin: props.sessionArchive.onReplayBegin,
onReplayAccepted: props.sessionArchive.onReplayAccepted,
onReplaySettled: props.sessionArchive.onReplaySettled,
});
const publishedWorks = useMemo(
() => sessions.items.filter((session) => session.lab !== null),
[sessions.items],
);
const rigLabel = useMemo(() => {
if (props.deviceLabel) return props.deviceLabel;
const coordinateFrame = publishedWorks
.map((session) => session.lab?.provenance.coordinate_frame)
.find((value) => typeof value === "string" && value.trim());
const sensorToken = typeof coordinateFrame === "string"
? coordinateFrame.split("-")[0]?.trim()
: "";
return sensorToken ? sensorToken.toLocaleUpperCase("ru-RU") : "Сенсорный риг";
}, [props.deviceLabel, publishedWorks]);
const profiles: readonly LaboratoryOption<LaboratoryProfileId>[] = [
{
id: "sensor-fusion",
label: `${rigLabel} · камера + LiDAR · worker D`,
},
{
id: "published-perception",
label: `${rigLabel} · опубликованный perception pipeline`,
},
];
const workOptions: readonly LaboratoryOption<LaboratoryWorkId>[] =
profileId === "sensor-fusion"
? [
{ id: "e28-local-surface", label: "LAB E28 · локальная поверхность L2.6" },
{ id: "e29-camera-geometry", label: "LAB E29 · camera-first + geometry" },
]
: publishedWorks.map((session) => ({
id: `session:${session.id}` as const,
label: `${session.lab?.labId ?? "LAB"} · ${laboratorySessionTitle(session)}`,
}));
const selectedSessionId = workId.startsWith("session:")
? workId.slice("session:".length)
: null;
const selectedSession = selectedSessionId
? publishedWorks.find((session) => session.id === selectedSessionId) ?? null
: null;
useEffect(() => {
if (profileId !== "published-perception" || workId.startsWith("session:")) return;
const first = publishedWorks[0];
if (!first) return;
setWorkId(`session:${first.id}`);
}, [profileId, publishedWorks, workId]);
const selectProfile = (next: LaboratoryProfileId) => {
setProfileId(next);
if (next === "sensor-fusion") {
setWorkId("e28-local-surface");
return;
}
const first = publishedWorks[0];
if (!first) return;
const nextWork = `session:${first.id}` as const;
setWorkId(nextWork);
void sessions.replay(first.id);
};
const selectWork = (next: LaboratoryWorkId) => {
setWorkId(next);
if (!next.startsWith("session:")) return;
void sessions.replay(next.slice("session:".length));
};
return (
<div className="lab-archive-workspace">
<LaboratorySelector
eyebrow="ПРОФИЛЬ ЛАБОРАТОРНОГО КОНТУРА"
title={profiles.find((profile) => profile.id === profileId)?.label ?? rigLabel}
description="Профиль фиксирует объект исследования, сенсорные модули и вычислительный контур. Исходные данные остаются read-only; профиль объединяет серию сопоставимых лабораторных работ."
label="Профиль"
value={profileId}
options={profiles}
onChange={selectProfile}
/>
<LaboratorySelector
eyebrow="ЛАБОРАТОРНАЯ РАБОТА"
title={workOptions.find((work) => work.id === workId)?.label ?? "Работа не выбрана"}
description="Выберите один зафиксированный эксперимент. Ниже откроются его задача, полноразмерное визуальное доказательство и структурированный результат."
label="Работа"
value={workId}
options={workOptions}
disabled={workOptions.length === 0}
onChange={selectWork}
/>
<div className="laboratory-work-output">
{workId === "e28-local-surface" ? (
<>
<LaboratoryTask
title="LAB E28 · локальная модель поверхности L2.6"
description="Полная запись RAVNOVES00 воспроизводится через bounded shadow-контур. Модель оценивает локальную поверхность, наблюдаемые препятствия, временные скачки и ошибки предсказания без изменения исходного сенсора и без командного канала."
status="Диагностический replay"
facts={[
{ label: "Конфигурация", value: `${rigLabel} · LiDAR + pose · worker D` },
{ label: "Покрытие", value: "3 928 / 3 928 кадров" },
{ label: "Режим", value: "Recorded-source-paced shadow" },
{ label: "Полномочия", value: "Навигация и команды запрещены" },
]}
/>
<LidarQualityWorkspace
embedded
deviceLabel={props.deviceLabel}
onOpenObservation={() => props.navigation.openView("spatial-scene")}
/>
</>
) : workId === "e29-camera-geometry" ? (
<E29LaboratoryResult rigLabel={rigLabel} />
) : selectedSession ? (
<PublishedLaboratoryResult
props={props}
session={selectedSession}
loading={sessions.replayingSessionId === selectedSession.id}
error={sessions.failedSessionId === selectedSession.id ? sessions.error : null}
/>
) : (
<div className="laboratory-result-pending">
<Icon name="database" size={20} />
<strong>В этом профиле пока нет опубликованных работ</strong>
<p>Работы появятся после публикации проверенного LAB-provenance.</p>
</div>
)}
</div>
</div>
);
}
@@ -172,6 +172,31 @@ test("opened archive is named in the scene header and trash hover has no pill",
assert.doesNotMatch(acquisitionPipeline, /Индикатор постоянно зелёный/);
});
test("data recordings keep the compact session dropdown and laboratory results stay separate", async () => {
const appSource = await readFile(new URL("../src/App.tsx", import.meta.url), "utf8");
const workspaceSource = await readFile(
new URL("../src/workspaces/Workspaces.tsx", import.meta.url),
"utf8",
);
const productSource = await readFile(
new URL("../src/productModel.ts", import.meta.url),
"utf8",
);
const recordingsSource = workspaceSource.slice(
workspaceSource.indexOf("function RecordingsWorkspace"),
workspaceSource.indexOf("type LaboratoryProfileId"),
);
assert.match(appSource, /activeDefinition\.kind === "recordings"[\s\S]*<ObservationSessionSelect/);
assert.match(recordingsSource, /<SpatialWorkspace \{\.\.\.props\} \/>/);
assert.doesNotMatch(recordingsSource, /ObservationSessionArchive/);
assert.match(productSource, /label: "Лабораторные контуры"/);
assert.match(workspaceSource, /ПРОФИЛЬ ЛАБОРАТОРНОГО КОНТУРА/);
assert.match(workspaceSource, /ЛАБОРАТОРНАЯ РАБОТА/);
assert.match(workspaceSource, /e28-local-surface/);
assert.match(workspaceSource, /e29-camera-geometry/);
});
test("session catalog exposes authoritative background preparation state", () => {
const catalog = decodeObservationSessionCatalog({
items: [session({