feat(node): consolidate board system views and plan fleet pairing

This commit is contained in:
DCCONSTRUCTIONS
2026-09-05 18:35:57 +03:00
parent d696842f5d
commit 79911eb316
13 changed files with 278 additions and 73 deletions
+7
View File
@@ -1,5 +1,12 @@
# Mission Core Node — Ubuntu system configuration candidate
0.3.1 consolidates host inventory, USB, Tailscale and SSH under «Система».
«Обзор БК» contains host facts, the board name and an observed connectivity
summary. The separate configuration page and redundant Node health badge are
removed. «Устройства» is reserved for driver-backed devices and remains disabled
until a real device workflow exists. The admitted Node/Core pairing and fleet
surface plan is in `docs/node/03_SYSTEM_AND_VEHICLE_PAIRING_SURFACE.md`.
0.3.0 adopts the canonical Mission Core shell, navigation, system views and a
GUI list of trusted SSH devices. ResourceRow is shared from Design Guideline;
see `docs/node/02_NODE_DESKTOP_SURFACE.md` for composition and acceptance.
+2 -2
View File
@@ -15,11 +15,11 @@ import (
)
func main() {
dir := "/private/tmp/mc-node-ui-030-qa"
dir := "/private/tmp/mc-node-ui-031-qa"
store, err := node.OpenStore(dir); if err != nil { log.Fatal(err) }
assets, _ := fs.Sub(web.Assets, "dist")
memory, available := uint64(8388608), uint64(5242880)
app := &node.Server{Store: store, Assets: assets, Origin: "http://127.0.0.1:8780", Version: "0.3.0-qa", Inventory: func() node.Inventory {
app := &node.Server{Store: store, Assets: assets, Origin: "http://127.0.0.1:8780", Version: "0.3.1-qa", Inventory: func() node.Inventory {
return node.Inventory{CollectedAt: time.Now().UTC().Format(time.RFC3339), Hostname:"qa-board", OS:"Ubuntu 24.04.4 LTS", Architecture:"amd64", CPUs:8, MemoryKiB:&memory, AvailableKiB:&available,
Networks: []node.Network{{Name:"ethernet-qa", Up:true, Addresses:[]string{"192.0.2.10/24"}}},
USB:[]node.USB{{Port:"2-1", Vendor:"8086", ProductID:"0b5c", Product:"Intel RealSense D455 · QA", Speed:"5000"}}, USBReadable:true, Warnings:[]string{}}
+1 -1
View File
@@ -13,7 +13,7 @@ import tarfile
ROOT = Path(__file__).resolve().parents[1]
VERSION = "0.3.0"
VERSION = "0.3.1"
BRAND_SHA256 = "8bfee8ca9f98e0db48d98aae3af4b32493b8593e18b064a0239d513d824182af"
+19
View File
@@ -0,0 +1,19 @@
import { ActivityIndicator, Button, Icon, ResourceList, ResourceRow, SettingsCard, StatusBadge } from "@nodedc/ui-react";
import type { Status } from "./api";
import type { ViewId } from "./nodeModel";
import { useAccess } from "./useAccess";
import { tailscaleLabel, useTailscaleStatus } from "./useTailscaleStatus";
export function BoardSummary({ value, failure, openView }: { value: Status; failure: (error: unknown) => void; openView: (id: ViewId) => void }) {
const { access, loading } = useAccess(value.host.collected_at, failure);
const tailnet = useTailscaleStatus(failure, value.host.collected_at);
const networks = value.host.networks.filter(item => item.name !== "lo" && item.name !== "lo0" && item.up && item.addresses.length > 0);
return <SettingsCard title="Сводка БК" description="Подключения и доступ к бортовому компьютеру.">
<ResourceList aria-label="Сводка подключений БК">
<li><ResourceRow icon={<Icon name="network" />} title="Сеть" description="Включённые интерфейсы с назначенными адресами" status={<StatusBadge tone={networks.length ? "neutral" : "warning"}>{networks.length}</StatusBadge>} actions={<Button onClick={() => openView("network")}>Открыть сеть</Button>} /></li>
<li><ResourceRow icon={<Icon name="camera" />} title="USB-устройства" description="Обнаружены операционной системой" status={<StatusBadge>{value.host.usb_readable ? value.host.usb.length : "Нет сведений"}</StatusBadge>} actions={<Button onClick={() => openView("usb")}>Открыть USB</Button>} /></li>
<li><ResourceRow icon={tailnet.checked ? <Icon name="globe" /> : <ActivityIndicator size="compact" />} title="Tailscale" description="Частная сеть" status={<StatusBadge tone={tailnet.value?.online ? "success" : "neutral"}>{tailscaleLabel(tailnet.value, tailnet.checked)}</StatusBadge>} actions={<Button onClick={() => openView("tailscale")}>Открыть Tailscale</Button>} /></li>
<li><ResourceRow icon={loading ? <ActivityIndicator size="compact" /> : <Icon name="key" />} title="SSH" description="Удалённое обслуживание БК" metadata={access ? `Разрешено ключей: ${access.keys.length}` : undefined} status={<StatusBadge tone={access?.ssh_ready ? "success" : "neutral"}>{loading ? "Проверяем" : !access ? "Нет сведений" : access.ssh_ready ? "Отвечает локально" : "Не отвечает"}</StatusBadge>} actions={<Button onClick={() => openView("ssh")}>Настроить SSH</Button>} /></li>
</ResourceList>
</SettingsCard>;
}
+2 -2
View File
@@ -1,11 +1,11 @@
import { Button, Icon, ResourceList, ResourceRow, SettingsCard, StatusBadge } from "@nodedc/ui-react";
import type { Status } from "./api";
export function NetworkView({ value }: { value: Status }) {
return <div className="node-content"><SettingsCard title="Сетевые подключения" description="Интерфейсы и адреса этого компьютера.">
return <div className="node-content"><SettingsCard title="Сетевые подключения" description="Интерфейсы и адреса бортового компьютера.">
{value.host.networks.length === 0 ? <p className="node-note">Сетевые интерфейсы не обнаружены.</p> : <ResourceList aria-label="Сетевые интерфейсы">{value.host.networks.map(network => <li key={network.name}><ResourceRow icon={<Icon name="network" />} title={network.name} description={network.addresses.join(" · ") || "Нет назначенного адреса"} status={<StatusBadge tone={network.up ? "neutral" : "warning"}>{network.up ? "Включён" : "Выключен"}</StatusBadge>} /></li>)}</ResourceList>}
</SettingsCard><p className="node-note">Наличие адреса не подтверждает доступность другого компьютера или устройства.</p></div>;
}
export function DevicesView({ value }: { value: Status }) {
export function USBView({ value }: { value: Status }) {
return <div className="node-content"><SettingsCard title="Подключённые по USB" description="Устройства, которые обнаружила операционная система." actions={<StatusBadge>{value.host.usb.length}</StatusBadge>}>
{!value.host.usb_readable ? <p className="node-note" role="status">Не удалось получить список устройств. Повторите обновление.</p> : value.host.usb.length === 0 ? <p className="node-note">Подключите устройство к USB, затем обновите список.</p> : <ResourceList aria-label="USB-устройства">{value.host.usb.map(device => <li key={device.port}><ResourceRow icon={<Icon name="camera" />} title={device.product || `USB ${device.vendor}:${device.product_id}`} description={`Порт ${device.port} · ${device.speed_mbps ? `${device.speed_mbps} Мбит/с` : "Скорость недоступна"}`} metadata={`${device.vendor}:${device.product_id}`} status={<StatusBadge>Обнаружено</StatusBadge>} /></li>)}</ResourceList>}
</SettingsCard><p className="node-note">Обнаружение USB ещё не означает готовность к съёмке.</p></div>;
+9 -6
View File
@@ -1,9 +1,11 @@
import { useEffect, useState } from "react";
import { Button, SettingsCard, StatusBadge, TextField } from "@nodedc/ui-react";
import { Button, SettingsCard, TextField } from "@nodedc/ui-react";
import { request, type Status } from "./api";
import type { ViewId } from "./nodeModel";
import { BoardSummary } from "./BoardSummary";
const memory = (value: number | null) => value === null ? "Недоступно" : `${(value / 1048576).toFixed(1)} ГиБ`;
export function NodeOverview({ value, refresh, failure }: { value: Status; refresh: () => Promise<void>; failure: (error: unknown) => void }) {
export function NodeOverview({ value, refresh, failure, openView }: { value: Status; refresh: () => Promise<void>; failure: (error: unknown) => void; openView: (id: ViewId) => void }) {
const [name, setName] = useState(value.name);
const [saving, setSaving] = useState(false);
useEffect(() => setName(value.name), [value.name]);
@@ -12,16 +14,17 @@ export function NodeOverview({ value, refresh, failure }: { value: Status; refre
try { await request("/api/name", "PUT", { name }); await refresh(); } catch (error) { failure(error); } finally { setSaving(false); }
}
return <div className="node-content">
<SettingsCard title={value.name} eyebrow="БОРТОВОЙ КОМПЬЮТЕР" description={value.host.hostname} actions={<StatusBadge tone="success">Node работает</StatusBadge>}>
<SettingsCard title={value.name} eyebrow="БОРТОВОЙ КОМПЬЮТЕР" description={value.host.hostname}>
<dl className="node-facts"><div><dt>Операционная система</dt><dd>{value.host.os}</dd></div><div><dt>Архитектура</dt><dd>{value.host.architecture}</dd></div><div><dt>Логических процессоров</dt><dd>{value.host.cpus}</dd></div><div><dt>Оперативная память</dt><dd>{memory(value.host.memory_kib)}</dd></div><div><dt>Доступно памяти</dt><dd>{memory(value.host.available_kib)}</dd></div><div><dt>Mission Core Node</dt><dd>{value.version}</dd></div></dl>
</SettingsCard>
<SettingsCard title="Название компьютера" description="Название, по которому вы узнаёте этот борт в Node.">
<SettingsCard title="Название БК" description="Название бортового компьютера.">
<form onSubmit={save} className="node-form" aria-busy={saving}>
<TextField label="Название ноды" value={name} maxLength={64} disabled={saving} onChange={event => setName(event.target.value)} autoComplete="off" />
<TextField label="Название БК" value={name} maxLength={64} disabled={saving} onChange={event => setName(event.target.value)} autoComplete="off" />
<Button type="submit" disabled={saving || !name.trim() || name.trim() === value.name}>{saving ? "Сохраняем…" : "Сохранить"}</Button>
</form>
<dl className="node-facts"><div><dt>ID ноды</dt><dd>{value.node_id}</dd></div></dl>
<dl className="node-facts"><div><dt>ID БК</dt><dd>{value.node_id}</dd></div></dl>
</SettingsCard>
<BoardSummary value={value} failure={failure} openView={openView} />
<p className="node-note">Сведения обновлены {new Date(value.host.collected_at).toLocaleString("ru-RU")}.</p>
</div>;
}
-14
View File
@@ -1,14 +0,0 @@
import { ActivityIndicator, Button, Icon, ResourceList, ResourceRow, StatusBadge } from "@nodedc/ui-react";
import { useAccess } from "./useAccess";
export function SetupView({ revision, failure, openSSH, openTailnet }: { revision: string; failure: (error: unknown) => void; openSSH: () => void; openTailnet: () => void }) {
const { access, loading } = useAccess(revision, failure);
return <div className="node-content">
<p className="node-note">Компоненты для работы с этим компьютером и его обслуживания.</p>
<ResourceList aria-label="Компоненты системы">
<li><ResourceRow icon={<Icon name="activity" />} title="Mission Core Node" description="Локальная служба и приложение" status={<StatusBadge tone="success">Работает</StatusBadge>} /></li>
<li><ResourceRow icon={loading ? <ActivityIndicator size="compact" /> : <Icon name="key" />} title="OpenSSH Server" description="Доступ для обслуживания компьютера" status={<StatusBadge tone={access?.ssh_ready ? "success" : "neutral"}>{loading ? "Проверяем" : !access ? "Нет сведений" : access.ssh_ready ? "Отвечает локально" : "Не отвечает"}</StatusBadge>} actions={<Button onClick={openSSH}>Настроить доступ</Button>} /></li>
<li><ResourceRow icon={<Icon name="globe" />} title="Tailscale" description="Подключение к частной сети" actions={<Button onClick={openTailnet}>Открыть</Button>} /></li>
</ResourceList>
<p className="node-note">OpenSSH устанавливается вместе с Node. Ответ локального сервера не подтверждает подключение с другого компьютера.</p>
</div>;
}
+5 -29
View File
@@ -1,40 +1,16 @@
import { useEffect, useState } from "react";
import { ActivityIndicator, Button, SettingsCard, StatusBadge } from "@nodedc/ui-react";
import { APIError, desktopAction, networkSetupAvailable, request } from "./api";
import { desktopAction, networkSetupAvailable } from "./api";
import { tailscaleLabel, useTailscaleStatus } from "./useTailscaleStatus";
interface NetworkStatus { installed: boolean; state: string; online: boolean; addresses: string[] }
interface NetworkResult { action: string; ok: boolean; error?: string; browser_opened?: boolean }
const states: Record<string, string> = {
not_installed: "Не установлен", unavailable: "Служба недоступна", NeedsLogin: "Требуется вход",
NeedsMachineAuth: "Ожидаем разрешения администратора сети", Stopped: "Отключён", Starting: "Подключаемся", NoState: "Запускается",
};
export function TailnetAccess({ failure, revision: hostRevision }: { failure: (error: unknown) => void; revision: string }) {
const [checked, setChecked] = useState(false);
const [value, setValue] = useState<NetworkStatus | null>(null);
const [pending, setPending] = useState<string | null>(null);
const [notice, setNotice] = useState("");
const [revision, setRevision] = useState(0);
useEffect(() => {
let active = true;
let timer: ReturnType<typeof setTimeout>;
async function update() {
try {
const next = await request<NetworkStatus>("/api/network/tailscale");
if (active) { setValue(next); if (next.online) setNotice(""); }
} catch (error) {
if (active) {
setValue(null);
// Service upgrades invalidate local sessions. Use the existing
// application login surface instead of hiding 401 as provider failure.
if (error instanceof APIError && error.status === 401) failure(error);
}
} finally { if (active) { setChecked(true); timer = setTimeout(() => void update(), 5000); } }
}
void update();
return () => { active = false; clearTimeout(timer); };
}, [revision, hostRevision, failure]);
const { value, checked } = useTailscaleStatus(failure, hostRevision, revision);
useEffect(() => { if (value?.online) setNotice(""); }, [value]);
useEffect(() => {
function completed(event: Event) {
const result = (event as CustomEvent<NetworkResult>).detail;
@@ -54,7 +30,7 @@ export function TailnetAccess({ failure, revision: hostRevision }: { failure: (e
setPending(null); failure(new Error("Откройте установленное приложение Mission Core Node из меню Ubuntu."));
}
}
const label = !checked ? "Проверяем подключение" : !value ? "Состояние недоступно" : value.state === "Running" ? value.online ? "В сети" : "Нет связи с координатором" : states[value.state] ?? "Состояние неизвестно";
const label = tailscaleLabel(value, checked);
const canConnect = value?.installed && ["NeedsLogin", "Stopped", "unavailable"].includes(value.state);
const supported = networkSetupAvailable();
return <div className="node-content"><SettingsCard title="Tailscale" description="Частная сеть для удалённого доступа к борту" actions={<StatusBadge tone={value?.online ? "success" : "neutral"}>{label}</StatusBadge>}>
+6 -8
View File
@@ -8,9 +8,8 @@ import { desktopLogin } from "./api";
import { useNode } from "./useNode";
import { roots, views, type RootId, type ViewId } from "./nodeModel";
import { NodeOverview } from "./NodeOverview";
import { DevicesView, DiagnosticsView, NetworkView } from "./InventoryViews";
import { USBView, DiagnosticsView, NetworkView } from "./InventoryViews";
import { SystemAccess } from "./SystemAccess";
import { SetupView } from "./SetupView";
import { TailnetAccess } from "./TailnetAccess";
import "./node.css";
@@ -26,17 +25,16 @@ function App() {
const currentRoot = roots.find(item => item.id === root)!;
const currentView = views.find(item => item.id === workspace.activeView);
function openView(id: ViewId) { setAdding(false); setRoot(views.find(item => item.id === id)!.root); workspace.openView(id); }
function selectRoot(id: RootId) { openView(roots.find(item => item.id === id)!.first); }
const content = !value ? null : workspace.activeView === "overview" ? <NodeOverview value={value} refresh={refresh} failure={failure} />
function selectRoot(id: RootId) { const first = roots.find(item => item.id === id)!.first; if (first) openView(first); }
const content = !value ? null : workspace.activeView === "overview" ? <NodeOverview value={value} refresh={refresh} failure={failure} openView={openView} />
: workspace.activeView === "network" ? <NetworkView value={value} />
: workspace.activeView === "usb" ? <DevicesView value={value} />
: workspace.activeView === "usb" ? <USBView value={value} />
: workspace.activeView === "diagnostics" ? <DiagnosticsView value={value} />
: workspace.activeView === "setup" ? <SetupView revision={value.host.collected_at} failure={failure} openSSH={() => openView("ssh")} openTailnet={() => openView("tailscale")} />
: workspace.activeView === "tailscale" ? <TailnetAccess failure={failure} revision={value.host.collected_at} />
: workspace.activeView === "ssh" ? <SystemAccess revision={value.host.collected_at} failure={failure} success={node.success} adding={adding} closeAdd={() => setAdding(false)} /> : null;
return <>
<ApplicationShell data-nodedc-ui className="node-app" header={<AppHeader brandMonochrome brand={<img src="/nodedc-logo.svg" alt="NODE.DC" />} brandLabel="Mission Core Node"
center={<><HeaderWorkspace monochrome kind="mark" label={value?.name ?? "Mission Core Node"} imageUrl="/nodedc-mark.svg" /><HeaderNavigation label="Разделы бортового компьютера" value={root} items={roots.map(item => ({ value: item.id, label: item.label, disabled: !value }))} onChange={selectRoot} /></>}
center={<><HeaderWorkspace monochrome kind="mark" label={value?.name ?? "Mission Core Node"} imageUrl="/nodedc-mark.svg" /><HeaderNavigation label="Разделы бортового компьютера" value={root} items={roots.map(item => ({ value: item.id, label: item.label, disabled: !value || !item.first }))} onChange={selectRoot} /></>}
right={<HeaderProfile><UserProfileMenu displayName="Node" subtitle={value?.name ?? "Mission Core Node"} triggerLabel={null} actions={[{ id: "refresh", label: "Обновить сведения", icon: "refresh", onSelect: () => void refresh() }, { id: "theme", label: theme === "dark" ? "Светлая тема" : "Тёмная тема", icon: "eye", onSelect: () => { const next = theme === "dark" ? "light" : "dark"; setTheme(next); localStorage.setItem("node-theme", next); } }]} /></HeaderProfile>} />}
navigationOpen={!!value && workspace.navigationOpen} contentOpen={!!value && workspace.contentOpen} contentExpanded={workspace.contentExpanded}
navigation={<AdminNavigationPanel eyebrow="MISSION CORE NODE" title={currentRoot.label} onClose={workspace.closeNavigation} closeLabel="Закрыть навигацию" navigationLabel="Разделы выбранной вкладки"
@@ -45,7 +43,7 @@ function App() {
content={currentView && <ApplicationPanel title={currentView.label} eyebrow={currentRoot.label} expanded={workspace.contentExpanded} onExpandedChange={workspace.setContentExpanded} onClose={workspace.closeView}
utilityActions={[...(workspace.activeView === "ssh" ? [{ label: "Добавить доверенное устройство", icon: "plus" as const, onClick: () => setAdding(true) }] : []), { label: "Обновить сведения", icon: "refresh", disabled: pending, onClick: () => void refresh() }]}>{content}</ApplicationPanel>}
stage={<div className="node-stage" aria-busy={pending}>
{value ? <SettingsCard title={value.name} eyebrow="MISSION CORE NODE" description={`${value.host.os} · ${value.host.architecture}`}><div className="node-home-actions">{roots.map(item => <Button key={item.id} onClick={() => selectRoot(item.id)}>{item.label}</Button>)}</div></SettingsCard> : <SettingsCard className="node-entry" title={pending ? "Подключаемся к ноде" : locked ? "Вход в Mission Core Node" : "Нода недоступна"}>
{value ? <SettingsCard title={value.name} eyebrow="MISSION CORE NODE" description={`${value.host.os} · ${value.host.architecture}`}><div className="node-home-actions">{roots.map(item => <Button key={item.id} disabled={!item.first} onClick={() => selectRoot(item.id)}>{item.label}</Button>)}</div></SettingsCard> : <SettingsCard className="node-entry" title={pending ? "Подключаемся к ноде" : locked ? "Вход в Mission Core Node" : "Нода недоступна"}>
{pending ? <ActivityIndicator label="Получение сведений о ноде" /> : <p className="node-note">{locked ? "Подтвердите доступ в системном окне Ubuntu." : "Не удалось связаться со службой. Повторите подключение."}</p>}
<Button disabled={pending} onClick={() => { if (locked) { if (!desktopLogin()) failure(new Error("Откройте установленное приложение Mission Core Node из меню Ubuntu.")); } else void refresh(); }}>{locked ? "Войти" : "Повторить подключение"}</Button>
</SettingsCard>}
+9 -11
View File
@@ -1,17 +1,15 @@
import type { IconName } from "@nodedc/ui-react";
export type RootId = "system" | "devices" | "remote";
export type ViewId = "overview" | "network" | "setup" | "diagnostics" | "usb" | "tailscale" | "ssh";
export const roots: { id: RootId; label: string; first: ViewId }[] = [
{ id: "system", label: "Состояние системы", first: "overview" },
{ id: "devices", label: "Устройства", first: "usb" },
{ id: "remote", label: "Удалённый контроль", first: "tailscale" },
export type RootId = "system" | "devices";
export type ViewId = "overview" | "network" | "diagnostics" | "usb" | "tailscale" | "ssh";
export const roots: { id: RootId; label: string; first: ViewId | null }[] = [
{ id: "system", label: "Система", first: "overview" },
{ id: "devices", label: "Устройства", first: null },
];
export const views: { id: ViewId; root: RootId; label: string; icon: IconName }[] = [
{ id: "overview", root: "system", label: "Обзор компьютера", icon: "activity" },
{ id: "overview", root: "system", label: "Обзор БК", icon: "activity" },
{ id: "network", root: "system", label: "Сеть", icon: "network" },
{ id: "setup", root: "system", label: "Конфигурация системы", icon: "settings" },
{ id: "usb", root: "system", label: "USB-устройства", icon: "camera" },
{ id: "tailscale", root: "system", label: "Tailscale", icon: "globe" },
{ id: "ssh", root: "system", label: "SSH · доверенные устройства", icon: "key" },
{ id: "diagnostics", root: "system", label: "Диагностика", icon: "clipboard" },
{ id: "usb", root: "devices", label: "USB-устройства", icon: "camera" },
{ id: "tailscale", root: "remote", label: "Tailscale", icon: "globe" },
{ id: "ssh", root: "remote", label: "SSH · доверенные устройства", icon: "key" },
];
@@ -0,0 +1,36 @@
import { useEffect, useState } from "react";
import { APIError, request } from "./api";
export interface NetworkStatus { installed: boolean; state: string; online: boolean; addresses: string[] }
const states: Record<string, string> = {
not_installed: "Не установлен", unavailable: "Служба недоступна", NeedsLogin: "Требуется вход",
NeedsMachineAuth: "Ожидаем разрешения администратора сети", Stopped: "Отключён", Starting: "Подключаемся", NoState: "Запускается",
};
export function tailscaleLabel(value: NetworkStatus | null, checked: boolean): string {
return !checked ? "Проверяем подключение" : !value ? "Состояние недоступно" : value.state === "Running" ? value.online ? "В сети" : "Нет связи с координатором" : states[value.state] ?? "Состояние неизвестно";
}
// Shared by the overview and provider page so session expiry and network states
// retain the same meaning. Only the mounted view owns a polling loop.
export function useTailscaleStatus(failure: (error: unknown) => void, hostRevision: string, revision = 0) {
const [checked, setChecked] = useState(false);
const [value, setValue] = useState<NetworkStatus | null>(null);
useEffect(() => {
let active = true;
let timer: ReturnType<typeof setTimeout>;
async function update() {
try {
const next = await request<NetworkStatus>("/api/network/tailscale");
if (active) setValue(next);
} catch (error) {
if (active) {
setValue(null);
if (error instanceof APIError && error.status === 401) failure(error);
}
} finally { if (active) { setChecked(true); timer = setTimeout(() => void update(), 5000); } }
}
void update();
return () => { active = false; clearTimeout(timer); };
}, [revision, hostRevision, failure]);
return { value, checked };
}