feat(observatory): add bounded M5.1 session review
This commit is contained in:
@@ -806,6 +806,8 @@ export default function App() {
|
||||
<StatusBadge tone="neutral">Offline evaluation</StatusBadge>
|
||||
) : activeDefinition.kind === "lab-archive" ? (
|
||||
laboratoryAnnotation.control
|
||||
) : activeDefinition.kind === "observatory" ? (
|
||||
<StatusBadge tone="neutral">Только наблюдение</StatusBadge>
|
||||
) : activeDefinition.root === "system" ? (
|
||||
<SystemWorkspaceSelector
|
||||
value={activeDefinition.id}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
fetchObservationSessionCatalog,
|
||||
type ObservationLabInstance,
|
||||
type ObservationSessionFetch,
|
||||
type ObservationSessionSummary,
|
||||
} from "../observation/sessionArchive";
|
||||
|
||||
export interface ObservatoryEvidence {
|
||||
readonly sessionId: string;
|
||||
readonly label: string;
|
||||
readonly status: ObservationSessionSummary["status"];
|
||||
readonly publishedAtUtc: string;
|
||||
readonly lab: ObservationLabInstance;
|
||||
}
|
||||
|
||||
export interface ObservatorySession {
|
||||
readonly source: ObservationSessionSummary;
|
||||
readonly evidence: readonly ObservatoryEvidence[];
|
||||
}
|
||||
|
||||
export interface ObservatoryCatalog {
|
||||
readonly items: readonly ObservatorySession[];
|
||||
readonly unresolvedEvidence: readonly ObservatoryEvidence[];
|
||||
readonly window: {
|
||||
readonly limit: number;
|
||||
readonly sourceCount: number;
|
||||
readonly laboratoryCount: number;
|
||||
readonly sourceLimitReached: boolean;
|
||||
readonly laboratoryLimitReached: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export class ObservatoryCatalogContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ObservatoryCatalogContractError";
|
||||
}
|
||||
}
|
||||
|
||||
function newestFirst(left: string, right: string): number {
|
||||
return Date.parse(right) - Date.parse(left);
|
||||
}
|
||||
|
||||
function boundedLimit(limit: number): number {
|
||||
return Number.isFinite(limit)
|
||||
? Math.min(100, Math.max(1, Math.floor(limit)))
|
||||
: 100;
|
||||
}
|
||||
|
||||
function evidenceFromSession(
|
||||
session: ObservationSessionSummary,
|
||||
): ObservatoryEvidence {
|
||||
if (!session.lab) {
|
||||
throw new ObservatoryCatalogContractError(
|
||||
`LAB-каталог вернул сессию ${session.id} без типизированной связи с источником.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
sessionId: session.id,
|
||||
label: session.label,
|
||||
status: session.status,
|
||||
publishedAtUtc: session.lab.publishedAtUtc,
|
||||
lab: session.lab,
|
||||
};
|
||||
}
|
||||
|
||||
function sortEvidence(
|
||||
evidence: readonly ObservatoryEvidence[],
|
||||
): ObservatoryEvidence[] {
|
||||
return [...evidence].sort((left, right) =>
|
||||
newestFirst(left.publishedAtUtc, right.publishedAtUtc)
|
||||
|| left.sessionId.localeCompare(right.sessionId));
|
||||
}
|
||||
|
||||
export function buildObservatoryCatalog(
|
||||
sourceSessions: readonly ObservationSessionSummary[],
|
||||
laboratorySessions: readonly ObservationSessionSummary[],
|
||||
limit = 100,
|
||||
): ObservatoryCatalog {
|
||||
const safeLimit = boundedLimit(limit);
|
||||
const sources = new Map<string, ObservationSessionSummary>();
|
||||
for (const source of sourceSessions) {
|
||||
if (source.lab !== null) {
|
||||
throw new ObservatoryCatalogContractError(
|
||||
`Каталог источников вернул LAB-сессию ${source.id}.`,
|
||||
);
|
||||
}
|
||||
sources.set(source.id, source);
|
||||
}
|
||||
|
||||
const linked = new Map<string, ObservatoryEvidence[]>();
|
||||
const unresolvedEvidence: ObservatoryEvidence[] = [];
|
||||
for (const laboratorySession of laboratorySessions) {
|
||||
const evidence = evidenceFromSession(laboratorySession);
|
||||
const sourceId = evidence.lab.sourceSessionId;
|
||||
if (!sources.has(sourceId)) {
|
||||
// Both API projections are bounded newest-first windows without a
|
||||
// cursor. Absence from the source window is not proof of a broken
|
||||
// relationship and must not be presented as an integrity fault.
|
||||
unresolvedEvidence.push(evidence);
|
||||
continue;
|
||||
}
|
||||
const current = linked.get(sourceId) ?? [];
|
||||
current.push(evidence);
|
||||
linked.set(sourceId, current);
|
||||
}
|
||||
|
||||
const items = [...sources.values()]
|
||||
.sort((left, right) =>
|
||||
newestFirst(left.startedAtUtc, right.startedAtUtc)
|
||||
|| left.id.localeCompare(right.id))
|
||||
.map((source) => ({
|
||||
source,
|
||||
evidence: sortEvidence(linked.get(source.id) ?? []),
|
||||
}));
|
||||
|
||||
return {
|
||||
items,
|
||||
unresolvedEvidence: sortEvidence(unresolvedEvidence),
|
||||
window: {
|
||||
limit: safeLimit,
|
||||
sourceCount: sourceSessions.length,
|
||||
laboratoryCount: laboratorySessions.length,
|
||||
sourceLimitReached: sourceSessions.length >= safeLimit,
|
||||
laboratoryLimitReached: laboratorySessions.length >= safeLimit,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchObservatoryCatalog({
|
||||
signal,
|
||||
limit = 100,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
limit?: number;
|
||||
fetcher?: ObservationSessionFetch;
|
||||
} = {}): Promise<ObservatoryCatalog> {
|
||||
const safeLimit = boundedLimit(limit);
|
||||
const [sourceCatalog, laboratoryCatalog] = await Promise.all([
|
||||
fetchObservationSessionCatalog({ signal, limit: safeLimit, scope: "source", fetcher }),
|
||||
fetchObservationSessionCatalog({ signal, limit: safeLimit, scope: "laboratory", fetcher }),
|
||||
]);
|
||||
return buildObservatoryCatalog(sourceCatalog.items, laboratoryCatalog.items, safeLimit);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
fetchObservatoryCatalog,
|
||||
type ObservatoryCatalog,
|
||||
} from "./catalog";
|
||||
|
||||
export type ObservatoryCatalogState =
|
||||
| "idle"
|
||||
| "loading"
|
||||
| "ready"
|
||||
| "refreshing"
|
||||
| "error";
|
||||
|
||||
export interface ObservatoryCatalogController {
|
||||
readonly catalog: ObservatoryCatalog | null;
|
||||
readonly state: ObservatoryCatalogState;
|
||||
readonly error: string | null;
|
||||
readonly refresh: () => void;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: "Каталог Обсерватории недоступен.";
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return error instanceof DOMException && error.name === "AbortError";
|
||||
}
|
||||
|
||||
export function useObservatoryCatalog(): ObservatoryCatalogController {
|
||||
const [catalog, setCatalog] = useState<ObservatoryCatalog | null>(null);
|
||||
const [state, setState] = useState<ObservatoryCatalogState>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [generation, setGeneration] = useState(0);
|
||||
const catalogRef = useRef<ObservatoryCatalog | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setState(catalogRef.current ? "refreshing" : "loading");
|
||||
setError(null);
|
||||
void fetchObservatoryCatalog({ signal: controller.signal })
|
||||
.then((next) => {
|
||||
if (controller.signal.aborted) return;
|
||||
catalogRef.current = next;
|
||||
setCatalog(next);
|
||||
setState("ready");
|
||||
})
|
||||
.catch((loadError: unknown) => {
|
||||
if (controller.signal.aborted || isAbortError(loadError)) return;
|
||||
setError(errorMessage(loadError));
|
||||
setState("error");
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [generation]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setGeneration((current) => current + 1);
|
||||
}, []);
|
||||
|
||||
return { catalog, state, error, refresh };
|
||||
}
|
||||
@@ -23,7 +23,8 @@ export type WorkspaceKind =
|
||||
| "datasets"
|
||||
| "artifact-health"
|
||||
| "lab-archive"
|
||||
| "simulations";
|
||||
| "simulations"
|
||||
| "observatory";
|
||||
|
||||
export type CapabilityStatus = "active" | "ready" | "contract" | "later";
|
||||
|
||||
@@ -166,6 +167,17 @@ export const workspaces: WorkspaceDefinition[] = [
|
||||
kind: "simulations",
|
||||
groups: [],
|
||||
},
|
||||
{
|
||||
id: "observatory",
|
||||
root: "polygon",
|
||||
label: "Обсерватория",
|
||||
title: "Обсерватория восприятия",
|
||||
eyebrow: "ТЕСТОВЫЙ КОНТУР / ОБСЕРВАТОРИЯ",
|
||||
description: "Сессии и квалификация компьютерного зрения без доступа к управлению.",
|
||||
icon: "eye",
|
||||
kind: "observatory",
|
||||
groups: [],
|
||||
},
|
||||
{
|
||||
id: "contour-health",
|
||||
root: "fleet",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
@import "./styles/device.css";
|
||||
@import "./styles/responsive.css";
|
||||
@import "./styles/observation.css";
|
||||
@import "./styles/observatory.css";
|
||||
@import "./styles/environment-settings.css";
|
||||
@import "./styles/system-telemetry.css";
|
||||
@import "./styles/artifact-health.css";
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
.observatory-workspace {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
min-height: 100%;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.observatory-lead,
|
||||
.observatory-catalog-bar,
|
||||
.observatory-catalog-bar__controls,
|
||||
.observatory-notice,
|
||||
.observatory-session-summary > header,
|
||||
.observatory-evidence > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.observatory-lead,
|
||||
.observatory-catalog-bar,
|
||||
.observatory-catalog-bar__controls,
|
||||
.observatory-notice {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.observatory-lead > div,
|
||||
.observatory-catalog-bar__copy,
|
||||
.observatory-session-summary header > div,
|
||||
.observatory-evidence header > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.observatory-lead h2,
|
||||
.observatory-catalog-bar h3,
|
||||
.observatory-session-summary h3,
|
||||
.observatory-evidence h3 {
|
||||
margin: 0.3rem 0 0;
|
||||
}
|
||||
|
||||
.observatory-lead p,
|
||||
.observatory-catalog-bar p,
|
||||
.observatory-state p,
|
||||
.observatory-evidence-empty p {
|
||||
max-width: 52rem;
|
||||
margin: 0.35rem 0 0;
|
||||
color: var(--nodedc-text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.observatory-catalog-bar__copy {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.observatory-catalog-bar__controls {
|
||||
flex: 1 1 32rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.observatory-catalog-bar__controls .nodedc-select-anchor {
|
||||
flex: 1 1 22rem;
|
||||
min-width: min(28rem, 100%);
|
||||
}
|
||||
|
||||
.observatory-state {
|
||||
display: grid;
|
||||
min-height: 19rem;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 0.7rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.observatory-state h3 {
|
||||
max-width: 42rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.observatory-session-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(24rem, 100%), 1fr));
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.observatory-session-summary,
|
||||
.observatory-evidence {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.observatory-session-summary dl,
|
||||
.observatory-evidence-list dl {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.observatory-session-summary dl > div,
|
||||
.observatory-evidence-list dl > div {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(7.5rem, 0.42fr) minmax(0, 1fr);
|
||||
gap: 0.85rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.observatory-session-summary dt,
|
||||
.observatory-evidence-list dt {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
}
|
||||
|
||||
.observatory-session-summary dd,
|
||||
.observatory-evidence-list dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--nodedc-text-secondary);
|
||||
}
|
||||
|
||||
.observatory-session-summary code,
|
||||
.observatory-evidence-list code {
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
}
|
||||
|
||||
.observatory-modalities {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
align-items: center;
|
||||
color: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
.observatory-evidence-list {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.observatory-evidence-card {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.observatory-evidence-list strong,
|
||||
.observatory-evidence-list span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.observatory-evidence-list span {
|
||||
margin-top: 0.25rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
}
|
||||
|
||||
.observatory-evidence-empty {
|
||||
display: grid;
|
||||
min-height: 13rem;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 0.55rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.observatory-evidence__bounded-note {
|
||||
margin: 0;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.observatory-notice {
|
||||
justify-content: flex-start;
|
||||
color: var(--nodedc-text-secondary);
|
||||
}
|
||||
|
||||
.observatory-notice__copy {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.observatory-lead,
|
||||
.observatory-catalog-bar,
|
||||
.observatory-catalog-bar__controls,
|
||||
.observatory-notice {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.observatory-catalog-bar__controls {
|
||||
flex-basis: auto;
|
||||
}
|
||||
|
||||
.observatory-catalog-bar__controls .nodedc-select-anchor {
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.observatory-session-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -104,3 +104,27 @@ test("laboratory UI is a bounded feature slice, not a central workspace branch",
|
||||
assert.doesNotMatch(laboratoryCss, /\.e30-human-review/);
|
||||
assert.match(e30HumanReviewCss, /\.e30-human-review/);
|
||||
});
|
||||
|
||||
test("Observatory is a bounded read-only slice outside legacy LAB and viewer lifecycles", async () => {
|
||||
const workspaceHub = await read("workspaces/Workspaces.tsx");
|
||||
const observatory = await read("workspaces/observatory/ObservatoryWorkspace.tsx");
|
||||
const observatoryCore = await read("core/observatory/catalog.ts");
|
||||
const workspaceCss = await read("styles/workspaces.css");
|
||||
const observatoryCss = await read("styles/observatory.css");
|
||||
|
||||
assert.match(
|
||||
workspaceHub,
|
||||
/case "observatory":[\s\S]*<ObservatoryWorkspace definition=\{props\.definition\} \/>/,
|
||||
);
|
||||
assert.match(observatory, /export function ObservatoryWorkspace/);
|
||||
assert.match(observatory, /useObservatoryCatalog/);
|
||||
assert.match(observatory, /data-observatory-authority="observation-only"/);
|
||||
assert.match(observatory, /data-observatory-viewer="detached"/);
|
||||
assert.doesNotMatch(
|
||||
`${observatory}\n${observatoryCore}`,
|
||||
/(?:core|components|workspaces)\/laboratory|\/api\/v1\/laboratory|RerunViewport|ObservationSessionSelect/,
|
||||
);
|
||||
assert.doesNotMatch(observatory, /replayObservationSession|deleteObservationSession/);
|
||||
assert.doesNotMatch(workspaceCss, /\.observatory-/);
|
||||
assert.match(observatoryCss, /\.observatory-workspace/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let buildObservatoryCatalog;
|
||||
let fetchObservatoryCatalog;
|
||||
let ObservatoryCatalogContractError;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
buildObservatoryCatalog,
|
||||
fetchObservatoryCatalog,
|
||||
ObservatoryCatalogContractError,
|
||||
} = await server.ssrLoadModule("/src/core/observatory/catalog.ts"));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
function source(id, startedAtUtc) {
|
||||
return {
|
||||
id,
|
||||
label: `Источник ${id}`,
|
||||
startedAtUtc,
|
||||
completedAtUtc: startedAtUtc,
|
||||
status: "ready",
|
||||
modalities: ["point-cloud", "video"],
|
||||
durationSeconds: 42,
|
||||
replayable: true,
|
||||
preparation: null,
|
||||
lab: null,
|
||||
};
|
||||
}
|
||||
|
||||
function evidence(id, sourceSessionId, publishedAtUtc) {
|
||||
return {
|
||||
id,
|
||||
label: `Результат ${id}`,
|
||||
startedAtUtc: publishedAtUtc,
|
||||
completedAtUtc: publishedAtUtc,
|
||||
status: "ready",
|
||||
modalities: ["video"],
|
||||
durationSeconds: 12,
|
||||
replayable: true,
|
||||
preparation: null,
|
||||
lab: {
|
||||
labId: `LAB-${id}`,
|
||||
sourceSessionId,
|
||||
resultKind: "recorded-evidence",
|
||||
resultId: `result-${id}`,
|
||||
sourceResultId: null,
|
||||
configSha256: "a".repeat(64),
|
||||
runCreatedAtUtc: publishedAtUtc,
|
||||
publishedAtUtc,
|
||||
provenance: { verdict: "must-not-be-inferred" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("Observatory joins evidence only by sourceSessionId and keeps deterministic order", () => {
|
||||
const catalog = buildObservatoryCatalog(
|
||||
[
|
||||
source("older", "2026-08-28T10:00:00Z"),
|
||||
source("newer", "2026-08-29T10:00:00Z"),
|
||||
],
|
||||
[
|
||||
evidence("old-result", "newer", "2026-08-29T11:00:00Z"),
|
||||
evidence("new-result", "newer", "2026-08-29T12:00:00Z"),
|
||||
evidence("orphan", "missing", "2026-08-29T13:00:00Z"),
|
||||
],
|
||||
);
|
||||
|
||||
assert.deepEqual(catalog.items.map(({ source: item }) => item.id), ["newer", "older"]);
|
||||
assert.deepEqual(
|
||||
catalog.items[0].evidence.map((item) => item.sessionId),
|
||||
["new-result", "old-result"],
|
||||
);
|
||||
assert.deepEqual(catalog.unresolvedEvidence.map((item) => item.sessionId), ["orphan"]);
|
||||
assert.deepEqual(catalog.window, {
|
||||
limit: 100,
|
||||
sourceCount: 2,
|
||||
laboratoryCount: 3,
|
||||
sourceLimitReached: false,
|
||||
laboratoryLimitReached: false,
|
||||
});
|
||||
assert.equal("verdict" in catalog.items[0].evidence[0], false);
|
||||
assert.equal(catalog.items[1].evidence.length, 0);
|
||||
});
|
||||
|
||||
test("Observatory fails visibly when the laboratory projection loses its typed link", () => {
|
||||
assert.throws(
|
||||
() => buildObservatoryCatalog(
|
||||
[source("source", "2026-08-29T10:00:00Z")],
|
||||
[source("not-lab", "2026-08-29T11:00:00Z")],
|
||||
),
|
||||
ObservatoryCatalogContractError,
|
||||
);
|
||||
});
|
||||
|
||||
test("Observatory fetches disjoint read-only source and laboratory projections", async () => {
|
||||
const calls = [];
|
||||
const fetcher = async (input, init) => {
|
||||
calls.push({ input: String(input), method: init?.method });
|
||||
return new Response(JSON.stringify({ items: [] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
|
||||
const catalog = await fetchObservatoryCatalog({ fetcher, limit: 50 });
|
||||
assert.deepEqual(catalog, {
|
||||
items: [],
|
||||
unresolvedEvidence: [],
|
||||
window: {
|
||||
limit: 50,
|
||||
sourceCount: 0,
|
||||
laboratoryCount: 0,
|
||||
sourceLimitReached: false,
|
||||
laboratoryLimitReached: false,
|
||||
},
|
||||
});
|
||||
assert.deepEqual(
|
||||
calls.map(({ input }) => input).sort(),
|
||||
[
|
||||
"/api/v1/observation-sessions?limit=50&scope=laboratory",
|
||||
"/api/v1/observation-sessions?limit=50&scope=source",
|
||||
],
|
||||
);
|
||||
assert.deepEqual(new Set(calls.map(({ method }) => method)), new Set(["GET"]));
|
||||
});
|
||||
|
||||
test("Observatory exposes bounded-window uncertainty without inventing a broken link", () => {
|
||||
const catalog = buildObservatoryCatalog(
|
||||
[
|
||||
source("newer", "2026-08-29T10:00:00Z"),
|
||||
source("older", "2026-08-28T10:00:00Z"),
|
||||
],
|
||||
[
|
||||
evidence("linked", "newer", "2026-08-29T11:00:00Z"),
|
||||
evidence("outside-window", "older-than-window", "2026-08-29T12:00:00Z"),
|
||||
],
|
||||
2,
|
||||
);
|
||||
|
||||
assert.equal(catalog.window.sourceLimitReached, true);
|
||||
assert.equal(catalog.window.laboratoryLimitReached, true);
|
||||
assert.deepEqual(
|
||||
catalog.unresolvedEvidence.map((item) => item.sessionId),
|
||||
["outside-window"],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let productModel;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
productModel = await server.ssrLoadModule("/src/productModel.ts");
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
async function read(relativePath) {
|
||||
return readFile(new URL(`../src/${relativePath}`, import.meta.url), "utf8");
|
||||
}
|
||||
|
||||
test("Observatory is the third independent Polygon workspace", () => {
|
||||
assert.deepEqual(
|
||||
productModel.workspacesForRoot("polygon").map(({ id }) => id),
|
||||
["lab-archive", "simulations", "observatory"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
productModel.workspaceById("observatory"),
|
||||
{
|
||||
id: "observatory",
|
||||
root: "polygon",
|
||||
label: "Обсерватория",
|
||||
title: "Обсерватория восприятия",
|
||||
eyebrow: "ТЕСТОВЫЙ КОНТУР / ОБСЕРВАТОРИЯ",
|
||||
description: "Сессии и квалификация компьютерного зрения без доступа к управлению.",
|
||||
icon: "eye",
|
||||
kind: "observatory",
|
||||
groups: [],
|
||||
},
|
||||
);
|
||||
assert.equal(productModel.workspaceById("lab-archive").kind, "lab-archive");
|
||||
});
|
||||
|
||||
test("Observatory reads catalog evidence without inheriting replay or LAB composition", async () => {
|
||||
const [app, workspaceHub, workspace, hook, viewerProfiles, styles] = await Promise.all([
|
||||
read("App.tsx"),
|
||||
read("workspaces/Workspaces.tsx"),
|
||||
read("workspaces/observatory/ObservatoryWorkspace.tsx"),
|
||||
read("core/observatory/useObservatoryCatalog.ts"),
|
||||
read("core/observation/viewerProfile.ts"),
|
||||
read("styles/observatory.css"),
|
||||
]);
|
||||
|
||||
assert.match(app, /activeDefinition\.kind === "observatory"[\s\S]*Только наблюдение/);
|
||||
assert.doesNotMatch(app, /\["recordings", "lab-archive", "observatory"\]/);
|
||||
assert.match(workspaceHub, /case "observatory":[\s\S]*<ObservatoryWorkspace/);
|
||||
assert.match(workspace, /useObservatoryCatalog/);
|
||||
assert.match(workspace, /Связанных результатов нет/);
|
||||
assert.match(workspace, /не является выводом о качестве/);
|
||||
assert.match(workspace, /\.evidence\.slice\([\s\S]*MAX_PRESENTED_EVIDENCE/);
|
||||
assert.match(workspace, /Полный архив остаётся в legacy LAB/);
|
||||
assert.match(workspace, /Полнота исторических/);
|
||||
assert.match(workspace, /вне текущего загруженного среза/);
|
||||
assert.match(workspace, /observatory-notice__copy/);
|
||||
assert.match(workspace, /observatory-evidence-card/);
|
||||
assert.doesNotMatch(workspace, /Нарушена связь|compactIdentity/);
|
||||
assert.doesNotMatch(
|
||||
`${workspace}\n${hook}`,
|
||||
/RerunViewport|ObservationSessionSelect|resolveObservationSessionReplay|deleteObservationSession|setInterval|setTimeout/,
|
||||
);
|
||||
assert.match(viewerProfiles, /kind: "live-acquisition"/);
|
||||
assert.match(viewerProfiles, /kind: "recorded-session"/);
|
||||
assert.match(viewerProfiles, /kind: "lab-recorded-evidence"/);
|
||||
assert.match(
|
||||
styles,
|
||||
/@media \(max-width: 920px\)[\s\S]*\.observatory-catalog-bar__controls \.nodedc-select-anchor \{[\s\S]*flex: 0 0 auto;/,
|
||||
);
|
||||
assert.doesNotMatch(styles, /nodedc-glass-surface|nodedc-status-badge/);
|
||||
});
|
||||
@@ -324,7 +324,7 @@ test("Polygon exposes one dataset surface and keeps legacy links compatible", ()
|
||||
assert.equal(workspaceById("datasets").kind, "datasets");
|
||||
assert.deepEqual(
|
||||
workspacesForRoot("polygon").map(({ id }) => id),
|
||||
["lab-archive", "simulations"],
|
||||
["lab-archive", "simulations", "observatory"],
|
||||
);
|
||||
assert.equal(
|
||||
workspacesForRoot("system").some(({ id }) => id === "polygon-run"),
|
||||
|
||||
@@ -27,6 +27,19 @@ test("top navigation has no Center and Park owns contour health first", () => {
|
||||
);
|
||||
assert.equal(productModel.workspacesForRoot("fleet")[0]?.id, "contour-health");
|
||||
assert.equal(productModel.workspaceById("contour-health")?.root, "fleet");
|
||||
assert.deepEqual(
|
||||
productModel.workspacesForRoot("polygon").map(({ id }) => id),
|
||||
["lab-archive", "simulations", "observatory"],
|
||||
);
|
||||
assert.equal(productModel.workspaceById("lab-archive")?.kind, "lab-archive");
|
||||
assert.deepEqual(
|
||||
{
|
||||
root: productModel.workspaceById("observatory")?.root,
|
||||
kind: productModel.workspaceById("observatory")?.kind,
|
||||
icon: productModel.workspaceById("observatory")?.icon,
|
||||
},
|
||||
{ root: "polygon", kind: "observatory", icon: "eye" },
|
||||
);
|
||||
});
|
||||
|
||||
test("every laboratory result uses the shared evidence template", async () => {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# Observatory — product-surface brief
|
||||
|
||||
Status: accepted by the product owner for the first M5.1 vertical slice on 2026-08-30.
|
||||
|
||||
## Operator and job story
|
||||
|
||||
The operator is an engineer qualifying the perception stack before any control authority is put on
|
||||
an unmanned platform. After a source session has been recorded, the engineer needs one lightweight
|
||||
place to identify that immutable source and see which laboratory results are actually linked to it.
|
||||
The first slice is used repeatedly while recordings and results are being produced; it is not a
|
||||
mission-planning surface and it never sends navigation, braking or actuation commands.
|
||||
|
||||
## Placement decision
|
||||
|
||||
The accepted placement is `Тестировочный контур → Обсерватория`, with the operator-facing
|
||||
description `Сессии и квалификация`. It is a third workspace alongside the existing
|
||||
`Лабораторные контуры` and `Симуляции` entries.
|
||||
|
||||
Alternatives considered:
|
||||
|
||||
1. Replace or refactor `Лабораторные контуры` in place. Rejected for M5.1 because the current LAB
|
||||
archive is a working, documented legacy projector and rollback reference.
|
||||
2. Add an Observatory mode to `Данные → Сессии и записи`. Rejected because that surface owns
|
||||
storage and replay, while Observatory owns the recurring qualification-review job.
|
||||
3. Add a dedicated workspace under `Тестировочный контур`. Selected because it creates a clean
|
||||
composition boundary without changing K1, Simulation or the legacy LAB lifecycle.
|
||||
|
||||
## Entity, evidence and authority
|
||||
|
||||
The source entity is an immutable observation Session. Its visible facts are limited to the
|
||||
validated Session catalog contract: identity, timestamps, status, duration, modalities and
|
||||
readability. Laboratory evidence is linked only by the typed `lab.sourceSessionId` relationship.
|
||||
Source and LAB projections are independent bounded newest-first windows. A LAB result whose source
|
||||
is absent from the loaded source window remains unresolved and visible as `вне среза`; it is never
|
||||
attached heuristically and its absence is not presented as a broken relationship. Reaching the
|
||||
100-item boundary marks historical completeness as unknown until cursor pagination exists.
|
||||
|
||||
The presence of a Session or linked LAB result is not a computer-vision pass and is not evidence of
|
||||
safe navigation. M5.1 does not infer a verdict from provenance, labels or result kind. The workspace
|
||||
is observation-only: command, navigation and safety authority are all absent.
|
||||
|
||||
## Viewer-profile boundary
|
||||
|
||||
The existing source-specific viewer contracts remain separate and unchanged:
|
||||
|
||||
| Source job | Existing profile | Clock / load ownership |
|
||||
| --- | --- | --- |
|
||||
| Live equipment | `live-acquisition` | `stream_time`; live receiver and recovery authority |
|
||||
| Historical Sessions | `recorded-session` | `session_time`; explicit preparation and progressive admission |
|
||||
| Legacy LAB evidence | `lab-recorded-evidence` | `source-sequence`; explicit comparison-only loading |
|
||||
|
||||
Canonical LAB compositions may configure the recorded Rerun engine inside the bounded LAB slice,
|
||||
but that does not make Observatory a LAB or live consumer. The first Observatory slice mounts no
|
||||
viewer at all. A later viewer slice may activate only the historical `recorded-session` profile
|
||||
after an explicit Session selection and a separate acceptance proof. It must not add a fourth
|
||||
profile or silently collapse the three existing lifecycles.
|
||||
|
||||
## Information hierarchy and states
|
||||
|
||||
1. Catalog authority and refresh state.
|
||||
2. Explicit source Session selection.
|
||||
3. Selected Session identity, timing, modalities and readability.
|
||||
4. Strictly linked immutable LAB evidence.
|
||||
5. Fail-visible bounded-window and unresolved-evidence status.
|
||||
|
||||
The admitted states are initial loading, ready with items, ready empty, refreshing with the last
|
||||
valid snapshot, and unavailable/error with retry. If a selected Session disappears after refresh,
|
||||
selection moves to the first valid source or to the empty state. No demo rows, fabricated progress,
|
||||
placeholder actions or hidden polling are allowed.
|
||||
|
||||
## Design Guideline composition
|
||||
|
||||
The existing `ApplicationShell`, `AdminNavigationPanel` and `ApplicationPanel` composition remains
|
||||
the owner of navigation and workspace framing. The feature reuses canonical `Select`, `Button`,
|
||||
`Icon`, `StatusBadge`, `GlassSurface` and `ActivityIndicator` primitives. The semantic Session and
|
||||
evidence projection belongs to `workspaces/observatory`; feature styles own layout only and do not
|
||||
introduce a parallel control or surface grammar. The registered `eye` icon identifies the new
|
||||
workspace.
|
||||
|
||||
## First-slice acceptance
|
||||
|
||||
- Polygon navigation order is `Лабораторные контуры → Симуляции → Обсерватория`.
|
||||
- Opening Observatory loads only the typed source and laboratory Session catalogs.
|
||||
- Opening Observatory mounts no Rerun, canvas, WebGL, RRD, WebSocket or LAB result route.
|
||||
- Source and LAB records are joined only by `sourceSessionId`; evidence outside the independently
|
||||
bounded source window remains visible as unresolved and never becomes a false integrity verdict.
|
||||
- Reaching either 100-item catalog boundary explicitly marks historical completeness as unknown.
|
||||
- The selected source presents at most six newest linked results while retaining the exact total;
|
||||
the historical tail remains in legacy LAB and is never mounted into a long Observatory DOM.
|
||||
- Loading, empty, refreshing, error/retry and ready states contain no synthetic data.
|
||||
- Linked evidence is never presented as a CV or safety pass.
|
||||
- Existing LAB, Simulation, K1 and Data/Sessions code paths remain unchanged.
|
||||
- Live, historical Session and legacy LAB viewer-profile contracts remain distinct.
|
||||
Reference in New Issue
Block a user