feat(system): manage compute contour network profile
This commit is contained in:
@@ -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="1–60 с"
|
||||
@@ -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" ? (
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface ComputeContour {
|
||||
ssh_port: number;
|
||||
mqtt_host: string;
|
||||
mqtt_port: number;
|
||||
mqtt_bind_address: string;
|
||||
telemetry_poll_interval_seconds: number;
|
||||
mqtt_publish_interval_seconds: number;
|
||||
revision: number;
|
||||
@@ -33,6 +34,7 @@ export interface ComputeContourDraft {
|
||||
ssh_port: number;
|
||||
mqtt_host: string;
|
||||
mqtt_port: number;
|
||||
mqtt_bind_address: string;
|
||||
telemetry_poll_interval_seconds: number;
|
||||
mqtt_publish_interval_seconds: number;
|
||||
}
|
||||
@@ -52,6 +54,45 @@ export interface ComputeContourAgentInstall {
|
||||
blocked_reason: string | null;
|
||||
}
|
||||
|
||||
export interface ComputeContourNetworkStatus {
|
||||
schema_version: "missioncore.compute-contour-network-status/v1";
|
||||
contour_id: string;
|
||||
observed_at_utc: string;
|
||||
latency_ms: number;
|
||||
broker: {
|
||||
configured_host: string;
|
||||
configured_port: number;
|
||||
bind_address: string;
|
||||
resolved_addresses: string[];
|
||||
endpoint_reachable: boolean;
|
||||
bind_reachable: boolean;
|
||||
bind_address_owned: boolean;
|
||||
ready: boolean;
|
||||
};
|
||||
worker: {
|
||||
checked: boolean;
|
||||
reachable: boolean;
|
||||
identity_matches: boolean;
|
||||
node_id: string | null;
|
||||
service_status: string | null;
|
||||
mqtt_host: string | null;
|
||||
mqtt_port: number | null;
|
||||
resolved_addresses: string[];
|
||||
broker_reachable: boolean;
|
||||
matches_profile: boolean;
|
||||
error_code: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ComputeContourNetworkApply {
|
||||
schema_version: "missioncore.compute-contour-network-apply/v1";
|
||||
contour_id: string;
|
||||
target: "broker" | "worker";
|
||||
changed: boolean;
|
||||
applied_at_utc: string;
|
||||
ready: boolean;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -66,7 +107,11 @@ async function requestJson(url: string, init: RequestInit): Promise<unknown> {
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Контуры вычисления: HTTP ${response.status}.`);
|
||||
const document = await response.json().catch(() => null);
|
||||
const detail = isRecord(document) && typeof document.detail === "string"
|
||||
? document.detail
|
||||
: null;
|
||||
throw new Error(detail ?? `Контуры вычисления: HTTP ${response.status}.`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
@@ -100,6 +145,7 @@ export async function createComputeContour(
|
||||
ssh_port: draft.ssh_port,
|
||||
mqtt_host: draft.mqtt_host,
|
||||
mqtt_port: draft.mqtt_port,
|
||||
mqtt_bind_address: draft.mqtt_bind_address,
|
||||
telemetry_poll_interval_seconds: draft.telemetry_poll_interval_seconds,
|
||||
mqtt_publish_interval_seconds: draft.mqtt_publish_interval_seconds,
|
||||
}),
|
||||
@@ -152,3 +198,45 @@ export async function fetchComputeContourAgentInstall(
|
||||
}
|
||||
return document as unknown as ComputeContourAgentInstall;
|
||||
}
|
||||
|
||||
export async function fetchComputeContourNetwork(
|
||||
contourId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ComputeContourNetworkStatus> {
|
||||
const document = await requestJson(
|
||||
`/api/v1/system/contours/${encodeURIComponent(contourId)}/network`,
|
||||
{
|
||||
method: "GET",
|
||||
signal,
|
||||
},
|
||||
);
|
||||
if (
|
||||
!isRecord(document)
|
||||
|| document.schema_version !== "missioncore.compute-contour-network-status/v1"
|
||||
) {
|
||||
throw new Error("Сетевой статус контура не соответствует контракту.");
|
||||
}
|
||||
return document as unknown as ComputeContourNetworkStatus;
|
||||
}
|
||||
|
||||
export async function applyComputeContourNetwork(
|
||||
contourId: string,
|
||||
target: "broker" | "worker",
|
||||
signal?: AbortSignal,
|
||||
): Promise<ComputeContourNetworkApply> {
|
||||
const document = await requestJson(
|
||||
`/api/v1/system/contours/${encodeURIComponent(contourId)}/network/${target}`,
|
||||
{
|
||||
method: "POST",
|
||||
signal,
|
||||
},
|
||||
);
|
||||
if (
|
||||
!isRecord(document)
|
||||
|| document.schema_version !== "missioncore.compute-contour-network-apply/v1"
|
||||
|| document.target !== target
|
||||
) {
|
||||
throw new Error("Результат применения сетевого профиля не соответствует контракту.");
|
||||
}
|
||||
return document as unknown as ComputeContourNetworkApply;
|
||||
}
|
||||
|
||||
@@ -16,19 +16,22 @@
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.compute-contour-settings__install {
|
||||
.compute-contour-settings__install,
|
||||
.compute-contour-settings__network {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.compute-contour-settings__install dl {
|
||||
.compute-contour-settings__install dl,
|
||||
.compute-contour-settings__network dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.65rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.compute-contour-settings__install dl > div {
|
||||
.compute-contour-settings__install dl > div,
|
||||
.compute-contour-settings__network dl > div {
|
||||
display: grid;
|
||||
gap: 0.22rem;
|
||||
min-width: 0;
|
||||
@@ -37,12 +40,14 @@
|
||||
background: var(--station-panel-soft);
|
||||
}
|
||||
|
||||
.compute-contour-settings__install dt {
|
||||
.compute-contour-settings__install dt,
|
||||
.compute-contour-settings__network dt {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.58rem;
|
||||
}
|
||||
|
||||
.compute-contour-settings__install dd {
|
||||
.compute-contour-settings__install dd,
|
||||
.compute-contour-settings__network dd {
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
color: var(--nodedc-text-primary);
|
||||
@@ -64,6 +69,7 @@
|
||||
}
|
||||
|
||||
.compute-contour-settings__install p,
|
||||
.compute-contour-settings__network p,
|
||||
.compute-contour-settings__empty {
|
||||
margin: 0;
|
||||
color: var(--nodedc-text-muted);
|
||||
@@ -71,6 +77,14 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.compute-contour-settings__network-statuses,
|
||||
.compute-contour-settings__network-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.system-workspace__lead,
|
||||
.system-section-heading {
|
||||
display: flex;
|
||||
@@ -626,7 +640,8 @@
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.compute-contour-settings__form,
|
||||
.compute-contour-settings__install dl {
|
||||
.compute-contour-settings__install dl,
|
||||
.compute-contour-settings__network dl {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user