Reuse canonical home and settings on Node and admit local K1 viewer

This commit is contained in:
DCCONSTRUCTIONS
2026-09-07 22:55:13 +03:00
parent 1832943558
commit a23c5b2005
23 changed files with 891 additions and 1139 deletions
+22
View File
@@ -0,0 +1,22 @@
import { EnvironmentSettingsWindow, LandingStage } from "@nodedc/ui-react";
import type { EnvironmentPage } from "@nodedc/ui-core";
import { views, type ViewId } from "./nodeModel";
import type { usePresentation } from "./usePresentation";
const surfaces = [{ id: "home", home: true, description: "Главная страница продукта", actions: views }];
export function Home({ page, openView }: { page: EnvironmentPage; openView: (id: ViewId) => void }) {
const actions = [page.primaryWorkspaceId, page.secondaryWorkspaceId].flatMap(id => {
const view = views.find(item => item.id === id);
return view ? [{ id: view.id, label: view.label, icon: view.icon, onSelect: () => openView(view.id) }] : [];
});
return <LandingStage page={page} actions={actions} />;
}
export function HomeSettings({ open, onClose, presentation }: {
open: boolean; onClose: () => void; presentation: ReturnType<typeof usePresentation>;
}) {
return <EnvironmentSettingsWindow productName="Mission Core Node" open={open} onClose={onClose}
surfaces={surfaces} settings={presentation.settings} state={presentation.state} error={presentation.error}
onSave={presentation.save} onUpload={presentation.upload} />;
}
+14 -9
View File
@@ -16,19 +16,23 @@ import { EnvironmentView } from "./EnvironmentView";
import { CoreConnectionView } from "./CoreConnectionView";
import "./node.css";
import { NodeSensors } from "./NodeSensors";
import { Home, HomeSettings } from "./Home";
import { usePresentation } from "./usePresentation";
function App() {
const node = useNode();
const { value, pending, locked, refresh, failure } = node;
const environment = useEnvironment(!!value, failure);
const presentation = usePresentation(!!value);
const [settingsOpen, setSettingsOpen] = useState(false);
const refreshAll = () => {if(!environment.running) {void refresh();void environment.refresh();}};
const [root, setRoot] = useState<RootId>("system");
const workspace = useApplicationWorkspace<ViewId>({ activeView: "environment" });
const [root, setRoot] = useState<RootId | null>(null);
const workspace = useApplicationWorkspace<ViewId>();
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 currentRoot = roots.find(item => item.id === root) ?? roots[0];
const currentView = views.find(item => item.id === workspace.activeView);
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); }
@@ -41,22 +45,23 @@ function App() {
: 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={<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>} />}
<ApplicationShell data-nodedc-ui className="node-app" header={<AppHeader brandMonochrome brandHref="/" brand={<img src="/nodedc-logo.svg" alt="NODE.DC" />} brandLabel={presentation.settings.pages.home.headerLabel}
center={<HeaderNavigation label="Разделы бортового компьютера" value={root ?? undefined} 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: "settings", label: "Настройки", icon: "settings", onSelect: () => { if (value) { void presentation.refresh(); setSettingsOpen(true); } } }, { 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 || 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.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" : "Нода недоступна"}>
stage={value ? <Home page={presentation.settings.pages.home} openView={openView} /> : <div className="node-stage" aria-busy={pending}>
<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>}
</SettingsCard>
</div>} />
<ToastStack items={node.toasts} onDismiss={node.dismiss} />
<HomeSettings open={!!value && settingsOpen} onClose={() => setSettingsOpen(false)} presentation={presentation} />
</>;
}
createRoot(document.getElementById("root")!).render(<App />);
-1
View File
@@ -11,4 +11,3 @@ body { margin: 0; background: var(--nodedc-canvas); color: var(--nodedc-text-pri
.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); }
+57
View File
@@ -0,0 +1,57 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { EnvironmentSettings, UploadedEnvironmentMedia } from "@nodedc/ui-core";
import { APIError, request } from "./api";
export function defaultPresentation(): EnvironmentSettings {
return { revision: 0, pages: { home: {
headerLabel: "Mission Core Node", eyebrow: "NODEDC / MISSION CORE NODE", title: "Mission Core Node",
description: "Подключение устройств, запись и просмотр данных на бортовом компьютере.",
primaryWorkspaceId: "sensors", secondaryWorkspaceId: "environment",
background: { enabled: false, imageDurationSeconds: 10, items: [] },
} } };
}
export function usePresentation(authorized: boolean) {
const [settings, setSettings] = useState(defaultPresentation);
const [state, setState] = useState<"loading" | "ready" | "saving" | "error">("loading");
const [error, setError] = useState<string | null>(null);
const epoch = useRef(0);
const refresh = useCallback(async () => {
if (!authorized) return;
const generation = ++epoch.current;
setState("loading");
try {
const next = await request<EnvironmentSettings & { schema: string }>("/api/presentation/settings");
if (generation !== epoch.current) return;
if (next.schema !== "missioncore.node.presentation/v1" || !next.pages.home || Object.keys(next.pages).length !== 1) throw new Error("Версия оформления главной не поддерживается.");
setSettings(next); setError(null); setState("ready");
} catch (reason) {
if (generation !== epoch.current) return;
setError(reason instanceof Error ? reason.message : "Не удалось загрузить оформление главной."); setState("error");
}
}, [authorized]);
useEffect(() => { void refresh(); return () => { epoch.current += 1; }; }, [refresh]);
const save = useCallback(async (draft: EnvironmentSettings) => {
setState("saving"); setError(null);
try {
const next = await request<EnvironmentSettings>("/api/presentation/settings", "PUT", draft);
setSettings(next); setState("ready"); return next;
} catch (reason) {
const message = reason instanceof Error ? reason.message : "Не удалось сохранить оформление главной.";
setError(message); setState("error"); throw new Error(message);
}
}, []);
const upload = useCallback(async (surfaceId: string, itemId: string, file: File): Promise<UploadedEnvironmentMedia> => {
if (surfaceId !== "home") throw new Error("Оформление доступно только для главной страницы.");
const response = await fetch(`/api/presentation/media/home/${encodeURIComponent(itemId)}`, {
method: "PUT", credentials: "same-origin", signal: AbortSignal.timeout(300000),
headers: { "Content-Type": file.type || "application/octet-stream", "X-NODEDC-File-Name": encodeURIComponent(file.name) }, body: file,
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new APIError(body.error ?? "Не удалось загрузить фон главной страницы.", response.status);
}
return response.json();
}, []);
return { settings, state, error, refresh, save, upload };
}