feat(node): configure system environment through the desktop workflow

This commit is contained in:
DCCONSTRUCTIONS
2026-09-05 19:46:52 +03:00
parent 15bb793e5e
commit 59e14c5bc0
29 changed files with 929 additions and 81 deletions
@@ -0,0 +1,37 @@
import { useState } from "react";
import { ActivityIndicator, Button, Icon, ResourceList, ResourceRow, SettingsCard, StatusBadge } from "@nodedc/ui-react";
import { environmentSetupAvailable } from "./api";
import type { useEnvironment } from "./useEnvironment";
import { TailnetAccess } from "./TailnetAccess";
import { SystemAccess } from "./SystemAccess";
const labels:Record<string,string>={pending:"Ожидает",running:"Выполняется",complete:"Готово",error:"Ошибка",blocked:"Не выполнено"};
export function EnvironmentView({environment,failure,success}: {environment:ReturnType<typeof useEnvironment>;failure:(error:unknown)=>void;success:(text:string)=>void}) {
const {value,pending,running,loading,start}=environment;
const [adding,setAdding]=useState(false);
const revision=String(value?.run?.updated_at??0);
const complete=value?.run?.state==="complete"&&value.run.profile_revision===value.profile.revision;
return <div className="node-content">
<SettingsCard title="Настройка окружения" description="Установка и проверка компонентов для работы с бортовым компьютером.">
<p className="node-note">Сконфигурируйте окружение при первом запуске или повторите проверку после изменений. Приложение установит нужные пакеты, настроит службы и получит системные сведения. Вход в частную сеть и доверенный SSH-ключ подтверждаются ниже.</p>
<Button onClick={start} disabled={running||!value||!environmentSetupAvailable()}>{running?"Настраиваем…":value?.run?"Проверить и сконфигурировать":"Сконфигурировать"}</Button>
{!environmentSetupAvailable()&&<p className="node-note">Настройка запускается из установленного приложения на БК.</p>}
{pending&&(!value?.run||value.run.state!=="running")&&<ActivityIndicator label="Подтвердите действие в системном окне. Ожидаем начало настройки…" />}
{!value?loading?<ActivityIndicator label="Получаем этапы настройки" />:<p className="node-note">Сведения о настройке недоступны. Обновите страницу.</p>:<>
{!value.available&&<p className="node-note">Не удалось прочитать результат предыдущей настройки. Запустите настройку, чтобы заново проверить окружение.</p>}
<ResourceList aria-label="Этапы настройки окружения">{value.profile.steps.map(specification=>{
const step=value.run?.steps.find(item=>item.id===specification.id);
const state=step?.state??"pending";
return <li key={specification.id}><ResourceRow icon={state==="running"?<ActivityIndicator size="compact" />:<Icon name={state==="complete"?"check":state==="error"?"alert":"circle"} />} title={specification.label} description={specification.description} metadata={step?.detail} status={<StatusBadge tone={state==="complete"?"success":state==="error"?"warning":"neutral"}>{labels[state]??"Неизвестно"}</StatusBadge>} /></li>;
})}</ResourceList>
{complete&&!running&&<p className="node-note">Системные этапы завершены. Ниже проверьте вход в Tailscale и доверенный ключ для SSH.</p>}
{value.run?.state==="error"&&!running&&<p className="node-note">Часть этапов не завершена. Исправьте указанные причины и повторите настройку.</p>}
{value.run?.state==="interrupted"&&<p className="node-note">Предыдущая настройка прервана. Повторный запуск проверит уже выполненные этапы.</p>}
</>}
</SettingsCard>
{!running&&<>
<SettingsCard title="Доверенное устройство для SSH"><SystemAccess revision={revision} failure={failure} success={success} adding={adding} closeAdd={()=>setAdding(false)} startAdd={()=>setAdding(true)} /></SettingsCard>
<TailnetAccess failure={failure} revision={revision} />
</>}
</div>;
}
+7 -6
View File
@@ -3,7 +3,7 @@ import { ActivityIndicator, Button, ConfirmationModal, Icon, IconButton, Resourc
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 }) {
export function SystemAccess({ revision, failure, success, adding, closeAdd, startAdd }: { revision: string; failure: (error: unknown) => void; success: (message: string) => void; adding: boolean; closeAdd: () => void; startAdd?: () => void }) {
const { access, loading, refresh } = useAccess(revision, failure);
const [user, setUser] = useState("");
const [label, setLabel] = useState("");
@@ -21,19 +21,20 @@ export function SystemAccess({ revision, failure, success, adding, closeAdd }: {
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>
{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={`Пользователь системы: ${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; открытые сеансы и отдельно настроенные способы входа в систему сохраняются.</p>
</>}
{startAdd && <Button onClick={startAdd} disabled={!access}>Добавить доверенное устройство</Button>}
<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}>
{!access ? <p className="node-note">{loading ? "Получаем пользователей системы…" : "Не удалось получить пользователей. Закройте окно и обновите список."}</p> : access.users.length === 0 ? <p className="node-note">Не найдены администраторы системы. Добавьте пользователя в настройках системы.</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} />
<Select label="Пользователь системы" 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>}
{detail && <dl className="node-facts"><div><dt>Пользователь системы</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;
+4 -4
View File
@@ -27,7 +27,7 @@ export function TailnetAccess({ failure, revision: hostRevision }: { failure: (e
if (pending) return;
setNotice(""); setPending(action);
if (!desktopAction(action)) {
setPending(null); failure(new Error("Откройте установленное приложение Mission Core Node из меню Ubuntu."));
setPending(null); failure(new Error("Откройте установленное приложение Mission Core Node из меню приложений."));
}
}
const label = tailscaleLabel(value, checked);
@@ -36,13 +36,13 @@ export function TailnetAccess({ failure, revision: hostRevision }: { failure: (e
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></>}
{!value.installed && <><p className="node-note">Приложение загрузит проверенный пакет Tailscale и включит его службу. Понадобятся интернет и системное подтверждение.</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. Проверяем подключение…"} />}
{!supported && <p className="node-note">Для настройки сети закройте окно и заново откройте установленное приложение из меню приложений. После обновления пакета требуется перезапуск окна.</p>}
{pending && <ActivityIndicator label={pending === "install-tailscale" ? "Подтвердите установку в системном окне. Загружаем и устанавливаем компонент…" : "Подтвердите действие в системном окне. Проверяем подключение…"} />}
{notice && <p className="node-note" role="status">{notice}</p>}
</SettingsCard></div>;
}
+5 -2
View File
@@ -11,12 +11,15 @@ export interface Status {
}
export class APIError extends Error { constructor(message: string, public status: number) { super(message); } }
export type DesktopAction = "authorize" | "install-tailscale" | "connect-tailscale";
export type DesktopAction = "authorize" | "install-tailscale" | "connect-tailscale" | "configure-system";
export function environmentSetupAvailable(): boolean {
return (window as Window & { missionCoreDesktop?: { environmentSetup?: boolean } }).missionCoreDesktop?.environmentSetup === true;
}
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;
if (action === "configure-system" ? !environmentSetupAvailable() : 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;
+13 -9
View File
@@ -11,22 +11,26 @@ import { NodeOverview } from "./NodeOverview";
import { USBView, DiagnosticsView, NetworkView } from "./InventoryViews";
import { SystemAccess } from "./SystemAccess";
import { TailnetAccess } from "./TailnetAccess";
import { useEnvironment } from "./useEnvironment";
import { EnvironmentView } from "./EnvironmentView";
import "./node.css";
function App() {
const node = useNode();
const { value, pending, locked, refresh, failure } = node;
const environment = useEnvironment(!!value, failure);
const refreshAll = () => {if(!environment.running) {void refresh();void environment.refresh();}};
const [root, setRoot] = useState<RootId>("system");
const workspace = useApplicationWorkspace<ViewId>({ activeView: "overview" });
const workspace = useApplicationWorkspace<ViewId>({ activeView: "environment" });
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 openView(id: ViewId) { if(environment.running) return; setAdding(false); setRoot(views.find(item => item.id === id)!.root); workspace.openView(id); }
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} />
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 === "diagnostics" ? <DiagnosticsView value={value} />
@@ -34,18 +38,18 @@ function App() {
: 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 || !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>} />}
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 || environment.running }))} onChange={selectRoot} /></>}
right={<HeaderProfile><UserProfileMenu displayName="Node" subtitle={value?.name ?? "Mission Core Node"} triggerLabel={null} actions={[{ id: "refresh", label: "Обновить сведения", icon: "refresh", onSelect: refreshAll }, { 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>}
utilityActions={[...(workspace.activeView === "ssh" ? [{ label: "Добавить доверенное устройство", icon: "plus" as const, onClick: () => setAdding(true) }] : []), { label: "Обновить сведения", icon: "refresh", disabled: pending || environment.running, onClick: refreshAll }]}>{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} 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>
{value ? <SettingsCard title={value.name} eyebrow="MISSION CORE NODE" description={`Бортовой компьютер · ${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 ? "Подтвердите доступ в системном окне." : "Не удалось связаться со службой. Повторите подключение."}</p>}
<Button disabled={pending} onClick={() => { if (locked) { if (!desktopLogin()) failure(new Error("Откройте установленное приложение Mission Core Node из меню приложений.")); } else void refresh(); }}>{locked ? "Войти" : "Повторить подключение"}</Button>
</SettingsCard>}
</div>} />
<ToastStack items={node.toasts} onDismiss={node.dismiss} />
+3 -2
View File
@@ -1,11 +1,12 @@
import type { IconName } from "@nodedc/ui-react";
export type RootId = "system" | "devices";
export type ViewId = "overview" | "network" | "diagnostics" | "usb" | "tailscale" | "ssh";
export type ViewId = "environment" | "overview" | "network" | "diagnostics" | "usb" | "tailscale" | "ssh";
export const roots: { id: RootId; label: string; first: ViewId | null }[] = [
{ id: "system", label: "Система", first: "overview" },
{ id: "system", label: "Система", first: "environment" },
{ id: "devices", label: "Устройства", first: null },
];
export const views: { id: ViewId; root: RootId; label: string; icon: IconName }[] = [
{ id: "environment", root: "system", label: "Настройка окружения", icon: "settings" },
{ id: "overview", root: "system", label: "Обзор БК", icon: "activity" },
{ id: "network", root: "system", label: "Сеть", icon: "network" },
{ id: "usb", root: "system", label: "USB-устройства", icon: "camera" },
+50
View File
@@ -0,0 +1,50 @@
import { useCallback, useEffect, useState } from "react";
import { desktopAction, request } from "./api";
export interface EnvironmentStep { id: string; state: string; detail: string }
export interface EnvironmentRun { schema: string; profile_revision: string; run_id: string; state: string; updated_at: number; steps: EnvironmentStep[] }
export interface EnvironmentStatus { available: boolean; profile: {revision: string; steps:{id:string;label:string;description:string}[]}; run: EnvironmentRun | null }
export function useEnvironment(authorized: boolean, failure: (error: unknown) => void) {
const [value, setValue] = useState<EnvironmentStatus | null>(null);
const [pending, setPending] = useState(false);
const [loading, setLoading] = useState(false);
const refresh = useCallback(async () => {
setLoading(true);
try {setValue(await request<EnvironmentStatus>("/api/environment"));}
catch(error) {failure(error);}
finally {setLoading(false);}
},[failure]);
// Native progress survives a service restart and never depends on a stale
// HTTP session. Reopening an existing run uses the read-only status API.
useEffect(() => {
function progress(event:Event) {
const run=(event as CustomEvent<EnvironmentRun>).detail;
if(run?.schema==="missioncore.node.environment/v1") setValue(current=>current?{...current,available:true,run}:current);
}
function complete(event:Event) {
const result=(event as CustomEvent<{ok:boolean;error?:string;reloading?:boolean}>).detail;
if(!result?.reloading) setPending(false);
if(result?.error) failure(new Error(result.error));
// The native launcher renews login after service changes. No immediate
// request with the expired cookie is made here.
}
const sessionReady=()=>setPending(false);
window.addEventListener("mission-core-session-ready",sessionReady);
window.addEventListener("mission-core-environment-progress",progress);
window.addEventListener("mission-core-environment-result",complete);
return()=>{window.removeEventListener("mission-core-session-ready",sessionReady);window.removeEventListener("mission-core-environment-progress",progress);window.removeEventListener("mission-core-environment-result",complete);};
},[failure]);
useEffect(()=>{if(authorized&&!pending) void refresh();},[authorized,pending,refresh]);
useEffect(()=>{
if(!authorized||pending||value?.run?.state!=="running") return;
const timer=setTimeout(()=>void refresh(),2000);return()=>clearTimeout(timer);
},[authorized,pending,value,refresh]);
function start() {
if(pending||value?.run?.state==="running") return;
if(!desktopAction("configure-system")) {failure(new Error("Откройте установленное приложение Node для настройки окружения."));return;}
setValue(current=>current?{...current,run:null}:current);
setPending(true);
}
return {value,pending,loading,refresh,start,running:pending||value?.run?.state==="running"};
}
+1 -1
View File
@@ -18,7 +18,7 @@ export function useNode() {
finally { setPending(false); }
}, [failure]);
useEffect(() => {
const launch = () => { void loginFromLaunch().then(refresh).catch(error => { failure(error); setPending(false); }); };
const launch = () => { void loginFromLaunch().then(refresh).then(()=>window.dispatchEvent(new Event("mission-core-session-ready"))).catch(error => { failure(error); setPending(false); }); };
launch(); window.addEventListener("hashchange", launch);
return () => window.removeEventListener("hashchange", launch);
}, [refresh, failure]);