fix(fleet): derive contour status from registered nodes and remove obsolete tabs

This commit is contained in:
DCCONSTRUCTIONS
2026-09-25 22:28:42 +03:00
parent aecae4a986
commit bd5873ef34
7 changed files with 227 additions and 397 deletions
+3 -2
View File
@@ -776,7 +776,8 @@ export default function App() {
{rootWorkspaces.length}{" "}
{rootWorkspaces.length === 1
? "рабочая поверхность"
: "рабочих поверхностей"}
: rootWorkspaces.length >= 2 && rootWorkspaces.length <= 4
? "рабочие поверхности" : "рабочих поверхностей"}
</span>
</>
}
@@ -841,7 +842,7 @@ export default function App() {
laboratoryAnnotation.control
) : activeDefinition.kind === "observatory" ? (
<StatusBadge tone="neutral">Только наблюдение</StatusBadge>
) : activeDefinition.root === "system" ? (
) : activeDefinition.kind === "contour-health" ? null : activeDefinition.root === "system" ? (
<SystemWorkspaceSelector
value={activeDefinition.id}
onChange={openNavigationView}
@@ -0,0 +1,66 @@
import {fetchComputeContours,type ComputeContour} from './computeContours';
import {fetchWorkerTelemetry,type WorkerTelemetry} from './workerTelemetry';
import type {Vehicle} from '../fleet/useFleet';
export interface CoreHealth {
ok:boolean; service:string; version:string;
plugin_runtimes:{ready:number;total:number};
}
export interface ContourSnapshot {
core:CoreHealth|null; latency:number|null;
workers:{contour:ComputeContour;telemetry:WorkerTelemetry|null}[]|null;
vehicles:Vehicle[]|null; errors:string[]; observedAt:number;
}
async function json(url:string,signal:AbortSignal):Promise<unknown>{
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<ContourSnapshot>{
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};
}
+1 -45
View File
@@ -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",
@@ -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 {
@@ -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<ControlPlaneHealth>;
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<PluginRuntimeHealth>;
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<ContourSnapshot | null>(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 (
<div className="contour-health-dashboard">
<section className="contour-health-summary">
<div>
<span className="section-eyebrow">ЖИВОЙ ДИАГНОСТИЧЕСКИЙ СРЕЗ</span>
<h2>Локальный вычислительный контур</h2>
<p>
Статусы читаются из Control Plane, plugin runtime и шлюза Simulation Worker.
Пустые значения не подменяются демонстрационными числами.
</p>
</div>
<div className="contour-health-summary__status">
<StatusBadge tone={controlPlaneReady ? "success" : "danger"}>
{nodeStatus}
</StatusBadge>
<Button
size="compact"
variant="secondary"
disabled={loading}
onClick={refresh}
>
<Icon name="refresh" size={14} />
{loading ? "Проверяем" : "Обновить"}
</Button>
</div>
</section>
<section className="contour-health-kpis" aria-label="Сводка состояния контура">
<div>
<span>Узлы</span>
<strong>{snapshot ? 2 : "—"}</strong>
<small>локальный + внешний worker</small>
</div>
<div>
<span>Процессы готовы</span>
<strong>{snapshot ? `${readyProcessCount} / ${processCount}` : "—"}</strong>
<small>по живым health-контрактам</small>
</div>
<div>
<span>Локальный API</span>
<strong>{formatLatency(snapshot?.controlPlaneLatencyMs ?? null)}</strong>
<small>браузер → Control Plane</small>
</div>
<div>
<span>Последняя проверка</span>
<strong>{formatObservedAt(snapshot?.observedAt ?? null)}</strong>
<small>автообновление каждые 5 секунд</small>
</div>
</section>
<section className="contour-node-grid" aria-label="Вычислительные узлы">
<article className="contour-node" data-state={controlPlaneReady ? "online" : "offline"}>
<header>
<div>
<span className="section-eyebrow">УЗЕЛ 01 · ЛОКАЛЬНЫЙ</span>
<h3>Mission Core Control Plane</h3>
</div>
<StatusBadge tone={controlPlaneReady ? "success" : "danger"}>
{controlPlaneReady ? "Доступен" : "Недоступен"}
</StatusBadge>
</header>
<dl>
<div><dt>Версия</dt><dd>{snapshot?.controlPlane?.version ?? "—"}</dd></div>
<div><dt>API latency</dt><dd>{formatLatency(snapshot?.controlPlaneLatencyMs ?? null)}</dd></div>
<div><dt>Plugin runtimes</dt><dd>{snapshot ? `${runtimeReady} / ${snapshot.pluginRuntimes.length}` : "—"}</dd></div>
<div><dt>Активный режим</dt><dd>{state?.sourceMode ?? "idle"}</dd></div>
</dl>
<div className="contour-process-list">
{snapshot?.pluginRuntimes.map((runtime) => (
<div key={runtime.runtime_instance_id}>
<i data-state={runtime.status === "ready" ? "online" : "offline"} />
<span>
<strong>Device runtime · {runtime.plugin_id}</strong>
<small>v{runtime.plugin_version} · {formatObservedAt(runtime.observed_at)}</small>
</span>
<em>{runtime.status}</em>
</div>
))}
<div>
<i data-state={aiActive ? "online" : "idle"} />
<span>
<strong>AI perception</strong>
<small>
{aiActive
? `${Math.round(state?.metrics?.aiFrameRateHz ?? 0)} кадр/с`
: "Нет активного задания"}
</small>
</span>
<em>{aiActive ? "active" : "idle"}</em>
</div>
</div>
</article>
<article
className="contour-node"
data-state={simulationWorker?.available ? "online" : "offline"}
>
<header>
<div>
<span className="section-eyebrow">УЗЕЛ 02 · ВНЕШНИЙ</span>
<h3>{simulationWorker?.workerId ?? "Simulation Worker"}</h3>
</div>
<StatusBadge tone={simulationWorker?.available ? "success" : "neutral"}>
{simulationWorker?.available ? "Доступен" : "Offline"}
</StatusBadge>
</header>
<dl>
<div><dt>Gateway latency</dt><dd>{formatLatency(snapshot?.simulationGatewayLatencyMs ?? null)}</dd></div>
<div><dt>Сеть worker</dt><dd>{simulationWorker?.isolation.network ?? "unavailable"}</dd></div>
<div><dt>Политика данных</dt><dd>{simulationWorker?.isolation.artifactPolicy ?? "d-only"}</dd></div>
<div><dt>Активный прогон</dt><dd>{simulationWorker?.activeRunId ?? "нет"}</dd></div>
</dl>
<div className="contour-process-list">
<div>
<i data-state={simulationWorker?.available ? "online" : "offline"} />
<span>
<strong>Simulation orchestration</strong>
<small>
{simulationWorker?.runState
? `Прогон: ${simulationWorker.runState}`
: "Нет активного прогона"}
</small>
</span>
<em>{simulationWorker?.available ? "ready" : "offline"}</em>
</div>
</div>
</article>
</section>
<section className="contour-network-strip" aria-label="Состояние сети">
<div>
<span className="section-eyebrow">СЕТЬ</span>
<strong>Браузер</strong>
<small>127.0.0.1:8000</small>
</div>
<Icon name="chevron-right" />
<div>
<i data-state={controlPlaneReady ? "online" : "offline"} />
<strong>Control Plane</strong>
<small>{formatLatency(snapshot?.controlPlaneLatencyMs ?? null)}</small>
</div>
<Icon name="chevron-right" />
<div>
<i data-state={simulationWorker?.available ? "online" : "offline"} />
<strong>Worker gateway</strong>
<small>{formatLatency(snapshot?.simulationGatewayLatencyMs ?? null)}</small>
</div>
</section>
{controlledDevice ? (
<section className="contour-connected-device">
<span className="section-eyebrow">ПОДТВЕРЖДЁННАЯ УПРАВЛЯЮЩАЯ СЕССИЯ</span>
<strong>{controlledDevice.displayName}</strong>
<small>
{controlledDevice.endpointLabel ?? controlledDevice.modelId}
{deviceControlConnectivity === "degraded"
? " · управление подтверждено, поток данных нарушен"
: " · управление подтверждено"}
</small>
</section>
) : null}
{snapshot?.error ? (
<p className="contour-health-error" role="status">{snapshot.error}</p>
) : null}
</div>
);
export function ContourHealthWorkspace({state}:{state:MissionRuntimeState|null}){
const [snapshot,setSnapshot]=useState<ContourSnapshot|null>(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 <div className="contour-health-dashboard">
<section className="contour-health-summary">
<div><h2>Узлы и связь</h2><p>Mission Core, зарегистрированные вычислители и бортовые компьютеры аппаратов.</p></div>
<div className="contour-health-summary__status"><StatusBadge tone={loading?'warning':summary.tone}>{loading?'Проверяем состояние':summary.label}</StatusBadge><Button size="compact" variant="secondary" disabled={loading} onClick={()=>setGeneration(value=>value+1)}><Icon name="refresh" size={14}/>{loading?'Проверяем':'Обновить'}</Button></div>
</section>
<section className="contour-health-kpis" aria-label="Сводка состояния контура">
<div><span>Узлы на связи</span><strong>{summary.online??'—'} / {summary.total??'—'}</strong><small>Core, вычислители и бортовые ПК</small></div>
<div><span>Вычислители</span><strong>{snapshot?.workers?.length??'—'}</strong><small>из настроек системы</small></div>
<div><span>Бортовые компьютеры</span><strong>{snapshot?.vehicles?.length??'—'}</strong><small>из реестра аппаратов</small></div>
<div><span>Последняя проверка</span><strong>{observed(snapshot?.observedAt)}</strong><small>обновление каждые 5 секунд</small></div>
</section>
<section className="contour-node-grid" aria-label="Узлы контура">
<article className="contour-node" data-state={core?.ok?'online':'offline'}>
<header><div><span className="section-eyebrow">КОМПЬЮТЕР ОПЕРАТОРА</span><h3>Mission Core</h3></div><StatusBadge tone={core?.ok?'success':core?'danger':'warning'}>{core?(core.ok?'Доступен':'Недоступен'):snapshot?'Нет свежих данных':'Проверяем'}</StatusBadge></header>
<dl><div><dt>Версия</dt><dd>{core?.version??'—'}</dd></div><div><dt>Время ответа</dt><dd>{snapshot?.latency==null?'—':`${Math.max(1,Math.round(snapshot.latency))} мс`}</dd></div><div><dt>Обработчики устройств готовы</dt><dd>{core?`${core.plugin_runtimes.ready} / ${core.plugin_runtimes.total}`:'—'}</dd></div></dl>
</article>
{snapshot?.workers?.map(({contour,telemetry})=>{
const online=workerAvailable(telemetry);
const mismatch=telemetry?.connection.reachable&&!telemetry.connection.identity_matches;
return <article key={contour.contour_id} className="contour-node" data-state={online?'online':'offline'}>
<header><div><span className="section-eyebrow">ВЫЧИСЛИТЕЛЬ</span><h3>{contour.display_name}</h3></div><StatusBadge tone={online?'success':!telemetry||mismatch?'warning':'neutral'}>{online?'На связи':mismatch?'Другой узел':telemetry?'Нет свежих данных':'Проверка недоступна'}</StatusBadge></header>
<dl><div><dt>Узел</dt><dd>{contour.expected_node_id}</dd></div><div><dt>Телеметрия</dt><dd>{contour.telemetry_mode==='agent-mqtt'?'Агент':'Диагностика SSH'}</dd></div><div><dt>Проверено</dt><dd>{observed(telemetry?.connection.observed_at_utc)}</dd></div><div><dt>Последнее измерение</dt><dd>{observed(telemetry?.node?.observed_at_utc)}</dd></div><div><dt>Операционная система</dt><dd>{telemetry?.node?.os.caption??'—'}</dd></div></dl>
</article>;
})}
{snapshot?.vehicles?.map(vehicle=>{
const online=boardAvailable(vehicle);
const status=vehicle.enrollment==='revoked'?'Привязка отозвана':vehicle.enrollment==='pending'?'Подключение':vehicle.enrollment==='failed'?'Не подключён':online?'На связи':'Нет связи';
return <article key={vehicle.id} className="contour-node" data-state={online?'online':'offline'}>
<header><div><span className="section-eyebrow">БОРТОВОЙ КОМПЬЮТЕР</span><h3>{vehicle.name}</h3></div><StatusBadge tone={online?'success':'neutral'}>{status}</StatusBadge></header>
<dl><div><dt>Имя БК</dt><dd>{vehicle.host?.hostname??'—'}</dd></div><div><dt>Операционная система</dt><dd>{vehicle.host?.os??'—'}</dd></div><div><dt>Последняя связь</dt><dd>{observed(vehicle.last_seen===null?null:vehicle.last_seen*1000)}</dd></div></dl>
{!online&&vehicle.host&&<small>Сведения о БК сохранены с последнего подключения.</small>}
</article>;
})}
</section>
{controlledDevice&&<section className="contour-connected-device"><span className="section-eyebrow">ПОДКЛЮЧЁННОЕ УСТРОЙСТВО ОПЕРАТОРА</span><strong>{controlledDevice.displayName}</strong><small>{deviceControlConnectivity==='degraded'?'Поток данных нарушен':'Управляющая связь подтверждена'}</small></section>}
{snapshot?.errors.map(error=><p key={error} className="contour-health-error" role="status">{error}</p>)}
</div>;
}
@@ -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});
});
@@ -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.