feat(control-station): add compute contour workspace
This commit is contained in:
@@ -21,12 +21,15 @@ import {
|
||||
Window,
|
||||
WindowFooterActions,
|
||||
useApplicationWorkspace,
|
||||
type ApplicationPanelUtilityAction,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import { LandingStage } from "./components/LandingStage";
|
||||
import { EnvironmentSettingsWindow } from "./components/EnvironmentSettingsWindow";
|
||||
import { ObservationSessionSelect } from "./components/ObservationSessionSelect";
|
||||
import { SystemNavigationPanel } from "./components/system/SystemNavigationPanel";
|
||||
import { SystemWorkspaceSelector } from "./components/system/SystemWorkspaceSelector";
|
||||
import { useComputeContourSettings } from "./components/system/useComputeContourSettings";
|
||||
import { useApplicationPanelActions } from "./components/useApplicationPanelActions";
|
||||
import { useEnvironmentSettings } from "./core/environment/useEnvironmentSettings";
|
||||
import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost";
|
||||
import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext";
|
||||
@@ -151,6 +154,7 @@ function mergeViewerSettings(
|
||||
export default function App() {
|
||||
const runtime = useMissionRuntime();
|
||||
const environment = useEnvironmentSettings();
|
||||
const computeContourSettings = useComputeContourSettings();
|
||||
const { selection } = useDevicePluginHost();
|
||||
const polygonDatasetRoute = useMemo(
|
||||
() => resolvePolygonRunRoute(typeof window === "undefined" ? "" : window.location.search),
|
||||
@@ -439,8 +443,14 @@ export default function App() {
|
||||
|
||||
const selectRoot = (rootId: RootId) => {
|
||||
setActiveRoot(rootId);
|
||||
workspace.closeView();
|
||||
workspace.openNavigation();
|
||||
if (rootId === "system") {
|
||||
workspace.openView(
|
||||
activeDefinition?.root === "system" ? activeDefinition.id : "modules",
|
||||
);
|
||||
} else {
|
||||
workspace.closeView();
|
||||
}
|
||||
};
|
||||
|
||||
const openView = (viewId: string) => {
|
||||
@@ -604,35 +614,13 @@ export default function App() {
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [layoutSaveNotice]);
|
||||
|
||||
const contentActions = useMemo<ApplicationPanelUtilityAction[]>(() => {
|
||||
const actions: ApplicationPanelUtilityAction[] = [];
|
||||
|
||||
if (activeDefinition?.kind === "device") {
|
||||
actions.push({
|
||||
label: "Обновить состояние локального контура",
|
||||
icon: "refresh",
|
||||
onClick: () => void runtime.refresh(),
|
||||
const contentActions = useApplicationPanelActions({
|
||||
definition: activeDefinition,
|
||||
refreshRuntime: runtime.refresh,
|
||||
saveWorkspaceLayout,
|
||||
workspaceLayoutSaving: workspaceLayoutProfile.state === "saving",
|
||||
systemUtilityAction: computeContourSettings.utilityAction,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
activeDefinition
|
||||
&& ["spatial", "recordings"].includes(activeDefinition.kind)
|
||||
) {
|
||||
actions.push(
|
||||
{
|
||||
label: workspaceLayoutProfile.state === "saving"
|
||||
? "Сохраняем компоновку"
|
||||
: "Сохранить компоновку",
|
||||
icon: "save",
|
||||
disabled: workspaceLayoutProfile.state === "saving",
|
||||
onClick: () => void saveWorkspaceLayout(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return actions;
|
||||
}, [activeDefinition?.kind, runtime, saveWorkspaceLayout, workspaceLayoutProfile.state]);
|
||||
|
||||
const header = (
|
||||
<AppHeader
|
||||
@@ -702,7 +690,13 @@ export default function App() {
|
||||
onOpenWorkspace={openView}
|
||||
/>
|
||||
}
|
||||
navigation={currentRoot ? (
|
||||
navigation={currentRoot && activeRoot === "system" ? (
|
||||
<SystemNavigationPanel
|
||||
title={currentRoot.title}
|
||||
onAdd={computeContourSettings.openCreate}
|
||||
onClose={workspace.closeNavigation}
|
||||
/>
|
||||
) : currentRoot ? (
|
||||
<AdminNavigationPanel
|
||||
eyebrow="MISSION CORE"
|
||||
title={currentRoot.title}
|
||||
@@ -718,7 +712,9 @@ export default function App() {
|
||||
selection?.model.displayName ||
|
||||
"Модель не выбрана",
|
||||
icon: <Icon name="network" />,
|
||||
active: runtime.backendStatus !== "offline" && runtime.backendStatus !== "unconfigured",
|
||||
active:
|
||||
runtime.backendStatus !== "offline"
|
||||
&& runtime.backendStatus !== "unconfigured",
|
||||
},
|
||||
]}
|
||||
items={rootWorkspaces.map((item) => ({
|
||||
@@ -795,10 +791,11 @@ export default function App() {
|
||||
<StatusBadge tone="neutral">Offline evaluation</StatusBadge>
|
||||
) : activeDefinition.kind === "lab-archive" ? (
|
||||
null
|
||||
) : activeDefinition.kind === "compute-modules" ? (
|
||||
<StatusBadge tone="neutral">Живая телеметрия</StatusBadge>
|
||||
) : activeDefinition.kind === "network-monitor" ? (
|
||||
<StatusBadge tone="neutral">Живая сеть</StatusBadge>
|
||||
) : activeDefinition.root === "system" ? (
|
||||
<SystemWorkspaceSelector
|
||||
value={activeDefinition.id}
|
||||
onChange={openView}
|
||||
/>
|
||||
) : (
|
||||
<StatusBadge tone="warning">Интерфейс готов</StatusBadge>
|
||||
)
|
||||
@@ -864,6 +861,7 @@ export default function App() {
|
||||
onSave={environment.save}
|
||||
onUpload={environment.upload}
|
||||
/>
|
||||
{computeContourSettings.window}
|
||||
|
||||
<Window
|
||||
open={sceneWorkspaceActive && sourceWindowOpen}
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
FeatureSettingsWindow,
|
||||
Icon,
|
||||
Select,
|
||||
SettingsCard,
|
||||
StatusBadge,
|
||||
TextField,
|
||||
WindowFooterActions,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
fetchComputeContourAgentInstall,
|
||||
type ComputeContour,
|
||||
type ComputeContourAgentInstall,
|
||||
type ComputeContourDraft,
|
||||
type ComputeContourPlatform,
|
||||
} from "../../core/system/computeContours";
|
||||
|
||||
interface ComputeContourSettingsWindowProps {
|
||||
open: boolean;
|
||||
contour: ComputeContour | null;
|
||||
mode: "create" | "edit";
|
||||
onClose: () => void;
|
||||
onCreate: (draft: ComputeContourDraft) => Promise<ComputeContour>;
|
||||
onUpdate: (
|
||||
contour: ComputeContour,
|
||||
draft: ComputeContourDraft,
|
||||
) => Promise<ComputeContour>;
|
||||
}
|
||||
|
||||
const platformOptions = [
|
||||
{ value: "windows", label: "Windows", description: "Worker с Windows service" },
|
||||
{ value: "linux", label: "Linux", description: "Worker с systemd service" },
|
||||
{ value: "unknown", label: "Не определено", description: "Платформа будет уточнена" },
|
||||
] satisfies Array<{
|
||||
value: ComputeContourPlatform;
|
||||
label: string;
|
||||
description: string;
|
||||
}>;
|
||||
|
||||
function emptyDraft(): ComputeContourDraft {
|
||||
return {
|
||||
display_name: "",
|
||||
expected_node_id: "",
|
||||
platform: "unknown",
|
||||
telemetry_mode: "agent-mqtt",
|
||||
address: "",
|
||||
ssh_port: 22,
|
||||
mqtt_host: "127.0.0.1",
|
||||
mqtt_port: 1883,
|
||||
};
|
||||
}
|
||||
|
||||
function draftFromContour(contour: ComputeContour | null): ComputeContourDraft {
|
||||
if (!contour) return emptyDraft();
|
||||
return {
|
||||
display_name: contour.display_name,
|
||||
expected_node_id: contour.expected_node_id,
|
||||
platform: contour.platform,
|
||||
telemetry_mode: contour.telemetry_mode,
|
||||
address: contour.address,
|
||||
ssh_port: contour.ssh_port,
|
||||
mqtt_host: contour.mqtt_host,
|
||||
mqtt_port: contour.mqtt_port,
|
||||
};
|
||||
}
|
||||
|
||||
export function ComputeContourSettingsWindow({
|
||||
open,
|
||||
contour,
|
||||
mode,
|
||||
onClose,
|
||||
onCreate,
|
||||
onUpdate,
|
||||
}: ComputeContourSettingsWindowProps) {
|
||||
const [draft, setDraft] = useState<ComputeContourDraft>(() => draftFromContour(contour));
|
||||
const [activeSection, setActiveSection] = useState<"connection" | "agent">("connection");
|
||||
const [install, setInstall] = useState<ComputeContourAgentInstall | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setDraft(draftFromContour(mode === "edit" ? contour : null));
|
||||
setActiveSection("connection");
|
||||
setInstall(null);
|
||||
setError(null);
|
||||
}, [contour, mode, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || mode !== "edit" || !contour) return;
|
||||
const controller = new AbortController();
|
||||
void fetchComputeContourAgentInstall(contour.contour_id, controller.signal)
|
||||
.then((document) => {
|
||||
if (!controller.signal.aborted) setInstall(document);
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setError(reason instanceof Error ? reason.message : "Инструкция агента недоступна.");
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [contour, mode, open]);
|
||||
|
||||
const valid = useMemo(() => (
|
||||
Boolean(draft.display_name.trim())
|
||||
&& Boolean(draft.expected_node_id.trim())
|
||||
&& Number.isInteger(draft.ssh_port)
|
||||
&& draft.ssh_port > 0
|
||||
&& Number.isInteger(draft.mqtt_port)
|
||||
&& draft.mqtt_port > 0
|
||||
), [draft]);
|
||||
|
||||
const save = async () => {
|
||||
if (!valid || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (mode === "edit" && contour) {
|
||||
await onUpdate(contour, draft);
|
||||
} else {
|
||||
await onCreate(draft);
|
||||
}
|
||||
onClose();
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "Контур не сохранён.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FeatureSettingsWindow
|
||||
open={open}
|
||||
title={mode === "create" ? "Добавить вычислительный контур" : "Настройки контура"}
|
||||
subtitle="Локальная переносимая конфигурация"
|
||||
identity={{
|
||||
title: contour?.display_name ?? "Новый контур",
|
||||
subtitle: contour?.expected_node_id ?? "Node ID не назначен",
|
||||
avatarLabel: contour?.display_name.slice(0, 2).toUpperCase() ?? "WC",
|
||||
}}
|
||||
sections={[
|
||||
{
|
||||
id: "connection",
|
||||
label: "Подключение",
|
||||
group: "ВЫЧИСЛИТЕЛЬНЫЙ КОНТУР",
|
||||
icon: "network",
|
||||
},
|
||||
{
|
||||
id: "agent",
|
||||
label: "Агент",
|
||||
group: "ТЕЛЕМЕТРИЯ",
|
||||
icon: "activity",
|
||||
disabled: mode === "create",
|
||||
},
|
||||
]}
|
||||
activeSection={activeSection}
|
||||
onSectionChange={setActiveSection}
|
||||
onClose={onClose}
|
||||
footer={(
|
||||
<WindowFooterActions>
|
||||
<Button variant="secondary" onClick={onClose}>Отмена</Button>
|
||||
<Button variant="primary" disabled={!valid || busy} onClick={() => void save()}>
|
||||
{busy ? "Сохраняем…" : mode === "create" ? "Добавить контур" : "Сохранить"}
|
||||
</Button>
|
||||
</WindowFooterActions>
|
||||
)}
|
||||
>
|
||||
<div className="compute-contour-settings">
|
||||
{activeSection === "connection" ? (
|
||||
<SettingsCard
|
||||
eyebrow="КОНТУР"
|
||||
title="Идентичность и локальная сеть"
|
||||
description="Node ID проверяется независимо от IP-адреса. Адрес можно менять при переносе комплекта в другую локальную сеть."
|
||||
>
|
||||
<div className="compute-contour-settings__form">
|
||||
<TextField
|
||||
label="Название"
|
||||
value={draft.display_name}
|
||||
placeholder="Worker 006"
|
||||
onChange={(event) => setDraft((current) => ({
|
||||
...current,
|
||||
display_name: event.currentTarget.value,
|
||||
}))}
|
||||
/>
|
||||
<TextField
|
||||
label="Ожидаемый Node ID"
|
||||
value={draft.expected_node_id}
|
||||
placeholder="DESKTOP-OPJ8J04"
|
||||
onChange={(event) => setDraft((current) => ({
|
||||
...current,
|
||||
expected_node_id: event.currentTarget.value,
|
||||
}))}
|
||||
/>
|
||||
<Select
|
||||
label="Платформа"
|
||||
value={draft.platform}
|
||||
options={platformOptions}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
onChange={(platform) => setDraft((current) => ({ ...current, platform }))}
|
||||
/>
|
||||
<TextField
|
||||
label="Адрес узла"
|
||||
hint="IP или hostname в текущей LAN"
|
||||
value={draft.address}
|
||||
onChange={(event) => setDraft((current) => ({
|
||||
...current,
|
||||
address: event.currentTarget.value,
|
||||
}))}
|
||||
/>
|
||||
<TextField
|
||||
label="MQTT broker"
|
||||
value={draft.mqtt_host}
|
||||
onChange={(event) => setDraft((current) => ({
|
||||
...current,
|
||||
mqtt_host: event.currentTarget.value,
|
||||
}))}
|
||||
/>
|
||||
<TextField
|
||||
label="MQTT port"
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={String(draft.mqtt_port)}
|
||||
onChange={(event) => setDraft((current) => ({
|
||||
...current,
|
||||
mqtt_port: Number(event.currentTarget.value),
|
||||
}))}
|
||||
/>
|
||||
{draft.telemetry_mode === "legacy-ssh" ? (
|
||||
<TextField
|
||||
label="SSH port"
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={String(draft.ssh_port)}
|
||||
onChange={(event) => setDraft((current) => ({
|
||||
...current,
|
||||
ssh_port: Number(event.currentTarget.value),
|
||||
}))}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
) : null}
|
||||
|
||||
{activeSection === "agent" ? (
|
||||
<SettingsCard
|
||||
eyebrow="АГЕНТ"
|
||||
title="Telegraf · универсальный сборщик"
|
||||
description="Hardware и runtime собирает готовый агент. Mission Core нормализует только контур, LAB/run и стадии обработки."
|
||||
actions={(
|
||||
<StatusBadge tone={install?.ready ? "success" : "warning"}>
|
||||
{install?.ready ? "Готов к установке" : "Нужен scoped MQTT credential"}
|
||||
</StatusBadge>
|
||||
)}
|
||||
>
|
||||
{install ? (
|
||||
<div className="compute-contour-settings__install">
|
||||
<dl>
|
||||
<div><dt>Шаблон</dt><dd>{install.agent.configuration_template}</dd></div>
|
||||
<div><dt>Секрет</dt><dd>интерактивный ввод · не хранится в UI</dd></div>
|
||||
</dl>
|
||||
<code>{install.command}</code>
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
icon={<Icon name="copy" size={14} />}
|
||||
onClick={() => void navigator.clipboard.writeText(install.command)}
|
||||
>
|
||||
Копировать команду
|
||||
</Button>
|
||||
{install.blocked_reason ? <p>{install.blocked_reason}</p> : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="compute-contour-settings__empty">
|
||||
Сохраните контур, чтобы получить платформенную инструкцию установки.
|
||||
</p>
|
||||
)}
|
||||
{error ? <StatusBadge tone="danger">{error}</StatusBadge> : null}
|
||||
</SettingsCard>
|
||||
) : null}
|
||||
</div>
|
||||
</FeatureSettingsWindow>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
AdminNavigationPanel,
|
||||
Icon,
|
||||
IconButton,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import { useComputeContours } from "../../core/system/ComputeContourContext";
|
||||
|
||||
interface SystemNavigationPanelProps {
|
||||
title: string;
|
||||
onAdd: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SystemNavigationPanel({
|
||||
title,
|
||||
onAdd,
|
||||
onClose,
|
||||
}: SystemNavigationPanelProps) {
|
||||
const { contours, selectedContour, selectContour } = useComputeContours();
|
||||
const contourCountLabel = contours.length === 1
|
||||
? "1 конфигурация"
|
||||
: `${contours.length} конфигураций`;
|
||||
|
||||
return (
|
||||
<AdminNavigationPanel
|
||||
eyebrow="MISSION CORE"
|
||||
title={title}
|
||||
closeLabel={`Закрыть раздел «${title}»`}
|
||||
navigationLabel="Вычислительные контуры"
|
||||
onClose={onClose}
|
||||
headerActions={(
|
||||
<IconButton label="Добавить вычислительный контур" onClick={onAdd}>
|
||||
<Icon name="plus" size={16} strokeWidth={1.6} />
|
||||
</IconButton>
|
||||
)}
|
||||
contexts={contours.map((contour) => ({
|
||||
id: contour.contour_id,
|
||||
label: contour.display_name,
|
||||
description: contour.expected_node_id,
|
||||
icon: <Icon name="apps" />,
|
||||
active: selectedContour?.contour_id === contour.contour_id,
|
||||
onSelect: () => selectContour(contour.contour_id),
|
||||
}))}
|
||||
items={[]}
|
||||
onItemChange={() => undefined}
|
||||
footer={(
|
||||
<>
|
||||
<span className="nodedc-admin-panel__nav-icon" aria-hidden="true">
|
||||
<Icon name="activity" />
|
||||
</span>
|
||||
<span>{contourCountLabel}</span>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { SegmentedControl } from "@nodedc/ui-react";
|
||||
|
||||
import { workspacesForRoot } from "../../productModel";
|
||||
|
||||
const systemWorkspaces = workspacesForRoot("system");
|
||||
const compactLabels: Record<string, string> = {
|
||||
modules: "Модули",
|
||||
integrations: "Интеграции",
|
||||
network: "Сеть",
|
||||
audit: "Аудит",
|
||||
};
|
||||
|
||||
interface SystemWorkspaceSelectorProps {
|
||||
value: string;
|
||||
onChange: (workspaceId: string) => void;
|
||||
}
|
||||
|
||||
export function SystemWorkspaceSelector({
|
||||
value,
|
||||
onChange,
|
||||
}: SystemWorkspaceSelectorProps) {
|
||||
return (
|
||||
<SegmentedControl
|
||||
value={value}
|
||||
items={systemWorkspaces.map((definition) => ({
|
||||
value: definition.id,
|
||||
label: compactLabels[definition.id] ?? definition.label,
|
||||
}))}
|
||||
label="Рабочая поверхность вычислительного контура"
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import type { ApplicationPanelUtilityAction } from "@nodedc/ui-react";
|
||||
|
||||
import { useComputeContours } from "../../core/system/ComputeContourContext";
|
||||
import { ComputeContourSettingsWindow } from "./ComputeContourSettingsWindow";
|
||||
|
||||
interface ComputeContourSettingsController {
|
||||
canEdit: boolean;
|
||||
openCreate: () => void;
|
||||
openEdit: () => void;
|
||||
utilityAction: ApplicationPanelUtilityAction;
|
||||
window: ReactNode;
|
||||
}
|
||||
|
||||
export function useComputeContourSettings(): ComputeContourSettingsController {
|
||||
const contours = useComputeContours();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [mode, setMode] = useState<"create" | "edit">("edit");
|
||||
const openCreate = useCallback(() => {
|
||||
setMode("create");
|
||||
setOpen(true);
|
||||
}, []);
|
||||
const openEdit = useCallback(() => {
|
||||
setMode("edit");
|
||||
setOpen(true);
|
||||
}, []);
|
||||
const utilityAction = useMemo<ApplicationPanelUtilityAction>(() => ({
|
||||
label: "Настроить выбранный вычислительный контур",
|
||||
icon: "settings",
|
||||
disabled: contours.selectedContour === null,
|
||||
onClick: openEdit,
|
||||
}), [contours.selectedContour, openEdit]);
|
||||
|
||||
return {
|
||||
canEdit: contours.selectedContour !== null,
|
||||
openCreate,
|
||||
openEdit,
|
||||
utilityAction,
|
||||
window: (
|
||||
<ComputeContourSettingsWindow
|
||||
open={open}
|
||||
mode={mode}
|
||||
contour={contours.selectedContour}
|
||||
onClose={() => setOpen(false)}
|
||||
onCreate={contours.createContour}
|
||||
onUpdate={contours.updateContour}
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useMemo } from "react";
|
||||
import type { ApplicationPanelUtilityAction } from "@nodedc/ui-react";
|
||||
|
||||
import type { WorkspaceDefinition } from "../productModel";
|
||||
|
||||
interface ApplicationPanelActionsOptions {
|
||||
definition: WorkspaceDefinition | null;
|
||||
refreshRuntime: () => void;
|
||||
saveWorkspaceLayout: () => Promise<void>;
|
||||
workspaceLayoutSaving: boolean;
|
||||
systemUtilityAction: ApplicationPanelUtilityAction;
|
||||
}
|
||||
|
||||
export function useApplicationPanelActions({
|
||||
definition,
|
||||
refreshRuntime,
|
||||
saveWorkspaceLayout,
|
||||
workspaceLayoutSaving,
|
||||
systemUtilityAction,
|
||||
}: ApplicationPanelActionsOptions): ApplicationPanelUtilityAction[] {
|
||||
return useMemo(() => {
|
||||
const actions: ApplicationPanelUtilityAction[] = [];
|
||||
if (definition?.kind === "device") {
|
||||
actions.push({
|
||||
label: "Обновить состояние локального контура",
|
||||
icon: "refresh",
|
||||
onClick: refreshRuntime,
|
||||
});
|
||||
}
|
||||
if (definition && ["spatial", "recordings"].includes(definition.kind)) {
|
||||
actions.push({
|
||||
label: workspaceLayoutSaving ? "Сохраняем компоновку" : "Сохранить компоновку",
|
||||
icon: "save",
|
||||
disabled: workspaceLayoutSaving,
|
||||
onClick: () => void saveWorkspaceLayout(),
|
||||
});
|
||||
}
|
||||
if (definition?.root === "system") actions.push(systemUtilityAction);
|
||||
return actions;
|
||||
}, [
|
||||
definition,
|
||||
refreshRuntime,
|
||||
saveWorkspaceLayout,
|
||||
systemUtilityAction,
|
||||
workspaceLayoutSaving,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import {
|
||||
createComputeContour,
|
||||
fetchComputeContours,
|
||||
updateComputeContour,
|
||||
type ComputeContour,
|
||||
type ComputeContourDraft,
|
||||
} from "./computeContours";
|
||||
|
||||
const SELECTED_CONTOUR_KEY = "mission-core.system.selected-contour.v1";
|
||||
|
||||
interface ComputeContourContextValue {
|
||||
contours: ComputeContour[];
|
||||
selectedContour: ComputeContour | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
selectContour: (contourId: string) => void;
|
||||
createContour: (draft: ComputeContourDraft) => Promise<ComputeContour>;
|
||||
updateContour: (
|
||||
contour: ComputeContour,
|
||||
draft: ComputeContourDraft,
|
||||
) => Promise<ComputeContour>;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
const ComputeContourContext = createContext<ComputeContourContextValue | null>(null);
|
||||
|
||||
export function ComputeContourProvider({ children }: { children: ReactNode }) {
|
||||
const [contours, setContours] = useState<ComputeContour[]>([]);
|
||||
const [selectedContourId, setSelectedContourId] = useState<string | null>(() => (
|
||||
typeof window === "undefined" ? null : window.localStorage.getItem(SELECTED_CONTOUR_KEY)
|
||||
));
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [generation, setGeneration] = useState(0);
|
||||
const refresh = useCallback(() => setGeneration((value) => value + 1), []);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
void fetchComputeContours(controller.signal)
|
||||
.then((nextContours) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setContours(nextContours);
|
||||
setSelectedContourId((current) => (
|
||||
current && nextContours.some((contour) => contour.contour_id === current)
|
||||
? current
|
||||
: nextContours[0]?.contour_id ?? null
|
||||
));
|
||||
setError(null);
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setError(reason instanceof Error ? reason.message : "Каталог контуров недоступен.");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [generation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !selectedContourId) return;
|
||||
window.localStorage.setItem(SELECTED_CONTOUR_KEY, selectedContourId);
|
||||
}, [selectedContourId]);
|
||||
|
||||
const selectContour = useCallback((contourId: string) => {
|
||||
setSelectedContourId(contourId);
|
||||
}, []);
|
||||
|
||||
const createContour = useCallback(async (draft: ComputeContourDraft) => {
|
||||
const created = await createComputeContour(draft);
|
||||
setContours((current) => [...current, created]);
|
||||
setSelectedContourId(created.contour_id);
|
||||
return created;
|
||||
}, []);
|
||||
|
||||
const updateContour = useCallback(async (
|
||||
contour: ComputeContour,
|
||||
draft: ComputeContourDraft,
|
||||
) => {
|
||||
const updated = await updateComputeContour(contour, draft);
|
||||
setContours((current) => current.map((candidate) => (
|
||||
candidate.contour_id === updated.contour_id ? updated : candidate
|
||||
)));
|
||||
return updated;
|
||||
}, []);
|
||||
|
||||
const selectedContour = contours.find(
|
||||
(contour) => contour.contour_id === selectedContourId,
|
||||
) ?? null;
|
||||
const value = useMemo<ComputeContourContextValue>(() => ({
|
||||
contours,
|
||||
selectedContour,
|
||||
loading,
|
||||
error,
|
||||
selectContour,
|
||||
createContour,
|
||||
updateContour,
|
||||
refresh,
|
||||
}), [
|
||||
contours,
|
||||
selectedContour,
|
||||
loading,
|
||||
error,
|
||||
selectContour,
|
||||
createContour,
|
||||
updateContour,
|
||||
refresh,
|
||||
]);
|
||||
|
||||
return (
|
||||
<ComputeContourContext.Provider value={value}>
|
||||
{children}
|
||||
</ComputeContourContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useComputeContours(): ComputeContourContextValue {
|
||||
const value = useContext(ComputeContourContext);
|
||||
if (!value) {
|
||||
throw new Error("useComputeContours must be used within ComputeContourProvider");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
export type ComputeContourPlatform = "windows" | "linux" | "unknown";
|
||||
export type ComputeContourTelemetryMode = "agent-mqtt" | "legacy-ssh";
|
||||
|
||||
export interface ComputeContour {
|
||||
schema_version: "missioncore.compute-contour/v1";
|
||||
contour_id: string;
|
||||
display_name: string;
|
||||
expected_node_id: string;
|
||||
agent_id: string;
|
||||
platform: ComputeContourPlatform;
|
||||
telemetry_mode: ComputeContourTelemetryMode;
|
||||
address: string;
|
||||
ssh_port: number;
|
||||
mqtt_host: string;
|
||||
mqtt_port: number;
|
||||
revision: number;
|
||||
updated_at_utc: string | null;
|
||||
}
|
||||
|
||||
export interface ComputeContourCatalog {
|
||||
schema_version: "missioncore.compute-contour-catalog/v1";
|
||||
contours: ComputeContour[];
|
||||
}
|
||||
|
||||
export interface ComputeContourDraft {
|
||||
display_name: string;
|
||||
expected_node_id: string;
|
||||
platform: ComputeContourPlatform;
|
||||
telemetry_mode: ComputeContourTelemetryMode;
|
||||
address: string;
|
||||
ssh_port: number;
|
||||
mqtt_host: string;
|
||||
mqtt_port: number;
|
||||
}
|
||||
|
||||
export interface ComputeContourAgentInstall {
|
||||
schema_version: "missioncore.compute-contour-agent-install/v1";
|
||||
contour_id: string;
|
||||
platform: ComputeContourPlatform;
|
||||
agent: {
|
||||
distribution: "Telegraf";
|
||||
configuration_template: string;
|
||||
environment: Record<string, string>;
|
||||
secret_delivery: "interactive-prompt";
|
||||
};
|
||||
command: string;
|
||||
ready: boolean;
|
||||
blocked_reason: string | null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
async function requestJson(url: string, init: RequestInit): Promise<unknown> {
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
...(init.body ? { "Content-Type": "application/json" } : {}),
|
||||
...init.headers,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Контуры вычисления: HTTP ${response.status}.`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchComputeContours(signal?: AbortSignal): Promise<ComputeContour[]> {
|
||||
const document = await requestJson("/api/v1/system/contours", {
|
||||
method: "GET",
|
||||
signal,
|
||||
});
|
||||
if (
|
||||
!isRecord(document)
|
||||
|| document.schema_version !== "missioncore.compute-contour-catalog/v1"
|
||||
|| !Array.isArray(document.contours)
|
||||
) {
|
||||
throw new Error("Каталог вычислительных контуров не соответствует контракту.");
|
||||
}
|
||||
return (document as unknown as ComputeContourCatalog).contours;
|
||||
}
|
||||
|
||||
export async function createComputeContour(
|
||||
draft: ComputeContourDraft,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ComputeContour> {
|
||||
const document = await requestJson("/api/v1/system/contours", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
display_name: draft.display_name,
|
||||
expected_node_id: draft.expected_node_id,
|
||||
platform: draft.platform,
|
||||
address: draft.address,
|
||||
ssh_port: draft.ssh_port,
|
||||
mqtt_host: draft.mqtt_host,
|
||||
mqtt_port: draft.mqtt_port,
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
if (!isRecord(document) || document.schema_version !== "missioncore.compute-contour/v1") {
|
||||
throw new Error("Созданный вычислительный контур не соответствует контракту.");
|
||||
}
|
||||
return document as unknown as ComputeContour;
|
||||
}
|
||||
|
||||
export async function updateComputeContour(
|
||||
contour: ComputeContour,
|
||||
draft: ComputeContourDraft,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ComputeContour> {
|
||||
const document = await requestJson(
|
||||
`/api/v1/system/contours/${encodeURIComponent(contour.contour_id)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
revision: contour.revision,
|
||||
...draft,
|
||||
}),
|
||||
signal,
|
||||
},
|
||||
);
|
||||
if (!isRecord(document) || document.schema_version !== "missioncore.compute-contour/v1") {
|
||||
throw new Error("Сохранённый вычислительный контур не соответствует контракту.");
|
||||
}
|
||||
return document as unknown as ComputeContour;
|
||||
}
|
||||
|
||||
export async function fetchComputeContourAgentInstall(
|
||||
contourId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ComputeContourAgentInstall> {
|
||||
const document = await requestJson(
|
||||
`/api/v1/system/contours/${encodeURIComponent(contourId)}/agent-install`,
|
||||
{
|
||||
method: "GET",
|
||||
signal,
|
||||
},
|
||||
);
|
||||
if (
|
||||
!isRecord(document)
|
||||
|| document.schema_version !== "missioncore.compute-contour-agent-install/v1"
|
||||
) {
|
||||
throw new Error("Инструкция агента не соответствует контракту.");
|
||||
}
|
||||
return document as unknown as ComputeContourAgentInstall;
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
type WorkerTelemetry,
|
||||
} from "./workerTelemetry";
|
||||
|
||||
export const WORKER_TELEMETRY_POLL_MILLISECONDS = 3_000;
|
||||
|
||||
export interface WorkerTelemetryState {
|
||||
telemetry: WorkerTelemetry | null;
|
||||
loading: boolean;
|
||||
@@ -12,7 +14,10 @@ export interface WorkerTelemetryState {
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
export function useWorkerTelemetry(pollMilliseconds = 10_000): WorkerTelemetryState {
|
||||
export function useWorkerTelemetry(
|
||||
pollMilliseconds = WORKER_TELEMETRY_POLL_MILLISECONDS,
|
||||
enabled = true,
|
||||
): WorkerTelemetryState {
|
||||
const [telemetry, setTelemetry] = useState<WorkerTelemetry | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -20,6 +25,12 @@ export function useWorkerTelemetry(pollMilliseconds = 10_000): WorkerTelemetrySt
|
||||
const refresh = useCallback(() => setGeneration((value) => value + 1), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setTelemetry(null);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
void fetchWorkerTelemetry(controller.signal)
|
||||
@@ -36,12 +47,13 @@ export function useWorkerTelemetry(pollMilliseconds = 10_000): WorkerTelemetrySt
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [generation]);
|
||||
}, [enabled, generation]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(refresh, pollMilliseconds);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [pollMilliseconds, refresh]);
|
||||
if (!enabled || loading) return;
|
||||
const timer = window.setTimeout(refresh, pollMilliseconds);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [enabled, loading, pollMilliseconds, refresh]);
|
||||
|
||||
return { telemetry, loading, error, refresh };
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface WorkerConnectionProfile {
|
||||
|
||||
export interface WorkerProbe {
|
||||
schema_version: "missioncore.worker-probe/v1";
|
||||
source: "agent-mqtt" | "legacy-ssh" | "unknown";
|
||||
reachable: boolean;
|
||||
identity_matches: boolean;
|
||||
node_id: string | null;
|
||||
|
||||
@@ -8,6 +8,7 @@ import "@nodedc/ui-core/styles.css";
|
||||
import App from "./App";
|
||||
import { installedDevicePlugins } from "./composition/devicePlugins";
|
||||
import { DevicePluginHostProvider } from "./core/device-plugins/DevicePluginHost";
|
||||
import { ComputeContourProvider } from "./core/system/ComputeContourContext";
|
||||
import "./styles.css";
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
@@ -23,7 +24,9 @@ applyNodedcTheme(rootElement, { theme: "dark" });
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<DevicePluginHostProvider plugins={installedDevicePlugins}>
|
||||
<ComputeContourProvider>
|
||||
<App />
|
||||
</ComputeContourProvider>
|
||||
</DevicePluginHostProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -591,7 +591,7 @@ export const workspaces: WorkspaceDefinition[] = [
|
||||
label: "Вычислительные модули",
|
||||
title: "Вычислительные модули",
|
||||
eyebrow: "СИСТЕМА / МОДУЛИ",
|
||||
description: "Живая аппаратная и процессинговая телеметрия выделенного Worker 006.",
|
||||
description: "Аппаратная и процессинговая телеметрия выбранного вычислительного контура.",
|
||||
icon: "apps",
|
||||
kind: "compute-modules",
|
||||
groups: [
|
||||
@@ -677,28 +677,6 @@ export const workspaces: WorkspaceDefinition[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
root: "system",
|
||||
label: "Настройки",
|
||||
title: "Настройки пункта управления",
|
||||
eyebrow: "СИСТЕМА / НАСТРОЙКИ",
|
||||
description: "Компоновки, визуальные профили, ограничения ресурсов и локальные параметры.",
|
||||
icon: "settings",
|
||||
kind: "catalog",
|
||||
groups: [
|
||||
{
|
||||
title: "Рабочая область",
|
||||
description: "Настройки не смешиваются с конфигурацией физического аппарата.",
|
||||
capabilities: [
|
||||
contract("Профили компоновки", "Компоновки представлений для наблюдения, разбора и миссий."),
|
||||
contract("Визуальные профили", "Градиенты, размеры, фон, сетка и подписи."),
|
||||
ready("Ограничение памяти визуализатора", "Контроль объёма потоковых и записанных данных."),
|
||||
contract("Локальные предпочтения", "Тема, единицы и поведение панелей."),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function rootById(id: RootId | null): RootDefinition | null {
|
||||
|
||||
@@ -5,6 +5,73 @@
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.compute-contour-settings {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.compute-contour-settings__form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.compute-contour-settings__install {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.compute-contour-settings__install dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.65rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.compute-contour-settings__install dl > div {
|
||||
display: grid;
|
||||
gap: 0.22rem;
|
||||
min-width: 0;
|
||||
padding: 0.75rem;
|
||||
border-radius: var(--nodedc-radius-control);
|
||||
background: var(--station-panel-soft);
|
||||
}
|
||||
|
||||
.compute-contour-settings__install dt {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.58rem;
|
||||
}
|
||||
|
||||
.compute-contour-settings__install dd {
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 680;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.compute-contour-settings__install code {
|
||||
overflow: auto;
|
||||
padding: 0.8rem;
|
||||
border: 1px solid var(--station-hairline);
|
||||
border-radius: var(--nodedc-radius-control);
|
||||
background: var(--station-panel-deep);
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.62rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.compute-contour-settings__install p,
|
||||
.compute-contour-settings__empty {
|
||||
margin: 0;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.65rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.system-workspace__lead,
|
||||
.system-section-heading {
|
||||
display: flex;
|
||||
@@ -299,7 +366,7 @@
|
||||
.worker-stage-list {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
gap: 0.48rem;
|
||||
@@ -518,6 +585,7 @@
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.worker-stage-list,
|
||||
.network-interface-list {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
@@ -543,6 +611,11 @@
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.compute-contour-settings__form,
|
||||
.compute-contour-settings__install dl {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.system-workspace__lead,
|
||||
.system-section-heading {
|
||||
display: grid;
|
||||
@@ -556,6 +629,7 @@
|
||||
.network-overview-grid,
|
||||
.worker-hardware__facts,
|
||||
.worker-runtime-grid,
|
||||
.worker-stage-list,
|
||||
.network-interface-list,
|
||||
.network-profile__security {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -13,7 +13,11 @@ import {
|
||||
formatDuration,
|
||||
formatOptionalPercent,
|
||||
} from "../../components/system/systemFormat";
|
||||
import { useWorkerTelemetry } from "../../core/system/useWorkerTelemetry";
|
||||
import { useComputeContours } from "../../core/system/ComputeContourContext";
|
||||
import {
|
||||
useWorkerTelemetry,
|
||||
WORKER_TELEMETRY_POLL_MILLISECONDS,
|
||||
} from "../../core/system/useWorkerTelemetry";
|
||||
|
||||
function pipelineStateLabel(state: string): string {
|
||||
if (state === "busy") return "Выполняет задачу";
|
||||
@@ -22,13 +26,21 @@ function pipelineStateLabel(state: string): string {
|
||||
}
|
||||
|
||||
export function ComputeModulesWorkspace() {
|
||||
const { telemetry, loading, error, refresh } = useWorkerTelemetry();
|
||||
const { selectedContour } = useComputeContours();
|
||||
const legacyDiagnostic = selectedContour?.contour_id === "worker-006"
|
||||
&& selectedContour.telemetry_mode === "legacy-ssh";
|
||||
const supportsLiveTelemetry = selectedContour?.contour_id === "worker-006";
|
||||
const { telemetry, loading, error, refresh } = useWorkerTelemetry(
|
||||
WORKER_TELEMETRY_POLL_MILLISECONDS,
|
||||
supportsLiveTelemetry,
|
||||
);
|
||||
const node = telemetry?.node ?? null;
|
||||
const missionCoreRuntimes = telemetry?.runtimes.filter((runtime) => !runtime.external) ?? [];
|
||||
const externalRuntimes = telemetry?.runtimes.filter((runtime) => runtime.external) ?? [];
|
||||
const connected = Boolean(
|
||||
telemetry?.connection.reachable && telemetry.connection.identity_matches && node,
|
||||
);
|
||||
const agentTelemetry = telemetry?.connection.source === "agent-mqtt";
|
||||
const history = telemetry?.history ?? [];
|
||||
const cpuPercent = node?.cpu.load_percent;
|
||||
const memoryPercent = node?.memory.used_percent;
|
||||
@@ -40,15 +52,17 @@ export function ComputeModulesWorkspace() {
|
||||
<section className="system-workspace__lead">
|
||||
<div>
|
||||
<span className="section-eyebrow">СИСТЕМА / ВЫЧИСЛИТЕЛЬНЫЕ МОДУЛИ</span>
|
||||
<h2>Worker 006</h2>
|
||||
<h2>{selectedContour?.display_name ?? "Контур не выбран"}</h2>
|
||||
<p>
|
||||
Живой аппаратный и процессинговый срез выделенного узла. Нагрузка внешних
|
||||
сервисов отделена от Mission Core и не входит в оценку наших runtime.
|
||||
Аппаратный и процессинговый срез выбранного узла. Нагрузка внешних сервисов
|
||||
отделена от Mission Core и не входит в оценку наших runtime.
|
||||
</p>
|
||||
</div>
|
||||
<div className="system-workspace__actions">
|
||||
<StatusBadge tone={connected ? "success" : "danger"}>
|
||||
{connected ? "Узел доступен" : "Нет связи с узлом"}
|
||||
{connected
|
||||
? agentTelemetry ? "Агент доступен" : "SSH-диагностика"
|
||||
: "Нет свежих данных"}
|
||||
</StatusBadge>
|
||||
<Button
|
||||
size="compact"
|
||||
@@ -67,6 +81,13 @@ export function ComputeModulesWorkspace() {
|
||||
<StatusBadge tone="warning">{error}</StatusBadge>
|
||||
</GlassSurface>
|
||||
) : null}
|
||||
{!legacyDiagnostic && !agentTelemetry ? (
|
||||
<GlassSurface className="system-workspace__notice" padding="md" tone="soft">
|
||||
<StatusBadge tone="warning">
|
||||
Агентный data-plane ещё не опубликовал нормализованный срез этого контура.
|
||||
</StatusBadge>
|
||||
</GlassSurface>
|
||||
) : null}
|
||||
|
||||
<section className="system-telemetry-grid" aria-label="Аппаратная телеметрия">
|
||||
<TelemetrySeries
|
||||
@@ -99,11 +120,11 @@ export function ComputeModulesWorkspace() {
|
||||
<header className="system-section-heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">HARDWARE</span>
|
||||
<h3>{node?.node_id ?? telemetry?.profile.expected_node_id ?? "Worker 006"}</h3>
|
||||
<h3>{node?.node_id ?? selectedContour?.expected_node_id ?? "Node ID не назначен"}</h3>
|
||||
<p>{node?.os.caption ?? "Аппаратный профиль недоступен"}</p>
|
||||
</div>
|
||||
<StatusBadge tone={node?.node_id ? "success" : "danger"}>
|
||||
{node?.node_id ? telemetry?.profile.display_name : "Нет данных"}
|
||||
{node?.node_id ? selectedContour?.display_name : "Нет данных"}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
<div className="worker-hardware__facts">
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
GlassSurface,
|
||||
Icon,
|
||||
StatusBadge,
|
||||
TextField,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import { TelemetrySeries } from "../../components/system/TelemetrySeries";
|
||||
@@ -14,118 +12,49 @@ import {
|
||||
formatLatency,
|
||||
formatRate,
|
||||
} from "../../components/system/systemFormat";
|
||||
import { useComputeContours } from "../../core/system/ComputeContourContext";
|
||||
import {
|
||||
fetchWorkerProfile,
|
||||
saveWorkerProfile,
|
||||
testWorkerProfile,
|
||||
type WorkerConnectionProfile,
|
||||
type WorkerProbe,
|
||||
} from "../../core/system/workerTelemetry";
|
||||
import { useWorkerTelemetry } from "../../core/system/useWorkerTelemetry";
|
||||
|
||||
type ProfileAction = "idle" | "testing" | "saving";
|
||||
|
||||
function probeLabel(probe: WorkerProbe | null): string {
|
||||
if (!probe) return "Изменения не проверены";
|
||||
if (!probe.reachable) return "Адрес не отвечает";
|
||||
if (!probe.identity_matches) return "Ответил другой узел";
|
||||
return "Worker 006 подтверждён";
|
||||
}
|
||||
useWorkerTelemetry,
|
||||
WORKER_TELEMETRY_POLL_MILLISECONDS,
|
||||
} from "../../core/system/useWorkerTelemetry";
|
||||
|
||||
export function NetworkWorkspace() {
|
||||
const { telemetry, loading, error, refresh } = useWorkerTelemetry();
|
||||
const [profile, setProfile] = useState<WorkerConnectionProfile | null>(null);
|
||||
const [address, setAddress] = useState("");
|
||||
const [port, setPort] = useState("22");
|
||||
const [profileAction, setProfileAction] = useState<ProfileAction>("idle");
|
||||
const [probe, setProbe] = useState<WorkerProbe | null>(null);
|
||||
const [profileError, setProfileError] = useState<string | null>(null);
|
||||
const { selectedContour } = useComputeContours();
|
||||
const legacyDiagnostic = selectedContour?.contour_id === "worker-006"
|
||||
&& selectedContour.telemetry_mode === "legacy-ssh";
|
||||
const supportsLiveTelemetry = selectedContour?.contour_id === "worker-006";
|
||||
const { telemetry, loading, error, refresh } = useWorkerTelemetry(
|
||||
WORKER_TELEMETRY_POLL_MILLISECONDS,
|
||||
supportsLiveTelemetry,
|
||||
);
|
||||
const aggregate = telemetry?.network.aggregate ?? null;
|
||||
const connected = Boolean(
|
||||
telemetry?.connection.reachable && telemetry.connection.identity_matches,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void fetchWorkerProfile(controller.signal)
|
||||
.then((document) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setProfile(document.profile);
|
||||
setAddress(document.profile.address);
|
||||
setPort(String(document.profile.port));
|
||||
setProfileError(null);
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setProfileError(reason instanceof Error ? reason.message : "Профиль не прочитан.");
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
const mutation = useMemo(() => {
|
||||
const numericPort = Number(port);
|
||||
if (!profile || !Number.isInteger(numericPort) || numericPort < 1 || numericPort > 65535) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
revision: profile.revision,
|
||||
address: address.trim(),
|
||||
port: numericPort,
|
||||
};
|
||||
}, [address, port, profile]);
|
||||
|
||||
const runTest = () => {
|
||||
if (!mutation) return;
|
||||
const controller = new AbortController();
|
||||
setProfileAction("testing");
|
||||
setProfileError(null);
|
||||
setProbe(null);
|
||||
void testWorkerProfile(mutation, controller.signal)
|
||||
.then(setProbe)
|
||||
.catch((reason: unknown) => {
|
||||
setProfileError(reason instanceof Error ? reason.message : "Проверка не выполнена.");
|
||||
})
|
||||
.finally(() => setProfileAction("idle"));
|
||||
};
|
||||
|
||||
const saveProfile = () => {
|
||||
if (!mutation) return;
|
||||
const controller = new AbortController();
|
||||
setProfileAction("saving");
|
||||
setProfileError(null);
|
||||
void saveWorkerProfile(mutation, controller.signal)
|
||||
.then((document) => {
|
||||
setProfile(document.profile);
|
||||
setAddress(document.profile.address);
|
||||
setPort(String(document.profile.port));
|
||||
setProbe(document.verification);
|
||||
refresh();
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
setProfileError(reason instanceof Error ? reason.message : "Профиль не сохранён.");
|
||||
})
|
||||
.finally(() => setProfileAction("idle"));
|
||||
};
|
||||
const agentTelemetry = telemetry?.connection.source === "agent-mqtt";
|
||||
const runtimeOnline = telemetry?.runtimes.some(
|
||||
(runtime) => !runtime.external && runtime.state === "running",
|
||||
) ?? false;
|
||||
|
||||
return (
|
||||
<div className="system-workspace network-workspace">
|
||||
<section className="system-workspace__lead">
|
||||
<div>
|
||||
<span className="section-eyebrow">СИСТЕМА / СЕТЬ</span>
|
||||
<h2>Локальный вычислительный контур</h2>
|
||||
<h2>{selectedContour?.display_name ?? "Контур не выбран"}</h2>
|
||||
<p>
|
||||
Переносимый профиль связи между Mission Core и Worker 006. Адрес можно заменить
|
||||
при переходе в другую локальную сеть; идентичность узла проверяется до сохранения.
|
||||
Фактический локальный маршрут и сетевые счётчики выбранного вычислительного
|
||||
контура. Адрес, транспорт и установка агента находятся в настройках контура.
|
||||
</p>
|
||||
</div>
|
||||
<div className="system-workspace__actions">
|
||||
<StatusBadge tone={connected ? "success" : "danger"}>
|
||||
{connected ? "Маршрут доступен" : "Маршрут недоступен"}
|
||||
{connected ? "Маршрут доступен" : "Нет свежих данных"}
|
||||
</StatusBadge>
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
disabled={loading}
|
||||
disabled={loading || !supportsLiveTelemetry}
|
||||
onClick={refresh}
|
||||
icon={<Icon name="refresh" size={14} />}
|
||||
>
|
||||
@@ -134,9 +63,16 @@ export function NetworkWorkspace() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{(error || profileError) ? (
|
||||
{error ? (
|
||||
<GlassSurface className="system-workspace__notice" padding="md" tone="soft">
|
||||
<StatusBadge tone="warning">{profileError ?? error}</StatusBadge>
|
||||
<StatusBadge tone="warning">{error}</StatusBadge>
|
||||
</GlassSurface>
|
||||
) : null}
|
||||
{!legacyDiagnostic && !agentTelemetry ? (
|
||||
<GlassSurface className="system-workspace__notice" padding="md" tone="soft">
|
||||
<StatusBadge tone="warning">
|
||||
Ожидается первый нормализованный MQTT sample выбранного контура.
|
||||
</StatusBadge>
|
||||
</GlassSurface>
|
||||
) : null}
|
||||
|
||||
@@ -156,122 +92,52 @@ export function NetworkWorkspace() {
|
||||
)}
|
||||
/>
|
||||
<div className="network-stat-card">
|
||||
<span>Сбор телеметрии</span>
|
||||
<span>Источник данных</span>
|
||||
<strong>{formatLatency(telemetry?.connection.latency_ms)}</strong>
|
||||
<small>полный SSH probe Worker 006</small>
|
||||
<small>{agentTelemetry ? "agent → MQTT → normalizer" : "SSH diagnostic fallback"}</small>
|
||||
</div>
|
||||
<div className="network-stat-card">
|
||||
<span>Активные интерфейсы</span>
|
||||
<strong>{telemetry?.network.interfaces.length ?? "—"}</strong>
|
||||
<small>по Windows network counters</small>
|
||||
<small>{agentTelemetry ? "Telegraf net input" : "Windows network counters"}</small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<GlassSurface className="network-profile" padding="lg">
|
||||
<header className="system-section-heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">ПРОФИЛЬ ПОДКЛЮЧЕНИЯ</span>
|
||||
<h3>Worker 006</h3>
|
||||
<p>
|
||||
Реальный Node ID: <code>{profile?.expected_node_id ?? "DESKTOP-OPJ8J04"}</code>.
|
||||
Пустой адрес использует закреплённый локальный SSH-профиль.
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge tone={
|
||||
probe?.reachable && probe.identity_matches ? "success"
|
||||
: probe ? "danger"
|
||||
: connected ? "success" : "neutral"
|
||||
}>
|
||||
{probe ? probeLabel(probe) : connected ? "Текущий профиль работает" : "Не проверено"}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
<div className="network-profile__form">
|
||||
<TextField
|
||||
label="Адрес Worker 006"
|
||||
hint="IP или hostname"
|
||||
value={address}
|
||||
placeholder="из SSH-профиля mission-gpu"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
onChange={(event) => {
|
||||
setAddress(event.target.value);
|
||||
setProbe(null);
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="SSH-порт"
|
||||
hint="1–65535"
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={port}
|
||||
onChange={(event) => {
|
||||
setPort(event.target.value);
|
||||
setProbe(null);
|
||||
}}
|
||||
/>
|
||||
<div className="network-profile__buttons">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={!mutation || profileAction !== "idle"}
|
||||
onClick={runTest}
|
||||
>
|
||||
{profileAction === "testing" ? "Проверяем" : "Проверить"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!mutation || profileAction !== "idle"}
|
||||
onClick={saveProfile}
|
||||
>
|
||||
{profileAction === "saving" ? "Сохраняем" : "Проверить и применить"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="network-profile__security">
|
||||
<div><dt>Транспорт</dt><dd>SSH · key-only</dd></div>
|
||||
<div><dt>Host key</dt><dd>strict · pinned</dd></div>
|
||||
<div><dt>Профиль</dt><dd>{profile?.ssh_host_alias ?? "mission-gpu"}</dd></div>
|
||||
<div><dt>Учётные данные</dt><dd>не доступны интерфейсу</dd></div>
|
||||
</dl>
|
||||
</GlassSurface>
|
||||
|
||||
<GlassSurface className="network-topology" padding="lg">
|
||||
<header className="system-section-heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">ФАКТИЧЕСКИЙ МАРШРУТ</span>
|
||||
<h3>Поток управления и обработки</h3>
|
||||
<p>Схема собрана из текущего профиля и live health runtime, без demo-узлов.</p>
|
||||
<h3>Поток телеметрии и обработки</h3>
|
||||
<p>Схема отражает выбранный профиль; отсутствующие узлы не подменяются demo-состоянием.</p>
|
||||
</div>
|
||||
</header>
|
||||
<div className="network-route">
|
||||
<div data-state="online">
|
||||
<span>Операторский UI</span>
|
||||
<strong>Browser</strong>
|
||||
<strong>Mission Core</strong>
|
||||
<small>127.0.0.1:8000</small>
|
||||
</div>
|
||||
<i aria-hidden="true" data-state="online" />
|
||||
<div data-state="online">
|
||||
<span>Control Plane</span>
|
||||
<strong>Mission Core</strong>
|
||||
<small>локальный API</small>
|
||||
<div data-state={agentTelemetry ? "online" : "offline"}>
|
||||
<span>Telemetry plane</span>
|
||||
<strong>{agentTelemetry ? "Mosquitto + Timescale" : "SSH diagnostic"}</strong>
|
||||
<small>
|
||||
{agentTelemetry
|
||||
? `${selectedContour?.mqtt_host ?? "—"}:${selectedContour?.mqtt_port ?? "—"}`
|
||||
: "временный диагностический путь"}
|
||||
</small>
|
||||
</div>
|
||||
<i aria-hidden="true" data-state={connected ? "online" : "offline"} />
|
||||
<div data-state={connected ? "online" : "offline"}>
|
||||
<span>Вычислительный узел</span>
|
||||
<strong>Worker 006</strong>
|
||||
<small>{profile?.address || profile?.ssh_host_alias || "mission-gpu"}:{profile?.port ?? 22}</small>
|
||||
<span>Вычислительный контур</span>
|
||||
<strong>{selectedContour?.display_name ?? "Не выбран"}</strong>
|
||||
<small>{selectedContour?.address || selectedContour?.expected_node_id || "—"}</small>
|
||||
</div>
|
||||
<i aria-hidden="true" data-state={
|
||||
telemetry?.runtimes.some((runtime) => !runtime.external && runtime.state === "running")
|
||||
? "online" : "offline"
|
||||
} />
|
||||
<div data-state={
|
||||
telemetry?.runtimes.some((runtime) => !runtime.external && runtime.state === "running")
|
||||
? "online" : "offline"
|
||||
}>
|
||||
<i aria-hidden="true" data-state={runtimeOnline ? "online" : "offline"} />
|
||||
<div data-state={runtimeOnline ? "online" : "offline"}>
|
||||
<span>Runtime</span>
|
||||
<strong>Triton + Pipeline</strong>
|
||||
<small>внутренний Docker-контур</small>
|
||||
<strong>Inference + Pipeline</strong>
|
||||
<small>контейнеры выбранного узла</small>
|
||||
</div>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
@@ -279,9 +145,9 @@ export function NetworkWorkspace() {
|
||||
<section className="network-interface-section">
|
||||
<header className="system-section-heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">ИНТЕРФЕЙСЫ WORKER 006</span>
|
||||
<span className="section-eyebrow">ИНТЕРФЕЙСЫ УЗЛА</span>
|
||||
<h3>Адаптеры и счётчики</h3>
|
||||
<p>Только активные интерфейсы, которые вернул сам узел.</p>
|
||||
<p>Только фактически опубликованные интерфейсы выбранного контура.</p>
|
||||
</div>
|
||||
</header>
|
||||
<div className="network-interface-list">
|
||||
|
||||
@@ -16,6 +16,8 @@ test("Worker 006 telemetry remains a bounded system feature slice", async () =>
|
||||
computeWorkspace,
|
||||
pipelineStages,
|
||||
networkWorkspace,
|
||||
telemetryPolling,
|
||||
telemetryStyles,
|
||||
styles,
|
||||
] = await Promise.all([
|
||||
read("productModel.ts"),
|
||||
@@ -24,6 +26,8 @@ test("Worker 006 telemetry remains a bounded system feature slice", async () =>
|
||||
read("workspaces/system/ComputeModulesWorkspace.tsx"),
|
||||
read("components/system/WorkerPipelineStages.tsx"),
|
||||
read("workspaces/system/NetworkWorkspace.tsx"),
|
||||
read("core/system/useWorkerTelemetry.ts"),
|
||||
read("styles/system-telemetry.css"),
|
||||
read("styles.css"),
|
||||
]);
|
||||
|
||||
@@ -37,10 +41,20 @@ test("Worker 006 telemetry remains a bounded system feature slice", async () =>
|
||||
assert.match(core, /share_percent/);
|
||||
assert.doesNotMatch(core, /@nodedc\/ui-react/);
|
||||
assert.match(computeWorkspace, /useWorkerTelemetry/);
|
||||
assert.match(computeWorkspace, /WORKER_TELEMETRY_POLL_MILLISECONDS/);
|
||||
assert.match(computeWorkspace, /WorkerPipelineStages/);
|
||||
assert.match(pipelineStages, /измеренного времени/);
|
||||
assert.doesNotMatch(pipelineStages, /CPU|GPU|hardware/);
|
||||
assert.match(
|
||||
telemetryPolling,
|
||||
/WORKER_TELEMETRY_POLL_MILLISECONDS\s*=\s*3_000/,
|
||||
);
|
||||
assert.match(
|
||||
telemetryStyles,
|
||||
/\.worker-stage-list\s*\{[^}]*grid-template-columns:\s*repeat\(3,/s,
|
||||
);
|
||||
assert.match(networkWorkspace, /127\.0\.0\.1:8000/);
|
||||
assert.match(networkWorkspace, /WORKER_TELEMETRY_POLL_MILLISECONDS/);
|
||||
assert.doesNotMatch(networkWorkspace, /8765/);
|
||||
assert.match(styles, /system-telemetry\.css/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user