feat(control-station): add compute contour workspace

This commit is contained in:
DCCONSTRUCTIONS
2026-07-28 00:54:49 +03:00
parent 1f4c960cc4
commit 1c0181297d
16 changed files with 987 additions and 262 deletions
+35 -37
View File
@@ -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(),
});
}
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 contentActions = useApplicationPanelActions({
definition: activeDefinition,
refreshRuntime: runtime.refresh,
saveWorkspaceLayout,
workspaceLayoutSaving: workspaceLayoutProfile.state === "saving",
systemUtilityAction: computeContourSettings.utilityAction,
});
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;
+4 -1
View File
@@ -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}>
<App />
<ComputeContourProvider>
<App />
</ComputeContourProvider>
</DevicePluginHostProvider>
</StrictMode>,
);
+1 -23
View File
@@ -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">