feat(node): consolidate board system views and plan fleet pairing
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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{}}
|
||||
|
||||
@@ -13,7 +13,7 @@ import tarfile
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
VERSION = "0.3.0"
|
||||
VERSION = "0.3.1"
|
||||
BRAND_SHA256 = "8bfee8ca9f98e0db48d98aae3af4b32493b8593e18b064a0239d513d824182af"
|
||||
|
||||
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>}>
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user