feat(node): pair onboard computers with the Core fleet through UI

This commit is contained in:
DCCONSTRUCTIONS
2026-09-05 21:16:13 +03:00
parent fc545f8440
commit e82d012907
29 changed files with 2442 additions and 19 deletions
+5 -1
View File
@@ -629,7 +629,10 @@ export default function App() {
return () => window.clearTimeout(timer);
}, [layoutSaveNotice]);
const [fleetCreateRequest, setFleetCreateRequest] = useState(0);
const onAddVehicle = useCallback(() => setFleetCreateRequest(value => value + 1), []);
const contentActions = useApplicationPanelActions({
onAddVehicle,
definition: activeDefinition,
refreshRuntime: runtime.refresh,
resetConnectionScenario: runtime.resetConnectionScenario,
@@ -802,7 +805,7 @@ export default function App() {
settleRecordedReplaySwitch(outcome)}
onDeleteBegin={releaseRecordedReplayForDelete}
/>
) : activeDefinition.kind === "datasets" ? (
) : activeDefinition.kind === "vehicles" ? null : activeDefinition.kind === "datasets" ? (
<StatusBadge tone="neutral">Offline evaluation</StatusBadge>
) : activeDefinition.kind === "lab-archive" ? (
laboratoryAnnotation.control
@@ -828,6 +831,7 @@ export default function App() {
) : (
<WorkspaceRenderer
definition={activeDefinition}
fleetCreateRequest={fleetCreateRequest}
state={activeRuntimeState}
backendStatus={runtime.backendStatus}
sourceUrl={effectiveSourceUrl}
@@ -5,6 +5,7 @@ import type { WorkspaceDefinition } from "../productModel";
interface ApplicationPanelActionsOptions {
definition: WorkspaceDefinition | null;
onAddVehicle?: () => void;
refreshRuntime: () => void;
resetConnectionScenario?: () => Promise<boolean>;
connectionScenarioResetting: boolean;
@@ -43,6 +44,7 @@ export function deviceRuntimeUtilityAction({
export function useApplicationPanelActions({
definition,
onAddVehicle,
refreshRuntime,
resetConnectionScenario,
connectionScenarioResetting,
@@ -52,6 +54,7 @@ export function useApplicationPanelActions({
}: ApplicationPanelActionsOptions): ApplicationPanelUtilityAction[] {
return useMemo(() => {
const actions: ApplicationPanelUtilityAction[] = [];
if (definition?.kind === "vehicles" && onAddVehicle) actions.push({ label: "Добавить аппарат", icon: "plus", onClick: onAddVehicle });
if (definition?.kind === "device") {
actions.push(deviceRuntimeUtilityAction({
refreshRuntime,
@@ -71,6 +74,7 @@ export function useApplicationPanelActions({
return actions;
}, [
definition,
onAddVehicle,
refreshRuntime,
resetConnectionScenario,
connectionScenarioResetting,
@@ -0,0 +1,50 @@
import { useCallback, useEffect, useState } from "react";
export interface BoardHost {
hostname: string; os: string; architecture: string; cpus: number;
memory_kib: number | null; collected_at: string;
networks: { name: string; up: boolean; addresses: string[] }[];
usb: { port: string; product: string }[];
}
export interface Vehicle {
id: string; node_id: string; name: string; platform: string;
enrollment: "pending" | "paired" | "revoked" | "failed";
connectivity: "online" | "offline"; last_seen: number | null;
host: BoardHost | null; notice: string; revision: number;
}
export interface FleetPreview {
preview_id: string; node_id: string; name: string; host: BoardHost;
endpoint: string; expires_at: number;
}
export async function fleetRequest<T>(path = "", method = "GET", body?: unknown): Promise<T> {
const response = await fetch(`/api/v1/fleet${path}`, {
method, credentials: "same-origin", cache: "no-store",
headers: body === undefined ? {} : { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(12000),
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(typeof data.detail === "string" ? data.detail : "Не удалось выполнить действие с аппаратом.");
}
return response.json();
}
export function useFleet() {
const [items, setItems] = useState<Vehicle[] | null>(null);
const [error, setError] = useState("");
const refresh = useCallback(async () => {
const value = await fleetRequest<{ items: Vehicle[] }>();
setItems(value.items); setError(""); return value.items;
}, []);
useEffect(() => {
let active = true;
let timer: ReturnType<typeof setTimeout>;
async function poll() {
try { const value = await fleetRequest<{ items: Vehicle[] }>(); if (active) { setItems(value.items); setError(""); } }
catch { if (active) setError("Реестр недоступен. Показаны последние полученные сведения; связь сейчас не подтверждена."); }
finally { if (active) timer = setTimeout(poll, 5000); }
}
void poll();
return () => { active = false; clearTimeout(timer); };
}, []);
return { items, error, refresh };
}
+5 -15
View File
@@ -16,6 +16,7 @@ export type WorkspaceKind =
| "map"
| "timeline"
| "missions"
| "vehicles"
| "catalog"
| "contour-health"
| "compute-modules"
@@ -193,23 +194,12 @@ export const workspaces: WorkspaceDefinition[] = [
id: "vehicles",
root: "fleet",
label: "Аппараты",
title: "Реестр аппаратов",
title: "Аппараты",
eyebrow: "ПАРК / РЕЕСТР",
description: "Нейтральный реестр наземных, воздушных и стационарных платформ.",
description: "Аппараты и их бортовые компьютеры в частном контуре.",
icon: "apps",
kind: "catalog",
groups: [
{
title: "Идентичность аппарата",
description: "Никакой привязки продуктовой модели к конкретному производителю.",
capabilities: [
ready("Локальный стенд", "Первый аппарат представлен текущим устройством и его адаптером."),
contract("Паспорт борта", "Тип, серийный профиль, вычислитель, питание и транспорт."),
contract("Состояние доступности", "Онлайн, занят, обслуживание, потеря связи."),
later("Группы и рои", "Логические группы, роли и совместное назначение миссий."),
],
},
],
kind: "vehicles",
groups: [],
},
{
id: "local-device",
@@ -1,3 +1,4 @@
import { VehiclesWorkspace } from "./fleet/VehiclesWorkspace";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button, GlassSurface, Icon, StatusBadge } from "@nodedc/ui-react";
import {
@@ -1167,6 +1168,8 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
return <TimelineWorkspace {...props} />;
case "missions":
return <MissionWorkspace {...props} />;
case "vehicles":
return <VehiclesWorkspace createRequest={props.fleetCreateRequest} />;
case "catalog":
return <CatalogWorkspace {...props} />;
case "contour-health":
@@ -35,6 +35,7 @@ export interface LaboratoryViewAction {
}
export interface WorkspaceRendererProps {
fleetCreateRequest?: number;
definition: WorkspaceDefinition;
state: MissionRuntimeState | null;
backendStatus: BackendStatus;
@@ -0,0 +1,67 @@
import { useEffect, useRef, useState } from "react";
import { ActivityIndicator, Button, ConfirmationModal, Icon, IconButton, ResourceList, ResourceRow, Select, SettingsCard, StatusBadge, TextAreaField, TextField, Window, WindowFooterActions } from "@nodedc/ui-react";
import { fleetRequest, useFleet, type FleetPreview, type Vehicle } from "../../core/fleet/useFleet";
import "./fleet.css";
const platforms = [{ value: "ugv", label: "Наземный (UGV)" }, { value: "uav", label: "Воздушный (UAV)" }, { value: "stationary", label: "Стационарный" }, { value: "other", label: "Другой" }];
const platformLabel = (value: string) => platforms.find(item => item.value === value)?.label ?? value;
function statusLabel(item: Vehicle) { return item.enrollment === "pending" ? "Подтверждаем привязку" : item.enrollment === "revoked" ? "Доверие отозвано" : item.enrollment === "failed" ? "Привязка не завершена" : item.connectivity === "online" ? "В сети" : "Нет связи"; }
export function VehiclesWorkspace({ createRequest = 0 }: { createRequest?: number }) {
const fleet = useFleet();
const [adding, setAdding] = useState(false);
const [code, setCode] = useState("");
const [name, setName] = useState("");
const [platform, setPlatform] = useState("ugv");
const [preview, setPreview] = useState<FleetPreview | null>(null);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const [selected, setSelected] = useState<string | null>(null);
const [revoking, setRevoking] = useState<Vehicle | null>(null);
const lastCreateRequest = useRef(createRequest);
useEffect(() => { if (createRequest !== lastCreateRequest.current) { lastCreateRequest.current = createRequest; setAdding(true); setError(""); } }, [createRequest]);
function close() { if (pending) return; setAdding(false); setPreview(null); setCode(""); setName(""); setError(""); }
async function inspect(event: React.FormEvent) {
event.preventDefault(); if (pending) return;
setPending(true); setError("");
try { const value = await fleetRequest<FleetPreview>("/preview", "POST", { code: code.trim() }); setPreview(value); setName(current => current || value.name); setCode(""); }
catch (error) { setError(error instanceof Error ? error.message : "Не удалось проверить приглашение."); }
finally { setPending(false); }
}
async function add() {
if (!preview || pending) return;
setPending(true); setError("");
try {
const item = await fleetRequest<Vehicle>("", "POST", { preview_id: preview.preview_id, name: name.trim(), platform });
await fleet.refresh(); setSelected(item.id); setAdding(false); setPreview(null); setCode(""); setName("");
} catch (error) { setError(error instanceof Error ? error.message : "Не удалось добавить аппарат."); void fleet.refresh().catch(() => undefined); }
finally { setPending(false); }
}
const detail = fleet.items?.find(item => item.id === selected);
return <div className="fleet-workspace">
{fleet.error && <p role="alert">{fleet.error}</p>}
{!adding && error && <p role="alert">{error}</p>}
{detail ? <>
<div><Button onClick={() => setSelected(null)}>К списку аппаратов</Button></div>
<SettingsCard title={detail.name} description={`${platformLabel(detail.platform)} · с бортовым компьютером`} actions={<StatusBadge tone={!fleet.error && detail.enrollment === "paired" && detail.connectivity === "online" ? "success" : "neutral"}>{fleet.error ? "Нет свежих данных" : statusLabel(detail)}</StatusBadge>}>
{detail.notice && <p role="status">{detail.notice}</p>}
<dl className="fleet-facts"><div><dt>Бортовой компьютер</dt><dd>{detail.node_id}</dd></div>
<div><dt>Последняя связь</dt><dd>{detail.last_seen ? new Date(detail.last_seen * 1000).toLocaleString("ru-RU") : "Соединение ещё не получено"}</dd></div>
{detail.host && <><div><dt>Имя БК в системе</dt><dd>{detail.host.hostname}</dd></div><div><dt>Операционная система</dt><dd>{detail.host.os}</dd></div><div><dt>Архитектура</dt><dd>{detail.host.architecture}</dd></div><div><dt>Процессоры</dt><dd>{detail.host.cpus}</dd></div><div><dt>Память</dt><dd>{detail.host.memory_kib ? `${(detail.host.memory_kib / 1024 / 1024).toFixed(1)} ГиБ` : "Нет сведений"}</dd></div></>}
</dl>
{detail.enrollment !== "revoked" && <Button onClick={() => setRevoking(detail)}>Отозвать привязку БК</Button>}
</SettingsCard>
<SettingsCard title="Устройства аппарата"><p>Устройства к аппарату ещё не подключены.</p></SettingsCard>
</> : !fleet.items ? <ActivityIndicator label="Получаем аппараты" /> : fleet.items.length === 0 ? <SettingsCard title="Аппаратов пока нет" description="Добавьте аппарат по приглашению из Mission Core Node на его бортовом компьютере."><Button onClick={() => setAdding(true)}>Добавить аппарат</Button></SettingsCard> : <ResourceList aria-label="Аппараты">{fleet.items.map(item => <li key={item.id}><ResourceRow icon={<Icon name="apps" />} title={item.name} description={`${platformLabel(item.platform)} · бортовой компьютер`} status={<StatusBadge tone={!fleet.error && item.enrollment === "paired" && item.connectivity === "online" ? "success" : "neutral"}>{fleet.error ? "Нет свежих данных" : statusLabel(item)}</StatusBadge>} actions={<IconButton label={`Конфигурация: ${item.name}`} onClick={() => setSelected(item.id)}><Icon name="eye" /></IconButton>} /></li>)}</ResourceList>}
<Window open={adding} title="Добавить аппарат" subtitle="Подключить бортовой компьютер по приглашению Node" size="md" closeOnBackdrop={false} closeOnEscape={!pending} onClose={close} footer={<WindowFooterActions><Button disabled={pending} onClick={close}>Отмена</Button>{preview ? <Button disabled={pending || !name.trim()} onClick={() => void add()}>{pending ? "Добавляем…" : "Добавить аппарат"}</Button> : <Button type="submit" form="fleet-invitation" disabled={pending || !code.trim()}>{pending ? "Проверяем БК…" : "Проверить БК"}</Button>}</WindowFooterActions>}>
<form id="fleet-invitation" className="fleet-form" onSubmit={inspect} aria-busy={pending}>
<Select label="Способ подключения" value="node" options={[{ value: "node", label: "С бортовым компьютером" }]} onChange={() => undefined} disabled={pending} />
<Select label="Класс аппарата" value={platform} options={platforms} onChange={setPlatform} disabled={pending} />
<TextField label="Название аппарата" value={name} maxLength={80} onChange={event => setName(event.target.value)} disabled={pending} autoComplete="off" />
{preview ? <SettingsCard title={preview.name} description="Идентичность БК проверена по приглашению"><dl className="fleet-facts"><div><dt>Идентификатор БК</dt><dd>{preview.node_id}</dd></div><div><dt>Система</dt><dd>{preview.host.os} · {preview.host.architecture}</dd></div><div><dt>Адрес БК</dt><dd>{preview.endpoint}</dd></div></dl><Button disabled={pending} onClick={() => setPreview(null)}>Другое приглашение</Button></SettingsCard> : <TextAreaField label="Код приглашения из Node" value={code} rows={5} maxLength={4096} spellCheck={false} autoComplete="off" disabled={pending} onChange={event => setCode(event.target.value)} />}
{error && <p role="alert">{error}</p>}
</form>
</Window>
<ConfirmationModal open={revoking !== null} title="Отозвать привязку БК?" description={`Аппарат «${revoking?.name ?? ""}» останется в реестре, а его БК потеряет доступ к Core. БК получит отзыв при следующем соединении.`} confirmLabel="Отозвать" cancelLabel="Отмена" danger onClose={() => setRevoking(null)} onConfirm={async () => { if (!revoking) return; try { await fleetRequest(`/${encodeURIComponent(revoking.id)}`, "DELETE"); await fleet.refresh(); setRevoking(null); } catch (error) { setError(error instanceof Error ? error.message : "Не удалось отозвать привязку."); throw error; } }} />
</div>;
}
@@ -0,0 +1,5 @@
.fleet-workspace, .fleet-form { display: flex; flex-direction: column; gap: var(--nodedc-space-4); }
.fleet-workspace { padding: var(--nodedc-space-4); }
.fleet-facts { display: grid; gap: var(--nodedc-space-3); }
.fleet-facts > div { display: grid; grid-template-columns: minmax(120px, 1fr) minmax(0, 2fr); gap: var(--nodedc-space-3); }
.fleet-facts dd { margin: 0; overflow-wrap: anywhere; }