feat(control-station): add configurable environment shell

This commit is contained in:
DCCONSTRUCTIONS
2026-07-26 14:18:08 +03:00
parent 42041b37cd
commit 453d760be4
14 changed files with 1473 additions and 246 deletions
@@ -0,0 +1,193 @@
import { roots, type RootId } from "../../productModel";
export type EnvironmentSurfaceId = "home" | RootId;
export type EnvironmentMediaKind = "image" | "video";
export type EnvironmentMediaSource = "file" | "url";
export interface EnvironmentBackground {
enabled: boolean;
source: EnvironmentMediaSource;
url: string | null;
mediaKind: EnvironmentMediaKind | null;
fileName: string | null;
}
export interface EnvironmentSettings {
revision: number;
headerLabels: Record<RootId, string>;
backgrounds: Record<EnvironmentSurfaceId, EnvironmentBackground>;
}
export interface UploadedEnvironmentMedia {
surfaceId: EnvironmentSurfaceId;
url: string;
fileName: string;
mediaKind: EnvironmentMediaKind;
mediaType: string;
byteLength: number;
sha256: string;
}
const surfaceIds: readonly EnvironmentSurfaceId[] = [
"home",
"center",
"fleet",
"observation",
"missions",
"data",
"system",
"polygon",
];
const rootIds = surfaceIds.filter((value): value is RootId => value !== "home");
function emptyBackground(): EnvironmentBackground {
return {
enabled: false,
source: "file",
url: null,
mediaKind: null,
fileName: null,
};
}
export function defaultEnvironmentSettings(): EnvironmentSettings {
return {
revision: 0,
headerLabels: Object.fromEntries(
roots.map((root) => [root.id, root.label]),
) as Record<RootId, string>,
backgrounds: Object.fromEntries(
surfaceIds.map((surfaceId) => [surfaceId, emptyBackground()]),
) as Record<EnvironmentSurfaceId, EnvironmentBackground>,
};
}
function requireRecord(value: unknown, path: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`${path} должен быть объектом.`);
}
return value as Record<string, unknown>;
}
function requireString(value: unknown, path: string, nullable = false): string | null {
if (nullable && value === null) return null;
if (typeof value !== "string" || !value.trim()) {
throw new Error(`${path} должен быть непустой строкой.`);
}
return value;
}
function decodeBackground(value: unknown, path: string): EnvironmentBackground {
const record = requireRecord(value, path);
if (typeof record.enabled !== "boolean") throw new Error(`${path}.enabled должен быть boolean.`);
if (!["file", "url"].includes(String(record.source))) {
throw new Error(`${path}.source не поддерживается.`);
}
if (
record.media_kind !== null
&& !["image", "video"].includes(String(record.media_kind))
) {
throw new Error(`${path}.media_kind не поддерживается.`);
}
return {
enabled: record.enabled,
source: record.source as EnvironmentMediaSource,
url: requireString(record.url, `${path}.url`, true),
mediaKind: record.media_kind as EnvironmentMediaKind | null,
fileName: requireString(record.file_name, `${path}.file_name`, true),
};
}
export function decodeEnvironmentSettings(value: unknown): EnvironmentSettings {
const record = requireRecord(value, "environment");
if (record.schema_version !== "missioncore.operator-environment/v1") {
throw new Error("Версия настроек окружения не поддерживается.");
}
if (
typeof record.revision !== "number"
|| !Number.isSafeInteger(record.revision)
|| record.revision < 0
) {
throw new Error("Ревизия настроек окружения некорректна.");
}
const labels = requireRecord(record.header_labels, "environment.header_labels");
const backgrounds = requireRecord(record.backgrounds, "environment.backgrounds");
return {
revision: record.revision,
headerLabels: Object.fromEntries(rootIds.map((rootId) => [
rootId,
requireString(labels[rootId], `environment.header_labels.${rootId}`),
])) as Record<RootId, string>,
backgrounds: Object.fromEntries(surfaceIds.map((surfaceId) => [
surfaceId,
decodeBackground(
backgrounds[surfaceId],
`environment.backgrounds.${surfaceId}`,
),
])) as Record<EnvironmentSurfaceId, EnvironmentBackground>,
};
}
export function encodeEnvironmentSettings(settings: EnvironmentSettings): unknown {
return {
revision: settings.revision,
header_labels: settings.headerLabels,
backgrounds: Object.fromEntries(surfaceIds.map((surfaceId) => {
const background = settings.backgrounds[surfaceId];
return [surfaceId, {
enabled: background.enabled,
source: background.source,
url: background.url,
media_kind: background.mediaKind,
file_name: background.fileName,
}];
})),
};
}
export function decodeUploadedEnvironmentMedia(
value: unknown,
): UploadedEnvironmentMedia {
const record = requireRecord(value, "environment media");
if (record.schema_version !== "missioncore.operator-environment-media/v1") {
throw new Error("Версия загруженного media не поддерживается.");
}
if (!surfaceIds.includes(record.surface_id as EnvironmentSurfaceId)) {
throw new Error("Экран загруженного media не поддерживается.");
}
if (!["image", "video"].includes(String(record.media_kind))) {
throw new Error("Тип загруженного media не поддерживается.");
}
if (
typeof record.byte_length !== "number"
|| !Number.isSafeInteger(record.byte_length)
|| record.byte_length <= 0
) {
throw new Error("Размер загруженного media некорректен.");
}
return {
surfaceId: record.surface_id as EnvironmentSurfaceId,
url: requireString(record.url, "environment media.url")!,
fileName: requireString(record.file_name, "environment media.file_name")!,
mediaKind: record.media_kind as EnvironmentMediaKind,
mediaType: requireString(record.media_type, "environment media.media_type")!,
byteLength: record.byte_length,
sha256: requireString(record.sha256, "environment media.sha256")!,
};
}
export function cloneEnvironmentSettings(
settings: EnvironmentSettings,
): EnvironmentSettings {
return {
revision: settings.revision,
headerLabels: { ...settings.headerLabels },
backgrounds: Object.fromEntries(surfaceIds.map((surfaceId) => [
surfaceId,
{ ...settings.backgrounds[surfaceId] },
])) as Record<EnvironmentSurfaceId, EnvironmentBackground>,
};
}
export const environmentSurfaceIds = surfaceIds;
@@ -0,0 +1,125 @@
import { useCallback, useEffect, useState } from "react";
import {
decodeEnvironmentSettings,
decodeUploadedEnvironmentMedia,
defaultEnvironmentSettings,
encodeEnvironmentSettings,
type EnvironmentSettings,
type EnvironmentSurfaceId,
type UploadedEnvironmentMedia,
} from "./environmentSettings";
interface EnvironmentSettingsController {
settings: EnvironmentSettings;
state: "loading" | "ready" | "saving" | "error";
error: string | null;
save: (draft: EnvironmentSettings) => Promise<EnvironmentSettings>;
upload: (
surfaceId: EnvironmentSurfaceId,
file: File,
) => Promise<UploadedEnvironmentMedia>;
}
async function responseError(response: Response, fallback: string): Promise<string> {
try {
const body = await response.json() as { detail?: unknown };
if (typeof body.detail === "string" && body.detail.trim()) return body.detail;
} catch {
// A non-JSON reverse-proxy response still gets a stable product message.
}
return fallback;
}
export function useEnvironmentSettings(): EnvironmentSettingsController {
const [settings, setSettings] = useState<EnvironmentSettings>(
defaultEnvironmentSettings,
);
const [state, setState] = useState<EnvironmentSettingsController["state"]>(
"loading",
);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
void fetch("/api/v1/environment/settings", {
signal: controller.signal,
headers: { Accept: "application/json" },
}).then(async (response) => {
if (!response.ok) {
throw new Error(await responseError(
response,
"Не удалось загрузить настройки окружения.",
));
}
return decodeEnvironmentSettings(await response.json());
}).then((document) => {
setSettings(document);
setState("ready");
setError(null);
}).catch((reason: unknown) => {
if (controller.signal.aborted) return;
setState("error");
setError(reason instanceof Error
? reason.message
: "Не удалось загрузить настройки окружения.");
});
return () => controller.abort();
}, []);
const save = useCallback(async (draft: EnvironmentSettings) => {
setState("saving");
setError(null);
try {
const response = await fetch("/api/v1/environment/settings", {
method: "PUT",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(encodeEnvironmentSettings(draft)),
});
if (!response.ok) {
throw new Error(await responseError(
response,
"Не удалось сохранить настройки окружения.",
));
}
const document = decodeEnvironmentSettings(await response.json());
setSettings(document);
setState("ready");
return document;
} catch (reason) {
const message = reason instanceof Error
? reason.message
: "Не удалось сохранить настройки окружения.";
setState("error");
setError(message);
throw new Error(message);
}
}, []);
const upload = useCallback(async (
surfaceId: EnvironmentSurfaceId,
file: File,
) => {
const response = await fetch(`/api/v1/environment/media/${surfaceId}`, {
method: "PUT",
headers: {
Accept: "application/json",
"Content-Type": file.type || "application/octet-stream",
"X-NODEDC-File-Name": encodeURIComponent(file.name),
},
body: file,
});
if (!response.ok) {
throw new Error(await responseError(
response,
"Не удалось загрузить фон окружения.",
));
}
return decodeUploadedEnvironmentMedia(await response.json());
}, []);
return { settings, state, error, save, upload };
}