fix(ui): unify connectivity labels and move contour refresh to header

This commit is contained in:
DCCONSTRUCTIONS
2026-09-25 23:24:45 +03:00
parent f3640026cf
commit e2cf3078b6
11 changed files with 92 additions and 31 deletions
+3 -1
View File
@@ -842,7 +842,9 @@ export default function App() {
laboratoryAnnotation.control
) : activeDefinition.kind === "observatory" ? (
<StatusBadge tone="neutral">Только наблюдение</StatusBadge>
) : activeDefinition.kind === "contour-health" ? null : activeDefinition.root === "system" ? (
) : activeDefinition.kind === "contour-health" ? (
<div ref={setWorkspaceHeaderToolsHost} />
) : activeDefinition.root === "system" ? (
<SystemWorkspaceSelector
value={activeDefinition.id}
onChange={openNavigationView}
@@ -1,10 +1,12 @@
import {fetchComputeContours,type ComputeContour} from './computeContours';
import {fetchWorkerTelemetry,type WorkerTelemetry} from './workerTelemetry';
import type {Vehicle} from '../fleet/useFleet';
import {workerConnectionStatus} from './workerConnectionStatus';
export interface CoreHealth {
ok:boolean; service:string; version:string;
plugin_runtimes:{ready:number;total:number};
components?:{recording_cache?:{status?:string}};
}
export interface ContourSnapshot {
core:CoreHealth|null; latency:number|null;
@@ -59,8 +61,10 @@ export function boardAvailable(vehicle:Vehicle):boolean{
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;
const online=Number(snapshot.core!==null)+(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};
if(!snapshot.core)return {tone:'danger' as const,label:'Mission Core не в сети',online,total};
if(!snapshot.core.ok)return {tone:'warning' as const,label:'Mission Core работает с ограничениями',online,total};
if(snapshot.workers?.some(item=>workerConnectionStatus(item.telemetry).state==='unknown'))return {tone:'warning' as const,label:'Состояние части узлов не подтверждено',online,total};
return {tone:online===total?'success' as const:'warning' as const,label:online===total?'Все узлы в сети':'Часть узлов не в сети',online,total};
}
@@ -0,0 +1,13 @@
import type {WorkerTelemetry} from './workerTelemetry';
// Receiver failure is not evidence that the remote host has shut down.
export function workerConnectionStatus(telemetry: WorkerTelemetry | null) {
const connection = telemetry?.connection;
if (!connection) return {state:'unknown', tone:'warning', label:'Не в сети', detail:'Состояние вычислителя пока не подтверждено.'} as const;
if (connection.reachable && !connection.identity_matches) return {state:'unknown', tone:'warning', label:'Не в сети', detail:'Ответивший узел не совпадает с выбранным вычислителем.'} as const;
if (connection.reachable && connection.identity_matches && telemetry.node) return {state:'online', tone:'success', label:'В сети', detail:null} as const;
if (['telemetry-receiver-unavailable','telemetry-agent-unavailable','invalid-telemetry-response'].includes(connection.error_code ?? '')) {
return {state:'unknown', tone:'warning', label:'Не в сети', detail:'Mission Core не получает данные через службу телеметрии. Состояние самого вычислителя не подтверждено.'} as const;
}
return {state:'unknown', tone:'warning', label:'Не в сети', detail:'Последние данные не подтверждают текущее состояние вычислителя.'} as const;
}
@@ -1,7 +1,9 @@
import {useEffect,useState} from 'react';
import {Button,Icon,StatusBadge} from '@nodedc/ui-react';
import {createPortal} from 'react-dom';
import {workerConnectionStatus} from '../core/system/workerConnectionStatus';
import {IconButton,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 {boardAvailable,contourSummary,readContourSnapshot,type ContourSnapshot} from '../core/system/contourHealth';
import {startSequentialPolling} from '../core/system/sequentialPolling';
export function contourRuntimeAuthorityPresentation(
@@ -28,7 +30,7 @@ function observed(value:number|string|null|undefined){
const date=new Date(value);
return Number.isFinite(date.getTime())?date.toLocaleString('ru-RU'):'—';
}
export function ContourHealthWorkspace({state}:{state:MissionRuntimeState|null}){
export function ContourHealthWorkspace({state,headerToolsHost}:{state:MissionRuntimeState|null;headerToolsHost?:HTMLElement|null}){
const [snapshot,setSnapshot]=useState<ContourSnapshot|null>(null);
const [loading,setLoading]=useState(true),[generation,setGeneration]=useState(0);
useEffect(()=>{
@@ -43,33 +45,36 @@ export function ContourHealthWorkspace({state}:{state:MissionRuntimeState|null})
const summary=contourSummary(snapshot);
const core=snapshot?.core;
const {controlledDevice,deviceControlConnectivity}=contourRuntimeAuthorityPresentation(state);
const refresh=<IconButton label={loading?"Проверяем состояние":"Обновить состояние контура"} disabled={loading} onClick={()=>setGeneration(value=>value+1)}><Icon name="refresh" size={16}/></IconButton>;
return <div className="contour-health-dashboard">
{headerToolsHost?createPortal(refresh,headerToolsHost):refresh}
<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>
<div className="contour-health-summary__status"><StatusBadge tone={loading?'warning':summary.tone}>{loading?'Проверяем состояние':summary.label}</StatusBadge></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>{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>
<article className="contour-node" data-state={core?(core.ok?'online':'degraded'):'unknown'}>
<header><div><span className="section-eyebrow">КОМПЬЮТЕР ОПЕРАТОРА</span><h3>Mission Core</h3></div><StatusBadge tone={core?.ok?'success':'warning'}>{core?'В сети':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>
{!core?.ok&&core?.components?.recording_cache?.status==='capacity-pressure'&&<small>Недостаточно свободного места для новых записей.</small>}
</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>
const status=workerConnectionStatus(telemetry);
return <article key={contour.contour_id} className="contour-node" data-state={status.state}>
<header><div><span className="section-eyebrow">ВЫЧИСЛИТЕЛЬ</span><h3>{contour.display_name}</h3></div><StatusBadge tone={status.tone}>{status.label}</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>
{status.detail&&<small>{status.detail}</small>}
</article>;
})}
{snapshot?.vehicles?.map(vehicle=>{
const online=boardAvailable(vehicle);
const status=vehicle.enrollment==='revoked'?'Привязка отозвана':vehicle.enrollment==='pending'?'Подключение':vehicle.enrollment==='failed'?'Не подключён':online?'На связи':'Нет связи';
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>
@@ -437,6 +437,7 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
case "contour-health":
return (
<ContourHealthWorkspace
headerToolsHost={props.headerToolsHost}
state={props.state}
/>
);
@@ -1,7 +1,7 @@
import {Button,Icon,IconButton,ResourceRow,StatusBadge,Window} from '@nodedc/ui-react';
import type {Vehicle} from '../../core/fleet/useFleet';
export const boardStatus=(item:Vehicle,available=true)=>item.enrollment==='pending'?'Подтверждаем привязку':item.enrollment==='revoked'?'Не привязан':item.enrollment==='failed'?'Привязка не завершена':item.connectivity==='online'?(available?'В сети':'Нет свежих данных'):'Нет связи';
export const boardStatus=(item:Vehicle,available=true)=>item.enrollment==='pending'?'Подтверждаем привязку':item.enrollment==='revoked'?'Не привязан':item.enrollment==='failed'?'Привязка не завершена':item.connectivity==='online'?(available?'В сети':'Не в сети'):'Не в сети';
export function BoardComputerRow({vehicle,enabled,showingSavedDevices,onSettings,onMonitor,onAdd,open,onToggle}:{vehicle:Vehicle;enabled:boolean;showingSavedDevices:boolean;onSettings:()=>void;onMonitor:()=>void;onAdd?:()=>void;open:boolean;onToggle:()=>void}){
const label=boardStatus(vehicle,enabled);
return <ResourceRow icon={<Icon name="apps"/>} title="Бортовой компьютер" description={vehicle.host?.hostname||'Mission Core Node'} metadata={`${label}${showingSavedDevices?' (показаны последние сохранённые устройства)':''}`} statusPlacement="leading"
@@ -1,3 +1,4 @@
import {workerConnectionStatus} from '../../core/system/workerConnectionStatus';
import {
GlassSurface,
StatusBadge,
@@ -77,9 +78,7 @@ export function ComputeModulesWorkspace() {
const node = telemetry?.node ?? null;
const missionCoreRuntimes = telemetry?.runtimes.filter((runtime) => !runtime.external) ?? [];
const externalRuntimes = telemetry?.runtimes.filter((runtime) => runtime.external) ?? [];
const connected = Boolean(
telemetry?.connection.reachable && telemetry.connection.identity_matches && node,
);
const connectionStatus = workerConnectionStatus(telemetry);
const agentTelemetry = telemetry?.connection.source === "agent-mqtt";
const history = telemetry?.history ?? [];
const cpuPercent = node?.cpu.load_percent;
@@ -105,14 +104,13 @@ export function ComputeModulesWorkspace() {
</p>
</div>
<div className="system-workspace__actions">
<StatusBadge tone={connected ? "success" : "danger"}>
{connected
? agentTelemetry ? "Агент доступен" : "SSH-диагностика"
: "Нет свежих данных"}
<StatusBadge tone={connectionStatus.tone}>
{connectionStatus.label}
</StatusBadge>
</div>
</section>
{connectionStatus.detail && <p role="status">{connectionStatus.detail}</p>}
{error ? (
<GlassSurface className="system-workspace__notice" padding="md" tone="soft">
<StatusBadge tone="warning">{error}</StatusBadge>
@@ -1,3 +1,4 @@
import {workerConnectionStatus} from '../../core/system/workerConnectionStatus';
import {
GlassSurface,
StatusBadge,
@@ -31,6 +32,7 @@ export function NetworkWorkspace() {
const connected = Boolean(
telemetry?.connection.reachable && telemetry.connection.identity_matches,
);
const connectionStatus = workerConnectionStatus(telemetry);
const agentTelemetry = telemetry?.connection.source === "agent-mqtt";
const runtimeOnline = telemetry?.runtimes.some(
(runtime) => !runtime.external && runtime.state === "running",
@@ -43,17 +45,18 @@ export function NetworkWorkspace() {
<span className="section-eyebrow">СИСТЕМА / СЕТЬ</span>
<h2>{selectedContour?.display_name ?? "Контур не выбран"}</h2>
<p>
Фактический локальный маршрут и сетевые счётчики выбранного вычислительного
Фактический маршрут и сетевые счётчики выбранного вычислительного
контура. Адрес, транспорт и установка агента находятся в настройках контура.
</p>
</div>
<div className="system-workspace__actions">
<StatusBadge tone={connected ? "success" : "danger"}>
{connected ? "Маршрут доступен" : "Нет свежих данных"}
<StatusBadge tone={connectionStatus.tone}>
{connectionStatus.label}
</StatusBadge>
</div>
</section>
{connectionStatus.detail && <p role="status">{connectionStatus.detail}</p>}
{error ? (
<GlassSurface className="system-workspace__notice" padding="md" tone="soft">
<StatusBadge tone="warning">{error}</StatusBadge>
@@ -109,7 +112,7 @@ export function NetworkWorkspace() {
<small>127.0.0.1:8000</small>
</div>
<i aria-hidden="true" data-state="online" />
<div data-state={agentTelemetry ? "online" : "offline"}>
<div data-state={connected ? "online" : "unknown"}>
<span>Telemetry plane</span>
<strong>{agentTelemetry ? "Mosquitto + Timescale" : "SSH diagnostic"}</strong>
<small>
@@ -33,7 +33,7 @@ test('actual registries determine node count and offline boards prevent all-onli
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.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=>{
@@ -54,5 +54,24 @@ test('invalid health never becomes reachable; identity mismatch and stale worker
});
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});
assert.deepEqual(contourSummary(await readContourSnapshot(new AbortController().signal)),{tone:'success',label:'Все узлы в сети',online:1,total:1});
});
test('receiver failure leaves worker unknown instead of declaring it offline',async t=>{
installFetch(t,{data:{'/api/v1/system/contours/test-compute/telemetry?history=90':{...worker,node:null,connection:{reachable:false,identity_matches:false,error_code:'telemetry-receiver-unavailable'}}}});
const snapshot=await readContourSnapshot(new AbortController().signal);
assert.equal(contourSummary(snapshot).label,'Состояние части узлов не подтверждено');
const {workerConnectionStatus}=await server.ssrLoadModule('/src/core/system/workerConnectionStatus.ts');
const status=workerConnectionStatus(snapshot.workers[0].telemetry);
assert.equal(status.state,'unknown');
assert.equal(status.label,'Не в сети');
assert.equal(workerConnectionStatus({...worker,node:null,connection:{reachable:false,error_code:'telemetry-agent-stale'}}).label,'Не в сети');
assert.equal(workerConnectionStatus(worker).state,'online');
});
test('a responding Core with recording capacity pressure remains reachable',async t=>{
installFetch(t,{data:{'/api/health':{...core,ok:false,components:{recording_cache:{status:'capacity-pressure'}}}}});
const snapshot=await readContourSnapshot(new AbortController().signal);
assert.equal(contourSummary(snapshot).online,2);
assert.equal(contourSummary(snapshot).label,'Mission Core работает с ограничениями');
});
+1 -1
View File
@@ -722,7 +722,7 @@ def run_worker_agent_probe(
):
return _failed_probe(
profile,
"telemetry-agent-unavailable",
"telemetry-receiver-unavailable",
started,
source="agent-mqtt",
)
+16
View File
@@ -497,3 +497,19 @@ def test_profile_apply_fails_closed_on_wrong_node_and_keeps_old_profile(
assert error.value.status_code == 409
assert WorkerProfileStore(tmp_path / "system").read().revision == 0
assert not WorkerProfileStore(tmp_path / "system").path.exists()
def test_receiver_failure_is_not_reported_as_worker_failure(monkeypatch):
import urllib.error
from k1link.web import system_telemetry_api as api
def unavailable(*args, **kwargs):
raise urllib.error.URLError("receiver down")
monkeypatch.setattr(api.urllib.request, "urlopen", unavailable)
result = api.run_worker_agent_probe(
api._profile_from_compute_contour(default_compute_contour())
)
assert result["error_code"] == "telemetry-receiver-unavailable"
assert result["reachable"] is False and result["raw"] is None