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
@@ -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}
/>
),
};
}