From 8d5aeb0533e3a99c3d57a842de9ee42dfbb0f49c Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sun, 30 Aug 2026 22:21:58 +0300 Subject: [PATCH] feat(observatory): add laboratory setup preflight --- .../src/core/observatory/laboratorySetups.ts | 385 +++++++++++++ .../useObservatoryLaboratorySetups.ts | 129 +++++ .../src/styles/observatory.css | 153 ++++- .../observatory/ObservatorySetupDetail.tsx | 139 +++++ .../observatory/ObservatoryWorkspace.tsx | 60 +- .../test/observatoryLaboratorySetups.test.mjs | 213 +++++++ .../test/observatoryWorkspace.test.mjs | 21 + config/observatory-laboratory-setups.json | 97 ++++ docs/24_M5_1_OBSERVATORY_SURFACE_BRIEF.md | 31 ++ src/k1link/observatory/__init__.py | 19 + src/k1link/observatory/canonical_result.py | 93 ++++ src/k1link/observatory/setups.py | 524 ++++++++++++++++++ src/k1link/web/app.py | 21 +- src/k1link/web/observatory_api.py | 211 ++++++- tests/test_observatory_setups.py | 404 ++++++++++++++ 15 files changed, 2491 insertions(+), 9 deletions(-) create mode 100644 apps/control-station/src/core/observatory/laboratorySetups.ts create mode 100644 apps/control-station/src/core/observatory/useObservatoryLaboratorySetups.ts create mode 100644 apps/control-station/src/workspaces/observatory/ObservatorySetupDetail.tsx create mode 100644 apps/control-station/test/observatoryLaboratorySetups.test.mjs create mode 100644 config/observatory-laboratory-setups.json create mode 100644 src/k1link/observatory/__init__.py create mode 100644 src/k1link/observatory/canonical_result.py create mode 100644 src/k1link/observatory/setups.py create mode 100644 tests/test_observatory_setups.py diff --git a/apps/control-station/src/core/observatory/laboratorySetups.ts b/apps/control-station/src/core/observatory/laboratorySetups.ts new file mode 100644 index 0000000..4574bcd --- /dev/null +++ b/apps/control-station/src/core/observatory/laboratorySetups.ts @@ -0,0 +1,385 @@ +const CATALOG_SCHEMA = "missioncore.observatory-laboratory-setup-catalog/v1"; +const PREFLIGHT_REQUEST_SCHEMA = "missioncore.observatory-run-preflight-request/v1"; +const PREFLIGHT_SCHEMA = "missioncore.observatory-run-preflight/v1"; +const SHA256 = /^[a-f0-9]{64}$/; + +export type ObservatoryLaboratorySetupOrigin = "archived-definition" | "existing-result"; +export type ObservatoryLaboratorySetupAction = "open-existing" | "open-legacy" | "blocked"; + +export interface ObservatoryLaboratoryRunDefinition { + readonly definitionId: string; + readonly version: number; + readonly workId: string; + readonly definitionSha256: string; + readonly configuration: readonly { + readonly role: string; + readonly sha256: string; + }[]; +} + +export interface ObservatoryLaboratoryPreservedResult { + readonly resultId: string; + readonly resultKind: string; + readonly relation: string; + readonly access: "legacy-lab" | "observatory" | "evidence-only"; + readonly createdAtUtc: string; + readonly observatoryProjectionAvailable: boolean; +} + +export interface ObservatoryLaboratorySetup { + readonly setupId: string; + readonly displayName: string; + readonly description: string; + readonly origin: ObservatoryLaboratorySetupOrigin; + readonly runDefinition: ObservatoryLaboratoryRunDefinition | null; + readonly compatibility: { + readonly compatible: boolean; + readonly reasons: readonly { readonly code: string; readonly message: string }[]; + }; + readonly executor: { + readonly contourId: string; + readonly state: "not-installed"; + readonly reasonCode: string; + readonly reason: string; + }; + readonly preservedResults: readonly ObservatoryLaboratoryPreservedResult[]; + readonly preflight: { + readonly outcome: "existing" | "blocked"; + readonly action: ObservatoryLaboratorySetupAction; + readonly reason: string; + readonly submissionAllowed: false; + readonly existingResultIds: readonly string[]; + }; +} + +export interface ObservatoryLaboratorySetupCatalog { + readonly sourceSessionId: string; + readonly setups: readonly ObservatoryLaboratorySetup[]; +} + +export interface ObservatoryLaboratoryRunPreflight { + readonly sourceSessionId: string; + readonly setupId: string; + readonly definitionSha256: string | null; + readonly outcome: "existing" | "blocked"; + readonly submissionAllowed: false; + readonly checks: readonly { + readonly checkId: string; + readonly outcome: "pass" | "fail" | "not-applicable"; + readonly reasonCode: string; + readonly message: string; + }[]; + readonly existingResultIds: readonly string[]; +} + +export type ObservatoryLaboratorySetupFetch = ( + input: RequestInfo | URL, + init?: RequestInit, +) => Promise; + +export class ObservatoryLaboratorySetupContractError extends Error { + readonly status: number | null; + + constructor(message: string, status: number | null = null) { + super(message); + this.name = "ObservatoryLaboratorySetupContractError"; + this.status = status; + } +} + +export async function fetchObservatoryLaboratorySetups( + sourceSessionId: string, + { + signal, + fetcher = globalThis.fetch, + }: { + signal?: AbortSignal; + fetcher?: ObservatoryLaboratorySetupFetch; + } = {}, +): Promise { + const response = await request( + fetcher, + `/api/v1/observatory/laboratory-setups?source_session_id=${encodeURIComponent(sourceSessionId)}`, + { method: "GET", headers: { Accept: "application/json" }, signal }, + ); + const body = await responseBody(response); + if (!response.ok) throw apiError(body, response.status); + const catalog = decodeCatalog(body); + if (catalog.sourceSessionId !== sourceSessionId) { + throw new ObservatoryLaboratorySetupContractError( + "Каталог сетапов относится к другой исходной сессии.", + ); + } + return catalog; +} + +export async function preflightObservatoryLaboratorySetup( + sourceSessionId: string, + setup: ObservatoryLaboratorySetup, + { + signal, + fetcher = globalThis.fetch, + }: { + signal?: AbortSignal; + fetcher?: ObservatoryLaboratorySetupFetch; + } = {}, +): Promise { + const response = await request( + fetcher, + "/api/v1/observatory/run-preflights", + { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/json" }, + body: JSON.stringify({ + schema_version: PREFLIGHT_REQUEST_SCHEMA, + source_session_id: sourceSessionId, + setup_id: setup.setupId, + definition_sha256: setup.runDefinition?.definitionSha256 ?? null, + }), + signal, + }, + ); + const body = await responseBody(response); + if (!response.ok) throw apiError(body, response.status); + const preflight = decodePreflight(body); + if (preflight.sourceSessionId !== sourceSessionId || preflight.setupId !== setup.setupId) { + throw new ObservatoryLaboratorySetupContractError( + "Preflight относится к другому источнику или сетапу.", + ); + } + if (preflight.definitionSha256 !== (setup.runDefinition?.definitionSha256 ?? null)) { + throw new ObservatoryLaboratorySetupContractError( + "Preflight относится к другой версии сетапа.", + ); + } + return preflight; +} + +function decodeCatalog(value: unknown): ObservatoryLaboratorySetupCatalog { + const row = record(value, "каталог сетапов"); + exactKeys(row, ["authority", "schema_version", "setups", "source_session_id"], "каталог сетапов"); + exact(row.schema_version, CATALOG_SCHEMA, "schema_version каталога сетапов"); + observationAuthority(row.authority); + return { + sourceSessionId: text(row.source_session_id, "source_session_id"), + setups: array(row.setups, "setups").map(decodeSetup), + }; +} + +function decodeSetup(value: unknown): ObservatoryLaboratorySetup { + const row = record(value, "сетап"); + exactKeys(row, [ + "authority", "compatibility", "description", "display_name", "executor", "origin", + "preflight", "preserved_results", "run_definition", "setup_id", "source", + ], "сетап"); + observationAuthority(row.authority); + const origin = oneOf(row.origin, ["archived-definition", "existing-result"] as const, "origin"); + const compatibility = record(row.compatibility, "compatibility"); + const executor = record(row.executor, "executor"); + const preflight = record(row.preflight, "preflight"); + const source = record(row.source, "source"); + const runDefinition = row.run_definition === null + ? null + : decodeRunDefinition(row.run_definition); + if ((origin === "archived-definition") !== (runDefinition !== null)) { + throw new ObservatoryLaboratorySetupContractError( + "Происхождение сетапа не совпадает с RunDefinition.", + ); + } + exactKeys(source, ["label", "required_modalities", "session_id"], "source"); + text(source.session_id, "source.session_id"); + text(source.label, "source.label"); + array(source.required_modalities, "source.required_modalities").forEach((item) => text(item, "source modality")); + exactKeys(compatibility, ["compatible", "reasons"], "compatibility"); + exactKeys(executor, ["contour_id", "reason", "reason_code", "state"], "executor"); + exactKeys(preflight, ["action", "existing_result_ids", "outcome", "reason", "submission_allowed"], "preflight"); + return { + setupId: text(row.setup_id, "setup_id"), + displayName: text(row.display_name, "display_name"), + description: text(row.description, "description"), + origin, + runDefinition, + compatibility: { + compatible: boolean(row.compatibility && compatibility.compatible, "compatible"), + reasons: array(compatibility.reasons, "compatibility.reasons").map((item) => { + const reason = record(item, "compatibility reason"); + exactKeys(reason, ["code", "message"], "compatibility reason"); + return { code: text(reason.code, "reason.code"), message: text(reason.message, "reason.message") }; + }), + }, + executor: { + contourId: text(executor.contour_id, "executor.contour_id"), + state: exact(executor.state, "not-installed", "executor.state"), + reasonCode: text(executor.reason_code, "executor.reason_code"), + reason: text(executor.reason, "executor.reason"), + }, + preservedResults: array(row.preserved_results, "preserved_results").map(decodePreservedResult), + preflight: { + outcome: oneOf(preflight.outcome, ["existing", "blocked"] as const, "preflight.outcome"), + action: oneOf(preflight.action, ["open-existing", "open-legacy", "blocked"] as const, "preflight.action"), + reason: text(preflight.reason, "preflight.reason"), + submissionAllowed: exact(preflight.submission_allowed, false, "preflight.submission_allowed"), + existingResultIds: array(preflight.existing_result_ids, "preflight.existing_result_ids").map((item) => text(item, "existing result id")), + }, + }; +} + +function decodeRunDefinition(value: unknown): ObservatoryLaboratoryRunDefinition { + const row = record(value, "RunDefinition"); + exactKeys(row, [ + "authority", "configuration", "definition_id", "definition_sha256", "schema_version", + "source", "version", "work_id", + ], "RunDefinition"); + exact(row.schema_version, "missioncore.observatory-run-definition/v1", "RunDefinition schema"); + observationAuthority(row.authority); + const source = record(row.source, "RunDefinition source"); + exactKeys(source, ["label", "required_modalities", "session_id"], "RunDefinition source"); + text(source.session_id, "RunDefinition source.session_id"); + text(source.label, "RunDefinition source.label"); + array(source.required_modalities, "RunDefinition source.required_modalities") + .forEach((item) => text(item, "RunDefinition source modality")); + const digest = text(row.definition_sha256, "definition_sha256"); + if (!SHA256.test(digest)) throw new ObservatoryLaboratorySetupContractError("Некорректный digest RunDefinition."); + return { + definitionId: text(row.definition_id, "definition_id"), + version: positiveInteger(row.version, "definition version"), + workId: text(row.work_id, "work_id"), + definitionSha256: digest, + configuration: array(row.configuration, "configuration").map((item) => { + const reference = record(item, "configuration reference"); + exactKeys(reference, ["role", "sha256"], "configuration reference"); + const sha256 = text(reference.sha256, "configuration sha256"); + if (!SHA256.test(sha256)) throw new ObservatoryLaboratorySetupContractError("Некорректный digest конфигурации."); + return { role: text(reference.role, "configuration role"), sha256 }; + }), + }; +} + +function decodePreservedResult(value: unknown): ObservatoryLaboratoryPreservedResult { + const row = record(value, "preserved result"); + exactKeys(row, [ + "access", "created_at_utc", "observatory_projection_available", "relation", + "result_id", "result_kind", + ], "preserved result"); + return { + resultId: text(row.result_id, "result_id"), + resultKind: text(row.result_kind, "result_kind"), + relation: text(row.relation, "relation"), + access: oneOf(row.access, ["legacy-lab", "observatory", "evidence-only"] as const, "result access"), + createdAtUtc: text(row.created_at_utc, "created_at_utc"), + observatoryProjectionAvailable: boolean(row.observatory_projection_available, "observatory projection availability"), + }; +} + +function decodePreflight(value: unknown): ObservatoryLaboratoryRunPreflight { + const row = record(value, "preflight"); + exactKeys(row, [ + "authority", "checks", "definition_sha256", "executor", "existing_result_ids", + "outcome", "schema_version", "setup_id", "source_session_id", "submission_allowed", + ], "preflight"); + exact(row.schema_version, PREFLIGHT_SCHEMA, "preflight schema"); + observationAuthority(row.authority); + const digest = row.definition_sha256; + if (digest !== null && (typeof digest !== "string" || !SHA256.test(digest))) { + throw new ObservatoryLaboratorySetupContractError("Некорректный digest preflight."); + } + return { + sourceSessionId: text(row.source_session_id, "preflight source_session_id"), + setupId: text(row.setup_id, "preflight setup_id"), + definitionSha256: digest, + outcome: oneOf(row.outcome, ["existing", "blocked"] as const, "preflight outcome"), + submissionAllowed: exact(row.submission_allowed, false, "preflight submission_allowed"), + checks: array(row.checks, "preflight checks").map((item) => { + const check = record(item, "preflight check"); + exactKeys(check, ["check_id", "message", "outcome", "reason_code"], "preflight check"); + return { + checkId: text(check.check_id, "check_id"), + outcome: oneOf(check.outcome, ["pass", "fail", "not-applicable"] as const, "check outcome"), + reasonCode: text(check.reason_code, "check reason_code"), + message: text(check.message, "check message"), + }; + }), + existingResultIds: array(row.existing_result_ids, "existing_result_ids").map((item) => text(item, "existing result id")), + }; +} + +function observationAuthority(value: unknown): void { + const row = record(value, "authority"); + const keys = ["commands_enabled", "actuation_allowed", "navigation_or_safety_accepted", "production_accepted"]; + exactKeys(row, keys, "authority"); + for (const key of keys) { + exact(row[key], false, `authority.${key}`); + } +} + +async function request(fetcher: ObservatoryLaboratorySetupFetch, input: string, init: RequestInit): Promise { + try { + return await fetcher(input, init); + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") throw error; + throw new ObservatoryLaboratorySetupContractError("Каталог сетапов недоступен."); + } +} + +async function responseBody(response: Response): Promise { + const textBody = await response.text(); + if (!textBody) return undefined; + try { return JSON.parse(textBody) as unknown; } catch { return textBody; } +} + +function apiError(body: unknown, status: number): ObservatoryLaboratorySetupContractError { + const detail = body && typeof body === "object" && !Array.isArray(body) + ? (body as Record).detail + : null; + return new ObservatoryLaboratorySetupContractError( + typeof detail === "string" && detail.trim() ? detail : `Observatory API вернул HTTP ${status}.`, + status, + ); +} + +function record(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new ObservatoryLaboratorySetupContractError(`${label}: ожидался объект.`); + } + return value as Record; +} + +function array(value: unknown, label: string): unknown[] { + if (!Array.isArray(value)) throw new ObservatoryLaboratorySetupContractError(`${label}: ожидался массив.`); + return value; +} + +function text(value: unknown, label: string): string { + if (typeof value !== "string" || !value.trim()) throw new ObservatoryLaboratorySetupContractError(`${label}: ожидался текст.`); + return value; +} + +function boolean(value: unknown, label: string): boolean { + if (typeof value !== "boolean") throw new ObservatoryLaboratorySetupContractError(`${label}: ожидался boolean.`); + return value; +} + +function positiveInteger(value: unknown, label: string): number { + if (!Number.isInteger(value) || Number(value) < 1) throw new ObservatoryLaboratorySetupContractError(`${label}: ожидалось положительное целое.`); + return Number(value); +} + +function exact(value: unknown, expected: T, label: string): T { + if (value !== expected) throw new ObservatoryLaboratorySetupContractError(`${label}: значение изменилось.`); + return expected; +} + +function oneOf(value: unknown, allowed: T, label: string): T[number] { + if (typeof value !== "string" || !allowed.includes(value)) { + throw new ObservatoryLaboratorySetupContractError(`${label}: значение не поддерживается.`); + } + return value as T[number]; +} + +function exactKeys(value: Record, expected: readonly string[], label: string): void { + const actual = Object.keys(value).sort(); + const sortedExpected = [...expected].sort(); + if (actual.length !== sortedExpected.length || actual.some((key, index) => key !== sortedExpected[index])) { + throw new ObservatoryLaboratorySetupContractError(`${label}: обнаружены неизвестные поля.`); + } +} diff --git a/apps/control-station/src/core/observatory/useObservatoryLaboratorySetups.ts b/apps/control-station/src/core/observatory/useObservatoryLaboratorySetups.ts new file mode 100644 index 0000000..e97eec1 --- /dev/null +++ b/apps/control-station/src/core/observatory/useObservatoryLaboratorySetups.ts @@ -0,0 +1,129 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { + fetchObservatoryLaboratorySetups, + preflightObservatoryLaboratorySetup, + type ObservatoryLaboratoryRunPreflight, + type ObservatoryLaboratorySetupCatalog, +} from "./laboratorySetups"; + +type SetupCatalogState = "idle" | "loading" | "ready" | "refreshing" | "error"; +export type SetupPreflightState = + | { readonly kind: "idle" } + | { readonly kind: "checking" } + | { readonly kind: "ready"; readonly value: ObservatoryLaboratoryRunPreflight } + | { readonly kind: "error"; readonly message: string }; + +export function useObservatoryLaboratorySetups(sourceSessionId: string) { + const [catalog, setCatalog] = useState(null); + const [state, setState] = useState("idle"); + const [error, setError] = useState(null); + const [selectedSetupId, setSelectedSetupId] = useState(""); + const [revision, setRevision] = useState(0); + const [preflight, setPreflight] = useState({ kind: "idle" }); + const requestSequence = useRef(0); + const preflightRequest = useRef(null); + const activeCatalog = catalog?.sourceSessionId === sourceSessionId ? catalog : null; + + useEffect(() => { + preflightRequest.current?.abort(); + preflightRequest.current = null; + if (!sourceSessionId) { + setCatalog(null); + setState("idle"); + setError(null); + setSelectedSetupId(""); + setPreflight({ kind: "idle" }); + return; + } + const sequence = ++requestSequence.current; + const request = new AbortController(); + setState((current) => activeCatalog && current !== "idle" ? "refreshing" : "loading"); + setError(null); + setPreflight({ kind: "idle" }); + void fetchObservatoryLaboratorySetups(sourceSessionId, { signal: request.signal }) + .then((next) => { + if (request.signal.aborted || requestSequence.current !== sequence) return; + setCatalog(next); + setState("ready"); + setSelectedSetupId((current) => { + if (next.setups.some( + (setup) => setup.setupId === current && setup.compatibility.compatible, + )) return current; + return next.setups.find( + (setup) => setup.compatibility.compatible && setup.preflight.outcome === "existing", + )?.setupId + ?? next.setups.find((setup) => setup.compatibility.compatible)?.setupId + ?? next.setups[0]?.setupId + ?? ""; + }); + }) + .catch((caught: unknown) => { + if (request.signal.aborted || requestSequence.current !== sequence) return; + setState("error"); + setError(caught instanceof Error && caught.message.trim() + ? caught.message + : "Каталог сетапов недоступен."); + }); + return () => request.abort(); + }, [revision, sourceSessionId]); + + useEffect(() => () => preflightRequest.current?.abort(), []); + + const selectedSetup = useMemo( + () => activeCatalog?.setups.find((setup) => setup.setupId === selectedSetupId) ?? null, + [activeCatalog, selectedSetupId], + ); + + const selectSetup = useCallback((setupId: string) => { + preflightRequest.current?.abort(); + preflightRequest.current = null; + setSelectedSetupId(setupId); + setPreflight({ kind: "idle" }); + }, []); + + const refresh = useCallback(() => setRevision((value) => value + 1), []); + + const check = useCallback(async () => { + if (!sourceSessionId || !selectedSetup || preflight.kind === "checking") return; + preflightRequest.current?.abort(); + const request = new AbortController(); + preflightRequest.current = request; + setPreflight({ kind: "checking" }); + try { + const value = await preflightObservatoryLaboratorySetup( + sourceSessionId, + selectedSetup, + { signal: request.signal }, + ); + if (request.signal.aborted || preflightRequest.current !== request) return; + setPreflight({ kind: "ready", value }); + } catch (caught) { + if (request.signal.aborted || preflightRequest.current !== request) return; + setPreflight({ + kind: "error", + message: caught instanceof Error && caught.message.trim() + ? caught.message + : "Не удалось проверить возможность запуска.", + }); + } finally { + if (preflightRequest.current === request) preflightRequest.current = null; + } + }, [preflight.kind, selectedSetup, sourceSessionId]); + + return { + catalog: activeCatalog, + state, + error, + selectedSetupId, + selectedSetup, + selectSetup, + refresh, + preflight, + check, + }; +} + +export type ObservatoryLaboratorySetupsController = ReturnType< + typeof useObservatoryLaboratorySetups +>; diff --git a/apps/control-station/src/styles/observatory.css b/apps/control-station/src/styles/observatory.css index 2cab185..ef1156d 100644 --- a/apps/control-station/src/styles/observatory.css +++ b/apps/control-station/src/styles/observatory.css @@ -52,13 +52,13 @@ } .observatory-catalog-bar__controls { - flex: 1 1 32rem; + flex: 2 1 48rem; justify-content: flex-end; } .observatory-catalog-bar__controls .nodedc-select-anchor { - flex: 1 1 22rem; - min-width: min(28rem, 100%); + flex: 1 1 18rem; + min-width: min(22rem, 100%); } .observatory-state { @@ -148,6 +148,130 @@ color: var(--nodedc-text-muted); } +.observatory-setup-detail { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.85rem 1rem; + container-name: observatory-setup; + container-type: inline-size; +} + +.observatory-setup-detail__identity { + min-width: 0; +} + +.observatory-setup-detail__identity h3 { + margin: 0.15rem 0 0; +} + +.observatory-setup-detail__identity p { + max-width: 68rem; + margin: 0.3rem 0 0; + color: var(--nodedc-text-muted); + line-height: 1.45; +} + +.observatory-setup-detail__badges, +.observatory-setup-detail__actions, +.observatory-setup-detail__reason { + display: flex; + align-items: center; + gap: 0.55rem; +} + +.observatory-setup-detail__badges { + align-self: start; + justify-content: flex-end; + flex-wrap: wrap; +} + +.observatory-setup-detail__facts { + display: grid; + grid-column: 1 / -1; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.75rem; + margin: 0; +} + +.observatory-setup-detail__facts > div { + display: grid; + min-width: 0; + gap: 0.2rem; + padding: 0.65rem 0.75rem; + border-radius: var(--nodedc-radius-control-compact); + background: var(--nodedc-glass-control-bg); +} + +.observatory-setup-detail__facts dt { + color: var(--nodedc-text-muted); + font-size: var(--nodedc-font-size-xs); +} + +.observatory-setup-detail__facts dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; + color: var(--nodedc-text-secondary); +} + +.observatory-setup-results { + display: grid; + grid-column: 1 / -1; + gap: 0.35rem; + margin: 0; + padding: 0; + list-style: none; +} + +.observatory-setup-results li { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 0.65rem; + min-height: 3.15rem; + padding: 0.5rem 0.7rem; + border-radius: var(--nodedc-radius-control-compact); + background: var(--nodedc-glass-control-bg); + color: var(--nodedc-text-secondary); +} + +.observatory-setup-results span { + display: grid; + min-width: 0; + gap: 0.1rem; +} + +.observatory-setup-results strong, +.observatory-setup-results small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.observatory-setup-results small { + color: var(--nodedc-text-muted); +} + +.observatory-setup-detail__footer { + display: flex; + grid-column: 1 / -1; + align-items: center; + justify-content: space-between; + gap: 0.8rem; + padding-top: 0.1rem; +} + +.observatory-setup-detail__reason { + min-width: 0; + color: var(--nodedc-text-muted); + font-size: var(--nodedc-font-size-sm); + line-height: 1.4; +} + +.observatory-setup-detail__actions { + flex: 0 0 auto; +} + .observatory-evidence { display: grid; gap: 0.65rem; @@ -315,6 +439,29 @@ } } +@container observatory-setup (max-width: 48rem) { + .observatory-setup-detail { + grid-template-columns: minmax(0, 1fr); + } + + .observatory-setup-detail__badges { + justify-content: flex-start; + } + + .observatory-setup-detail__facts { + grid-template-columns: minmax(0, 1fr); + } + + .observatory-setup-detail__footer { + align-items: stretch; + flex-direction: column; + } + + .observatory-setup-detail__actions { + flex-wrap: wrap; + } +} + @container observatory-session (max-width: 48rem) { .observatory-evidence-card { grid-template-columns: auto minmax(0, 1fr) auto; diff --git a/apps/control-station/src/workspaces/observatory/ObservatorySetupDetail.tsx b/apps/control-station/src/workspaces/observatory/ObservatorySetupDetail.tsx new file mode 100644 index 0000000..cdc2cca --- /dev/null +++ b/apps/control-station/src/workspaces/observatory/ObservatorySetupDetail.tsx @@ -0,0 +1,139 @@ +import { + ActivityIndicator, + Button, + GlassSurface, + Icon, + StatusBadge, +} from "@nodedc/ui-react"; + +import type { ObservatoryEvidence } from "../../core/observatory/catalog"; +import type { ObservatoryLaboratorySetup } from "../../core/observatory/laboratorySetups"; +import type { ObservatoryRecordedRunBinding } from "../../core/observatory/recordedRun"; +import type { SetupPreflightState } from "../../core/observatory/useObservatoryLaboratorySetups"; + +const relationLabel: Readonly> = { + "primary-visual": "Основной визуальный разбор", + "compute-successor": "Связанное вычислительное доказательство", + "canonical-projection": "Готовый записанный разбор", +}; + +function workerLabel(value: string): string { + const match = /^worker-(\d+)$/.exec(value); + return match ? `Worker ${match[1]}` : value; +} + +export function ObservatorySetupDetail({ + setup, + evidence, + preflight, + onCheck, + onOpenExisting, +}: { + setup: ObservatoryLaboratorySetup; + evidence: readonly ObservatoryEvidence[]; + preflight: SetupPreflightState; + onCheck: () => void; + onOpenExisting: (binding: ObservatoryRecordedRunBinding) => void; +}) { + const exactExisting = evidence.find( + (candidate) => candidate.recordedRun + && setup.preflight.existingResultIds.includes(candidate.recordedRun.resultId), + )?.recordedRun ?? null; + const displayedReason = preflight.kind === "ready" + ? preflight.value.checks.find((check) => check.checkId === "executor")?.message + ?? setup.executor.reason + : preflight.kind === "error" + ? preflight.message + : setup.preflight.reason; + + return ( + +
+ СЕТАП ЛАБОРАТОРИИ +

{setup.displayName}

+

{setup.description}

+
+
+ + {setup.compatibility.compatible ? "Совместим" : "Другая сессия"} + + + {setup.origin === "existing-result" ? "Готовый результат" : "Архивный сетап"} + +
+
+
+
Идентичность
+
+ {setup.runDefinition + ? `Версия ${setup.runDefinition.version} · конфигурация зафиксирована` + : "Готовый записанный результат без повторно запускаемой конфигурации"} +
+
+
+
Исполнитель
+
+ {setup.preflight.outcome === "existing" + ? "Не требуется для просмотра" + : `${workerLabel(setup.executor.contourId)} · повторный запуск не подключён`} +
+
+
+
Сохранено
+
{setup.preservedResults.length} неизменяемых результатов
+
+
+
    + {setup.preservedResults.map((result) => ( +
  1. + + + {relationLabel[result.relation] ?? "Сохранённый результат"} + + {result.access === "observatory" + ? "Доступен в Обсерватории" + : result.access === "legacy-lab" + ? "Сохранён в legacy LAB" + : "Сохранено как связанное доказательство"} + + + + {result.access === "observatory" + ? result.observatoryProjectionAvailable ? "Готов" : "Сохранён" + : result.access === "legacy-lab" ? "Legacy LAB" : "Evidence"} + +
  2. + ))} +
+
+
+ + {displayedReason} +
+
+ {exactExisting ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/apps/control-station/src/workspaces/observatory/ObservatoryWorkspace.tsx b/apps/control-station/src/workspaces/observatory/ObservatoryWorkspace.tsx index 5f87834..35b6f17 100644 --- a/apps/control-station/src/workspaces/observatory/ObservatoryWorkspace.tsx +++ b/apps/control-station/src/workspaces/observatory/ObservatoryWorkspace.tsx @@ -31,7 +31,9 @@ import { renameObservatoryLabProjection, } from "../../core/observatory/catalogMutations"; import { useObservatoryCatalog } from "../../core/observatory/useObservatoryCatalog"; +import { useObservatoryLaboratorySetups } from "../../core/observatory/useObservatoryLaboratorySetups"; import type { WorkspaceDefinition } from "../../productModel"; +import { ObservatorySetupDetail } from "./ObservatorySetupDetail"; const MAX_PRESENTED_EVIDENCE = 6; const EMPTY_OBSERVATORY_ITEMS = [] as const; @@ -133,6 +135,7 @@ export function ObservatoryWorkspace({ }) { const controller = useObservatoryCatalog(); const [selectedSessionId, setSelectedSessionId] = useState(""); + const setupController = useObservatoryLaboratorySetups(selectedSessionId); const [replay, setReplay] = useState({ kind: "closed" }); const [renameTarget, setRenameTarget] = useState(null); const [renameValue, setRenameValue] = useState(""); @@ -170,6 +173,15 @@ export function ObservatoryWorkspace({ label: source.label, description: `${formatTimestamp(source.startedAtUtc)} · ${formatDuration(source.durationSeconds)} · ${evidence.length} результатов`, })), [items]); + const setupOptions = useMemo(() => ( + setupController.catalog?.setups.map((setup) => ({ + value: setup.setupId, + label: setup.displayName, + description: setup.compatibility.compatible + ? setup.origin === "existing-result" ? "Готовый результат" : "Совместимый архивный сетап" + : "Несовместим с выбранной сессией", + })) ?? [] + ), [setupController.catalog]); const initialLoading = !controller.catalog && ["idle", "loading"].includes(controller.state); const unavailable = !controller.catalog && controller.state === "error"; @@ -375,12 +387,32 @@ export function ObservatoryWorkspace({ menuWidth={460} onChange={selectSession} /> +