feat(control-station): add configurable environment shell
This commit is contained in:
@@ -8,10 +8,8 @@ import {
|
|||||||
Checker,
|
Checker,
|
||||||
ColorField,
|
ColorField,
|
||||||
ControlRow,
|
ControlRow,
|
||||||
HeaderAvatar,
|
|
||||||
HeaderNavigation,
|
HeaderNavigation,
|
||||||
HeaderProfile,
|
HeaderProfile,
|
||||||
HeaderProfileButton,
|
|
||||||
HeaderWorkspace,
|
HeaderWorkspace,
|
||||||
Icon,
|
Icon,
|
||||||
Inspector,
|
Inspector,
|
||||||
@@ -19,6 +17,7 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
TextField,
|
TextField,
|
||||||
|
UserProfileMenu,
|
||||||
Window,
|
Window,
|
||||||
WindowFooterActions,
|
WindowFooterActions,
|
||||||
useApplicationWorkspace,
|
useApplicationWorkspace,
|
||||||
@@ -26,7 +25,9 @@ import {
|
|||||||
} from "@nodedc/ui-react";
|
} from "@nodedc/ui-react";
|
||||||
|
|
||||||
import { LandingStage } from "./components/LandingStage";
|
import { LandingStage } from "./components/LandingStage";
|
||||||
|
import { EnvironmentSettingsWindow } from "./components/EnvironmentSettingsWindow";
|
||||||
import { ObservationSessionSelect } from "./components/ObservationSessionSelect";
|
import { ObservationSessionSelect } from "./components/ObservationSessionSelect";
|
||||||
|
import { useEnvironmentSettings } from "./core/environment/useEnvironmentSettings";
|
||||||
import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost";
|
import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost";
|
||||||
import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext";
|
import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext";
|
||||||
import type { ViewerSettings } from "./core/runtime/contracts";
|
import type { ViewerSettings } from "./core/runtime/contracts";
|
||||||
@@ -149,6 +150,7 @@ function mergeViewerSettings(
|
|||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const runtime = useMissionRuntime();
|
const runtime = useMissionRuntime();
|
||||||
|
const environment = useEnvironmentSettings();
|
||||||
const { selection } = useDevicePluginHost();
|
const { selection } = useDevicePluginHost();
|
||||||
const polygonDatasetRoute = useMemo(
|
const polygonDatasetRoute = useMemo(
|
||||||
() => resolvePolygonRunRoute(typeof window === "undefined" ? "" : window.location.search),
|
() => resolvePolygonRunRoute(typeof window === "undefined" ? "" : window.location.search),
|
||||||
@@ -162,6 +164,7 @@ export default function App() {
|
|||||||
const [activeRoot, setActiveRoot] = useState<RootId | null>(
|
const [activeRoot, setActiveRoot] = useState<RootId | null>(
|
||||||
polygonDatasetRoute.active ? "data" : null,
|
polygonDatasetRoute.active ? "data" : null,
|
||||||
);
|
);
|
||||||
|
const [environmentSettingsOpen, setEnvironmentSettingsOpen] = useState(false);
|
||||||
const [sourceUrl, setSourceUrl] = useState("");
|
const [sourceUrl, setSourceUrl] = useState("");
|
||||||
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
|
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
|
||||||
const [recordedReplayLabel, setRecordedReplayLabel] = useState<string | null>(null);
|
const [recordedReplayLabel, setRecordedReplayLabel] = useState<string | null>(null);
|
||||||
@@ -423,11 +426,6 @@ export default function App() {
|
|||||||
|
|
||||||
const selectRoot = (rootId: RootId) => {
|
const selectRoot = (rootId: RootId) => {
|
||||||
setActiveRoot(rootId);
|
setActiveRoot(rootId);
|
||||||
if (rootId === "polygon") {
|
|
||||||
workspace.openView("lab-archive");
|
|
||||||
workspace.openNavigation();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
workspace.closeView();
|
workspace.closeView();
|
||||||
workspace.openNavigation();
|
workspace.openNavigation();
|
||||||
};
|
};
|
||||||
@@ -626,6 +624,7 @@ export default function App() {
|
|||||||
const header = (
|
const header = (
|
||||||
<AppHeader
|
<AppHeader
|
||||||
brand={<img src="/nodedc-logo.svg" alt="NODE.DC" />}
|
brand={<img src="/nodedc-logo.svg" alt="NODE.DC" />}
|
||||||
|
brandHref="/"
|
||||||
brandLabel="NODEDC MISSION CORE"
|
brandLabel="NODEDC MISSION CORE"
|
||||||
center={
|
center={
|
||||||
<>
|
<>
|
||||||
@@ -633,18 +632,35 @@ export default function App() {
|
|||||||
<HeaderNavigation
|
<HeaderNavigation
|
||||||
label="Архитектурные блоки пункта управления"
|
label="Архитектурные блоки пункта управления"
|
||||||
value={activeRoot ?? undefined}
|
value={activeRoot ?? undefined}
|
||||||
items={visibleRoots.map((root) => ({ value: root.id, label: root.label }))}
|
items={visibleRoots.map((root) => ({
|
||||||
|
value: root.id,
|
||||||
|
label: environment.settings.headerLabels[root.id],
|
||||||
|
}))}
|
||||||
onChange={selectRoot}
|
onChange={selectRoot}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
right={
|
right={
|
||||||
<HeaderProfile>
|
<HeaderProfile>
|
||||||
<HeaderProfileButton onClick={() => void runtime.refresh()} title="Обновить локальный контур">
|
<UserProfileMenu
|
||||||
<span className="api-dot" data-status={runtime.backendStatus} aria-hidden="true" />
|
displayName="DC"
|
||||||
{backendLabel(runtime.backendStatus)}
|
subtitle="Mission Core"
|
||||||
</HeaderProfileButton>
|
triggerLabel={null}
|
||||||
<HeaderAvatar label="DC" />
|
actions={[
|
||||||
|
{
|
||||||
|
id: "refresh",
|
||||||
|
label: "Обновить контур",
|
||||||
|
icon: "refresh",
|
||||||
|
onSelect: () => void runtime.refresh(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "settings",
|
||||||
|
label: "Настройки",
|
||||||
|
icon: "settings",
|
||||||
|
onSelect: () => setEnvironmentSettingsOpen(true),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</HeaderProfile>
|
</HeaderProfile>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -668,6 +684,9 @@ export default function App() {
|
|||||||
backendStatus={runtime.backendStatus}
|
backendStatus={runtime.backendStatus}
|
||||||
phase={runtime.state?.phase}
|
phase={runtime.state?.phase}
|
||||||
message={runtime.state?.message}
|
message={runtime.state?.message}
|
||||||
|
background={
|
||||||
|
environment.settings.backgrounds[activeRoot ?? "home"]
|
||||||
|
}
|
||||||
onOpenObservation={() => openView("spatial-scene")}
|
onOpenObservation={() => openView("spatial-scene")}
|
||||||
onOpenDevice={() => openView("local-device")}
|
onOpenDevice={() => openView("local-device")}
|
||||||
/>
|
/>
|
||||||
@@ -764,7 +783,7 @@ export default function App() {
|
|||||||
) : activeDefinition.kind === "datasets" ? (
|
) : activeDefinition.kind === "datasets" ? (
|
||||||
<StatusBadge tone="neutral">Offline evaluation</StatusBadge>
|
<StatusBadge tone="neutral">Offline evaluation</StatusBadge>
|
||||||
) : activeDefinition.kind === "lab-archive" ? (
|
) : activeDefinition.kind === "lab-archive" ? (
|
||||||
<StatusBadge tone="accent">Лаборатория</StatusBadge>
|
null
|
||||||
) : (
|
) : (
|
||||||
<StatusBadge tone="warning">Интерфейс готов</StatusBadge>
|
<StatusBadge tone="warning">Интерфейс готов</StatusBadge>
|
||||||
)
|
)
|
||||||
@@ -821,6 +840,16 @@ export default function App() {
|
|||||||
) : null}
|
) : null}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<EnvironmentSettingsWindow
|
||||||
|
open={environmentSettingsOpen}
|
||||||
|
settings={environment.settings}
|
||||||
|
state={environment.state}
|
||||||
|
error={environment.error}
|
||||||
|
onClose={() => setEnvironmentSettingsOpen(false)}
|
||||||
|
onSave={environment.save}
|
||||||
|
onUpload={environment.upload}
|
||||||
|
/>
|
||||||
|
|
||||||
<Window
|
<Window
|
||||||
open={sceneWorkspaceActive && sourceWindowOpen}
|
open={sceneWorkspaceActive && sourceWindowOpen}
|
||||||
title="Визуальный движок"
|
title="Визуальный движок"
|
||||||
|
|||||||
@@ -0,0 +1,291 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
FeatureSettingsWindow,
|
||||||
|
MediaSourceField,
|
||||||
|
Select,
|
||||||
|
SettingsCard,
|
||||||
|
Switch,
|
||||||
|
TextField,
|
||||||
|
WindowFooterActions,
|
||||||
|
} from "@nodedc/ui-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
cloneEnvironmentSettings,
|
||||||
|
type EnvironmentBackground,
|
||||||
|
type EnvironmentMediaKind,
|
||||||
|
type EnvironmentSettings,
|
||||||
|
type EnvironmentSurfaceId,
|
||||||
|
type UploadedEnvironmentMedia,
|
||||||
|
} from "../core/environment/environmentSettings";
|
||||||
|
import { roots, type RootId } from "../productModel";
|
||||||
|
|
||||||
|
interface EnvironmentSettingsWindowProps {
|
||||||
|
open: boolean;
|
||||||
|
settings: EnvironmentSettings;
|
||||||
|
state: "loading" | "ready" | "saving" | "error";
|
||||||
|
error: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (settings: EnvironmentSettings) => Promise<EnvironmentSettings>;
|
||||||
|
onUpload: (
|
||||||
|
surfaceId: EnvironmentSurfaceId,
|
||||||
|
file: File,
|
||||||
|
) => Promise<UploadedEnvironmentMedia>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerLabelFields: readonly { id: RootId; label: string }[] = [
|
||||||
|
{ id: "center", label: "Центр" },
|
||||||
|
{ id: "fleet", label: "Парк" },
|
||||||
|
{ id: "observation", label: "Наблюдение" },
|
||||||
|
{ id: "missions", label: "Миссии" },
|
||||||
|
{ id: "data", label: "Данные" },
|
||||||
|
{ id: "system", label: "Система" },
|
||||||
|
{ id: "polygon", label: "Тестировочный контур" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const backgroundSurfaceOptions: Array<{
|
||||||
|
value: EnvironmentSurfaceId;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
value: "home",
|
||||||
|
label: "Mission Core",
|
||||||
|
description: "Главная страница продукта",
|
||||||
|
},
|
||||||
|
...roots.map((root) => ({
|
||||||
|
value: root.id,
|
||||||
|
label: root.title,
|
||||||
|
description: `Стартовая страница раздела «${root.label}»`,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
function inferMediaKind(url: string): EnvironmentMediaKind {
|
||||||
|
return /\.(mp4|webm|mov|m4v)(?:[?#].*)?$/i.test(url) ? "video" : "image";
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchBackground(
|
||||||
|
draft: EnvironmentSettings,
|
||||||
|
surfaceId: EnvironmentSurfaceId,
|
||||||
|
patch: Partial<EnvironmentBackground>,
|
||||||
|
): EnvironmentSettings {
|
||||||
|
return {
|
||||||
|
...draft,
|
||||||
|
backgrounds: {
|
||||||
|
...draft.backgrounds,
|
||||||
|
[surfaceId]: {
|
||||||
|
...draft.backgrounds[surfaceId],
|
||||||
|
...patch,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EnvironmentSettingsWindow({
|
||||||
|
open,
|
||||||
|
settings,
|
||||||
|
state,
|
||||||
|
error,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
onUpload,
|
||||||
|
}: EnvironmentSettingsWindowProps) {
|
||||||
|
const [draft, setDraft] = useState(() => cloneEnvironmentSettings(settings));
|
||||||
|
const [surfaceId, setSurfaceId] = useState<EnvironmentSurfaceId>("home");
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [localError, setLocalError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setDraft(cloneEnvironmentSettings(settings));
|
||||||
|
setLocalError(null);
|
||||||
|
}, [open, settings]);
|
||||||
|
|
||||||
|
const selectedBackground = draft.backgrounds[surfaceId];
|
||||||
|
const previewKind = selectedBackground.mediaKind
|
||||||
|
?? (selectedBackground.url ? inferMediaKind(selectedBackground.url) : null);
|
||||||
|
const dirty = useMemo(
|
||||||
|
() => JSON.stringify(draft) !== JSON.stringify(settings),
|
||||||
|
[draft, settings],
|
||||||
|
);
|
||||||
|
const busy = state === "saving" || uploading;
|
||||||
|
|
||||||
|
const updateHeaderLabel = (rootId: RootId, value: string) => {
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
headerLabels: { ...current.headerLabels, [rootId]: value },
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadFile = async (file?: File) => {
|
||||||
|
if (!file) return;
|
||||||
|
setUploading(true);
|
||||||
|
setLocalError(null);
|
||||||
|
try {
|
||||||
|
const uploaded = await onUpload(surfaceId, file);
|
||||||
|
setDraft((current) => patchBackground(current, surfaceId, {
|
||||||
|
enabled: true,
|
||||||
|
source: "file",
|
||||||
|
url: uploaded.url,
|
||||||
|
mediaKind: uploaded.mediaKind,
|
||||||
|
fileName: uploaded.fileName,
|
||||||
|
}));
|
||||||
|
} catch (reason) {
|
||||||
|
setLocalError(reason instanceof Error
|
||||||
|
? reason.message
|
||||||
|
: "Не удалось загрузить фон окружения.");
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
const blank = headerLabelFields.find(({ id }) => !draft.headerLabels[id].trim());
|
||||||
|
if (blank) {
|
||||||
|
setLocalError(`Название «${blank.label}» не может быть пустым.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLocalError(null);
|
||||||
|
try {
|
||||||
|
await onSave({
|
||||||
|
...draft,
|
||||||
|
headerLabels: Object.fromEntries(headerLabelFields.map(({ id }) => [
|
||||||
|
id,
|
||||||
|
draft.headerLabels[id].trim(),
|
||||||
|
])) as Record<RootId, string>,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} catch (reason) {
|
||||||
|
setLocalError(reason instanceof Error
|
||||||
|
? reason.message
|
||||||
|
: "Не удалось сохранить настройки окружения.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FeatureSettingsWindow
|
||||||
|
open={open}
|
||||||
|
title="Настройки Mission Core"
|
||||||
|
subtitle="Локальное операторское окружение"
|
||||||
|
identity={{
|
||||||
|
title: "DC",
|
||||||
|
subtitle: "Mission Core",
|
||||||
|
avatarLabel: "DC",
|
||||||
|
}}
|
||||||
|
sections={[
|
||||||
|
{
|
||||||
|
id: "environment",
|
||||||
|
label: "Окружение",
|
||||||
|
group: "MISSION CORE",
|
||||||
|
icon: "settings",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
activeSection="environment"
|
||||||
|
onSectionChange={() => undefined}
|
||||||
|
onClose={onClose}
|
||||||
|
footer={(
|
||||||
|
<WindowFooterActions>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
disabled={!dirty || busy}
|
||||||
|
onClick={() => {
|
||||||
|
setDraft(cloneEnvironmentSettings(settings));
|
||||||
|
setLocalError(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Сбросить изменения
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
disabled={!dirty || busy}
|
||||||
|
onClick={() => void save()}
|
||||||
|
>
|
||||||
|
{state === "saving" ? "Сохраняем…" : "Сохранить"}
|
||||||
|
</Button>
|
||||||
|
</WindowFooterActions>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="environment-settings">
|
||||||
|
<SettingsCard
|
||||||
|
eyebrow="ШАПКА"
|
||||||
|
title="Названия разделов"
|
||||||
|
description="Подписи применяются к верхней навигации. Продуктовые идентификаторы и маршруты не меняются."
|
||||||
|
>
|
||||||
|
<div className="environment-settings__labels">
|
||||||
|
{headerLabelFields.map((field) => (
|
||||||
|
<TextField
|
||||||
|
key={field.id}
|
||||||
|
label={field.label}
|
||||||
|
value={draft.headerLabels[field.id]}
|
||||||
|
maxLength={40}
|
||||||
|
onChange={(event) => updateHeaderLabel(field.id, event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
|
<SettingsCard
|
||||||
|
eyebrow="ПОДЛОЖКИ"
|
||||||
|
title="Фото или видео стартовой страницы"
|
||||||
|
description="Медиа заполняет выбранную стартовую страницу с автокадрированием. Рабочие поверхности и viewer не затрагиваются."
|
||||||
|
actions={(
|
||||||
|
<Switch
|
||||||
|
checked={selectedBackground.enabled}
|
||||||
|
label="Показывать"
|
||||||
|
onChange={(enabled) => {
|
||||||
|
if (enabled && !selectedBackground.url) {
|
||||||
|
setLocalError("Сначала загрузите файл или укажите URL.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLocalError(null);
|
||||||
|
setDraft((current) =>
|
||||||
|
patchBackground(current, surfaceId, { enabled }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="environment-settings__media">
|
||||||
|
<div className="environment-settings__surface">
|
||||||
|
<span>Экран</span>
|
||||||
|
<Select
|
||||||
|
label="Выбрать стартовую страницу"
|
||||||
|
value={surfaceId}
|
||||||
|
options={backgroundSurfaceOptions}
|
||||||
|
variant="split"
|
||||||
|
menuWidth="anchor"
|
||||||
|
onChange={(value) => {
|
||||||
|
setSurfaceId(value);
|
||||||
|
setLocalError(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<MediaSourceField
|
||||||
|
label="Видео / картинка"
|
||||||
|
kindLabel={previewKind ?? "media"}
|
||||||
|
source={selectedBackground.source}
|
||||||
|
url={selectedBackground.url ?? ""}
|
||||||
|
fileName={selectedBackground.fileName}
|
||||||
|
uploading={uploading}
|
||||||
|
previewSrc={selectedBackground.url}
|
||||||
|
previewKind={previewKind}
|
||||||
|
accept="image/png,image/jpeg,image/gif,image/webp,image/avif,video/mp4,video/webm,video/quicktime,.png,.jpg,.jpeg,.gif,.webp,.avif,.mp4,.webm,.mov"
|
||||||
|
path={`${surfaceId}.background → server environment media`}
|
||||||
|
hint="Файл сохраняется в Mission Core data root. URL должен быть доступен браузеру по HTTP(S)."
|
||||||
|
error={localError ?? error}
|
||||||
|
onSourceChange={(source) => setDraft((current) =>
|
||||||
|
patchBackground(current, surfaceId, { source }))}
|
||||||
|
onUrlChange={(url) => setDraft((current) =>
|
||||||
|
patchBackground(current, surfaceId, {
|
||||||
|
source: "url",
|
||||||
|
url: url || null,
|
||||||
|
mediaKind: url ? inferMediaKind(url) : null,
|
||||||
|
fileName: null,
|
||||||
|
}))}
|
||||||
|
onFileChange={uploadFile}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsCard>
|
||||||
|
</div>
|
||||||
|
</FeatureSettingsWindow>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Button, Icon, StatusBadge } from "@nodedc/ui-react";
|
import { Button, Icon, StatusBadge } from "@nodedc/ui-react";
|
||||||
|
|
||||||
import type { BackendStatus, RuntimePhase } from "../core/runtime/contracts";
|
import type { BackendStatus, RuntimePhase } from "../core/runtime/contracts";
|
||||||
|
import type { EnvironmentBackground } from "../core/environment/environmentSettings";
|
||||||
import type { RootDefinition } from "../productModel";
|
import type { RootDefinition } from "../productModel";
|
||||||
import { backendLabel, backendTone, phaseLabel, phaseTone } from "../presentation";
|
import { backendLabel, backendTone, phaseLabel, phaseTone } from "../presentation";
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ export interface LandingStageProps {
|
|||||||
backendStatus: BackendStatus;
|
backendStatus: BackendStatus;
|
||||||
phase?: RuntimePhase | null;
|
phase?: RuntimePhase | null;
|
||||||
message?: string | null;
|
message?: string | null;
|
||||||
|
background: EnvironmentBackground;
|
||||||
onOpenObservation: () => void;
|
onOpenObservation: () => void;
|
||||||
onOpenDevice: () => void;
|
onOpenDevice: () => void;
|
||||||
}
|
}
|
||||||
@@ -18,11 +20,33 @@ export function LandingStage({
|
|||||||
backendStatus,
|
backendStatus,
|
||||||
phase,
|
phase,
|
||||||
message,
|
message,
|
||||||
|
background,
|
||||||
onOpenObservation,
|
onOpenObservation,
|
||||||
onOpenDevice,
|
onOpenDevice,
|
||||||
}: LandingStageProps) {
|
}: LandingStageProps) {
|
||||||
return (
|
return (
|
||||||
<section className="landing-stage" data-root={root?.id ?? "home"}>
|
<section
|
||||||
|
className="landing-stage"
|
||||||
|
data-root={root?.id ?? "home"}
|
||||||
|
data-has-media={background.enabled && background.url ? "true" : undefined}
|
||||||
|
>
|
||||||
|
{background.enabled && background.url ? (
|
||||||
|
<div className="landing-stage__media" aria-hidden="true">
|
||||||
|
{background.mediaKind === "video" ? (
|
||||||
|
<video
|
||||||
|
key={background.url}
|
||||||
|
src={background.url}
|
||||||
|
autoPlay
|
||||||
|
muted
|
||||||
|
loop
|
||||||
|
playsInline
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<img src={background.url} alt="" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="landing-stage__shade" aria-hidden="true" />
|
||||||
<div className="landing-stage__copy">
|
<div className="landing-stage__copy">
|
||||||
<span className="section-eyebrow">{root?.eyebrow ?? "NODEDC / MISSION CORE"}</span>
|
<span className="section-eyebrow">{root?.eyebrow ?? "NODEDC / MISSION CORE"}</span>
|
||||||
<h1>{root?.title ?? "Mission Core"}</h1>
|
<h1>{root?.title ?? "Mission Core"}</h1>
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -5,3 +5,4 @@
|
|||||||
@import "./styles/device.css";
|
@import "./styles/device.css";
|
||||||
@import "./styles/responsive.css";
|
@import "./styles/responsive.css";
|
||||||
@import "./styles/observation.css";
|
@import "./styles/observation.css";
|
||||||
|
@import "./styles/environment-settings.css";
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
.environment-settings {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.environment-settings__labels {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.environment-settings__media {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.environment-settings__surface {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(18rem, 24rem);
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.environment-settings__surface > span {
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.environment-settings__surface .nodedc-select-anchor,
|
||||||
|
.environment-settings__surface .nodedc-select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.environment-settings__labels {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.environment-settings__surface {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,6 +58,34 @@
|
|||||||
background: var(--station-stage);
|
background: var(--station-stage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.landing-stage__media,
|
||||||
|
.landing-stage__shade {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-stage__media img,
|
||||||
|
.landing-stage__media video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-stage__shade {
|
||||||
|
z-index: 1;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgb(5 6 8 / 0.82) 0%, rgb(5 6 8 / 0.54) 46%, rgb(5 6 8 / 0.24) 100%),
|
||||||
|
linear-gradient(0deg, rgb(5 6 8 / 0.58), transparent 38%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-stage:not([data-has-media="true"]) .landing-stage__shade {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 68% 42%, rgb(255 255 255 / 0.035), transparent 34%),
|
||||||
|
linear-gradient(90deg, rgb(5 6 8 / 0.2), transparent 62%);
|
||||||
|
}
|
||||||
|
|
||||||
.landing-stage__copy {
|
.landing-stage__copy {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
|
|||||||
@@ -2441,12 +2441,12 @@
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.laboratory-selector label {
|
.laboratory-selector__control {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.laboratory-selector label > span {
|
.laboratory-selector__control > span {
|
||||||
color: var(--nodedc-text-muted);
|
color: var(--nodedc-text-muted);
|
||||||
font-size: 0.55rem;
|
font-size: 0.55rem;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
@@ -2454,26 +2454,9 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.laboratory-selector select {
|
.laboratory-selector__control .nodedc-select-anchor,
|
||||||
|
.laboratory-selector__control .nodedc-select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 999px;
|
|
||||||
outline: 0;
|
|
||||||
background: rgb(255 255 255 / 0.075);
|
|
||||||
color: var(--nodedc-text-primary);
|
|
||||||
padding: 0.58rem 0.85rem;
|
|
||||||
font: inherit;
|
|
||||||
font-size: 0.65rem;
|
|
||||||
font-weight: 620;
|
|
||||||
}
|
|
||||||
|
|
||||||
.laboratory-selector select:focus-visible {
|
|
||||||
background: rgb(255 255 255 / 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.laboratory-selector select:disabled {
|
|
||||||
opacity: 0.45;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.laboratory-work-output {
|
.laboratory-work-output {
|
||||||
@@ -2482,16 +2465,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.laboratory-task,
|
.laboratory-task,
|
||||||
.laboratory-result-summary,
|
.laboratory-result-summary {
|
||||||
.laboratory-visual-result {
|
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
background: rgb(255 255 255 / 0.025);
|
background: rgb(255 255 255 / 0.025);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.laboratory-task > header,
|
.laboratory-task > header,
|
||||||
.laboratory-result-summary > header,
|
.laboratory-result-summary > header {
|
||||||
.laboratory-visual-result > header {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -2502,15 +2483,12 @@
|
|||||||
.laboratory-task p,
|
.laboratory-task p,
|
||||||
.laboratory-task dl,
|
.laboratory-task dl,
|
||||||
.laboratory-result-summary h2,
|
.laboratory-result-summary h2,
|
||||||
.laboratory-result-summary p,
|
.laboratory-result-summary p {
|
||||||
.laboratory-visual-result h2,
|
|
||||||
.laboratory-visual-result p {
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.laboratory-task h2,
|
.laboratory-task h2,
|
||||||
.laboratory-result-summary h2,
|
.laboratory-result-summary h2 {
|
||||||
.laboratory-visual-result h2 {
|
|
||||||
margin-top: 0.3rem;
|
margin-top: 0.3rem;
|
||||||
color: var(--nodedc-text-primary);
|
color: var(--nodedc-text-primary);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
@@ -2518,8 +2496,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.laboratory-task p,
|
.laboratory-task p,
|
||||||
.laboratory-result-summary > p,
|
.laboratory-result-summary > p {
|
||||||
.laboratory-visual-result header p {
|
|
||||||
max-width: 66rem;
|
max-width: 66rem;
|
||||||
margin-top: 0.38rem;
|
margin-top: 0.38rem;
|
||||||
color: var(--nodedc-text-muted);
|
color: var(--nodedc-text-muted);
|
||||||
@@ -2566,7 +2543,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.lab-result-surface {
|
.lab-result-surface {
|
||||||
min-height: 36rem;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lab-result-surface .spatial-workspace {
|
.lab-result-surface .spatial-workspace {
|
||||||
@@ -2575,12 +2552,12 @@
|
|||||||
|
|
||||||
.laboratory-result-pending {
|
.laboratory-result-pending {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-height: 34rem;
|
min-height: 10rem;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
align-content: center;
|
align-content: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
background: #06070a;
|
background: rgb(255 255 255 / 0.025);
|
||||||
color: var(--nodedc-text-muted);
|
color: var(--nodedc-text-muted);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -2596,123 +2573,6 @@
|
|||||||
font-size: 0.62rem;
|
font-size: 0.62rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.laboratory-visual-result {
|
|
||||||
display: grid;
|
|
||||||
min-height: 34rem;
|
|
||||||
align-content: start;
|
|
||||||
gap: 1rem;
|
|
||||||
background: #08090c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.laboratory-e29-chart {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.85rem;
|
|
||||||
border-radius: 0.9rem;
|
|
||||||
background: rgb(255 255 255 / 0.025);
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.laboratory-e29-chart__bar {
|
|
||||||
display: flex;
|
|
||||||
height: 1.1rem;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgb(255 255 255 / 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.laboratory-e29-chart__bar i[data-status="agree"],
|
|
||||||
.laboratory-e29-chart__legend i[data-status="agree"] {
|
|
||||||
background: rgb(var(--nodedc-success-rgb));
|
|
||||||
}
|
|
||||||
|
|
||||||
.laboratory-e29-chart__bar i[data-status="camera"],
|
|
||||||
.laboratory-e29-chart__legend i[data-status="camera"] {
|
|
||||||
background: rgb(var(--nodedc-accent-rgb));
|
|
||||||
}
|
|
||||||
|
|
||||||
.laboratory-e29-chart__bar i[data-status="conflict"],
|
|
||||||
.laboratory-e29-chart__legend i[data-status="conflict"] {
|
|
||||||
min-width: 0.25rem;
|
|
||||||
background: rgb(var(--nodedc-warning-rgb));
|
|
||||||
}
|
|
||||||
|
|
||||||
.laboratory-e29-chart__legend {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.laboratory-e29-chart__legend > div {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem 0.45rem;
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
background: rgb(255 255 255 / 0.025);
|
|
||||||
padding: 0.7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.laboratory-e29-chart__legend i {
|
|
||||||
width: 0.5rem;
|
|
||||||
height: 0.5rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.laboratory-e29-chart__legend span,
|
|
||||||
.laboratory-e29-chart__legend small {
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
font-size: 0.56rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.laboratory-e29-chart__legend strong {
|
|
||||||
color: var(--nodedc-text-primary);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.laboratory-e29-chart__legend small {
|
|
||||||
grid-column: 2 / 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.laboratory-e29-evidence {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 0.6rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.laboratory-e29-evidence article {
|
|
||||||
display: grid;
|
|
||||||
align-content: start;
|
|
||||||
gap: 0.45rem;
|
|
||||||
border-radius: 0.9rem;
|
|
||||||
background: rgb(255 255 255 / 0.025);
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.laboratory-e29-evidence strong {
|
|
||||||
color: var(--nodedc-text-primary);
|
|
||||||
font-size: 1.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.laboratory-e29-evidence p {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
font-size: 0.62rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.laboratory-e29-evidence article > div {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.laboratory-e29-evidence article > div span {
|
|
||||||
border-radius: 0.6rem;
|
|
||||||
background: rgb(255 255 255 / 0.03);
|
|
||||||
color: var(--nodedc-text-secondary);
|
|
||||||
padding: 0.45rem 0.55rem;
|
|
||||||
font-size: 0.58rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.laboratory-result-summary {
|
.laboratory-result-summary {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.85rem;
|
gap: 0.85rem;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
GlassSurface,
|
GlassSurface,
|
||||||
Icon,
|
Icon,
|
||||||
|
Select,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
} from "@nodedc/ui-react";
|
} from "@nodedc/ui-react";
|
||||||
|
|
||||||
@@ -1218,18 +1219,21 @@ function LaboratorySelector<T extends string>({
|
|||||||
<h2>{title}</h2>
|
<h2>{title}</h2>
|
||||||
<p>{description}</p>
|
<p>{description}</p>
|
||||||
</div>
|
</div>
|
||||||
<label>
|
<div className="laboratory-selector__control">
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
<select
|
<Select
|
||||||
|
label={`Выбрать: ${label}`}
|
||||||
value={value}
|
value={value}
|
||||||
|
options={options.map((option) => ({
|
||||||
|
value: option.id,
|
||||||
|
label: option.label,
|
||||||
|
}))}
|
||||||
|
variant="split"
|
||||||
|
menuWidth="anchor"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={(event) => onChange(event.currentTarget.value as T)}
|
onChange={(next) => onChange(next)}
|
||||||
>
|
/>
|
||||||
{options.map((option) => (
|
</div>
|
||||||
<option key={option.id} value={option.id}>{option.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1268,20 +1272,6 @@ function LaboratoryTask({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function E29LaboratoryResult({ rigLabel }: { rigLabel: string }) {
|
function E29LaboratoryResult({ rigLabel }: { rigLabel: string }) {
|
||||||
const semanticTotal = 19_625;
|
|
||||||
const statuses = [
|
|
||||||
{ id: "agree", label: "Камера + геометрия", value: 6_341 },
|
|
||||||
{ id: "camera", label: "Только камера", value: 13_246 },
|
|
||||||
{ id: "conflict", label: "Конфликт", value: 38 },
|
|
||||||
] as const;
|
|
||||||
const conflictEpisodes = [
|
|
||||||
"track 115 · 69,799–71,398 с",
|
|
||||||
"track 470 · 215,324–218,708 с",
|
|
||||||
"track 679 · 282,569–283,281 с",
|
|
||||||
"track 1011 · 354,937–355,242 с",
|
|
||||||
"track 1276 · 402,606–402,995 с",
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<LaboratoryTask
|
<LaboratoryTask
|
||||||
@@ -1296,61 +1286,6 @@ function E29LaboratoryResult({ rigLabel }: { rigLabel: string }) {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section className="laboratory-visual-result" aria-label="Визуальный результат LAB E29">
|
|
||||||
<header>
|
|
||||||
<div>
|
|
||||||
<span className="section-eyebrow">ВИЗУАЛЬНЫЙ РЕЗУЛЬТАТ</span>
|
|
||||||
<h2>Покрытие семантических наблюдений геометрией</h2>
|
|
||||||
<p>
|
|
||||||
Это покрытие одного воспроизводимого replay, а не accuracy и не допуск
|
|
||||||
планировщика. Конфликты сохранены для ручного покадрового разбора.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<StatusBadge tone="warning">16 конфликтных эпизодов</StatusBadge>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="laboratory-e29-chart" role="img" aria-label="Распределение статусов геометрии">
|
|
||||||
<div className="laboratory-e29-chart__bar">
|
|
||||||
{statuses.map((status) => (
|
|
||||||
<i
|
|
||||||
key={status.id}
|
|
||||||
data-status={status.id}
|
|
||||||
style={{ width: `${(status.value / semanticTotal) * 100}%` }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="laboratory-e29-chart__legend">
|
|
||||||
{statuses.map((status) => (
|
|
||||||
<div key={status.id}>
|
|
||||||
<i data-status={status.id} />
|
|
||||||
<span>{status.label}</span>
|
|
||||||
<strong>{status.value.toLocaleString("ru-RU")}</strong>
|
|
||||||
<small>{((status.value / semanticTotal) * 100).toLocaleString("ru-RU", {
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
})}%</small>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="laboratory-e29-evidence">
|
|
||||||
<article>
|
|
||||||
<span className="section-eyebrow">НЕЗАВИСИМАЯ ГЕОМЕТРИЯ</span>
|
|
||||||
<strong>21 321 компонент</strong>
|
|
||||||
<p>
|
|
||||||
Незасемантизированные занятые компоненты сохранены отдельным слоем:
|
|
||||||
им не назначается выдуманный класс и они не считаются свободным местом.
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
<article>
|
|
||||||
<span className="section-eyebrow">КОНФЛИКТЫ ДЛЯ РАЗБОРА</span>
|
|
||||||
<div>
|
|
||||||
{conflictEpisodes.map((episode) => <span key={episode}>{episode}</span>)}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="laboratory-result-summary">
|
<section className="laboratory-result-summary">
|
||||||
<header>
|
<header>
|
||||||
<div>
|
<div>
|
||||||
@@ -1575,7 +1510,7 @@ function LabArchiveWorkspace(props: WorkspaceRendererProps) {
|
|||||||
<LaboratorySelector
|
<LaboratorySelector
|
||||||
eyebrow="ЛАБОРАТОРНАЯ РАБОТА"
|
eyebrow="ЛАБОРАТОРНАЯ РАБОТА"
|
||||||
title={workOptions.find((work) => work.id === workId)?.label ?? "Работа не выбрана"}
|
title={workOptions.find((work) => work.id === workId)?.label ?? "Работа не выбрана"}
|
||||||
description="Выберите один зафиксированный эксперимент. Ниже откроются его задача, полноразмерное визуальное доказательство и структурированный результат."
|
description="Выберите один зафиксированный эксперимент. Ниже откроются его задача и структурированный результат; viewer появляется только у опубликованного серверного доказательства."
|
||||||
label="Работа"
|
label="Работа"
|
||||||
value={workId}
|
value={workId}
|
||||||
options={workOptions}
|
options={workOptions}
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { after, before, test } from "node:test";
|
||||||
|
|
||||||
|
import { createServer } from "vite";
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let environment;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
server = await createServer({
|
||||||
|
appType: "custom",
|
||||||
|
logLevel: "silent",
|
||||||
|
server: { middlewareMode: true },
|
||||||
|
});
|
||||||
|
environment = await server.ssrLoadModule(
|
||||||
|
"/src/core/environment/environmentSettings.ts",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await server?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
function serverDocument(overrides = {}) {
|
||||||
|
const defaults = environment.defaultEnvironmentSettings();
|
||||||
|
return {
|
||||||
|
schema_version: "missioncore.operator-environment/v1",
|
||||||
|
revision: defaults.revision,
|
||||||
|
header_labels: defaults.headerLabels,
|
||||||
|
backgrounds: Object.fromEntries(
|
||||||
|
Object.entries(defaults.backgrounds).map(([surfaceId, background]) => [
|
||||||
|
surfaceId,
|
||||||
|
{
|
||||||
|
enabled: background.enabled,
|
||||||
|
source: background.source,
|
||||||
|
url: background.url,
|
||||||
|
media_kind: background.mediaKind,
|
||||||
|
file_name: background.fileName,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("default environment exposes every header and landing surface", () => {
|
||||||
|
const defaults = environment.defaultEnvironmentSettings();
|
||||||
|
assert.deepEqual(Object.keys(defaults.headerLabels), [
|
||||||
|
"center",
|
||||||
|
"fleet",
|
||||||
|
"observation",
|
||||||
|
"missions",
|
||||||
|
"data",
|
||||||
|
"system",
|
||||||
|
"polygon",
|
||||||
|
]);
|
||||||
|
assert.deepEqual(Object.keys(defaults.backgrounds), [
|
||||||
|
"home",
|
||||||
|
"center",
|
||||||
|
"fleet",
|
||||||
|
"observation",
|
||||||
|
"missions",
|
||||||
|
"data",
|
||||||
|
"system",
|
||||||
|
"polygon",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("server document decodes and re-encodes without leaking schema internals", () => {
|
||||||
|
const payload = serverDocument({
|
||||||
|
revision: 4,
|
||||||
|
header_labels: {
|
||||||
|
...serverDocument().header_labels,
|
||||||
|
center: "Командный центр",
|
||||||
|
},
|
||||||
|
backgrounds: {
|
||||||
|
...serverDocument().backgrounds,
|
||||||
|
home: {
|
||||||
|
enabled: true,
|
||||||
|
source: "file",
|
||||||
|
url: `/api/v1/environment/media/home?generation=${"a".repeat(64)}`,
|
||||||
|
media_kind: "video",
|
||||||
|
file_name: "stage.mp4",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const decoded = environment.decodeEnvironmentSettings(payload);
|
||||||
|
const encoded = environment.encodeEnvironmentSettings(decoded);
|
||||||
|
|
||||||
|
assert.equal(decoded.revision, 4);
|
||||||
|
assert.equal(decoded.headerLabels.center, "Командный центр");
|
||||||
|
assert.equal(decoded.backgrounds.home.mediaKind, "video");
|
||||||
|
assert.equal(encoded.backgrounds.home.media_kind, "video");
|
||||||
|
assert.equal("schema_version" in encoded, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("decoder fails closed on an unknown environment schema", () => {
|
||||||
|
assert.throws(
|
||||||
|
() => environment.decodeEnvironmentSettings(
|
||||||
|
serverDocument({ schema_version: "future/v9" }),
|
||||||
|
),
|
||||||
|
/не поддерживается/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("editing a cloned environment cannot mutate the accepted settings", () => {
|
||||||
|
const accepted = environment.decodeEnvironmentSettings(serverDocument());
|
||||||
|
const draft = environment.cloneEnvironmentSettings(accepted);
|
||||||
|
draft.headerLabels.center = "Изменено";
|
||||||
|
draft.backgrounds.home.enabled = true;
|
||||||
|
|
||||||
|
assert.equal(accepted.headerLabels.center, "Центр");
|
||||||
|
assert.equal(accepted.backgrounds.home.enabled, false);
|
||||||
|
});
|
||||||
@@ -33,6 +33,7 @@ from k1link.sessions import (
|
|||||||
SessionStore,
|
SessionStore,
|
||||||
)
|
)
|
||||||
from k1link.web.device_plugin_composition import load_installed_device_plugins
|
from k1link.web.device_plugin_composition import load_installed_device_plugins
|
||||||
|
from k1link.web.environment_api import build_environment_router
|
||||||
from k1link.web.lidar_api import build_lidar_router
|
from k1link.web.lidar_api import build_lidar_router
|
||||||
from k1link.web.plugin_catalog import DevicePluginCatalog, PluginCatalogError
|
from k1link.web.plugin_catalog import DevicePluginCatalog, PluginCatalogError
|
||||||
from k1link.web.plugin_runtime import (
|
from k1link.web.plugin_runtime import (
|
||||||
@@ -398,6 +399,11 @@ app.include_router(
|
|||||||
point_color_renderers=plugin_environment.point_color_renderers,
|
point_color_renderers=plugin_environment.point_color_renderers,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
app.include_router(
|
||||||
|
build_environment_router(
|
||||||
|
root_provider=lambda: session_store.data_dir / "ui-environment"
|
||||||
|
)
|
||||||
|
)
|
||||||
app.include_router(
|
app.include_router(
|
||||||
build_polygon_router(
|
build_polygon_router(
|
||||||
root_provider=lambda: configured_polygon_runs_root()
|
root_provider=lambda: configured_polygon_runs_root()
|
||||||
|
|||||||
@@ -0,0 +1,381 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from collections.abc import Callable
|
||||||
|
from pathlib import Path
|
||||||
|
from threading import Lock
|
||||||
|
from typing import Annotated, Literal
|
||||||
|
from urllib.parse import unquote, urlsplit
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Header, HTTPException, Query, Request
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
|
EnvironmentSurfaceId = Literal[
|
||||||
|
"home",
|
||||||
|
"center",
|
||||||
|
"fleet",
|
||||||
|
"observation",
|
||||||
|
"missions",
|
||||||
|
"data",
|
||||||
|
"system",
|
||||||
|
"polygon",
|
||||||
|
]
|
||||||
|
EnvironmentMediaKind = Literal["image", "video"]
|
||||||
|
EnvironmentMediaSource = Literal["file", "url"]
|
||||||
|
|
||||||
|
ENVIRONMENT_SCHEMA_VERSION: Literal["missioncore.operator-environment/v1"] = (
|
||||||
|
"missioncore.operator-environment/v1"
|
||||||
|
)
|
||||||
|
ENVIRONMENT_MEDIA_SCHEMA_VERSION: Literal[
|
||||||
|
"missioncore.operator-environment-media/v1"
|
||||||
|
] = "missioncore.operator-environment-media/v1"
|
||||||
|
MAX_ENVIRONMENT_MEDIA_BYTES = 256 * 1024 * 1024
|
||||||
|
SAFE_FILE_NAME = re.compile(r"[^A-Za-z0-9А-Яа-яЁё._ -]+")
|
||||||
|
SUPPORTED_MEDIA_TYPES: dict[str, tuple[EnvironmentMediaKind, str]] = {
|
||||||
|
"image/avif": ("image", ".avif"),
|
||||||
|
"image/gif": ("image", ".gif"),
|
||||||
|
"image/jpeg": ("image", ".jpg"),
|
||||||
|
"image/png": ("image", ".png"),
|
||||||
|
"image/webp": ("image", ".webp"),
|
||||||
|
"video/mp4": ("video", ".mp4"),
|
||||||
|
"video/quicktime": ("video", ".mov"),
|
||||||
|
"video/webm": ("video", ".webm"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class StrictApiModel(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
||||||
|
class EnvironmentHeaderLabels(StrictApiModel):
|
||||||
|
center: str = Field(min_length=1, max_length=40)
|
||||||
|
fleet: str = Field(min_length=1, max_length=40)
|
||||||
|
observation: str = Field(min_length=1, max_length=40)
|
||||||
|
missions: str = Field(min_length=1, max_length=40)
|
||||||
|
data: str = Field(min_length=1, max_length=40)
|
||||||
|
system: str = Field(min_length=1, max_length=40)
|
||||||
|
polygon: str = Field(min_length=1, max_length=40)
|
||||||
|
|
||||||
|
|
||||||
|
class EnvironmentBackground(StrictApiModel):
|
||||||
|
enabled: bool = False
|
||||||
|
source: EnvironmentMediaSource = "file"
|
||||||
|
url: str | None = Field(default=None, max_length=2048)
|
||||||
|
media_kind: EnvironmentMediaKind | None = None
|
||||||
|
file_name: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_source(self) -> EnvironmentBackground:
|
||||||
|
if not self.enabled:
|
||||||
|
return self
|
||||||
|
if not self.url or self.media_kind is None:
|
||||||
|
raise ValueError("enabled background requires url and media kind")
|
||||||
|
if self.source == "url":
|
||||||
|
parsed = urlsplit(self.url)
|
||||||
|
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||||
|
raise ValueError("external background URL must use HTTP or HTTPS")
|
||||||
|
elif not self.url.startswith("/api/v1/environment/media/"):
|
||||||
|
raise ValueError("file background must use the Mission Core media endpoint")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class EnvironmentBackgrounds(StrictApiModel):
|
||||||
|
home: EnvironmentBackground
|
||||||
|
center: EnvironmentBackground
|
||||||
|
fleet: EnvironmentBackground
|
||||||
|
observation: EnvironmentBackground
|
||||||
|
missions: EnvironmentBackground
|
||||||
|
data: EnvironmentBackground
|
||||||
|
system: EnvironmentBackground
|
||||||
|
polygon: EnvironmentBackground
|
||||||
|
|
||||||
|
|
||||||
|
class EnvironmentSettingsPut(StrictApiModel):
|
||||||
|
revision: int = Field(ge=0)
|
||||||
|
header_labels: EnvironmentHeaderLabels
|
||||||
|
backgrounds: EnvironmentBackgrounds
|
||||||
|
|
||||||
|
|
||||||
|
class EnvironmentSettingsDocument(EnvironmentSettingsPut):
|
||||||
|
schema_version: Literal["missioncore.operator-environment/v1"] = ENVIRONMENT_SCHEMA_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
class EnvironmentMediaDocument(StrictApiModel):
|
||||||
|
schema_version: Literal["missioncore.operator-environment-media/v1"] = (
|
||||||
|
ENVIRONMENT_MEDIA_SCHEMA_VERSION
|
||||||
|
)
|
||||||
|
surface_id: EnvironmentSurfaceId
|
||||||
|
url: str
|
||||||
|
file_name: str
|
||||||
|
media_kind: EnvironmentMediaKind
|
||||||
|
media_type: str
|
||||||
|
byte_length: int = Field(ge=1, le=MAX_ENVIRONMENT_MEDIA_BYTES)
|
||||||
|
sha256: str = Field(pattern=r"^[a-f0-9]{64}$")
|
||||||
|
|
||||||
|
|
||||||
|
def default_environment_settings() -> EnvironmentSettingsDocument:
|
||||||
|
background = EnvironmentBackground()
|
||||||
|
return EnvironmentSettingsDocument(
|
||||||
|
revision=0,
|
||||||
|
header_labels=EnvironmentHeaderLabels(
|
||||||
|
center="Центр",
|
||||||
|
fleet="Парк",
|
||||||
|
observation="Наблюдение",
|
||||||
|
missions="Миссии",
|
||||||
|
data="Данные",
|
||||||
|
system="Система",
|
||||||
|
polygon="Тестировочный контур",
|
||||||
|
),
|
||||||
|
backgrounds=EnvironmentBackgrounds(
|
||||||
|
home=background.model_copy(),
|
||||||
|
center=background.model_copy(),
|
||||||
|
fleet=background.model_copy(),
|
||||||
|
observation=background.model_copy(),
|
||||||
|
missions=background.model_copy(),
|
||||||
|
data=background.model_copy(),
|
||||||
|
system=background.model_copy(),
|
||||||
|
polygon=background.model_copy(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EnvironmentSettingsStore:
|
||||||
|
def __init__(self, root: Path) -> None:
|
||||||
|
self.root = root.expanduser().resolve()
|
||||||
|
self.settings_path = self.root / "settings.json"
|
||||||
|
self.media_root = self.root / "media"
|
||||||
|
self._lock = Lock()
|
||||||
|
|
||||||
|
def read(self) -> EnvironmentSettingsDocument:
|
||||||
|
with self._lock:
|
||||||
|
if not self.settings_path.is_file():
|
||||||
|
return default_environment_settings()
|
||||||
|
try:
|
||||||
|
payload = json.loads(self.settings_path.read_text(encoding="utf-8"))
|
||||||
|
return EnvironmentSettingsDocument.model_validate(payload)
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
raise RuntimeError("operator environment settings are corrupt") from exc
|
||||||
|
|
||||||
|
def save(self, request: EnvironmentSettingsPut) -> EnvironmentSettingsDocument:
|
||||||
|
with self._lock:
|
||||||
|
current = self._read_unlocked()
|
||||||
|
if request.revision != current.revision:
|
||||||
|
raise RuntimeError("operator environment settings revision changed")
|
||||||
|
document = EnvironmentSettingsDocument(
|
||||||
|
revision=current.revision + 1,
|
||||||
|
header_labels=request.header_labels,
|
||||||
|
backgrounds=request.backgrounds,
|
||||||
|
)
|
||||||
|
self.root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
temporary = self.settings_path.with_suffix(".json.tmp")
|
||||||
|
temporary.write_text(
|
||||||
|
json.dumps(
|
||||||
|
document.model_dump(mode="json"),
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
+ "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
os.replace(temporary, self.settings_path)
|
||||||
|
return document
|
||||||
|
|
||||||
|
def _read_unlocked(self) -> EnvironmentSettingsDocument:
|
||||||
|
if not self.settings_path.is_file():
|
||||||
|
return default_environment_settings()
|
||||||
|
try:
|
||||||
|
payload = json.loads(self.settings_path.read_text(encoding="utf-8"))
|
||||||
|
return EnvironmentSettingsDocument.model_validate(payload)
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
raise RuntimeError("operator environment settings are corrupt") from exc
|
||||||
|
|
||||||
|
def media_metadata_path(self, surface_id: EnvironmentSurfaceId) -> Path:
|
||||||
|
return self.media_root / f"{surface_id}.json"
|
||||||
|
|
||||||
|
def read_media(
|
||||||
|
self,
|
||||||
|
surface_id: EnvironmentSurfaceId,
|
||||||
|
generation: str,
|
||||||
|
) -> tuple[Path, EnvironmentMediaDocument]:
|
||||||
|
with self._lock:
|
||||||
|
metadata_path = self.media_metadata_path(surface_id)
|
||||||
|
try:
|
||||||
|
metadata = EnvironmentMediaDocument.model_validate_json(
|
||||||
|
metadata_path.read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
raise FileNotFoundError(surface_id) from exc
|
||||||
|
if generation != metadata.sha256:
|
||||||
|
raise PermissionError("media generation changed")
|
||||||
|
path = self.media_root / f"{surface_id}{SUPPORTED_MEDIA_TYPES[metadata.media_type][1]}"
|
||||||
|
if not path.is_file() or path.stat().st_size != metadata.byte_length:
|
||||||
|
raise FileNotFoundError(surface_id)
|
||||||
|
return path, metadata
|
||||||
|
|
||||||
|
def finalize_media(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
surface_id: EnvironmentSurfaceId,
|
||||||
|
temporary_path: Path,
|
||||||
|
file_name: str,
|
||||||
|
media_type: str,
|
||||||
|
byte_length: int,
|
||||||
|
sha256: str,
|
||||||
|
) -> EnvironmentMediaDocument:
|
||||||
|
media_kind, extension = SUPPORTED_MEDIA_TYPES[media_type]
|
||||||
|
with self._lock:
|
||||||
|
self.media_root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
destination = self.media_root / f"{surface_id}{extension}"
|
||||||
|
for stale_path in self.media_root.glob(f"{surface_id}.*"):
|
||||||
|
if stale_path == self.media_metadata_path(surface_id):
|
||||||
|
continue
|
||||||
|
if stale_path != destination and stale_path.is_file():
|
||||||
|
stale_path.unlink()
|
||||||
|
os.replace(temporary_path, destination)
|
||||||
|
document = EnvironmentMediaDocument(
|
||||||
|
surface_id=surface_id,
|
||||||
|
url=f"/api/v1/environment/media/{surface_id}?generation={sha256}",
|
||||||
|
file_name=file_name,
|
||||||
|
media_kind=media_kind,
|
||||||
|
media_type=media_type,
|
||||||
|
byte_length=byte_length,
|
||||||
|
sha256=sha256,
|
||||||
|
)
|
||||||
|
metadata_path = self.media_metadata_path(surface_id)
|
||||||
|
metadata_temporary = metadata_path.with_suffix(".json.tmp")
|
||||||
|
metadata_temporary.write_text(
|
||||||
|
json.dumps(document.model_dump(mode="json"), ensure_ascii=False, indent=2)
|
||||||
|
+ "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
os.replace(metadata_temporary, metadata_path)
|
||||||
|
return document
|
||||||
|
|
||||||
|
|
||||||
|
async def _write_upload(
|
||||||
|
request: Request,
|
||||||
|
temporary_path: Path,
|
||||||
|
) -> tuple[int, str]:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
byte_length = 0
|
||||||
|
try:
|
||||||
|
with temporary_path.open("xb") as output:
|
||||||
|
async for chunk in request.stream():
|
||||||
|
if not chunk:
|
||||||
|
continue
|
||||||
|
byte_length += len(chunk)
|
||||||
|
if byte_length > MAX_ENVIRONMENT_MEDIA_BYTES:
|
||||||
|
raise HTTPException(status_code=413, detail="Файл превышает лимит 256 МБ.")
|
||||||
|
output.write(chunk)
|
||||||
|
digest.update(chunk)
|
||||||
|
if byte_length == 0:
|
||||||
|
raise HTTPException(status_code=422, detail="Пустой файл не поддерживается.")
|
||||||
|
return byte_length, digest.hexdigest()
|
||||||
|
except BaseException:
|
||||||
|
temporary_path.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_file_name(value: str | None, extension: str) -> str:
|
||||||
|
decoded = unquote(value) if value else f"background{extension}"
|
||||||
|
candidate = SAFE_FILE_NAME.sub("_", decoded.strip())
|
||||||
|
if not candidate:
|
||||||
|
candidate = f"background{extension}"
|
||||||
|
return candidate[:255]
|
||||||
|
|
||||||
|
|
||||||
|
def build_environment_router(
|
||||||
|
root_provider: Callable[[], Path],
|
||||||
|
) -> APIRouter:
|
||||||
|
router = APIRouter(prefix="/api/v1/environment", tags=["environment"])
|
||||||
|
|
||||||
|
def store() -> EnvironmentSettingsStore:
|
||||||
|
return EnvironmentSettingsStore(root_provider())
|
||||||
|
|
||||||
|
@router.get("/settings")
|
||||||
|
def get_environment_settings() -> EnvironmentSettingsDocument:
|
||||||
|
try:
|
||||||
|
return store().read()
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="Настройки окружения повреждены.",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
@router.put("/settings")
|
||||||
|
def put_environment_settings(
|
||||||
|
request: EnvironmentSettingsPut,
|
||||||
|
) -> EnvironmentSettingsDocument:
|
||||||
|
try:
|
||||||
|
return store().save(request)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
if "revision changed" in str(exc):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=412,
|
||||||
|
detail="Настройки окружения были изменены в другом окне.",
|
||||||
|
) from exc
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="Настройки окружения повреждены.",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
@router.put("/media/{surface_id}")
|
||||||
|
async def upload_environment_media(
|
||||||
|
surface_id: EnvironmentSurfaceId,
|
||||||
|
request: Request,
|
||||||
|
file_name: Annotated[str | None, Header(alias="X-NODEDC-File-Name")] = None,
|
||||||
|
) -> EnvironmentMediaDocument:
|
||||||
|
media_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
|
||||||
|
if media_type not in SUPPORTED_MEDIA_TYPES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=415,
|
||||||
|
detail="Поддерживаются PNG, JPEG, WebP, GIF, AVIF, MP4, WebM и MOV.",
|
||||||
|
)
|
||||||
|
content_length = request.headers.get("content-length")
|
||||||
|
if content_length is not None:
|
||||||
|
try:
|
||||||
|
if int(content_length) > MAX_ENVIRONMENT_MEDIA_BYTES:
|
||||||
|
raise HTTPException(status_code=413, detail="Файл превышает лимит 256 МБ.")
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail="Некорректный размер файла.") from exc
|
||||||
|
environment_store = store()
|
||||||
|
environment_store.media_root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
temporary_path = environment_store.media_root / f".{surface_id}.{os.getpid()}.upload"
|
||||||
|
temporary_path.unlink(missing_ok=True)
|
||||||
|
byte_length, sha256 = await _write_upload(request, temporary_path)
|
||||||
|
extension = SUPPORTED_MEDIA_TYPES[media_type][1]
|
||||||
|
return environment_store.finalize_media(
|
||||||
|
surface_id=surface_id,
|
||||||
|
temporary_path=temporary_path,
|
||||||
|
file_name=_safe_file_name(file_name, extension),
|
||||||
|
media_type=media_type,
|
||||||
|
byte_length=byte_length,
|
||||||
|
sha256=sha256,
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/media/{surface_id}")
|
||||||
|
def get_environment_media(
|
||||||
|
surface_id: EnvironmentSurfaceId,
|
||||||
|
generation: Annotated[str, Query(pattern=r"^[a-f0-9]{64}$")],
|
||||||
|
) -> FileResponse:
|
||||||
|
try:
|
||||||
|
path, metadata = store().read_media(surface_id, generation)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail="Фон окружения не найден.") from exc
|
||||||
|
except PermissionError as exc:
|
||||||
|
raise HTTPException(status_code=412, detail="Фон окружения был заменён.") from exc
|
||||||
|
return FileResponse(
|
||||||
|
path,
|
||||||
|
media_type=metadata.media_type,
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "private, max-age=31536000, immutable, no-transform",
|
||||||
|
"ETag": f'"{metadata.sha256}"',
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return router
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
from collections.abc import Callable
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.routing import APIRoute
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from k1link.web.environment_api import (
|
||||||
|
EnvironmentBackground,
|
||||||
|
EnvironmentMediaDocument,
|
||||||
|
EnvironmentSettingsPut,
|
||||||
|
EnvironmentSettingsStore,
|
||||||
|
build_environment_router,
|
||||||
|
default_environment_settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _endpoint(router: APIRouter, path: str, method: str) -> Callable[..., Any]:
|
||||||
|
for route in router.routes:
|
||||||
|
if (
|
||||||
|
isinstance(route, APIRoute)
|
||||||
|
and route.path == path
|
||||||
|
and route.methods is not None
|
||||||
|
and method in route.methods
|
||||||
|
):
|
||||||
|
return route.endpoint
|
||||||
|
raise AssertionError(f"{method} {path} route is missing")
|
||||||
|
|
||||||
|
|
||||||
|
def _streaming_request(payload: bytes, media_type: str) -> Request:
|
||||||
|
chunks = iter((payload[:8], payload[8:]))
|
||||||
|
|
||||||
|
async def receive() -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
chunk = next(chunks)
|
||||||
|
return {
|
||||||
|
"type": "http.request",
|
||||||
|
"body": chunk,
|
||||||
|
"more_body": True,
|
||||||
|
}
|
||||||
|
except StopIteration:
|
||||||
|
return {
|
||||||
|
"type": "http.request",
|
||||||
|
"body": b"",
|
||||||
|
"more_body": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
return Request(
|
||||||
|
{
|
||||||
|
"type": "http",
|
||||||
|
"http_version": "1.1",
|
||||||
|
"method": "PUT",
|
||||||
|
"scheme": "http",
|
||||||
|
"path": "/api/v1/environment/media/home",
|
||||||
|
"raw_path": b"/api/v1/environment/media/home",
|
||||||
|
"query_string": b"",
|
||||||
|
"headers": [
|
||||||
|
(b"content-type", media_type.encode("ascii")),
|
||||||
|
(b"content-length", str(len(payload)).encode("ascii")),
|
||||||
|
],
|
||||||
|
"client": ("127.0.0.1", 1),
|
||||||
|
"server": ("127.0.0.1", 8000),
|
||||||
|
},
|
||||||
|
receive,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_environment_settings_are_versioned_and_persist_header_labels(tmp_path: Path) -> None:
|
||||||
|
store = EnvironmentSettingsStore(tmp_path / "operator-environment")
|
||||||
|
initial = store.read()
|
||||||
|
assert initial.revision == 0
|
||||||
|
assert initial.header_labels.polygon == "Тестировочный контур"
|
||||||
|
assert initial.backgrounds.home.enabled is False
|
||||||
|
|
||||||
|
request = EnvironmentSettingsPut(
|
||||||
|
revision=initial.revision,
|
||||||
|
header_labels=initial.header_labels.model_copy(update={"center": "Командный центр"}),
|
||||||
|
backgrounds=initial.backgrounds,
|
||||||
|
)
|
||||||
|
saved = store.save(request)
|
||||||
|
restored = EnvironmentSettingsStore(store.root).read()
|
||||||
|
|
||||||
|
assert saved.revision == 1
|
||||||
|
assert restored == saved
|
||||||
|
assert restored.header_labels.center == "Командный центр"
|
||||||
|
assert str(tmp_path) not in restored.model_dump_json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_environment_settings_reject_stale_revision(tmp_path: Path) -> None:
|
||||||
|
store = EnvironmentSettingsStore(tmp_path / "operator-environment")
|
||||||
|
initial = store.read()
|
||||||
|
store.save(
|
||||||
|
EnvironmentSettingsPut(
|
||||||
|
revision=0,
|
||||||
|
header_labels=initial.header_labels,
|
||||||
|
backgrounds=initial.backgrounds,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="revision changed"):
|
||||||
|
store.save(
|
||||||
|
EnvironmentSettingsPut(
|
||||||
|
revision=0,
|
||||||
|
header_labels=initial.header_labels,
|
||||||
|
backgrounds=initial.backgrounds,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_environment_background_rejects_untrusted_enabled_source() -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
EnvironmentBackground(
|
||||||
|
enabled=True,
|
||||||
|
source="url",
|
||||||
|
url="file:///tmp/background.mp4",
|
||||||
|
media_kind="video",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
EnvironmentBackground(
|
||||||
|
enabled=True,
|
||||||
|
source="file",
|
||||||
|
url="/private/operator/background.mp4",
|
||||||
|
media_kind="video",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_environment_media_is_generation_bound_and_stored_outside_git(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
store = EnvironmentSettingsStore(tmp_path / "mission-data" / "ui-environment")
|
||||||
|
store.media_root.mkdir(parents=True)
|
||||||
|
payload = b"\x89PNG\r\n\x1a\nsynthetic-redacted"
|
||||||
|
temporary = store.media_root / ".home.upload"
|
||||||
|
temporary.write_bytes(payload)
|
||||||
|
digest = hashlib.sha256(payload).hexdigest()
|
||||||
|
|
||||||
|
document = store.finalize_media(
|
||||||
|
surface_id="home",
|
||||||
|
temporary_path=temporary,
|
||||||
|
file_name="mission-core.png",
|
||||||
|
media_type="image/png",
|
||||||
|
byte_length=len(payload),
|
||||||
|
sha256=digest,
|
||||||
|
)
|
||||||
|
path, restored = store.read_media("home", digest)
|
||||||
|
|
||||||
|
assert path == store.media_root / "home.png"
|
||||||
|
assert path.read_bytes() == payload
|
||||||
|
assert restored == document
|
||||||
|
assert document.url == f"/api/v1/environment/media/home?generation={digest}"
|
||||||
|
with pytest.raises(PermissionError):
|
||||||
|
store.read_media("home", "0" * 64)
|
||||||
|
|
||||||
|
|
||||||
|
def test_environment_media_upload_route_streams_and_publishes_safe_metadata(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
root = tmp_path / "mission-data" / "ui-environment"
|
||||||
|
router = build_environment_router(lambda: root)
|
||||||
|
payload = b"\x89PNG\r\n\x1a\nsynthetic-redacted"
|
||||||
|
upload = _endpoint(router, "/api/v1/environment/media/{surface_id}", "PUT")
|
||||||
|
|
||||||
|
document = asyncio.run(
|
||||||
|
upload(
|
||||||
|
surface_id="home",
|
||||||
|
request=_streaming_request(payload, "image/png"),
|
||||||
|
file_name="%D1%84%D0%BE%D0%BD.png",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(document, EnvironmentMediaDocument)
|
||||||
|
assert document.file_name == "фон.png"
|
||||||
|
assert document.media_kind == "image"
|
||||||
|
assert document.sha256 == hashlib.sha256(payload).hexdigest()
|
||||||
|
assert (root / "media" / "home.png").read_bytes() == payload
|
||||||
|
assert str(tmp_path) not in document.model_dump_json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_environment_has_every_product_surface() -> None:
|
||||||
|
document = default_environment_settings()
|
||||||
|
assert set(document.backgrounds.model_dump()) == {
|
||||||
|
"home",
|
||||||
|
"center",
|
||||||
|
"fleet",
|
||||||
|
"observation",
|
||||||
|
"missions",
|
||||||
|
"data",
|
||||||
|
"system",
|
||||||
|
"polygon",
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user