feat(node): pair onboard computers with the Core fleet through UI
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user