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});
});