feat(control-station): add compute contour workspace
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user