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