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 };
}
+3
View File
@@ -1,5 +1,8 @@
# Node 0.3 — структура настольного приложения
История композиции 0.3.0. Последующее уточнение владельца, перенос системных
функций в 0.3.1 и план сопряжения: [03_SYSTEM_AND_VEHICLE_PAIRING_SURFACE.md](03_SYSTEM_AND_VEHICLE_PAIRING_SURFACE.md).
Решение владельца от 2026-09-05: привести доступные функции Node к визуальной
системе действующего Mission Core, повторно использовать шапку, навигацию и
длинные строки AI Inference. Это согласование состава вкладок и композиции.
@@ -0,0 +1,179 @@
# Система БК и добавление аппарата в Mission Core
Уточнение владельца от 2026-09-05 к MISSIONCOR-76. Базовое тело Ops не
переписывается. Этот документ фиксирует согласованную композицию следующего
приращения и порядок реализации; наличие описанного действия не означает,
что его backend уже реализован. История 0.3.0 остаётся в документе 02.
## Задача и границы сущностей
Оператор настраивает бортовой компьютер (БК), видит фактическое состояние его
системы и связывает его с аппаратом в Core. Это настройка при первом запуске
и обслуживание, а не лабораторный отчёт. После сопряжения оператор открывает
аппарат в парке и работает с его реально зарегистрированными устройствами.
БК — вычислительный корень подключений этого аппарата. USB-контроллер, hub,
клавиатура и найденная камера относятся к системной инвентаризации. Запись USB
не является регистрацией сенсора, доказательством его identity, набором
capabilities или готовностью к съёмке. Устройства — объекты драйверов,
подключённые и подтверждённые через принятый контракт Plugin SDK v0alpha2.
## Node: согласованное размещение
- Верхний раздел «Система» вместо «Состояние системы».
- «Обзор БК»: ОС, архитектура, CPU/RAM, имя, сводка фактических подключений.
Название редактируется в блоке «Название БК». Самоподтверждающий зелёный
статус «Node работает» удаляется. Сведения имеют время получения.
- «Сеть»: интерфейсы и адреса БК.
- «USB-устройства»: вся системная USB-инвентаризация перенесена сюда.
- «Tailscale» и «SSH · доверенные устройства» принадлежат «Системе».
Отдельного верхнего «Удалённого контроля» больше нет.
- «Mission Core»: выбранное место для создания приглашения и обслуживания
привязки к Core. Подключение Core отделено от настройки сетевого транспорта.
- «Диагностика»: существующий очищенный отчёт и замечания инвентаризации.
Это ограниченная диагностика, не полный support bundle всех будущих модулей.
- Отдельная «Конфигурация системы» удаляется. Состояния SSH/Tailscale и
оборудования входят в сводку; операции остаются в своих подразделах.
- Верхние «Устройства» предназначены для драйверных K1, D455 и следующих
сенсоров. Системный USB-список там не дублируется. В этом приращении
вкладка недоступна до появления рабочего подключения через драйвер;
пустой экран и фиктивные зарегистрированные устройства не создаются.
Отдельный раздел телеметрии и VPN-профили сейчас не вводятся: владелец оставил
их дальнейшую композицию на следующий этап. Текущий обзор показывает
измеренные сведения, не имитирует будущую телеметрию аппарата.
Выбрана системная композиция вместо трёх прежних верхних разделов: установка
сети и доступ относятся к одному БК, а сенсоры имеют отдельный жизненный цикл.
Альтернатива «всё внутри Сети» отклонена для сопряжения: частная достижимость
и доверенная привязка к Core являются разными фактами. Альтернатива переноса
всех операций в обзор также отклонена: обзор остаётся компактной сводкой и
ведёт к соответствующей настройке. Это изменение композиции класса B,
прямо согласованное владельцем в текущем сообщении, без нового общего контрола.
## Обзор БК: состояния и доказательства
Обзор сохраняет сведения об ОС, архитектуре, CPU/RAM и отдельную форму имени.
Сводка содержит сеть (включённые интерфейсы с адресами, без loopback), число
обнаруженных USB-устройств, фактическое состояние Tailscale, локальный ответ
SSH и количество разрешённых ключей. Действия открывают нужный подраздел.
Количество ключей не является числом online операторов. Ответ локального
SSH не обещает удалённый вход. Наличие сетевого адреса не обещает связь с Core.
Во время запроса — загрузка, при ошибке — отсутствие актуальных сведений,
а не зелёная готовность. Tailscale использует один общий hook статуса с той же
проверкой истёкшего сеанса и polling, что и его страница. Старый сеанс
возвращает на штатный вход Node. Диагностические сведения остаются snapshot
с временем получения и ручным обновлением; новая телеметрия не объявляется.
## Core: Парк → Аппараты
Название «Аппараты» сохраняется. Текущий catalog в productModel.ts с текстами
о готовности/контрактах заменяется настоящим реестром вместе с backend.
«Состояние контура» не поглощает реестр. Прежнее «Подключение» K1 остаётся
отдельным legacy-путём и не используется скрыто для устройств удалённого БК.
В шапке реестра плюс «Добавить аппарат». Канонический modal Window содержит
выбор варианта, от которого зависит состав формы:
1. С бортовым компьютером — название аппарата и код приглашения, созданный
в Node. Успешное подтверждение связывает аппарат с устойчивой identity БК.
2. Прямое подключение / управление с пульта — регистрация через отдельный
реально поддерживаемый адаптер и его поля. Приглашение Node для этого
варианта не требуется; вымышленный БК не создаётся. Телеметрия и позиция
появляются только если их предоставляет данный адаптер.
Упомянутые владельцем «автономный» и «с пульта» фиксируют разные сценарии
подключения. Само наличие БК не доказывает автономное движение: аппарат с БК
может управляться дистанционно. Наземный беспилотный аппарат (UGV), воздушный
аппарат и другие классы описывают платформу; способ подключения и возможности
автономности не подменяют этот класс. Первая реализуемая форма — «С бортовым
компьютером»; неподдерживаемая прямая интеграция не выдаётся за рабочую.
После успешного сопряжения аппарат появляется длинной строкой ResourceRow.
Глазик с подписью «Открыть аппарат» открывает постоянную конфигурацию выбранного
аппарата в ApplicationPanel, а не длинную модальную форму. Детали показывают
название/класс, привязанный БК, актуальность связи, состав устройств и доступные
действия. При потере сети строка не исчезает: конфигурация сохранена,
последние данные помечены временем, недоступные команды заблокированы.
Дерево первой интеграции: аппарат → БК → реально зарегистрированные D455/K1.
Конкретные устройства отображаются по факту появления и регистрации, а не
заранее как два обязательных online-объекта. Открытие сенсора ведёт в
предметную конфигурацию и принятые слои данных, не выполняет команду съёмки.
## Сопряжение: обязательный полный сценарий P1
Node: Система → Mission Core → создать приглашение → скопировать код через UI.
Показываются срок действия и возможность отменить приглашение. Уже привязанный
БК показывает соответствующий Core и состояние связи; повторная привязка
проходит явный сценарий. Секрет не сохраняется в истории URL, логах и отчёте.
Core: Парк → Аппараты → плюс → с БК → вставить код → подтвердить сведения
выбранного БК → связать. Запись в реестре и подтверждение доверия должны
согласованно переживать обрыв, повторный запрос, рестарт и отмену.
Сохраняются базовые одноразовость, expiry, pin публичной identity, проверка
версии, ограничение окна сопряжения, один владелец и подтверждённая привязка.
Ни IP/hostname, ни членство в Tailscale не являются identity или authority.
Код не является адресом для произвольного сетевого запроса: private endpoint
и перенаправления проверяются до соединения. После bootstrap Node использует
исходящий аутентифицированный канал с mTLS; UI не передаёт команды через SSH.
Обязательны replay/expiry/conflict, отмена, partial binding recovery,
revoke/re-pair, ротация сертификатов и запрет потери identity при ремонте.
Жизненные циклы enrollment/connectivity/acquisition, revisions, operations,
capabilities, streams и evidence используют семантику Plugin SDK v0alpha2;
параллельная runtime-онтология для нового экрана не вводится.
## Устройства и authority
Управление K1 из Core идёт через runtime выбранного БК, его Linux BLE/network
adapter и K1 Bridge в общей с БК локальной сети. Близость к операторскому Mac
не нужна. Включённый K1 должен быть достижим для БК и подтвердить identity и
совместимый профиль. Состояние «рядом и включён» само по себе не даёт права
команды. Не происходит скрытого fallback в legacy runtime Mac.
Локальный драйвер владеет command session и сырой записью. Открытие глазика
или reconnect не повторяют START/STOP. Unknown result сохраняется как
неизвестный результат до разрешённого восстановления. Локальная запись
продолжается независимо от окна, Core и WAN. Preview/WebRTC не заменяет raw.
## Порядок реализации и приёмка
1. Текущее приращение: перенос существующих страниц в «Систему», обзор БК,
переименование полей, устранение отдельной конфигурации и self-status.
Сборка .deb и GUI-проверка на физическом Mini.
2. Закрыть оставшиеся системные проверки P1 по предыдущему отчёту:
чистая установка, GUI upgrade/repair, reboot и SSH lifecycle без консоли.
3. Реализовать backend сопряжения и реестр Core; одновременно выпустить
рабочие Node «Mission Core», форму добавления и детали аппарата. Проверить
позитивные и негативные сценарии через два интерфейса.
4. P2: драйвер D455, фактические profiles/options/streams, raw recording,
live-слои и открываемая в Core запись. Затем P3: K1 Linux Bridge.
5. Далее совместный P4 и полный P5 восстановления. Вариант прямого аппарата
включается после появления конкретного адаптера и собственной приёмки.
Используемые exports: AppHeader/HeaderNavigation, ApplicationShell/Panel,
AdminNavigationPanel, SettingsCard, ResourceRow/List, Button/IconButton, Icon,
Select/TextField/TextAreaField, Window/ConfirmationModal, StatusBadge,
ActivityIndicator, ToastStack. Новых визуальных примитивов и копий DG нет.
Для текущей приёмки: USB доступен из «Системы»; прежних remote/setup маршрутов
нет; имя БК сохраняется; сводка не объявляет подключение камеры по USB готовым
драйвером; Tailscale/SSH открываются из сводки; плюс SSH работает после переноса;
темы/узкое окно и recovery сеанса не нарушены. Для будущего сопряжения:
добавленный аппарат сохраняется после reload/restart и открывает именно
свой БК/устройства, без дубликатов и зависимости от legacy-коннектора.
## Проверка приращения 0.3.1
Через интерфейс изолированной QA-ноды с production API проверены сохранение
нового имени БК и его отображение в shell; USB в «Системе»; переходы из сводки
в USB/Tailscale; недоступность Tailscale и восстановление «В сети»; SSH и его
плюс; открытие формы и Escape с возвратом фокуса. Проверены светлая/тёмная темы,
сводка и её действия в окне 720×800. После рестарта QA-службы именно из обзора
приложение возвращается на штатный вход, без ложного статуса Tailscale.
Typecheck, production build и существующий UI boundary прошли. Это GUI-проверка
перестановки функций, не закрытие приёмки чистой установки или сопряжения.
Аппаратная установка и её результат фиксируются отдельно в отчёте Ops.