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}{" "}
|
||||||
{rootWorkspaces.length === 1
|
{rootWorkspaces.length === 1
|
||||||
? "рабочая поверхность"
|
? "рабочая поверхность"
|
||||||
: "рабочих поверхностей"}
|
: rootWorkspaces.length >= 2 && rootWorkspaces.length <= 4
|
||||||
|
? "рабочие поверхности" : "рабочих поверхностей"}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -841,7 +842,7 @@ export default function App() {
|
|||||||
laboratoryAnnotation.control
|
laboratoryAnnotation.control
|
||||||
) : activeDefinition.kind === "observatory" ? (
|
) : activeDefinition.kind === "observatory" ? (
|
||||||
<StatusBadge tone="neutral">Только наблюдение</StatusBadge>
|
<StatusBadge tone="neutral">Только наблюдение</StatusBadge>
|
||||||
) : activeDefinition.root === "system" ? (
|
) : activeDefinition.kind === "contour-health" ? null : activeDefinition.root === "system" ? (
|
||||||
<SystemWorkspaceSelector
|
<SystemWorkspaceSelector
|
||||||
value={activeDefinition.id}
|
value={activeDefinition.id}
|
||||||
onChange={openNavigationView}
|
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: "Состояние контура",
|
label: "Состояние контура",
|
||||||
title: "Состояние контура",
|
title: "Состояние контура",
|
||||||
eyebrow: "ПАРК / СОСТОЯНИЕ",
|
eyebrow: "ПАРК / СОСТОЯНИЕ",
|
||||||
description: "Узлы, процессы, сеть и подключённые устройства по данным живого контура.",
|
description: "Связь с Mission Core, вычислителями и бортовыми компьютерами аппаратов.",
|
||||||
icon: "shield",
|
icon: "shield",
|
||||||
kind: "contour-health",
|
kind: "contour-health",
|
||||||
groups: [],
|
groups: [],
|
||||||
@@ -213,50 +213,6 @@ export const workspaces: WorkspaceDefinition[] = [
|
|||||||
kind: "device",
|
kind: "device",
|
||||||
groups: [],
|
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",
|
id: "spatial-scene",
|
||||||
root: "polygon",
|
root: "polygon",
|
||||||
|
|||||||
@@ -2173,6 +2173,7 @@
|
|||||||
|
|
||||||
.contour-health-summary {
|
.contour-health-summary {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2203,7 +2204,7 @@
|
|||||||
|
|
||||||
.contour-health-kpis {
|
.contour-health-kpis {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 10rem), 1fr));
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2235,7 +2236,7 @@
|
|||||||
|
|
||||||
.contour-node-grid {
|
.contour-node-grid {
|
||||||
display: 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;
|
gap: 0.65rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2251,6 +2252,7 @@
|
|||||||
|
|
||||||
.contour-node header {
|
.contour-node header {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2262,7 +2264,7 @@
|
|||||||
|
|
||||||
.contour-node dl {
|
.contour-node dl {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
margin: 0;
|
margin: 0;
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
background: rgb(255 255 255 / 0.022);
|
background: rgb(255 255 255 / 0.022);
|
||||||
@@ -2277,11 +2279,10 @@
|
|||||||
|
|
||||||
.contour-node dd {
|
.contour-node dd {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
overflow: hidden;
|
overflow-wrap: anywhere;
|
||||||
color: var(--nodedc-text-secondary);
|
color: var(--nodedc-text-secondary);
|
||||||
font-size: 0.62rem;
|
font-size: var(--nodedc-font-size-sm);
|
||||||
text-overflow: ellipsis;
|
white-space: normal;
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.contour-process-list {
|
.contour-process-list {
|
||||||
|
|||||||
@@ -1,41 +1,8 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import {useEffect,useState} from 'react';
|
||||||
import { Button, Icon, StatusBadge } from "@nodedc/ui-react";
|
import {Button,Icon,StatusBadge} from '@nodedc/ui-react';
|
||||||
|
import type {MissionRuntimeState} from '../core/runtime/contracts';
|
||||||
import type { MissionRuntimeState } from "../core/runtime/contracts";
|
import {boardAvailable,contourSummary,readContourSnapshot,workerAvailable,type ContourSnapshot} from '../core/system/contourHealth';
|
||||||
import {
|
import {startSequentialPolling} from '../core/system/sequentialPolling';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function contourRuntimeAuthorityPresentation(
|
export function contourRuntimeAuthorityPresentation(
|
||||||
state: MissionRuntimeState | null,
|
state: MissionRuntimeState | null,
|
||||||
@@ -56,310 +23,61 @@ export function contourRuntimeAuthorityPresentation(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function validControlPlaneHealth(value: unknown): value is ControlPlaneHealth {
|
function observed(value:number|string|null|undefined){
|
||||||
if (!value || typeof value !== "object") return false;
|
if(value===null||value===undefined)return '—';
|
||||||
const item = value as Partial<ControlPlaneHealth>;
|
const date=new Date(value);
|
||||||
return typeof item.ok === "boolean"
|
return Number.isFinite(date.getTime())?date.toLocaleString('ru-RU'):'—';
|
||||||
&& 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";
|
|
||||||
}
|
}
|
||||||
|
export function ContourHealthWorkspace({state}:{state:MissionRuntimeState|null}){
|
||||||
function validPluginRuntime(value: unknown): value is PluginRuntimeHealth {
|
const [snapshot,setSnapshot]=useState<ContourSnapshot|null>(null);
|
||||||
if (!value || typeof value !== "object") return false;
|
const [loading,setLoading]=useState(true),[generation,setGeneration]=useState(0);
|
||||||
const item = value as Partial<PluginRuntimeHealth>;
|
useEffect(()=>{
|
||||||
return typeof item.runtime_instance_id === "string"
|
const controller=new AbortController();
|
||||||
&& typeof item.plugin_id === "string"
|
const stop=startSequentialPolling(async()=>{
|
||||||
&& typeof item.plugin_version === "string"
|
setLoading(true);
|
||||||
&& typeof item.status === "string"
|
try{const next=await readContourSnapshot(controller.signal);if(!controller.signal.aborted)setSnapshot(next);}
|
||||||
&& typeof item.observed_at === "string";
|
finally{if(!controller.signal.aborted)setLoading(false);}
|
||||||
}
|
},5000);
|
||||||
|
return ()=>{stop();controller.abort();};
|
||||||
async function timedJson(
|
},[generation]);
|
||||||
url: string,
|
const summary=contourSummary(snapshot);
|
||||||
signal: AbortSignal,
|
const core=snapshot?.core;
|
||||||
): Promise<{ payload: unknown; latencyMs: number }> {
|
const {controlledDevice,deviceControlConnectivity}=contourRuntimeAuthorityPresentation(state);
|
||||||
const started = performance.now();
|
return <div className="contour-health-dashboard">
|
||||||
const response = await fetch(url, {
|
<section className="contour-health-summary">
|
||||||
method: "GET",
|
<div><h2>Узлы и связь</h2><p>Mission Core, зарегистрированные вычислители и бортовые компьютеры аппаратов.</p></div>
|
||||||
headers: { Accept: "application/json" },
|
<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>
|
||||||
signal,
|
</section>
|
||||||
});
|
<section className="contour-health-kpis" aria-label="Сводка состояния контура">
|
||||||
const latencyMs = performance.now() - started;
|
<div><span>Узлы на связи</span><strong>{summary.online??'—'} / {summary.total??'—'}</strong><small>Core, вычислители и бортовые ПК</small></div>
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
<div><span>Вычислители</span><strong>{snapshot?.workers?.length??'—'}</strong><small>из настроек системы</small></div>
|
||||||
return { payload: await response.json(), latencyMs };
|
<div><span>Бортовые компьютеры</span><strong>{snapshot?.vehicles?.length??'—'}</strong><small>из реестра аппаратов</small></div>
|
||||||
}
|
<div><span>Последняя проверка</span><strong>{observed(snapshot?.observedAt)}</strong><small>обновление каждые 5 секунд</small></div>
|
||||||
|
</section>
|
||||||
function formatLatency(value: number | null): string {
|
<section className="contour-node-grid" aria-label="Узлы контура">
|
||||||
if (value === null) return "—";
|
<article className="contour-node" data-state={core?.ok?'online':'offline'}>
|
||||||
return `${Math.max(1, Math.round(value))} мс`;
|
<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>
|
||||||
function formatObservedAt(value: Date | string | null): string {
|
{snapshot?.workers?.map(({contour,telemetry})=>{
|
||||||
if (!value) return "—";
|
const online=workerAvailable(telemetry);
|
||||||
const date = value instanceof Date ? value : new Date(value);
|
const mismatch=telemetry?.connection.reachable&&!telemetry.connection.identity_matches;
|
||||||
if (!Number.isFinite(date.getTime())) return "—";
|
return <article key={contour.contour_id} className="contour-node" data-state={online?'online':'offline'}>
|
||||||
return new Intl.DateTimeFormat("ru-RU", {
|
<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>
|
||||||
hour: "2-digit",
|
<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>
|
||||||
minute: "2-digit",
|
</article>;
|
||||||
second: "2-digit",
|
})}
|
||||||
}).format(date);
|
{snapshot?.vehicles?.map(vehicle=>{
|
||||||
}
|
const online=boardAvailable(vehicle);
|
||||||
|
const status=vehicle.enrollment==='revoked'?'Привязка отозвана':vehicle.enrollment==='pending'?'Подключение':vehicle.enrollment==='failed'?'Не подключён':online?'На связи':'Нет связи';
|
||||||
export function ContourHealthWorkspace({
|
return <article key={vehicle.id} className="contour-node" data-state={online?'online':'offline'}>
|
||||||
state,
|
<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>
|
||||||
state: MissionRuntimeState | null;
|
{!online&&vehicle.host&&<small>Сведения о БК сохранены с последнего подключения.</small>}
|
||||||
}) {
|
</article>;
|
||||||
const [snapshot, setSnapshot] = useState<ContourSnapshot | null>(null);
|
})}
|
||||||
const [loading, setLoading] = useState(false);
|
</section>
|
||||||
const [generation, setGeneration] = useState(0);
|
{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>)}
|
||||||
const refresh = useCallback(() => setGeneration((value) => value + 1), []);
|
</div>;
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
hardware or shows an endless discovery spinner. Cached devices stay visible;
|
||||||
their current connection is unconfirmed, and mutations stay disabled.
|
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
|
## Computer replacement boundary
|
||||||
|
|
||||||
POST /fleet/{vehicle}/computer consumes a verified Node invitation and an
|
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.
|
LaunchAgent restarted the sole Core process; exact health recovered.
|
||||||
The real board is powered off: new pairing/replacement and live device operation
|
The real board is powered off: new pairing/replacement and live device operation
|
||||||
require subsequent hardware acceptance; no such action is performed here.
|
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