feat(system): manage compute contour network profile

This commit is contained in:
DCCONSTRUCTIONS
2026-07-29 12:32:41 +03:00
parent 1eba7c82ba
commit d8ccc9345a
8 changed files with 1139 additions and 9 deletions
@@ -12,10 +12,13 @@ import {
} from "@nodedc/ui-react";
import {
applyComputeContourNetwork,
fetchComputeContourAgentInstall,
fetchComputeContourNetwork,
type ComputeContour,
type ComputeContourAgentInstall,
type ComputeContourDraft,
type ComputeContourNetworkStatus,
type ComputeContourPlatform,
} from "../../core/system/computeContours";
import {
@@ -56,6 +59,7 @@ function emptyDraft(): ComputeContourDraft {
ssh_port: 22,
mqtt_host: "127.0.0.1",
mqtt_port: 1883,
mqtt_bind_address: "127.0.0.1",
telemetry_poll_interval_seconds: DEFAULT_TELEMETRY_POLL_INTERVAL_SECONDS,
mqtt_publish_interval_seconds: 2,
};
@@ -72,6 +76,7 @@ function draftFromContour(contour: ComputeContour | null): ComputeContourDraft {
ssh_port: contour.ssh_port,
mqtt_host: contour.mqtt_host,
mqtt_port: contour.mqtt_port,
mqtt_bind_address: contour.mqtt_bind_address,
telemetry_poll_interval_seconds: contour.telemetry_poll_interval_seconds,
mqtt_publish_interval_seconds: contour.mqtt_publish_interval_seconds,
};
@@ -94,6 +99,10 @@ export function ComputeContourSettingsWindow({
);
const [activeSection, setActiveSection] = useState<"connection" | "agent">("connection");
const [install, setInstall] = useState<ComputeContourAgentInstall | null>(null);
const [network, setNetwork] = useState<ComputeContourNetworkStatus | null>(null);
const [networkAction, setNetworkAction] = useState<
"refresh" | "broker" | "worker" | null
>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -105,9 +114,30 @@ export function ComputeContourSettingsWindow({
setMqttPublishIntervalDraft(String(nextDraft.mqtt_publish_interval_seconds));
setActiveSection("connection");
setInstall(null);
setNetwork(null);
setNetworkAction(null);
setError(null);
}, [contour, mode, open]);
useEffect(() => {
if (!open || mode !== "edit" || !contour) return;
const controller = new AbortController();
setNetworkAction("refresh");
void fetchComputeContourNetwork(contour.contour_id, controller.signal)
.then((document) => {
if (!controller.signal.aborted) setNetwork(document);
})
.catch((reason: unknown) => {
if (!controller.signal.aborted) {
setError(reason instanceof Error ? reason.message : "Сетевая проверка недоступна.");
}
})
.finally(() => {
if (!controller.signal.aborted) setNetworkAction(null);
});
return () => controller.abort();
}, [contour, mode, open]);
useEffect(() => {
if (!open || mode !== "edit" || !contour) return;
const controller = new AbortController();
@@ -135,6 +165,8 @@ export function ComputeContourSettingsWindow({
const valid = useMemo(() => (
Boolean(draft.display_name.trim())
&& Boolean(draft.expected_node_id.trim())
&& Boolean(draft.mqtt_host.trim())
&& Boolean(draft.mqtt_bind_address.trim())
&& Number.isInteger(draft.ssh_port)
&& draft.ssh_port > 0
&& Number.isInteger(draft.mqtt_port)
@@ -143,6 +175,16 @@ export function ComputeContourSettingsWindow({
&& mqttPublishIntervalSeconds !== null
), [draft, mqttPublishIntervalSeconds, telemetryPollIntervalSeconds]);
const networkProfileDirty = Boolean(
contour
&& (
draft.mqtt_host !== contour.mqtt_host
|| draft.mqtt_port !== contour.mqtt_port
|| draft.mqtt_bind_address !== contour.mqtt_bind_address
|| draft.mqtt_publish_interval_seconds !== contour.mqtt_publish_interval_seconds
),
);
const commitTelemetryPollInterval = () => {
const resolution = resolveTelemetryPollIntervalDraft(
telemetryPollIntervalDraft,
@@ -205,6 +247,35 @@ export function ComputeContourSettingsWindow({
}
};
const refreshNetwork = async () => {
if (!contour || networkAction) return;
setNetworkAction("refresh");
setError(null);
try {
setNetwork(await fetchComputeContourNetwork(contour.contour_id));
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Сетевая проверка недоступна.");
} finally {
setNetworkAction(null);
}
};
const applyNetwork = async (target: "broker" | "worker") => {
if (!contour || networkAction || networkProfileDirty) return;
setNetworkAction(target);
setError(null);
try {
await applyComputeContourNetwork(contour.contour_id, target);
setNetwork(await fetchComputeContourNetwork(contour.contour_id));
} catch (reason) {
setError(
reason instanceof Error ? reason.message : "Сетевой профиль не применён.",
);
} finally {
setNetworkAction(null);
}
};
return (
<FeatureSettingsWindow
open={open}
@@ -244,6 +315,7 @@ export function ComputeContourSettingsWindow({
>
<div className="compute-contour-settings">
{activeSection === "connection" ? (
<>
<SettingsCard
eyebrow="КОНТУР"
title="Идентичность и локальная сеть"
@@ -289,6 +361,7 @@ export function ComputeContourSettingsWindow({
/>
<TextField
label="MQTT broker"
hint="Стабильный hostname рекомендуется вместо выдаваемого DHCP IP"
value={draft.mqtt_host}
onChange={(event) => setDraft((current) => ({
...current,
@@ -306,6 +379,15 @@ export function ComputeContourSettingsWindow({
mqtt_port: Number(event.currentTarget.value),
}))}
/>
<TextField
label="Публикация broker"
hint="IP текущего Mac в разрешённой LAN · 0.0.0.0 запрещён"
value={draft.mqtt_bind_address}
onChange={(event) => setDraft((current) => ({
...current,
mqtt_bind_address: event.currentTarget.value,
}))}
/>
<TextField
label="Обновление интерфейса"
hint="160 с"
@@ -369,6 +451,90 @@ export function ComputeContourSettingsWindow({
) : null}
</div>
</SettingsCard>
<SettingsCard
eyebrow="СЕТЕВОЙ ПРОФИЛЬ"
title="MQTT · Mac ↔ Worker"
description="Mesh-точки одной LAN не закрепляются по отдельности. Профиль хранит стабильное имя Mac и адрес публикации broker в текущей сети."
actions={(
<StatusBadge tone={network?.broker.ready ? "success" : "warning"}>
{network?.broker.ready ? "Broker доступен" : "Нужна проверка"}
</StatusBadge>
)}
>
<div className="compute-contour-settings__network">
<dl>
<div>
<dt>Endpoint Worker</dt>
<dd>{network
? `${network.broker.configured_host}:${network.broker.configured_port}`
: "—"}</dd>
</div>
<div>
<dt>Текущий IP</dt>
<dd>{network?.broker.resolved_addresses.join(", ") || "—"}</dd>
</div>
<div>
<dt>Публикация Mac</dt>
<dd>{network?.broker.bind_address ?? "—"}</dd>
</div>
<div>
<dt>Worker 006</dt>
<dd>{network?.worker.reachable
? `${network.worker.mqtt_host ?? "—"}:${network.worker.mqtt_port ?? "—"}`
: "Недоступен"}</dd>
</div>
</dl>
<div className="compute-contour-settings__network-statuses">
<StatusBadge tone={network?.broker.bind_reachable ? "success" : "danger"}>
{network?.broker.bind_reachable
? "Listener Mac доступен"
: "Listener Mac недоступен"}
</StatusBadge>
<StatusBadge tone={network?.worker.broker_reachable ? "success" : "danger"}>
{network?.worker.broker_reachable
? "Worker видит broker"
: "Worker не видит broker"}
</StatusBadge>
<StatusBadge tone={network?.worker.matches_profile ? "success" : "warning"}>
{network?.worker.matches_profile
? "Профиль синхронизирован"
: "Профиль отличается"}
</StatusBadge>
</div>
<div className="compute-contour-settings__network-actions">
<Button
size="compact"
variant="secondary"
disabled={Boolean(networkAction)}
icon={<Icon name="refresh" size={14} />}
onClick={() => void refreshNetwork()}
>
{networkAction === "refresh" ? "Проверяем…" : "Проверить"}
</Button>
<Button
size="compact"
variant="secondary"
disabled={Boolean(networkAction) || networkProfileDirty}
onClick={() => void applyNetwork("broker")}
>
{networkAction === "broker" ? "Применяем…" : "Применить на Mac"}
</Button>
<Button
size="compact"
variant="secondary"
disabled={Boolean(networkAction) || networkProfileDirty}
onClick={() => void applyNetwork("worker")}
>
{networkAction === "worker" ? "Применяем…" : "Применить на Worker"}
</Button>
</div>
{networkProfileDirty ? (
<p>Сначала сохраните изменённый сетевой профиль, затем примените его.</p>
) : null}
{error ? <StatusBadge tone="danger">{error}</StatusBadge> : null}
</div>
</SettingsCard>
</>
) : null}
{activeSection === "agent" ? (