From bd5873ef341b90884f1e7823250b0b8d751027ad Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Fri, 25 Sep 2026 22:28:42 +0300 Subject: [PATCH] fix(fleet): derive contour status from registered nodes and remove obsolete tabs --- apps/control-station/src/App.tsx | 5 +- .../src/core/system/contourHealth.ts | 66 +++ apps/control-station/src/productModel.ts | 46 +- .../control-station/src/styles/workspaces.css | 15 +- .../src/workspaces/ContourHealthWorkspace.tsx | 404 +++--------------- .../test/contourHealth.test.mjs | 58 +++ docs/adr/0050_VEHICLE_EQUIPMENT_HIERARCHY.md | 30 ++ 7 files changed, 227 insertions(+), 397 deletions(-) create mode 100644 apps/control-station/src/core/system/contourHealth.ts create mode 100644 apps/control-station/test/contourHealth.test.mjs diff --git a/apps/control-station/src/App.tsx b/apps/control-station/src/App.tsx index 83fe32c..27d35fb 100644 --- a/apps/control-station/src/App.tsx +++ b/apps/control-station/src/App.tsx @@ -776,7 +776,8 @@ export default function App() { {rootWorkspaces.length}{" "} {rootWorkspaces.length === 1 ? "рабочая поверхность" - : "рабочих поверхностей"} + : rootWorkspaces.length >= 2 && rootWorkspaces.length <= 4 + ? "рабочие поверхности" : "рабочих поверхностей"} } @@ -841,7 +842,7 @@ export default function App() { laboratoryAnnotation.control ) : activeDefinition.kind === "observatory" ? ( Только наблюдение - ) : activeDefinition.root === "system" ? ( + ) : activeDefinition.kind === "contour-health" ? null : activeDefinition.root === "system" ? ( { + const response=await fetch(url,{signal,cache:'no-store',headers:{Accept:'application/json'}}); + if(!response.ok)throw new Error(`HTTP ${response.status}`); + return response.json(); +} +export async function readContourSnapshot(signal:AbortSignal):Promise{ + const bounded=AbortSignal.any([signal,AbortSignal.timeout(8000)]); + const errors:string[]=[]; + const [coreResult,workersResult,fleetResult]=await Promise.allSettled([ + (async()=>{ + const start=performance.now(); + const data=await json('/api/health',bounded) as CoreHealth; + if(!data||typeof data.ok!=='boolean'||data.service!=='mission-core-control-plane'||typeof data.version!=='string'||!Number.isFinite(data.plugin_runtimes?.ready)||!Number.isFinite(data.plugin_runtimes?.total))throw new Error('Invalid health'); + return {data,latency:performance.now()-start}; + })(), + (async()=>{ + const contours=await fetchComputeContours(bounded); + return Promise.all(contours.map(async contour=>{ + try{return {contour,telemetry:await fetchWorkerTelemetry(contour.contour_id,bounded)};} + catch{errors.push(`Не удалось проверить вычислитель «${contour.display_name}».`);return {contour,telemetry:null};} + })); + })(), + (async()=>{ + const data=await json('/api/v1/fleet',bounded) as {items:Vehicle[]}; + if(!data||!Array.isArray(data.items)||data.items.some(item=>!item||typeof item.id!=='string'||typeof item.name!=='string'||!['online','offline'].includes(item.connectivity)))throw new Error('Invalid fleet'); + return data.items; + })(), + ]); + if(coreResult.status==='rejected')errors.push('Не удалось проверить Mission Core.'); + if(workersResult.status==='rejected')errors.push('Не удалось получить список вычислителей.'); + if(fleetResult.status==='rejected')errors.push('Не удалось получить бортовые компьютеры.'); + return { + core:coreResult.status==='fulfilled'?coreResult.value.data:null, + latency:coreResult.status==='fulfilled'?coreResult.value.latency:null, + workers:workersResult.status==='fulfilled'?workersResult.value:null, + vehicles:fleetResult.status==='fulfilled'?fleetResult.value:null, + errors,observedAt:Date.now(), + }; +} +export function workerAvailable(telemetry:WorkerTelemetry|null):boolean{ + return Boolean(telemetry?.connection.reachable&&telemetry.connection.identity_matches&&telemetry.node); +} +export function boardAvailable(vehicle:Vehicle):boolean{ + return vehicle.enrollment==='paired'&&vehicle.connectivity==='online'; +} +export function contourSummary(snapshot:ContourSnapshot|null){ + if(!snapshot)return {tone:'warning' as const,label:'Проверяем состояние',online:null,total:null}; + const total=snapshot.workers!==null&&snapshot.vehicles!==null?1+snapshot.workers.length+snapshot.vehicles.length:null; + const online=Number(Boolean(snapshot.core?.ok))+(snapshot.workers??[]).filter(item=>workerAvailable(item.telemetry)).length+(snapshot.vehicles??[]).filter(boardAvailable).length; + if(snapshot.errors.length)return {tone:'warning' as const,label:'Проверка неполная',online,total}; + if(!snapshot.core?.ok)return {tone:'danger' as const,label:'Mission Core недоступен',online,total}; + return {tone:online===total?'success' as const:'warning' as const,label:online===total?'Все узлы на связи':'Часть узлов не на связи',online,total}; +} diff --git a/apps/control-station/src/productModel.ts b/apps/control-station/src/productModel.ts index c8a2a70..bda86b1 100644 --- a/apps/control-station/src/productModel.ts +++ b/apps/control-station/src/productModel.ts @@ -186,7 +186,7 @@ export const workspaces: WorkspaceDefinition[] = [ label: "Состояние контура", title: "Состояние контура", eyebrow: "ПАРК / СОСТОЯНИЕ", - description: "Узлы, процессы, сеть и подключённые устройства по данным живого контура.", + description: "Связь с Mission Core, вычислителями и бортовыми компьютерами аппаратов.", icon: "shield", kind: "contour-health", groups: [], @@ -213,50 +213,6 @@ export const workspaces: WorkspaceDefinition[] = [ kind: "device", groups: [], }, - { - id: "payload", - root: "fleet", - label: "Сенсоры и каналы", - title: "Сенсоры и каналы", - eyebrow: "ПАРК / ПОЛЕЗНАЯ НАГРУЗКА", - description: "Полезная нагрузка аппарата и каналы данных, которые она публикует.", - icon: "grid", - kind: "catalog", - groups: [ - { - title: "Пространственные сенсоры", - description: "Единый контракт для разных источников.", - capabilities: [ - active("Облако точек", "Доказанный декодированный поток точек текущего устройства."), - active("Поза и траектория", "Доказанный поток позы и накопление пути."), - contract("Камеры", "Изображение, видео, глубина, внутренние и внешние параметры."), - contract("Навигация", "IMU, GNSS/RTK, одометрия и система координат."), - ], - }, - ], - }, - { - id: "configurations", - root: "fleet", - label: "Компоновки", - title: "Компоновки борта", - eyebrow: "ПАРК / КОМПОНОВКИ", - description: "Версионируемые сочетания сенсоров, вычислителей, транспорта и питания.", - icon: "settings", - kind: "catalog", - groups: [ - { - title: "Шаблон компоновки", - description: "Повторяемая конфигурация для одинаковых аппаратов.", - capabilities: [ - contract("Состав модулей", "Сенсоры, адаптеры, обработчики и зависимости."), - contract("Калибровки", "Системы координат, преобразования, внутренние и внешние параметры."), - contract("Профиль сети", "Локальная сеть, ячеистая сеть, LTE и серверный маршрут."), - later("Развёртывание на борт", "Пакет конфигурации и контролируемое обновление."), - ], - }, - ], - }, { id: "spatial-scene", root: "polygon", diff --git a/apps/control-station/src/styles/workspaces.css b/apps/control-station/src/styles/workspaces.css index 25e22cd..9e8c502 100644 --- a/apps/control-station/src/styles/workspaces.css +++ b/apps/control-station/src/styles/workspaces.css @@ -2173,6 +2173,7 @@ .contour-health-summary { justify-content: space-between; + flex-wrap: wrap; gap: 1rem; } @@ -2203,7 +2204,7 @@ .contour-health-kpis { display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(min(100%, 10rem), 1fr)); gap: 0.45rem; } @@ -2235,7 +2236,7 @@ .contour-node-grid { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr)); gap: 0.65rem; } @@ -2251,6 +2252,7 @@ .contour-node header { justify-content: space-between; + flex-wrap: wrap; gap: 1rem; } @@ -2262,7 +2264,7 @@ .contour-node dl { display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; border-radius: 0.75rem; background: rgb(255 255 255 / 0.022); @@ -2277,11 +2279,10 @@ .contour-node dd { margin: 0; - overflow: hidden; + overflow-wrap: anywhere; color: var(--nodedc-text-secondary); - font-size: 0.62rem; - text-overflow: ellipsis; - white-space: nowrap; + font-size: var(--nodedc-font-size-sm); + white-space: normal; } .contour-process-list { diff --git a/apps/control-station/src/workspaces/ContourHealthWorkspace.tsx b/apps/control-station/src/workspaces/ContourHealthWorkspace.tsx index c991e23..c3abffe 100644 --- a/apps/control-station/src/workspaces/ContourHealthWorkspace.tsx +++ b/apps/control-station/src/workspaces/ContourHealthWorkspace.tsx @@ -1,41 +1,8 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; -import { Button, Icon, StatusBadge } from "@nodedc/ui-react"; - -import type { MissionRuntimeState } from "../core/runtime/contracts"; -import { - fetchPolygonWorkerStatus, - type PolygonWorkerStatus, -} from "../core/polygon/liveWorker"; - -interface ControlPlaneHealth { - ok: boolean; - status: string; - service: string; - version: string; - plugin_runtimes: { - ready: number; - total: number; - }; -} - -interface PluginRuntimeHealth { - runtime_instance_id: string; - plugin_id: string; - plugin_version: string; - status: string; - observed_at: string; - detail_code: string | null; -} - -interface ContourSnapshot { - controlPlane: ControlPlaneHealth | null; - pluginRuntimes: PluginRuntimeHealth[]; - simulationWorker: PolygonWorkerStatus | null; - controlPlaneLatencyMs: number | null; - simulationGatewayLatencyMs: number | null; - observedAt: Date; - error: string | null; -} +import {useEffect,useState} from 'react'; +import {Button,Icon,StatusBadge} from '@nodedc/ui-react'; +import type {MissionRuntimeState} from '../core/runtime/contracts'; +import {boardAvailable,contourSummary,readContourSnapshot,workerAvailable,type ContourSnapshot} from '../core/system/contourHealth'; +import {startSequentialPolling} from '../core/system/sequentialPolling'; export function contourRuntimeAuthorityPresentation( state: MissionRuntimeState | null, @@ -56,310 +23,61 @@ export function contourRuntimeAuthorityPresentation( }; } -function validControlPlaneHealth(value: unknown): value is ControlPlaneHealth { - if (!value || typeof value !== "object") return false; - const item = value as Partial; - return typeof item.ok === "boolean" - && typeof item.status === "string" - && typeof item.service === "string" - && typeof item.version === "string" - && Boolean(item.plugin_runtimes) - && typeof item.plugin_runtimes?.ready === "number" - && typeof item.plugin_runtimes?.total === "number"; +function observed(value:number|string|null|undefined){ + if(value===null||value===undefined)return '—'; + const date=new Date(value); + return Number.isFinite(date.getTime())?date.toLocaleString('ru-RU'):'—'; } - -function validPluginRuntime(value: unknown): value is PluginRuntimeHealth { - if (!value || typeof value !== "object") return false; - const item = value as Partial; - return typeof item.runtime_instance_id === "string" - && typeof item.plugin_id === "string" - && typeof item.plugin_version === "string" - && typeof item.status === "string" - && typeof item.observed_at === "string"; -} - -async function timedJson( - url: string, - signal: AbortSignal, -): Promise<{ payload: unknown; latencyMs: number }> { - const started = performance.now(); - const response = await fetch(url, { - method: "GET", - headers: { Accept: "application/json" }, - signal, - }); - const latencyMs = performance.now() - started; - if (!response.ok) throw new Error(`HTTP ${response.status}`); - return { payload: await response.json(), latencyMs }; -} - -function formatLatency(value: number | null): string { - if (value === null) return "—"; - return `${Math.max(1, Math.round(value))} мс`; -} - -function formatObservedAt(value: Date | string | null): string { - if (!value) return "—"; - const date = value instanceof Date ? value : new Date(value); - if (!Number.isFinite(date.getTime())) return "—"; - return new Intl.DateTimeFormat("ru-RU", { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }).format(date); -} - -export function ContourHealthWorkspace({ - state, -}: { - state: MissionRuntimeState | null; -}) { - const [snapshot, setSnapshot] = useState(null); - const [loading, setLoading] = useState(false); - const [generation, setGeneration] = useState(0); - - const refresh = useCallback(() => setGeneration((value) => value + 1), []); - - useEffect(() => { - const controller = new AbortController(); - setLoading(true); - void Promise.allSettled([ - timedJson("/api/health", controller.signal), - timedJson("/api/v1/device-plugin-runtimes", controller.signal), - (async () => { - const started = performance.now(); - const worker = await fetchPolygonWorkerStatus({ signal: controller.signal }); - return { worker, latencyMs: performance.now() - started }; - })(), - ]).then(([healthResult, runtimesResult, workerResult]) => { - if (controller.signal.aborted) return; - const healthPayload = healthResult.status === "fulfilled" - ? healthResult.value.payload - : null; - const runtimesPayload = runtimesResult.status === "fulfilled" - ? runtimesResult.value.payload - : null; - const runtimeItems = runtimesPayload - && typeof runtimesPayload === "object" - && Array.isArray((runtimesPayload as { items?: unknown }).items) - ? (runtimesPayload as { items: unknown[] }).items.filter(validPluginRuntime) - : []; - const failures = [healthResult, runtimesResult, workerResult] - .filter((result) => result.status === "rejected").length; - setSnapshot({ - controlPlane: validControlPlaneHealth(healthPayload) ? healthPayload : null, - pluginRuntimes: runtimeItems, - simulationWorker: workerResult.status === "fulfilled" - ? workerResult.value.worker - : null, - controlPlaneLatencyMs: healthResult.status === "fulfilled" - ? healthResult.value.latencyMs - : null, - simulationGatewayLatencyMs: workerResult.status === "fulfilled" - ? workerResult.value.latencyMs - : null, - observedAt: new Date(), - error: failures === 0 - ? null - : `Не ответили ${failures} из 3 диагностических контрактов.`, - }); - }).finally(() => { - if (!controller.signal.aborted) setLoading(false); - }); - return () => controller.abort(); - }, [generation]); - - useEffect(() => { - const timer = window.setInterval(refresh, 5_000); - return () => window.clearInterval(timer); - }, [refresh]); - - const { - aiActive, - deviceControlConnectivity, - controlledDevice, - } = contourRuntimeAuthorityPresentation(state); - const simulationWorker = snapshot?.simulationWorker ?? null; - const controlPlaneReady = Boolean(snapshot?.controlPlane?.ok); - const runtimeReady = snapshot?.pluginRuntimes.filter( - (runtime) => runtime.status === "ready", - ).length ?? 0; - const processCount = (snapshot?.pluginRuntimes.length ?? 0) + 2; - const readyProcessCount = runtimeReady - + (controlPlaneReady ? 1 : 0) - + (simulationWorker?.available ? 1 : 0); - const nodeStatus = useMemo(() => { - if (!snapshot) return "Проверяем"; - if (controlPlaneReady && snapshot.error === null) return "Контур отвечает"; - if (controlPlaneReady) return "Частично доступен"; - return "Нет связи"; - }, [controlPlaneReady, snapshot]); - - return ( -
-
-
- ЖИВОЙ ДИАГНОСТИЧЕСКИЙ СРЕЗ -

Локальный вычислительный контур

-

- Статусы читаются из Control Plane, plugin runtime и шлюза Simulation Worker. - Пустые значения не подменяются демонстрационными числами. -

-
-
- - {nodeStatus} - - -
-
- -
-
- Узлы - {snapshot ? 2 : "—"} - локальный + внешний worker -
-
- Процессы готовы - {snapshot ? `${readyProcessCount} / ${processCount}` : "—"} - по живым health-контрактам -
-
- Локальный API - {formatLatency(snapshot?.controlPlaneLatencyMs ?? null)} - браузер → Control Plane -
-
- Последняя проверка - {formatObservedAt(snapshot?.observedAt ?? null)} - автообновление каждые 5 секунд -
-
- -
-
-
-
- УЗЕЛ 01 · ЛОКАЛЬНЫЙ -

Mission Core Control Plane

-
- - {controlPlaneReady ? "Доступен" : "Недоступен"} - -
-
-
Версия
{snapshot?.controlPlane?.version ?? "—"}
-
API latency
{formatLatency(snapshot?.controlPlaneLatencyMs ?? null)}
-
Plugin runtimes
{snapshot ? `${runtimeReady} / ${snapshot.pluginRuntimes.length}` : "—"}
-
Активный режим
{state?.sourceMode ?? "idle"}
-
-
- {snapshot?.pluginRuntimes.map((runtime) => ( -
- - - Device runtime · {runtime.plugin_id} - v{runtime.plugin_version} · {formatObservedAt(runtime.observed_at)} - - {runtime.status} -
- ))} -
- - - AI perception - - {aiActive - ? `${Math.round(state?.metrics?.aiFrameRateHz ?? 0)} кадр/с` - : "Нет активного задания"} - - - {aiActive ? "active" : "idle"} -
-
-
- -
-
-
- УЗЕЛ 02 · ВНЕШНИЙ -

{simulationWorker?.workerId ?? "Simulation Worker"}

-
- - {simulationWorker?.available ? "Доступен" : "Offline"} - -
-
-
Gateway latency
{formatLatency(snapshot?.simulationGatewayLatencyMs ?? null)}
-
Сеть worker
{simulationWorker?.isolation.network ?? "unavailable"}
-
Политика данных
{simulationWorker?.isolation.artifactPolicy ?? "d-only"}
-
Активный прогон
{simulationWorker?.activeRunId ?? "нет"}
-
-
-
- - - Simulation orchestration - - {simulationWorker?.runState - ? `Прогон: ${simulationWorker.runState}` - : "Нет активного прогона"} - - - {simulationWorker?.available ? "ready" : "offline"} -
-
-
-
- -
-
- СЕТЬ - Браузер - 127.0.0.1:8000 -
- -
- - Control Plane - {formatLatency(snapshot?.controlPlaneLatencyMs ?? null)} -
- -
- - Worker gateway - {formatLatency(snapshot?.simulationGatewayLatencyMs ?? null)} -
-
- - {controlledDevice ? ( -
- ПОДТВЕРЖДЁННАЯ УПРАВЛЯЮЩАЯ СЕССИЯ - {controlledDevice.displayName} - - {controlledDevice.endpointLabel ?? controlledDevice.modelId} - {deviceControlConnectivity === "degraded" - ? " · управление подтверждено, поток данных нарушен" - : " · управление подтверждено"} - -
- ) : null} - - {snapshot?.error ? ( -

{snapshot.error}

- ) : null} -
- ); +export function ContourHealthWorkspace({state}:{state:MissionRuntimeState|null}){ + const [snapshot,setSnapshot]=useState(null); + const [loading,setLoading]=useState(true),[generation,setGeneration]=useState(0); + useEffect(()=>{ + const controller=new AbortController(); + const stop=startSequentialPolling(async()=>{ + setLoading(true); + try{const next=await readContourSnapshot(controller.signal);if(!controller.signal.aborted)setSnapshot(next);} + finally{if(!controller.signal.aborted)setLoading(false);} + },5000); + return ()=>{stop();controller.abort();}; + },[generation]); + const summary=contourSummary(snapshot); + const core=snapshot?.core; + const {controlledDevice,deviceControlConnectivity}=contourRuntimeAuthorityPresentation(state); + return
+
+

Узлы и связь

Mission Core, зарегистрированные вычислители и бортовые компьютеры аппаратов.

+
{loading?'Проверяем состояние':summary.label}
+
+
+
Узлы на связи{summary.online??'—'} / {summary.total??'—'}Core, вычислители и бортовые ПК
+
Вычислители{snapshot?.workers?.length??'—'}из настроек системы
+
Бортовые компьютеры{snapshot?.vehicles?.length??'—'}из реестра аппаратов
+
Последняя проверка{observed(snapshot?.observedAt)}обновление каждые 5 секунд
+
+
+
+
КОМПЬЮТЕР ОПЕРАТОРА

Mission Core

{core?(core.ok?'Доступен':'Недоступен'):snapshot?'Нет свежих данных':'Проверяем'}
+
Версия
{core?.version??'—'}
Время ответа
{snapshot?.latency==null?'—':`${Math.max(1,Math.round(snapshot.latency))} мс`}
Обработчики устройств готовы
{core?`${core.plugin_runtimes.ready} / ${core.plugin_runtimes.total}`:'—'}
+
+ {snapshot?.workers?.map(({contour,telemetry})=>{ + const online=workerAvailable(telemetry); + const mismatch=telemetry?.connection.reachable&&!telemetry.connection.identity_matches; + return
+
ВЫЧИСЛИТЕЛЬ

{contour.display_name}

{online?'На связи':mismatch?'Другой узел':telemetry?'Нет свежих данных':'Проверка недоступна'}
+
Узел
{contour.expected_node_id}
Телеметрия
{contour.telemetry_mode==='agent-mqtt'?'Агент':'Диагностика SSH'}
Проверено
{observed(telemetry?.connection.observed_at_utc)}
Последнее измерение
{observed(telemetry?.node?.observed_at_utc)}
Операционная система
{telemetry?.node?.os.caption??'—'}
+
; + })} + {snapshot?.vehicles?.map(vehicle=>{ + const online=boardAvailable(vehicle); + const status=vehicle.enrollment==='revoked'?'Привязка отозвана':vehicle.enrollment==='pending'?'Подключение':vehicle.enrollment==='failed'?'Не подключён':online?'На связи':'Нет связи'; + return
+
БОРТОВОЙ КОМПЬЮТЕР

{vehicle.name}

{status}
+
Имя БК
{vehicle.host?.hostname??'—'}
Операционная система
{vehicle.host?.os??'—'}
Последняя связь
{observed(vehicle.last_seen===null?null:vehicle.last_seen*1000)}
+ {!online&&vehicle.host&&Сведения о БК сохранены с последнего подключения.} +
; + })} +
+ {controlledDevice&&
ПОДКЛЮЧЁННОЕ УСТРОЙСТВО ОПЕРАТОРА{controlledDevice.displayName}{deviceControlConnectivity==='degraded'?'Поток данных нарушен':'Управляющая связь подтверждена'}
} + {snapshot?.errors.map(error=>

{error}

)} +
; } diff --git a/apps/control-station/test/contourHealth.test.mjs b/apps/control-station/test/contourHealth.test.mjs new file mode 100644 index 0000000..b98a61d --- /dev/null +++ b/apps/control-station/test/contourHealth.test.mjs @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import {before,after,test} from 'node:test'; +import {createServer} from 'vite'; + +let server,readContourSnapshot,contourSummary,workerAvailable; +before(async()=>{ + server=await createServer({server:{middlewareMode:true,hmr:false},optimizeDeps:{noDiscovery:true,include:[]},appType:'custom'}); + ({readContourSnapshot,contourSummary,workerAvailable}=await server.ssrLoadModule('/src/core/system/contourHealth.ts')); +}); +after(async()=>{await server?.close();}); + +const core={ok:true,service:'mission-core-control-plane',version:'test',plugin_runtimes:{ready:1,total:1}}; +const contour={contour_id:'test-compute',display_name:'Test compute',expected_node_id:'test-node'}; +const worker={schema_version:'missioncore.worker-telemetry/v1',profile:{},connection:{reachable:true,identity_matches:true},node:{node_id:'test-node'},runtimes:[],pipeline:{},network:{},history:[]}; +const vehicle={id:'test-vehicle',name:'Test rover',enrollment:'paired',connectivity:'offline'}; +function installFetch(t,{failed=[],data={}}={}){ + const calls=[]; + t.mock.method(globalThis,'fetch',async url=>{ + calls.push(url); + if(failed.includes(url))throw new Error('unavailable'); + const defaults={ + '/api/health':core, + '/api/v1/system/contours':{schema_version:'missioncore.compute-contour-catalog/v1',contours:[contour]}, + '/api/v1/system/contours/test-compute/telemetry?history=90':worker, + '/api/v1/fleet':{items:[vehicle]}, + }; + assert.ok(url in defaults,`Unexpected probe: ${url}`); + return new Response(JSON.stringify(url in data?data[url]:defaults[url])); + }); + return calls; +} +test('actual registries determine node count and offline boards prevent all-online status',async t=>{ + const calls=installFetch(t); + const snapshot=await readContourSnapshot(new AbortController().signal); + assert.equal(snapshot.workers[0].contour.display_name,'Test compute'); + assert.deepEqual(contourSummary(snapshot),{tone:'warning',label:'Часть узлов не на связи',online:2,total:3}); + assert.equal(calls.length,4); +}); +test('failed checks discard previous online data and distinguish unknown from empty',async t=>{ + installFetch(t,{failed:['/api/v1/fleet','/api/v1/system/contours/test-compute/telemetry?history=90']}); + const snapshot=await readContourSnapshot(new AbortController().signal); + assert.equal(snapshot.vehicles,null); + assert.equal(snapshot.workers[0].telemetry,null); + assert.equal(snapshot.errors.length,2); + assert.deepEqual(contourSummary(snapshot),{tone:'warning',label:'Проверка неполная',online:1,total:null}); +}); +test('invalid health never becomes reachable; identity mismatch and stale worker are not online',async t=>{ + installFetch(t,{data:{'/api/health':{ok:true}}}); + const snapshot=await readContourSnapshot(new AbortController().signal); + assert.equal(snapshot.core,null); + assert.equal(workerAvailable({...worker,connection:{reachable:true,identity_matches:false}}),false); + assert.equal(workerAvailable({...worker,connection:{reachable:false,identity_matches:true}}),false); + assert.equal(workerAvailable({...worker,node:null}),false); +}); +test('an empty configured contour contains only the operator, with no fabricated worker',async t=>{ + installFetch(t,{data:{'/api/v1/system/contours':{schema_version:'missioncore.compute-contour-catalog/v1',contours:[]},'/api/v1/fleet':{items:[]}}}); + assert.deepEqual(contourSummary(await readContourSnapshot(new AbortController().signal)),{tone:'success',label:'Все узлы на связи',online:1,total:1}); +}); diff --git a/docs/adr/0050_VEHICLE_EQUIPMENT_HIERARCHY.md b/docs/adr/0050_VEHICLE_EQUIPMENT_HIERARCHY.md index 268e326..855e203 100644 --- a/docs/adr/0050_VEHICLE_EQUIPMENT_HIERARCHY.md +++ b/docs/adr/0050_VEHICLE_EQUIPMENT_HIERARCHY.md @@ -39,6 +39,26 @@ Offline Core reads only its cached fleet inventory, never queries the absent hardware or shows an endless discovery spinner. Cached devices stay visible; their current connection is unconfirmed, and mutations stay disabled. +Apparatus metadata is an exception to the hardware mutation gate: the name is +editable in Apparatus settings while the board is offline. PATCH /fleet/{id} +accepts only name and expected_revision through the local-operator boundary. +The transaction preserves apparatus/node identity, binding, platform, device +assignments and history. It checks revisions to prevent a stale editor from +overwriting another change, emits the existing fleet snapshot event, and +returns public metadata. Repeating the already-saved name is idempotent. +The list, panel name/type title, monitor and observation center use this same +registry name. No board request or OS configuration is involved. + +The Park navigation now contains only Contour status and Vehicles. Sensor and +composition catalog placeholders are removed. Contour status reads local Core +health, the configured compute-contour catalog and its telemetry, plus the +fleet registry. Simulation gateway fallback identifiers are not inventory. +Node counts are derived from these registries; unavailable/invalid checks are +explicitly unknown, not empty or online. Worker availability requires both +reachability and identity match, with backend telemetry freshness checks; +board status uses the authenticated fleet heartbeat expiry. Polling is bounded, +sequential and stopped when the workspace unmounts. + ## Computer replacement boundary POST /fleet/{vehicle}/computer consumes a verified Node invitation and an @@ -73,3 +93,13 @@ folding persistence across navigation and a full page reload. The existing LaunchAgent restarted the sole Core process; exact health recovered. The real board is powered off: new pairing/replacement and live device operation require subsequent hardware acceptance; no such action is performed here. + +Later name/status acceptance on the same date: 44 focused identity, pairing +and replacement tests passed, as did all 982 Control Station unit tests, +TypeScript and the production build. Final presentation refinements passed +the architecture/status checks again (8 tests), typecheck and build. Browser +QA covered normal/expanded views, empty-name rejection, cancellation, a +temporary rename visible in the list/header/observation center, persistence +after reload and restoration of the original name. The live status view +showed Core reachable, a configured worker without fresh telemetry and the +powered-off board without a link. No actuator or device commands were sent.