feat(node): package Ubuntu desktop setup and trusted access
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
<!doctype html>
|
||||
<html lang="ru"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>Mission Core Node</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
|
||||
Generated
+1244
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@nodedc/mission-core-node-ui",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --test test/*.test.mjs",
|
||||
"build": "tsc --noEmit && vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nodedc/ui-react": "file:../../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||
"@nodedc/ui-core": "file:../../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
||||
"@nodedc/tokens": "file:../../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^7.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg id="nodedc-logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220.82 54.55">
|
||||
<defs>
|
||||
<style>.cls-1{fill:#e2e1e1}.cls-2{fill:#dbdbdb;stroke:#dbdbdb;stroke-miterlimit:10;stroke-width:.75px}</style>
|
||||
</defs>
|
||||
<path class="cls-1" d="M52.8 23.61 46.92 33.76 41.05 23.61H52.8m18-10.39H23.06l23.86 41.33Z"/>
|
||||
<polygon class="cls-1" points="31.28 33.13 18.11 10.34 75.73 10.34 62.59 33.13 74.28 33.13 93.22 0 0 0 19.61 33.13 31.28 33.13"/>
|
||||
<path class="cls-2" d="M116.35 18.49V1h1.27l10.34 15V1h1.33v17.49H128l-10.34-15v15ZM140.43 18.64c-4.79 0-8.16-3.72-8.16-8.89S135.64.86 140.43.86s8.17 3.72 8.17 8.89-3.35 8.89-8.17 8.89Zm0-1.25c4 0 6.79-3.17 6.79-7.64s-2.77-7.64-6.79-7.64-6.77 3.17-6.77 7.64 2.78 7.64 6.77 7.64ZM151.6 18.49V1h5.1c5.54 0 8.79 3.42 8.79 8.74s-3.25 8.74-8.79 8.74Zm1.4-1.25h3.75c4.77 0 7.42-2.92 7.42-7.49s-2.65-7.49-7.42-7.49H153ZM168.49 1h10.77v1.26h-9.42v6.67h7.89v1.25h-7.89v7.06h9.74v1.25h-11.09ZM188.88 18.49V1H194c5.54 0 8.79 3.42 8.79 8.74s-3.25 8.74-8.79 8.74Zm1.35-1.25H194c4.77 0 7.41-2.92 7.41-7.49S198.75 2.26 194 2.26h-3.75ZM205.15 9.75c0-5.24 3.19-8.89 8.11-8.89a6.8 6.8 0 0 1 7.1 5.52h-1.43a5.54 5.54 0 0 0-5.74-4.27c-4.05 0-6.64 3.17-6.64 7.64s2.54 7.64 6.59 7.64a5.46 5.46 0 0 0 5.74-4.29h1.43c-.75 3.52-3.4 5.54-7.15 5.54-4.89 0-8.01-3.59-8.01-8.89Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg id="nodedc-mark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 93.22 54.55">
|
||||
<path fill="#e2e1e1" d="M52.8 23.61 46.92 33.76 41.05 23.61H52.8m18-10.39H23.06l23.86 41.33Z"/>
|
||||
<polygon fill="#e2e1e1" points="31.28 33.13 18.11 10.34 75.73 10.34 62.59 33.13 74.28 33.13 93.22 0 0 0 19.61 33.13 31.28 33.13"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 322 B |
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 />);
|
||||
@@ -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); }
|
||||
@@ -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" },
|
||||
];
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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)) };
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
|
||||
test("Node UI uses canonical controls and never exposes vendor commands", () => {
|
||||
for (const file of readdirSync(new URL("../src/", import.meta.url)).filter(name => name.endsWith(".tsx"))) {
|
||||
const source = readFileSync(new URL(`../src/${file}`, import.meta.url), "utf8");
|
||||
assert.doesNotMatch(source, /<(button|input|select|textarea)\b/, file);
|
||||
assert.doesNotMatch(source, /mqtt|Quick Connect|BLE_UUID|openapi_key|192\.168\.68\.50|dcsudo/, file);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022", "lib": ["ES2022", "DOM"], "module": "ESNext",
|
||||
"moduleResolution": "bundler", "jsx": "react-jsx", "strict": true,
|
||||
"skipLibCheck": true, "noEmit": true, "esModuleInterop": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
// Design Guideline packages are linked during development. Their own React
|
||||
// must never become a second hook dispatcher in the portable production bundle.
|
||||
resolve: { dedupe: ["react", "react-dom"] },
|
||||
});
|
||||
Reference in New Issue
Block a user