diff --git a/apps/control-station/src/App.tsx b/apps/control-station/src/App.tsx index b4ff40e..9c39cff 100644 --- a/apps/control-station/src/App.tsx +++ b/apps/control-station/src/App.tsx @@ -806,6 +806,8 @@ export default function App() { Offline evaluation ) : activeDefinition.kind === "lab-archive" ? ( laboratoryAnnotation.control + ) : activeDefinition.kind === "observatory" ? ( + Только наблюдение ) : activeDefinition.root === "system" ? ( + 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(); + for (const source of sourceSessions) { + if (source.lab !== null) { + throw new ObservatoryCatalogContractError( + `Каталог источников вернул LAB-сессию ${source.id}.`, + ); + } + sources.set(source.id, source); + } + + const linked = new Map(); + 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 { + 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); +} diff --git a/apps/control-station/src/core/observatory/useObservatoryCatalog.ts b/apps/control-station/src/core/observatory/useObservatoryCatalog.ts new file mode 100644 index 0000000..84d7c27 --- /dev/null +++ b/apps/control-station/src/core/observatory/useObservatoryCatalog.ts @@ -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(null); + const [state, setState] = useState("idle"); + const [error, setError] = useState(null); + const [generation, setGeneration] = useState(0); + const catalogRef = useRef(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 }; +} diff --git a/apps/control-station/src/productModel.ts b/apps/control-station/src/productModel.ts index fe83942..3b6575e 100644 --- a/apps/control-station/src/productModel.ts +++ b/apps/control-station/src/productModel.ts @@ -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", diff --git a/apps/control-station/src/styles.css b/apps/control-station/src/styles.css index 4fc9d60..da07e55 100644 --- a/apps/control-station/src/styles.css +++ b/apps/control-station/src/styles.css @@ -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"; diff --git a/apps/control-station/src/styles/observatory.css b/apps/control-station/src/styles/observatory.css new file mode 100644 index 0000000..f145b78 --- /dev/null +++ b/apps/control-station/src/styles/observatory.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); + } +} diff --git a/apps/control-station/src/workspaces/Workspaces.tsx b/apps/control-station/src/workspaces/Workspaces.tsx index 552b7d3..e2fdfd9 100644 --- a/apps/control-station/src/workspaces/Workspaces.tsx +++ b/apps/control-station/src/workspaces/Workspaces.tsx @@ -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 ; + case "observatory": + return ; case "device": return null; } diff --git a/apps/control-station/src/workspaces/observatory/ObservatoryWorkspace.tsx b/apps/control-station/src/workspaces/observatory/ObservatoryWorkspace.tsx new file mode 100644 index 0000000..0d23d4a --- /dev/null +++ b/apps/control-station/src/workspaces/observatory/ObservatoryWorkspace.tsx @@ -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 = { + recording: "Запись идёт", + ready: "Готова", + degraded: "С деградацией", + interrupted: "Прервана", + failed: "Ошибка", +}; + +const modalityLabel: Record = { + "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["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 ( +
+
+
+ {definition.eyebrow} +

{definition.title}

+

+ Записанные источники и строго связанные результаты без запуска тяжёлого + визуализатора и без доступа к управлению аппаратом. +

+
+ + {catalogStateLabel(controller.state)} + +
+ + +
+ ИСТОЧНИК ДОКАЗАТЕЛЬСТВ +

Сохранённая сессия

+

Выбор меняет только читаемую карточку и не готовит Rerun-запись в фоне.

+
+
+