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,134 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import {
createComputeContour,
fetchComputeContours,
updateComputeContour,
type ComputeContour,
type ComputeContourDraft,
} from "./computeContours";
const SELECTED_CONTOUR_KEY = "mission-core.system.selected-contour.v1";
interface ComputeContourContextValue {
contours: ComputeContour[];
selectedContour: ComputeContour | null;
loading: boolean;
error: string | null;
selectContour: (contourId: string) => void;
createContour: (draft: ComputeContourDraft) => Promise<ComputeContour>;
updateContour: (
contour: ComputeContour,
draft: ComputeContourDraft,
) => Promise<ComputeContour>;
refresh: () => void;
}
const ComputeContourContext = createContext<ComputeContourContextValue | null>(null);
export function ComputeContourProvider({ children }: { children: ReactNode }) {
const [contours, setContours] = useState<ComputeContour[]>([]);
const [selectedContourId, setSelectedContourId] = useState<string | null>(() => (
typeof window === "undefined" ? null : window.localStorage.getItem(SELECTED_CONTOUR_KEY)
));
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [generation, setGeneration] = useState(0);
const refresh = useCallback(() => setGeneration((value) => value + 1), []);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
void fetchComputeContours(controller.signal)
.then((nextContours) => {
if (controller.signal.aborted) return;
setContours(nextContours);
setSelectedContourId((current) => (
current && nextContours.some((contour) => contour.contour_id === current)
? current
: nextContours[0]?.contour_id ?? null
));
setError(null);
})
.catch((reason: unknown) => {
if (controller.signal.aborted) return;
setError(reason instanceof Error ? reason.message : "Каталог контуров недоступен.");
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, [generation]);
useEffect(() => {
if (typeof window === "undefined" || !selectedContourId) return;
window.localStorage.setItem(SELECTED_CONTOUR_KEY, selectedContourId);
}, [selectedContourId]);
const selectContour = useCallback((contourId: string) => {
setSelectedContourId(contourId);
}, []);
const createContour = useCallback(async (draft: ComputeContourDraft) => {
const created = await createComputeContour(draft);
setContours((current) => [...current, created]);
setSelectedContourId(created.contour_id);
return created;
}, []);
const updateContour = useCallback(async (
contour: ComputeContour,
draft: ComputeContourDraft,
) => {
const updated = await updateComputeContour(contour, draft);
setContours((current) => current.map((candidate) => (
candidate.contour_id === updated.contour_id ? updated : candidate
)));
return updated;
}, []);
const selectedContour = contours.find(
(contour) => contour.contour_id === selectedContourId,
) ?? null;
const value = useMemo<ComputeContourContextValue>(() => ({
contours,
selectedContour,
loading,
error,
selectContour,
createContour,
updateContour,
refresh,
}), [
contours,
selectedContour,
loading,
error,
selectContour,
createContour,
updateContour,
refresh,
]);
return (
<ComputeContourContext.Provider value={value}>
{children}
</ComputeContourContext.Provider>
);
}
export function useComputeContours(): ComputeContourContextValue {
const value = useContext(ComputeContourContext);
if (!value) {
throw new Error("useComputeContours must be used within ComputeContourProvider");
}
return value;
}
@@ -0,0 +1,148 @@
export type ComputeContourPlatform = "windows" | "linux" | "unknown";
export type ComputeContourTelemetryMode = "agent-mqtt" | "legacy-ssh";
export interface ComputeContour {
schema_version: "missioncore.compute-contour/v1";
contour_id: string;
display_name: string;
expected_node_id: string;
agent_id: string;
platform: ComputeContourPlatform;
telemetry_mode: ComputeContourTelemetryMode;
address: string;
ssh_port: number;
mqtt_host: string;
mqtt_port: number;
revision: number;
updated_at_utc: string | null;
}
export interface ComputeContourCatalog {
schema_version: "missioncore.compute-contour-catalog/v1";
contours: ComputeContour[];
}
export interface ComputeContourDraft {
display_name: string;
expected_node_id: string;
platform: ComputeContourPlatform;
telemetry_mode: ComputeContourTelemetryMode;
address: string;
ssh_port: number;
mqtt_host: string;
mqtt_port: number;
}
export interface ComputeContourAgentInstall {
schema_version: "missioncore.compute-contour-agent-install/v1";
contour_id: string;
platform: ComputeContourPlatform;
agent: {
distribution: "Telegraf";
configuration_template: string;
environment: Record<string, string>;
secret_delivery: "interactive-prompt";
};
command: string;
ready: boolean;
blocked_reason: string | null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
async function requestJson(url: string, init: RequestInit): Promise<unknown> {
const response = await fetch(url, {
...init,
headers: {
Accept: "application/json",
...(init.body ? { "Content-Type": "application/json" } : {}),
...init.headers,
},
});
if (!response.ok) {
throw new Error(`Контуры вычисления: HTTP ${response.status}.`);
}
return response.json();
}
export async function fetchComputeContours(signal?: AbortSignal): Promise<ComputeContour[]> {
const document = await requestJson("/api/v1/system/contours", {
method: "GET",
signal,
});
if (
!isRecord(document)
|| document.schema_version !== "missioncore.compute-contour-catalog/v1"
|| !Array.isArray(document.contours)
) {
throw new Error("Каталог вычислительных контуров не соответствует контракту.");
}
return (document as unknown as ComputeContourCatalog).contours;
}
export async function createComputeContour(
draft: ComputeContourDraft,
signal?: AbortSignal,
): Promise<ComputeContour> {
const document = await requestJson("/api/v1/system/contours", {
method: "POST",
body: JSON.stringify({
display_name: draft.display_name,
expected_node_id: draft.expected_node_id,
platform: draft.platform,
address: draft.address,
ssh_port: draft.ssh_port,
mqtt_host: draft.mqtt_host,
mqtt_port: draft.mqtt_port,
}),
signal,
});
if (!isRecord(document) || document.schema_version !== "missioncore.compute-contour/v1") {
throw new Error("Созданный вычислительный контур не соответствует контракту.");
}
return document as unknown as ComputeContour;
}
export async function updateComputeContour(
contour: ComputeContour,
draft: ComputeContourDraft,
signal?: AbortSignal,
): Promise<ComputeContour> {
const document = await requestJson(
`/api/v1/system/contours/${encodeURIComponent(contour.contour_id)}`,
{
method: "PUT",
body: JSON.stringify({
revision: contour.revision,
...draft,
}),
signal,
},
);
if (!isRecord(document) || document.schema_version !== "missioncore.compute-contour/v1") {
throw new Error("Сохранённый вычислительный контур не соответствует контракту.");
}
return document as unknown as ComputeContour;
}
export async function fetchComputeContourAgentInstall(
contourId: string,
signal?: AbortSignal,
): Promise<ComputeContourAgentInstall> {
const document = await requestJson(
`/api/v1/system/contours/${encodeURIComponent(contourId)}/agent-install`,
{
method: "GET",
signal,
},
);
if (
!isRecord(document)
|| document.schema_version !== "missioncore.compute-contour-agent-install/v1"
) {
throw new Error("Инструкция агента не соответствует контракту.");
}
return document as unknown as ComputeContourAgentInstall;
}
@@ -5,6 +5,8 @@ import {
type WorkerTelemetry,
} from "./workerTelemetry";
export const WORKER_TELEMETRY_POLL_MILLISECONDS = 3_000;
export interface WorkerTelemetryState {
telemetry: WorkerTelemetry | null;
loading: boolean;
@@ -12,7 +14,10 @@ export interface WorkerTelemetryState {
refresh: () => void;
}
export function useWorkerTelemetry(pollMilliseconds = 10_000): WorkerTelemetryState {
export function useWorkerTelemetry(
pollMilliseconds = WORKER_TELEMETRY_POLL_MILLISECONDS,
enabled = true,
): WorkerTelemetryState {
const [telemetry, setTelemetry] = useState<WorkerTelemetry | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -20,6 +25,12 @@ export function useWorkerTelemetry(pollMilliseconds = 10_000): WorkerTelemetrySt
const refresh = useCallback(() => setGeneration((value) => value + 1), []);
useEffect(() => {
if (!enabled) {
setTelemetry(null);
setLoading(false);
setError(null);
return;
}
const controller = new AbortController();
setLoading(true);
void fetchWorkerTelemetry(controller.signal)
@@ -36,12 +47,13 @@ export function useWorkerTelemetry(pollMilliseconds = 10_000): WorkerTelemetrySt
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, [generation]);
}, [enabled, generation]);
useEffect(() => {
const timer = window.setInterval(refresh, pollMilliseconds);
return () => window.clearInterval(timer);
}, [pollMilliseconds, refresh]);
if (!enabled || loading) return;
const timer = window.setTimeout(refresh, pollMilliseconds);
return () => window.clearTimeout(timer);
}, [enabled, loading, pollMilliseconds, refresh]);
return { telemetry, loading, error, refresh };
}
@@ -12,6 +12,7 @@ export interface WorkerConnectionProfile {
export interface WorkerProbe {
schema_version: "missioncore.worker-probe/v1";
source: "agent-mqtt" | "legacy-ssh" | "unknown";
reachable: boolean;
identity_matches: boolean;
node_id: string | null;