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

This commit is contained in:
DCCONSTRUCTIONS
2026-09-05 21:16:13 +03:00
parent fc545f8440
commit e82d012907
29 changed files with 2442 additions and 19 deletions
@@ -0,0 +1,70 @@
import { useEffect, useState } from "react";
import { ActivityIndicator, Button, ConfirmationModal, Select, SettingsCard, StatusBadge, TextAreaField } from "@nodedc/ui-react";
import { request } from "./api";
interface CoreState {
phase: "unpaired" | "inviting" | "pending" | "paired" | "revoked";
connection: string; node_id: string; last_seen: number; notice: string; addresses: string[];
invitation?: { id: string; endpoint: string; expires_at: number };
binding?: { core_id: string; core_name: string; endpoint: string };
}
export function CoreConnectionView({ failure }: { failure: (error: unknown) => void }) {
const [value, setValue] = useState<CoreState | null>(null);
const [address, setAddress] = useState("");
const [code, setCode] = useState("");
const [pending, setPending] = useState(false);
const [remove, setRemove] = useState(false);
const [notice, setNotice] = useState("");
const [available, setAvailable] = useState(true);
async function refresh() { const next = await request<CoreState>("/api/core"); setValue(next); setAvailable(true); return next; }
useEffect(() => {
let active = true;
let timer: ReturnType<typeof setTimeout>;
async function poll() {
try { const next = await request<CoreState>("/api/core"); if (active) { setValue(next); setAvailable(true); } }
catch (error) { if (active) { setAvailable(false); failure(error); } }
finally { if (active) timer = setTimeout(poll, 3000); }
}
void poll();
return () => { active = false; clearTimeout(timer); };
}, [failure]);
useEffect(() => {
if (value) {
setAddress(current => value.addresses.includes(current) ? current : value.addresses[0] ?? "");
if (value.phase !== "inviting") setCode("");
}
}, [value]);
async function create() {
if (pending) return;
setPending(true); setNotice(""); setCode("");
try {
const result = await request<{ code: string }>("/api/core/invitation", "POST", { address });
await refresh(); setCode(result.code);
} catch (error) { failure(error); } finally { setPending(false); }
}
const paired = value?.phase === "paired";
const label = !available ? "Состояние недоступно" : paired ? value.connection === "online" ? "В сети" : "Нет соединения с Core" : value?.phase === "pending" ? "Подтверждаем привязку" : value?.phase === "inviting" ? "Приглашение открыто" : value?.phase === "revoked" ? "Доверие отозвано" : "Не подключён";
return <div className="node-content"><SettingsCard title="Mission Core" description="Подключение бортового компьютера к аппарату в парке" actions={<StatusBadge tone={available && paired && value.connection === "online" ? "success" : "neutral"}>{label}</StatusBadge>}>
{!value ? <ActivityIndicator label="Получаем состояние подключения" /> : <>
<dl className="node-facts"><div><dt>Идентификатор БК</dt><dd>{value.node_id}</dd></div>
{value.binding && <><div><dt>Core</dt><dd>{value.binding.core_name}</dd></div><div><dt>Идентификатор Core</dt><dd>{value.binding.core_id}</dd></div><div><dt>Частный адрес Core</dt><dd>{value.binding.endpoint}</dd></div></>}
{value.last_seen > 0 && <div><dt>Последняя связь</dt><dd>{new Date(value.last_seen * 1000).toLocaleString("ru-RU")}</dd></div>}
</dl>
{value.notice && <p className="node-note" role="status">{value.notice}</p>}
{paired ? <p className="node-note">БК сохраняет привязку при перезапуске и восстанавливает соединение автоматически. Для подключения к другому Core сначала отзовите текущую привязку.</p> : value.phase === "pending" ? <ActivityIndicator label="Core принимает приглашение. Дождитесь подтверждения связи." /> : <div className="node-form">
<p className="node-note">Выберите адрес, доступный компьютеру с Mission Core: в общей локальной сети или Tailscale. Создайте приглашение и вставьте его в Core: «Парк Аппараты Добавить аппарат».</p>
<Select label="Адрес БК для подключения" value={address} options={value.addresses.map(item => ({ value: item, label: item }))} onChange={setAddress} disabled={pending || !available} />
{value.addresses.length === 0 && <p className="node-note">Подключите БК к частной сети. Доступные адреса появятся автоматически.</p>}
<Button disabled={pending || !available || !address} onClick={() => void create()}>{pending ? "Создаём…" : value.phase === "inviting" ? "Создать новое приглашение" : "Создать приглашение"}</Button>
{value.invitation && <p className="node-note">Действует до {new Date(value.invitation.expires_at * 1000).toLocaleTimeString("ru-RU")}. Приглашение позволяет одному Core получить доверие этого БК. Передавайте его только нужному оператору.</p>}
{code && <><TextAreaField label="Код приглашения" value={code} readOnly rows={5} spellCheck={false} onFocus={event => event.target.select()} /><Button onClick={async () => { try { await navigator.clipboard.writeText(code); setNotice("Код скопирован"); } catch { setNotice("Выделите код в поле и скопируйте его сочетанием Ctrl+C."); } }}>Скопировать код</Button></>}
{value.phase === "inviting" && !code && <p className="node-note">Код показывается только при создании. Создайте новое приглашение, если он не сохранился у вас.</p>}
</div>}
{value.phase !== "unpaired" && <Button disabled={pending || !available} onClick={() => setRemove(true)}>{value.binding ? "Отозвать привязку" : "Отменить приглашение"}</Button>}
{notice && <p role="status" className="node-note">{notice}</p>}
</>}
</SettingsCard>
<ConfirmationModal open={remove} title={value?.binding ? "Отозвать привязку к Core?" : "Отменить приглашение?"} description={value?.binding ? "Этот БК прекратит соединение с Core. Аппарат останется в его реестре; отзыв будет доставлен при доступности сети." : "Этот код больше нельзя будет использовать. Вы сможете создать новый."} confirmLabel="Отозвать" cancelLabel="Отмена" danger onClose={() => setRemove(false)} onConfirm={async () => { try { await request("/api/core", "DELETE"); setCode(""); setNotice(""); await refresh(); setRemove(false); } catch (error) { failure(error); throw error; } }} />
</div>;
}
+2
View File
@@ -13,6 +13,7 @@ import { SystemAccess } from "./SystemAccess";
import { TailnetAccess } from "./TailnetAccess";
import { useEnvironment } from "./useEnvironment";
import { EnvironmentView } from "./EnvironmentView";
import { CoreConnectionView } from "./CoreConnectionView";
import "./node.css";
function App() {
@@ -33,6 +34,7 @@ function App() {
const content = !value ? null : workspace.activeView === "environment" ? <EnvironmentView environment={environment} failure={failure} success={node.success} /> : workspace.activeView === "overview" ? <NodeOverview value={value} refresh={refresh} failure={failure} openView={openView} />
: workspace.activeView === "network" ? <NetworkView value={value} />
: workspace.activeView === "usb" ? <USBView value={value} />
: workspace.activeView === "core" ? <CoreConnectionView failure={failure} />
: workspace.activeView === "diagnostics" ? <DiagnosticsView value={value} />
: 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;
+2 -1
View File
@@ -1,6 +1,6 @@
import type { IconName } from "@nodedc/ui-react";
export type RootId = "system" | "devices";
export type ViewId = "environment" | "overview" | "network" | "diagnostics" | "usb" | "tailscale" | "ssh";
export type ViewId = "environment" | "overview" | "network" | "diagnostics" | "usb" | "tailscale" | "ssh" | "core";
export const roots: { id: RootId; label: string; first: ViewId | null }[] = [
{ id: "system", label: "Система", first: "environment" },
{ id: "devices", label: "Устройства", first: null },
@@ -10,6 +10,7 @@ export const views: { id: ViewId; root: RootId; label: string; icon: IconName }[
{ id: "overview", root: "system", label: "Обзор БК", icon: "activity" },
{ id: "network", root: "system", label: "Сеть", icon: "network" },
{ id: "usb", root: "system", label: "USB-устройства", icon: "camera" },
{ id: "core", root: "system", label: "Mission Core", icon: "network" },
{ id: "tailscale", root: "system", label: "Tailscale", icon: "globe" },
{ id: "ssh", root: "system", label: "SSH · доверенные устройства", icon: "key" },
{ id: "diagnostics", root: "system", label: "Диагностика", icon: "clipboard" },