feat(control-station): add compute contour workspace
This commit is contained in:
@@ -21,12 +21,15 @@ import {
|
|||||||
Window,
|
Window,
|
||||||
WindowFooterActions,
|
WindowFooterActions,
|
||||||
useApplicationWorkspace,
|
useApplicationWorkspace,
|
||||||
type ApplicationPanelUtilityAction,
|
|
||||||
} from "@nodedc/ui-react";
|
} from "@nodedc/ui-react";
|
||||||
|
|
||||||
import { LandingStage } from "./components/LandingStage";
|
import { LandingStage } from "./components/LandingStage";
|
||||||
import { EnvironmentSettingsWindow } from "./components/EnvironmentSettingsWindow";
|
import { EnvironmentSettingsWindow } from "./components/EnvironmentSettingsWindow";
|
||||||
import { ObservationSessionSelect } from "./components/ObservationSessionSelect";
|
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 { useEnvironmentSettings } from "./core/environment/useEnvironmentSettings";
|
||||||
import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost";
|
import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost";
|
||||||
import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext";
|
import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext";
|
||||||
@@ -151,6 +154,7 @@ function mergeViewerSettings(
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
const runtime = useMissionRuntime();
|
const runtime = useMissionRuntime();
|
||||||
const environment = useEnvironmentSettings();
|
const environment = useEnvironmentSettings();
|
||||||
|
const computeContourSettings = useComputeContourSettings();
|
||||||
const { selection } = useDevicePluginHost();
|
const { selection } = useDevicePluginHost();
|
||||||
const polygonDatasetRoute = useMemo(
|
const polygonDatasetRoute = useMemo(
|
||||||
() => resolvePolygonRunRoute(typeof window === "undefined" ? "" : window.location.search),
|
() => resolvePolygonRunRoute(typeof window === "undefined" ? "" : window.location.search),
|
||||||
@@ -439,8 +443,14 @@ export default function App() {
|
|||||||
|
|
||||||
const selectRoot = (rootId: RootId) => {
|
const selectRoot = (rootId: RootId) => {
|
||||||
setActiveRoot(rootId);
|
setActiveRoot(rootId);
|
||||||
workspace.closeView();
|
|
||||||
workspace.openNavigation();
|
workspace.openNavigation();
|
||||||
|
if (rootId === "system") {
|
||||||
|
workspace.openView(
|
||||||
|
activeDefinition?.root === "system" ? activeDefinition.id : "modules",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
workspace.closeView();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openView = (viewId: string) => {
|
const openView = (viewId: string) => {
|
||||||
@@ -604,35 +614,13 @@ export default function App() {
|
|||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [layoutSaveNotice]);
|
}, [layoutSaveNotice]);
|
||||||
|
|
||||||
const contentActions = useMemo<ApplicationPanelUtilityAction[]>(() => {
|
const contentActions = useApplicationPanelActions({
|
||||||
const actions: ApplicationPanelUtilityAction[] = [];
|
definition: activeDefinition,
|
||||||
|
refreshRuntime: runtime.refresh,
|
||||||
if (activeDefinition?.kind === "device") {
|
saveWorkspaceLayout,
|
||||||
actions.push({
|
workspaceLayoutSaving: workspaceLayoutProfile.state === "saving",
|
||||||
label: "Обновить состояние локального контура",
|
systemUtilityAction: computeContourSettings.utilityAction,
|
||||||
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 header = (
|
const header = (
|
||||||
<AppHeader
|
<AppHeader
|
||||||
@@ -702,7 +690,13 @@ export default function App() {
|
|||||||
onOpenWorkspace={openView}
|
onOpenWorkspace={openView}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
navigation={currentRoot ? (
|
navigation={currentRoot && activeRoot === "system" ? (
|
||||||
|
<SystemNavigationPanel
|
||||||
|
title={currentRoot.title}
|
||||||
|
onAdd={computeContourSettings.openCreate}
|
||||||
|
onClose={workspace.closeNavigation}
|
||||||
|
/>
|
||||||
|
) : currentRoot ? (
|
||||||
<AdminNavigationPanel
|
<AdminNavigationPanel
|
||||||
eyebrow="MISSION CORE"
|
eyebrow="MISSION CORE"
|
||||||
title={currentRoot.title}
|
title={currentRoot.title}
|
||||||
@@ -718,7 +712,9 @@ export default function App() {
|
|||||||
selection?.model.displayName ||
|
selection?.model.displayName ||
|
||||||
"Модель не выбрана",
|
"Модель не выбрана",
|
||||||
icon: <Icon name="network" />,
|
icon: <Icon name="network" />,
|
||||||
active: runtime.backendStatus !== "offline" && runtime.backendStatus !== "unconfigured",
|
active:
|
||||||
|
runtime.backendStatus !== "offline"
|
||||||
|
&& runtime.backendStatus !== "unconfigured",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
items={rootWorkspaces.map((item) => ({
|
items={rootWorkspaces.map((item) => ({
|
||||||
@@ -795,10 +791,11 @@ export default function App() {
|
|||||||
<StatusBadge tone="neutral">Offline evaluation</StatusBadge>
|
<StatusBadge tone="neutral">Offline evaluation</StatusBadge>
|
||||||
) : activeDefinition.kind === "lab-archive" ? (
|
) : activeDefinition.kind === "lab-archive" ? (
|
||||||
null
|
null
|
||||||
) : activeDefinition.kind === "compute-modules" ? (
|
) : activeDefinition.root === "system" ? (
|
||||||
<StatusBadge tone="neutral">Живая телеметрия</StatusBadge>
|
<SystemWorkspaceSelector
|
||||||
) : activeDefinition.kind === "network-monitor" ? (
|
value={activeDefinition.id}
|
||||||
<StatusBadge tone="neutral">Живая сеть</StatusBadge>
|
onChange={openView}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<StatusBadge tone="warning">Интерфейс готов</StatusBadge>
|
<StatusBadge tone="warning">Интерфейс готов</StatusBadge>
|
||||||
)
|
)
|
||||||
@@ -864,6 +861,7 @@ export default function App() {
|
|||||||
onSave={environment.save}
|
onSave={environment.save}
|
||||||
onUpload={environment.upload}
|
onUpload={environment.upload}
|
||||||
/>
|
/>
|
||||||
|
{computeContourSettings.window}
|
||||||
|
|
||||||
<Window
|
<Window
|
||||||
open={sceneWorkspaceActive && sourceWindowOpen}
|
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,
|
type WorkerTelemetry,
|
||||||
} from "./workerTelemetry";
|
} from "./workerTelemetry";
|
||||||
|
|
||||||
|
export const WORKER_TELEMETRY_POLL_MILLISECONDS = 3_000;
|
||||||
|
|
||||||
export interface WorkerTelemetryState {
|
export interface WorkerTelemetryState {
|
||||||
telemetry: WorkerTelemetry | null;
|
telemetry: WorkerTelemetry | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -12,7 +14,10 @@ export interface WorkerTelemetryState {
|
|||||||
refresh: () => void;
|
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 [telemetry, setTelemetry] = useState<WorkerTelemetry | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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), []);
|
const refresh = useCallback(() => setGeneration((value) => value + 1), []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
setTelemetry(null);
|
||||||
|
setLoading(false);
|
||||||
|
setError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
void fetchWorkerTelemetry(controller.signal)
|
void fetchWorkerTelemetry(controller.signal)
|
||||||
@@ -36,12 +47,13 @@ export function useWorkerTelemetry(pollMilliseconds = 10_000): WorkerTelemetrySt
|
|||||||
if (!controller.signal.aborted) setLoading(false);
|
if (!controller.signal.aborted) setLoading(false);
|
||||||
});
|
});
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [generation]);
|
}, [enabled, generation]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = window.setInterval(refresh, pollMilliseconds);
|
if (!enabled || loading) return;
|
||||||
return () => window.clearInterval(timer);
|
const timer = window.setTimeout(refresh, pollMilliseconds);
|
||||||
}, [pollMilliseconds, refresh]);
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [enabled, loading, pollMilliseconds, refresh]);
|
||||||
|
|
||||||
return { telemetry, loading, error, refresh };
|
return { telemetry, loading, error, refresh };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface WorkerConnectionProfile {
|
|||||||
|
|
||||||
export interface WorkerProbe {
|
export interface WorkerProbe {
|
||||||
schema_version: "missioncore.worker-probe/v1";
|
schema_version: "missioncore.worker-probe/v1";
|
||||||
|
source: "agent-mqtt" | "legacy-ssh" | "unknown";
|
||||||
reachable: boolean;
|
reachable: boolean;
|
||||||
identity_matches: boolean;
|
identity_matches: boolean;
|
||||||
node_id: string | null;
|
node_id: string | null;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import "@nodedc/ui-core/styles.css";
|
|||||||
import App from "./App";
|
import App from "./App";
|
||||||
import { installedDevicePlugins } from "./composition/devicePlugins";
|
import { installedDevicePlugins } from "./composition/devicePlugins";
|
||||||
import { DevicePluginHostProvider } from "./core/device-plugins/DevicePluginHost";
|
import { DevicePluginHostProvider } from "./core/device-plugins/DevicePluginHost";
|
||||||
|
import { ComputeContourProvider } from "./core/system/ComputeContourContext";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
const rootElement = document.getElementById("root");
|
const rootElement = document.getElementById("root");
|
||||||
@@ -23,7 +24,9 @@ applyNodedcTheme(rootElement, { theme: "dark" });
|
|||||||
createRoot(rootElement).render(
|
createRoot(rootElement).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<DevicePluginHostProvider plugins={installedDevicePlugins}>
|
<DevicePluginHostProvider plugins={installedDevicePlugins}>
|
||||||
|
<ComputeContourProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</ComputeContourProvider>
|
||||||
</DevicePluginHostProvider>
|
</DevicePluginHostProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -591,7 +591,7 @@ export const workspaces: WorkspaceDefinition[] = [
|
|||||||
label: "Вычислительные модули",
|
label: "Вычислительные модули",
|
||||||
title: "Вычислительные модули",
|
title: "Вычислительные модули",
|
||||||
eyebrow: "СИСТЕМА / МОДУЛИ",
|
eyebrow: "СИСТЕМА / МОДУЛИ",
|
||||||
description: "Живая аппаратная и процессинговая телеметрия выделенного Worker 006.",
|
description: "Аппаратная и процессинговая телеметрия выбранного вычислительного контура.",
|
||||||
icon: "apps",
|
icon: "apps",
|
||||||
kind: "compute-modules",
|
kind: "compute-modules",
|
||||||
groups: [
|
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 {
|
export function rootById(id: RootId | null): RootDefinition | null {
|
||||||
|
|||||||
@@ -5,6 +5,73 @@
|
|||||||
padding-bottom: 1rem;
|
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-workspace__lead,
|
||||||
.system-section-heading {
|
.system-section-heading {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -299,7 +366,7 @@
|
|||||||
.worker-stage-list {
|
.worker-stage-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
gap: 0.48rem;
|
gap: 0.48rem;
|
||||||
@@ -518,6 +585,7 @@
|
|||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.worker-stage-list,
|
||||||
.network-interface-list {
|
.network-interface-list {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
@@ -543,6 +611,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
|
.compute-contour-settings__form,
|
||||||
|
.compute-contour-settings__install dl {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.system-workspace__lead,
|
.system-workspace__lead,
|
||||||
.system-section-heading {
|
.system-section-heading {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -556,6 +629,7 @@
|
|||||||
.network-overview-grid,
|
.network-overview-grid,
|
||||||
.worker-hardware__facts,
|
.worker-hardware__facts,
|
||||||
.worker-runtime-grid,
|
.worker-runtime-grid,
|
||||||
|
.worker-stage-list,
|
||||||
.network-interface-list,
|
.network-interface-list,
|
||||||
.network-profile__security {
|
.network-profile__security {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ import {
|
|||||||
formatDuration,
|
formatDuration,
|
||||||
formatOptionalPercent,
|
formatOptionalPercent,
|
||||||
} from "../../components/system/systemFormat";
|
} 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 {
|
function pipelineStateLabel(state: string): string {
|
||||||
if (state === "busy") return "Выполняет задачу";
|
if (state === "busy") return "Выполняет задачу";
|
||||||
@@ -22,13 +26,21 @@ function pipelineStateLabel(state: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ComputeModulesWorkspace() {
|
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 node = telemetry?.node ?? null;
|
||||||
const missionCoreRuntimes = telemetry?.runtimes.filter((runtime) => !runtime.external) ?? [];
|
const missionCoreRuntimes = telemetry?.runtimes.filter((runtime) => !runtime.external) ?? [];
|
||||||
const externalRuntimes = telemetry?.runtimes.filter((runtime) => runtime.external) ?? [];
|
const externalRuntimes = telemetry?.runtimes.filter((runtime) => runtime.external) ?? [];
|
||||||
const connected = Boolean(
|
const connected = Boolean(
|
||||||
telemetry?.connection.reachable && telemetry.connection.identity_matches && node,
|
telemetry?.connection.reachable && telemetry.connection.identity_matches && node,
|
||||||
);
|
);
|
||||||
|
const agentTelemetry = telemetry?.connection.source === "agent-mqtt";
|
||||||
const history = telemetry?.history ?? [];
|
const history = telemetry?.history ?? [];
|
||||||
const cpuPercent = node?.cpu.load_percent;
|
const cpuPercent = node?.cpu.load_percent;
|
||||||
const memoryPercent = node?.memory.used_percent;
|
const memoryPercent = node?.memory.used_percent;
|
||||||
@@ -40,15 +52,17 @@ export function ComputeModulesWorkspace() {
|
|||||||
<section className="system-workspace__lead">
|
<section className="system-workspace__lead">
|
||||||
<div>
|
<div>
|
||||||
<span className="section-eyebrow">СИСТЕМА / ВЫЧИСЛИТЕЛЬНЫЕ МОДУЛИ</span>
|
<span className="section-eyebrow">СИСТЕМА / ВЫЧИСЛИТЕЛЬНЫЕ МОДУЛИ</span>
|
||||||
<h2>Worker 006</h2>
|
<h2>{selectedContour?.display_name ?? "Контур не выбран"}</h2>
|
||||||
<p>
|
<p>
|
||||||
Живой аппаратный и процессинговый срез выделенного узла. Нагрузка внешних
|
Аппаратный и процессинговый срез выбранного узла. Нагрузка внешних сервисов
|
||||||
сервисов отделена от Mission Core и не входит в оценку наших runtime.
|
отделена от Mission Core и не входит в оценку наших runtime.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="system-workspace__actions">
|
<div className="system-workspace__actions">
|
||||||
<StatusBadge tone={connected ? "success" : "danger"}>
|
<StatusBadge tone={connected ? "success" : "danger"}>
|
||||||
{connected ? "Узел доступен" : "Нет связи с узлом"}
|
{connected
|
||||||
|
? agentTelemetry ? "Агент доступен" : "SSH-диагностика"
|
||||||
|
: "Нет свежих данных"}
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
<Button
|
<Button
|
||||||
size="compact"
|
size="compact"
|
||||||
@@ -67,6 +81,13 @@ export function ComputeModulesWorkspace() {
|
|||||||
<StatusBadge tone="warning">{error}</StatusBadge>
|
<StatusBadge tone="warning">{error}</StatusBadge>
|
||||||
</GlassSurface>
|
</GlassSurface>
|
||||||
) : null}
|
) : 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="Аппаратная телеметрия">
|
<section className="system-telemetry-grid" aria-label="Аппаратная телеметрия">
|
||||||
<TelemetrySeries
|
<TelemetrySeries
|
||||||
@@ -99,11 +120,11 @@ export function ComputeModulesWorkspace() {
|
|||||||
<header className="system-section-heading">
|
<header className="system-section-heading">
|
||||||
<div>
|
<div>
|
||||||
<span className="section-eyebrow">HARDWARE</span>
|
<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>
|
<p>{node?.os.caption ?? "Аппаратный профиль недоступен"}</p>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge tone={node?.node_id ? "success" : "danger"}>
|
<StatusBadge tone={node?.node_id ? "success" : "danger"}>
|
||||||
{node?.node_id ? telemetry?.profile.display_name : "Нет данных"}
|
{node?.node_id ? selectedContour?.display_name : "Нет данных"}
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
</header>
|
</header>
|
||||||
<div className="worker-hardware__facts">
|
<div className="worker-hardware__facts">
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
GlassSurface,
|
GlassSurface,
|
||||||
Icon,
|
Icon,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
TextField,
|
|
||||||
} from "@nodedc/ui-react";
|
} from "@nodedc/ui-react";
|
||||||
|
|
||||||
import { TelemetrySeries } from "../../components/system/TelemetrySeries";
|
import { TelemetrySeries } from "../../components/system/TelemetrySeries";
|
||||||
@@ -14,118 +12,49 @@ import {
|
|||||||
formatLatency,
|
formatLatency,
|
||||||
formatRate,
|
formatRate,
|
||||||
} from "../../components/system/systemFormat";
|
} from "../../components/system/systemFormat";
|
||||||
|
import { useComputeContours } from "../../core/system/ComputeContourContext";
|
||||||
import {
|
import {
|
||||||
fetchWorkerProfile,
|
useWorkerTelemetry,
|
||||||
saveWorkerProfile,
|
WORKER_TELEMETRY_POLL_MILLISECONDS,
|
||||||
testWorkerProfile,
|
} from "../../core/system/useWorkerTelemetry";
|
||||||
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 подтверждён";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NetworkWorkspace() {
|
export function NetworkWorkspace() {
|
||||||
const { telemetry, loading, error, refresh } = useWorkerTelemetry();
|
const { selectedContour } = useComputeContours();
|
||||||
const [profile, setProfile] = useState<WorkerConnectionProfile | null>(null);
|
const legacyDiagnostic = selectedContour?.contour_id === "worker-006"
|
||||||
const [address, setAddress] = useState("");
|
&& selectedContour.telemetry_mode === "legacy-ssh";
|
||||||
const [port, setPort] = useState("22");
|
const supportsLiveTelemetry = selectedContour?.contour_id === "worker-006";
|
||||||
const [profileAction, setProfileAction] = useState<ProfileAction>("idle");
|
const { telemetry, loading, error, refresh } = useWorkerTelemetry(
|
||||||
const [probe, setProbe] = useState<WorkerProbe | null>(null);
|
WORKER_TELEMETRY_POLL_MILLISECONDS,
|
||||||
const [profileError, setProfileError] = useState<string | null>(null);
|
supportsLiveTelemetry,
|
||||||
|
);
|
||||||
const aggregate = telemetry?.network.aggregate ?? null;
|
const aggregate = telemetry?.network.aggregate ?? null;
|
||||||
const connected = Boolean(
|
const connected = Boolean(
|
||||||
telemetry?.connection.reachable && telemetry.connection.identity_matches,
|
telemetry?.connection.reachable && telemetry.connection.identity_matches,
|
||||||
);
|
);
|
||||||
|
const agentTelemetry = telemetry?.connection.source === "agent-mqtt";
|
||||||
useEffect(() => {
|
const runtimeOnline = telemetry?.runtimes.some(
|
||||||
const controller = new AbortController();
|
(runtime) => !runtime.external && runtime.state === "running",
|
||||||
void fetchWorkerProfile(controller.signal)
|
) ?? false;
|
||||||
.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"));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="system-workspace network-workspace">
|
<div className="system-workspace network-workspace">
|
||||||
<section className="system-workspace__lead">
|
<section className="system-workspace__lead">
|
||||||
<div>
|
<div>
|
||||||
<span className="section-eyebrow">СИСТЕМА / СЕТЬ</span>
|
<span className="section-eyebrow">СИСТЕМА / СЕТЬ</span>
|
||||||
<h2>Локальный вычислительный контур</h2>
|
<h2>{selectedContour?.display_name ?? "Контур не выбран"}</h2>
|
||||||
<p>
|
<p>
|
||||||
Переносимый профиль связи между Mission Core и Worker 006. Адрес можно заменить
|
Фактический локальный маршрут и сетевые счётчики выбранного вычислительного
|
||||||
при переходе в другую локальную сеть; идентичность узла проверяется до сохранения.
|
контура. Адрес, транспорт и установка агента находятся в настройках контура.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="system-workspace__actions">
|
<div className="system-workspace__actions">
|
||||||
<StatusBadge tone={connected ? "success" : "danger"}>
|
<StatusBadge tone={connected ? "success" : "danger"}>
|
||||||
{connected ? "Маршрут доступен" : "Маршрут недоступен"}
|
{connected ? "Маршрут доступен" : "Нет свежих данных"}
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
<Button
|
<Button
|
||||||
size="compact"
|
size="compact"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
disabled={loading}
|
disabled={loading || !supportsLiveTelemetry}
|
||||||
onClick={refresh}
|
onClick={refresh}
|
||||||
icon={<Icon name="refresh" size={14} />}
|
icon={<Icon name="refresh" size={14} />}
|
||||||
>
|
>
|
||||||
@@ -134,9 +63,16 @@ export function NetworkWorkspace() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{(error || profileError) ? (
|
{error ? (
|
||||||
<GlassSurface className="system-workspace__notice" padding="md" tone="soft">
|
<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>
|
</GlassSurface>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -156,122 +92,52 @@ export function NetworkWorkspace() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="network-stat-card">
|
<div className="network-stat-card">
|
||||||
<span>Сбор телеметрии</span>
|
<span>Источник данных</span>
|
||||||
<strong>{formatLatency(telemetry?.connection.latency_ms)}</strong>
|
<strong>{formatLatency(telemetry?.connection.latency_ms)}</strong>
|
||||||
<small>полный SSH probe Worker 006</small>
|
<small>{agentTelemetry ? "agent → MQTT → normalizer" : "SSH diagnostic fallback"}</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="network-stat-card">
|
<div className="network-stat-card">
|
||||||
<span>Активные интерфейсы</span>
|
<span>Активные интерфейсы</span>
|
||||||
<strong>{telemetry?.network.interfaces.length ?? "—"}</strong>
|
<strong>{telemetry?.network.interfaces.length ?? "—"}</strong>
|
||||||
<small>по Windows network counters</small>
|
<small>{agentTelemetry ? "Telegraf net input" : "Windows network counters"}</small>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<GlassSurface className="network-topology" padding="lg">
|
||||||
<header className="system-section-heading">
|
<header className="system-section-heading">
|
||||||
<div>
|
<div>
|
||||||
<span className="section-eyebrow">ФАКТИЧЕСКИЙ МАРШРУТ</span>
|
<span className="section-eyebrow">ФАКТИЧЕСКИЙ МАРШРУТ</span>
|
||||||
<h3>Поток управления и обработки</h3>
|
<h3>Поток телеметрии и обработки</h3>
|
||||||
<p>Схема собрана из текущего профиля и live health runtime, без demo-узлов.</p>
|
<p>Схема отражает выбранный профиль; отсутствующие узлы не подменяются demo-состоянием.</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="network-route">
|
<div className="network-route">
|
||||||
<div data-state="online">
|
<div data-state="online">
|
||||||
<span>Операторский UI</span>
|
<span>Операторский UI</span>
|
||||||
<strong>Browser</strong>
|
<strong>Mission Core</strong>
|
||||||
<small>127.0.0.1:8000</small>
|
<small>127.0.0.1:8000</small>
|
||||||
</div>
|
</div>
|
||||||
<i aria-hidden="true" data-state="online" />
|
<i aria-hidden="true" data-state="online" />
|
||||||
<div data-state="online">
|
<div data-state={agentTelemetry ? "online" : "offline"}>
|
||||||
<span>Control Plane</span>
|
<span>Telemetry plane</span>
|
||||||
<strong>Mission Core</strong>
|
<strong>{agentTelemetry ? "Mosquitto + Timescale" : "SSH diagnostic"}</strong>
|
||||||
<small>локальный API</small>
|
<small>
|
||||||
|
{agentTelemetry
|
||||||
|
? `${selectedContour?.mqtt_host ?? "—"}:${selectedContour?.mqtt_port ?? "—"}`
|
||||||
|
: "временный диагностический путь"}
|
||||||
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<i aria-hidden="true" data-state={connected ? "online" : "offline"} />
|
<i aria-hidden="true" data-state={connected ? "online" : "offline"} />
|
||||||
<div data-state={connected ? "online" : "offline"}>
|
<div data-state={connected ? "online" : "offline"}>
|
||||||
<span>Вычислительный узел</span>
|
<span>Вычислительный контур</span>
|
||||||
<strong>Worker 006</strong>
|
<strong>{selectedContour?.display_name ?? "Не выбран"}</strong>
|
||||||
<small>{profile?.address || profile?.ssh_host_alias || "mission-gpu"}:{profile?.port ?? 22}</small>
|
<small>{selectedContour?.address || selectedContour?.expected_node_id || "—"}</small>
|
||||||
</div>
|
</div>
|
||||||
<i aria-hidden="true" data-state={
|
<i aria-hidden="true" data-state={runtimeOnline ? "online" : "offline"} />
|
||||||
telemetry?.runtimes.some((runtime) => !runtime.external && runtime.state === "running")
|
<div data-state={runtimeOnline ? "online" : "offline"}>
|
||||||
? "online" : "offline"
|
|
||||||
} />
|
|
||||||
<div data-state={
|
|
||||||
telemetry?.runtimes.some((runtime) => !runtime.external && runtime.state === "running")
|
|
||||||
? "online" : "offline"
|
|
||||||
}>
|
|
||||||
<span>Runtime</span>
|
<span>Runtime</span>
|
||||||
<strong>Triton + Pipeline</strong>
|
<strong>Inference + Pipeline</strong>
|
||||||
<small>внутренний Docker-контур</small>
|
<small>контейнеры выбранного узла</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</GlassSurface>
|
</GlassSurface>
|
||||||
@@ -279,9 +145,9 @@ export function NetworkWorkspace() {
|
|||||||
<section className="network-interface-section">
|
<section className="network-interface-section">
|
||||||
<header className="system-section-heading">
|
<header className="system-section-heading">
|
||||||
<div>
|
<div>
|
||||||
<span className="section-eyebrow">ИНТЕРФЕЙСЫ WORKER 006</span>
|
<span className="section-eyebrow">ИНТЕРФЕЙСЫ УЗЛА</span>
|
||||||
<h3>Адаптеры и счётчики</h3>
|
<h3>Адаптеры и счётчики</h3>
|
||||||
<p>Только активные интерфейсы, которые вернул сам узел.</p>
|
<p>Только фактически опубликованные интерфейсы выбранного контура.</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="network-interface-list">
|
<div className="network-interface-list">
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ test("Worker 006 telemetry remains a bounded system feature slice", async () =>
|
|||||||
computeWorkspace,
|
computeWorkspace,
|
||||||
pipelineStages,
|
pipelineStages,
|
||||||
networkWorkspace,
|
networkWorkspace,
|
||||||
|
telemetryPolling,
|
||||||
|
telemetryStyles,
|
||||||
styles,
|
styles,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
read("productModel.ts"),
|
read("productModel.ts"),
|
||||||
@@ -24,6 +26,8 @@ test("Worker 006 telemetry remains a bounded system feature slice", async () =>
|
|||||||
read("workspaces/system/ComputeModulesWorkspace.tsx"),
|
read("workspaces/system/ComputeModulesWorkspace.tsx"),
|
||||||
read("components/system/WorkerPipelineStages.tsx"),
|
read("components/system/WorkerPipelineStages.tsx"),
|
||||||
read("workspaces/system/NetworkWorkspace.tsx"),
|
read("workspaces/system/NetworkWorkspace.tsx"),
|
||||||
|
read("core/system/useWorkerTelemetry.ts"),
|
||||||
|
read("styles/system-telemetry.css"),
|
||||||
read("styles.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.match(core, /share_percent/);
|
||||||
assert.doesNotMatch(core, /@nodedc\/ui-react/);
|
assert.doesNotMatch(core, /@nodedc\/ui-react/);
|
||||||
assert.match(computeWorkspace, /useWorkerTelemetry/);
|
assert.match(computeWorkspace, /useWorkerTelemetry/);
|
||||||
|
assert.match(computeWorkspace, /WORKER_TELEMETRY_POLL_MILLISECONDS/);
|
||||||
assert.match(computeWorkspace, /WorkerPipelineStages/);
|
assert.match(computeWorkspace, /WorkerPipelineStages/);
|
||||||
assert.match(pipelineStages, /измеренного времени/);
|
assert.match(pipelineStages, /измеренного времени/);
|
||||||
assert.doesNotMatch(pipelineStages, /CPU|GPU|hardware/);
|
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, /127\.0\.0\.1:8000/);
|
||||||
|
assert.match(networkWorkspace, /WORKER_TELEMETRY_POLL_MILLISECONDS/);
|
||||||
assert.doesNotMatch(networkWorkspace, /8765/);
|
assert.doesNotMatch(networkWorkspace, /8765/);
|
||||||
assert.match(styles, /system-telemetry\.css/);
|
assert.match(styles, /system-telemetry\.css/);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user