feat(node): package Ubuntu desktop setup and trusted access

This commit is contained in:
DCCONSTRUCTIONS
2026-09-05 17:27:59 +03:00
parent 57f2537af3
commit d696842f5d
52 changed files with 4457 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
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="Интерфейсы и адреса этого компьютера.">
{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 }) {
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>;
}
export function DiagnosticsView({ value }: { value: Status }) {
return <div className="node-content"><SettingsCard title="Диагностический отчёт" description="Сведения о системе и доступности компонентов без имени компьютера, сетевых адресов и ID ноды.">
<Button icon={<Icon name="download" />} onClick={() => location.assign("/api/report")}>Скачать отчёт</Button>
<p className="node-note">Сведения получены {new Date(value.host.collected_at).toLocaleString("ru-RU")}.</p>
</SettingsCard><SettingsCard title="Замечания системы">{value.host.warnings.length ? value.host.warnings.map(warning => <p className="node-note" key={warning}><StatusBadge tone="warning">{warning}</StatusBadge></p>) : <p className="node-note">При последнем сборе сведений замечаний нет.</p>}</SettingsCard></div>;
}
+27
View File
@@ -0,0 +1,27 @@
import { useEffect, useState } from "react";
import { Button, SettingsCard, StatusBadge, TextField } from "@nodedc/ui-react";
import { request, type Status } from "./api";
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 }) {
const [name, setName] = useState(value.name);
const [saving, setSaving] = useState(false);
useEffect(() => setName(value.name), [value.name]);
async function save(event: React.FormEvent) {
event.preventDefault(); if (saving) return; setSaving(true);
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>}>
<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.">
<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" />
<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>
</SettingsCard>
<p className="node-note">Сведения обновлены {new Date(value.host.collected_at).toLocaleString("ru-RU")}.</p>
</div>;
}
+14
View File
@@ -0,0 +1,14 @@
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>;
}
+43
View File
@@ -0,0 +1,43 @@
import { useEffect, useState } from "react";
import { ActivityIndicator, Button, ConfirmationModal, Icon, IconButton, ResourceList, ResourceRow, Select, StatusBadge, TextAreaField, TextField, Window, WindowFooterActions } from "@nodedc/ui-react";
import { request } from "./api";
import { useAccess, type AccessKey } from "./useAccess";
export function SystemAccess({ revision, failure, success, adding, closeAdd }: { revision: string; failure: (error: unknown) => void; success: (message: string) => void; adding: boolean; closeAdd: () => void }) {
const { access, loading, refresh } = useAccess(revision, failure);
const [user, setUser] = useState("");
const [label, setLabel] = useState("");
const [key, setKey] = useState("");
const [pending, setPending] = useState(false);
const [remove, setRemove] = useState<AccessKey | null>(null);
const [detail, setDetail] = useState<AccessKey | null>(null);
useEffect(() => { if (access) setUser(current => access.users.includes(current) ? current : access.users[0] ?? ""); }, [access]);
useEffect(() => { if (!adding) { setLabel(""); setKey(""); } }, [adding]);
async function add(event: React.FormEvent) {
event.preventDefault(); if (pending) return; setPending(true);
try { await request("/api/access", "POST", { user, label, key: key.trim() }); await refresh(); closeAdd(); success("Доступ устройства добавлен"); }
catch (error) { failure(error); } finally { setPending(false); }
}
return <div className="node-content">
<div className="node-section-heading"><p className="node-note">Компьютеры, которым разрешён вход по SSH через Node.</p><StatusBadge tone={access?.ssh_ready ? "success" : "neutral"}>{loading ? "Проверяем SSH" : !access ? "Нет сведений" : access.ssh_ready ? "SSH отвечает локально" : "SSH не отвечает"}</StatusBadge></div>
{loading && !access ? <ActivityIndicator label="Получение доверенных устройств" /> : !access ? <p className="node-note">Не удалось загрузить список. Повторите обновление.</p> : <>
{access.keys.length === 0 ? <p className="node-note">Доверенных устройств пока нет. Нажмите плюс в шапке, чтобы добавить компьютер.</p> : <ResourceList aria-label="Доверенные SSH-устройства">{access.keys.map(item => <li key={`${item.user}:${item.id}`}><ResourceRow icon={<Icon name="key" />} title={item.label} description={`Пользователь Ubuntu: ${item.user}`} metadata={<span title={item.id}>{item.id}</span>} status={<StatusBadge>Доступ разрешён</StatusBadge>} actions={<><IconButton label={`Сведения: ${item.label}`} onClick={() => setDetail(item)}><Icon name="eye" /></IconButton><IconButton label={`Отозвать доступ: ${item.label}`} onClick={() => setRemove(item)}><Icon name="trash" /></IconButton></>} /></li>)}</ResourceList>}
<p className="node-note">Разрешённый ключ не означает, что компьютер сейчас подключён. Отзыв закрывает новые подключения через Node; открытые сеансы и отдельно настроенные способы входа Ubuntu сохраняются.</p>
</>}
<Window open={adding} title="Добавить доверенное устройство" subtitle="Разрешить компьютеру подключаться к этому борту по SSH" size="md" closeOnBackdrop={false} closeOnEscape={!pending} onClose={() => { if (!pending) closeAdd(); }} footer={<WindowFooterActions><Button disabled={pending} onClick={closeAdd}>Отмена</Button><Button type="submit" form="node-add-ssh" disabled={pending || !access || !key.trim() || !label.trim() || !user}>{pending ? "Добавляем…" : "Разрешить доступ"}</Button></WindowFooterActions>}>
{!access ? <p className="node-note">{loading ? "Получаем пользователей Ubuntu…" : "Не удалось получить пользователей. Закройте окно и обновите список."}</p> : access.users.length === 0 ? <p className="node-note">Не найдены администраторы Ubuntu. Добавьте пользователя в настройках системы.</p> : <form id="node-add-ssh" className="node-form" onSubmit={add} aria-busy={pending}>
<TextField label="Название устройства" placeholder="Например, ноутбук оператора" value={label} maxLength={64} disabled={pending} onChange={event => setLabel(event.target.value)} autoComplete="off" />
<Select label="Пользователь Ubuntu" value={user} options={access.users.map(value => ({ value, label: value }))} onChange={setUser} disabled={pending} />
<TextAreaField label="Публичный SSH-ключ Ed25519" placeholder="ssh-ed25519 …" value={key} rows={4} disabled={pending} onChange={event => setKey(event.target.value)} autoComplete="off" spellCheck={false} />
<p className="node-note">Вставьте содержимое публичного файла .pub с доверенного компьютера. Приватный ключ остаётся на том компьютере. Этот доступ действует в частной сети.</p>
</form>}
</Window>
<Window open={detail !== null} title={detail?.label ?? "Доверенное устройство"} subtitle="SSH-доступ к этому борту" onClose={() => setDetail(null)}>
{detail && <dl className="node-facts"><div><dt>Пользователь Ubuntu</dt><dd>{detail.user}</dd></div><div><dt>Отпечаток</dt><dd>{detail.id}</dd></div><div><dt>Публичный ключ</dt><dd>{detail.public_key}</dd></div></dl>}
</Window>
<ConfirmationModal open={remove !== null} title="Отозвать доступ устройства?" description={`Новые подключения через Node с устройства «${remove?.label ?? ""}» станут недоступны. Открытые сеансы продолжат работать.`} confirmLabel="Отозвать" cancelLabel="Отмена" pendingLabel="Отзываем…" danger onClose={() => setRemove(null)} onConfirm={async () => {
if (!remove) return;
try { await request("/api/access", "DELETE", { user: remove.user, id: remove.id }); await refresh(); setRemove(null); success("Доступ устройства отозван"); } catch (error) { failure(error); throw error; }
}} />
</div>;
}
+72
View File
@@ -0,0 +1,72 @@
import { useEffect, useState } from "react";
import { ActivityIndicator, Button, SettingsCard, StatusBadge } from "@nodedc/ui-react";
import { APIError, desktopAction, networkSetupAvailable, request } from "./api";
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]);
useEffect(() => {
function completed(event: Event) {
const result = (event as CustomEvent<NetworkResult>).detail;
if (!result || !["install-tailscale", "connect-tailscale"].includes(result.action)) return;
setPending(null);
if (!result.ok) failure(new Error(result.error ?? "Настройка не завершена. Повторите действие."));
setNotice(result.browser_opened ? "Завершите вход в открывшемся браузере. Состояние здесь обновится автоматически." : "");
setRevision(value => value + 1);
}
window.addEventListener("mission-core-network-result", completed);
return () => window.removeEventListener("mission-core-network-result", completed);
}, [failure]);
function perform(action: "install-tailscale" | "connect-tailscale") {
if (pending) return;
setNotice(""); setPending(action);
if (!desktopAction(action)) {
setPending(null); failure(new Error("Откройте установленное приложение Mission Core Node из меню Ubuntu."));
}
}
const label = !checked ? "Проверяем подключение" : !value ? "Состояние недоступно" : value.state === "Running" ? value.online ? "В сети" : "Нет связи с координатором" : states[value.state] ?? "Состояние неизвестно";
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>}>
<p className="node-note">Частная сеть для доступа к борту с другого компьютера. Войдите в ту же сеть Tailscale, что и на компьютере оператора. При первом подключении настройки локальной сети сохраняются.</p>
{!checked ? <ActivityIndicator label="Проверяем подключение Tailscale" /> : !value ? <p className="node-note">Не удалось получить состояние. Повторная проверка выполняется автоматически.</p> : <>
{!value.installed && <><p className="node-note">Приложение загрузит проверенный пакет Tailscale и включит его службу. Понадобятся интернет и системное подтверждение Ubuntu.</p><Button disabled={pending !== null || !supported} onClick={() => perform("install-tailscale")}>Установить Tailscale</Button></>}
{canConnect && <Button disabled={pending !== null || !supported} onClick={() => perform("connect-tailscale")}>{value.state === "NeedsLogin" ? "Войти в Tailscale" : "Подключить Tailscale"}</Button>}
{value.state === "NeedsMachineAuth" && <p className="node-note">Администратор вашей сети должен разрешить подключение этого компьютера в Tailscale.</p>}
{value.addresses.length > 0 && <dl className="node-facts"><div><dt>Адреса в Tailscale</dt><dd>{value.addresses.join(" · ")}</dd></div></dl>}
</>}
{!supported && <p className="node-note">Для настройки сети закройте окно и заново откройте установленное приложение из меню Ubuntu. После обновления пакета требуется перезапуск окна.</p>}
{pending && <ActivityIndicator label={pending === "install-tailscale" ? "Подтвердите установку в системном окне Ubuntu. Загружаем и устанавливаем компонент…" : "Подтвердите действие в системном окне Ubuntu. Проверяем подключение…"} />}
{notice && <p className="node-note" role="status">{notice}</p>}
</SettingsCard></div>;
}
+41
View File
@@ -0,0 +1,41 @@
export interface Status {
version: string; node_id: string; name: string;
host: {
collected_at: string; hostname: string; os: string; architecture: string; cpus: number;
memory_kib: number | null; available_kib: number | null;
networks: { name: string; up: boolean; addresses: string[] }[];
usb: { port: string; vendor: string; product_id: string; product: string; speed_mbps: string }[];
usb_readable: boolean; warnings: string[];
};
}
export class APIError extends Error { constructor(message: string, public status: number) { super(message); } }
export type DesktopAction = "authorize" | "install-tailscale" | "connect-tailscale";
export function networkSetupAvailable(): boolean {
return (window as Window & { missionCoreDesktop?: { networkSetup?: boolean } }).missionCoreDesktop?.networkSetup === true;
}
export function desktopAction(action: DesktopAction): boolean {
if (action !== "authorize" && !networkSetupAvailable()) return false;
const host = window as Window & { webkit?: { messageHandlers?: { node?: { postMessage: (message: string) => void } } } };
const channel = host.webkit?.messageHandlers?.node;
if (!channel) return false;
channel.postMessage(action);
return true;
}
export function desktopLogin(): boolean { return desktopAction("authorize"); }
export async function request<T>(path: string, method = "GET", body?: unknown): Promise<T> {
const res = await fetch(path, { method, credentials: "same-origin", cache: "no-store", headers: body === undefined ? {} : {"Content-Type": "application/json"}, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(10000) });
if (!res.ok) {
const error = await res.json().catch(() => ({}));
throw new APIError(error.error ?? "Не удалось выполнить запрос к ноде", res.status);
}
return res.json();
}
export async function loginFromLaunch(): Promise<void> {
const params = new URLSearchParams(location.hash.slice(1));
const token = params.get("login");
// Remove the one-use credential from history before any asynchronous work.
if (location.hash) history.replaceState(null, "", location.pathname);
if (token) await request("/api/session", "POST", { token });
}
+56
View File
@@ -0,0 +1,56 @@
import { useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import { ActivityIndicator, AdminNavigationPanel, AppHeader, ApplicationPanel, ApplicationShell, Button, HeaderNavigation, HeaderProfile, HeaderWorkspace, Icon, SettingsCard, ToastStack, UserProfileMenu, useApplicationWorkspace } from "@nodedc/ui-react";
import "@nodedc/tokens/tokens.css";
import "@nodedc/tokens/themes.css";
import "@nodedc/ui-core/styles.css";
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 { SystemAccess } from "./SystemAccess";
import { SetupView } from "./SetupView";
import { TailnetAccess } from "./TailnetAccess";
import "./node.css";
function App() {
const node = useNode();
const { value, pending, locked, refresh, failure } = node;
const [root, setRoot] = useState<RootId>("system");
const workspace = useApplicationWorkspace<ViewId>({ activeView: "overview" });
const [adding, setAdding] = useState(false);
const [theme, setTheme] = useState(() => localStorage.getItem("node-theme") === "light" ? "light" : "dark");
// Theme applies to body portals as well as the application shell.
useEffect(() => { document.documentElement.dataset.nodedcTheme = theme; }, [theme]);
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} />
: workspace.activeView === "network" ? <NetworkView value={value} />
: workspace.activeView === "usb" ? <DevicesView 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} /></>}
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="Разделы выбранной вкладки"
contexts={value ? [{ id: "board", label: value.name, description: value.host.hostname, icon: <Icon name="activity" />, onSelect: () => openView("overview") }] : []}
items={views.filter(item => item.root === root).map(item => ({ id: item.id, label: item.label, icon: <Icon name={item.icon} /> }))} activeId={workspace.activeView ?? undefined} onItemChange={id => openView(id as ViewId)} footer={<span>Mission Core Node · {value?.version}</span>} />}
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" : "Нода недоступна"}>
{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>}
</div>} />
<ToastStack items={node.toasts} onDismiss={node.dismiss} />
</>;
}
createRoot(document.getElementById("root")!).render(<App />);
+14
View File
@@ -0,0 +1,14 @@
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; background: var(--nodedc-canvas); color: var(--nodedc-text-primary); font-family: var(--nodedc-font-family); font-size: var(--nodedc-font-size-md); }
.node-stage { height: 100%; overflow: auto; padding: var(--nodedc-space-5); }
.node-content { display: grid; align-content: start; gap: var(--nodedc-space-5); min-width: 0; }
.node-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); gap: var(--nodedc-space-5); margin: 0; font-size: var(--nodedc-font-size-sm); }
.node-facts > div { min-width: 0; }
.node-facts dt { color: var(--nodedc-text-muted); margin-bottom: var(--nodedc-space-2); }
.node-facts dd { margin: 0; overflow-wrap: anywhere; }
.node-section-heading { display: flex; flex-wrap: wrap; align-items: center; gap: var(--nodedc-space-3); justify-content: space-between; }
.node-form { display: grid; gap: var(--nodedc-space-4); }
.node-form > button { justify-self: start; }
.node-note { margin: 0; color: var(--nodedc-text-secondary); font-size: var(--nodedc-font-size-sm); line-height: 1.6; overflow-wrap: anywhere; }
.node-entry { max-width: 640px; margin: var(--nodedc-space-8) auto; }
.node-home-actions { display: flex; flex-wrap: wrap; gap: var(--nodedc-space-3); }
+17
View File
@@ -0,0 +1,17 @@
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 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: "setup", root: "system", label: "Конфигурация системы", icon: "settings" },
{ 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" },
];
+16
View File
@@ -0,0 +1,16 @@
import { useCallback, useEffect, useState } from "react";
import { request } from "./api";
export interface AccessKey { id: string; user: string; label: string; public_key: string }
export interface Access { users: string[]; keys: AccessKey[]; ssh_ready: boolean }
export function useAccess(revision: string, failure: (error: unknown) => void) {
const [access, setAccess] = useState<Access | null>(null);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
setLoading(true);
try { setAccess(await request<Access>("/api/access")); }
catch (error) { setAccess(null); failure(error); throw error; }
finally { setLoading(false); }
}, [failure]);
useEffect(() => { void refresh().catch(() => {}); }, [revision, refresh]);
return { access, loading, refresh };
}
+28
View File
@@ -0,0 +1,28 @@
import { useCallback, useEffect, useState } from "react";
import { type ToastItem } from "@nodedc/ui-react";
import { APIError, loginFromLaunch, request, type Status } from "./api";
export function useNode() {
const [value, setValue] = useState<Status | null>(null);
const [pending, setPending] = useState(true);
const [locked, setLocked] = useState(false);
const [toasts, setToasts] = useState<ToastItem[]>([]);
const failure = useCallback((error: unknown) => {
if (error instanceof APIError && error.status === 401) { setValue(null); setLocked(true); }
setToasts([{ id: "request", tone: "error", title: error instanceof Error ? error.message : "Нода недоступна", durationMs: null }]);
}, []);
const refresh = useCallback(async () => {
setPending(true);
try { const result = await request<Status>("/api/status"); setValue(result); setLocked(false); setToasts([]); }
catch (error) { failure(error); }
finally { setPending(false); }
}, [failure]);
useEffect(() => {
const launch = () => { void loginFromLaunch().then(refresh).catch(error => { failure(error); setPending(false); }); };
launch(); window.addEventListener("hashchange", launch);
return () => window.removeEventListener("hashchange", launch);
}, [refresh, failure]);
const success = useCallback((title: string) => setToasts([{ id: "request", tone: "success", title }]), []);
return { value, pending, locked, refresh, failure, success, toasts,
dismiss: (id: string) => setToasts(items => items.filter(item => item.id !== id)) };
}