fix(fleet): derive contour status from registered nodes and remove obsolete tabs
This commit is contained in:
@@ -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};
|
||||
}
|
||||
@@ -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;
|
||||
}) {
|
||||
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), []);
|
||||
|
||||
const [loading,setLoading]=useState(true),[generation,setGeneration]=useState(0);
|
||||
useEffect(()=>{
|
||||
const controller=new AbortController();
|
||||
const stop=startSequentialPolling(async()=>{
|
||||
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();
|
||||
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]);
|
||||
|
||||
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">
|
||||
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>
|
||||
<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>
|
||||
<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>{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>
|
||||
<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={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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
);
|
||||
{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.
|
||||
|
||||
Reference in New Issue
Block a user