feat(control-station): add configurable environment shell
This commit is contained in:
@@ -8,10 +8,8 @@ import {
|
||||
Checker,
|
||||
ColorField,
|
||||
ControlRow,
|
||||
HeaderAvatar,
|
||||
HeaderNavigation,
|
||||
HeaderProfile,
|
||||
HeaderProfileButton,
|
||||
HeaderWorkspace,
|
||||
Icon,
|
||||
Inspector,
|
||||
@@ -19,6 +17,7 @@ import {
|
||||
Select,
|
||||
StatusBadge,
|
||||
TextField,
|
||||
UserProfileMenu,
|
||||
Window,
|
||||
WindowFooterActions,
|
||||
useApplicationWorkspace,
|
||||
@@ -26,7 +25,9 @@ import {
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import { LandingStage } from "./components/LandingStage";
|
||||
import { EnvironmentSettingsWindow } from "./components/EnvironmentSettingsWindow";
|
||||
import { ObservationSessionSelect } from "./components/ObservationSessionSelect";
|
||||
import { useEnvironmentSettings } from "./core/environment/useEnvironmentSettings";
|
||||
import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost";
|
||||
import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext";
|
||||
import type { ViewerSettings } from "./core/runtime/contracts";
|
||||
@@ -149,6 +150,7 @@ function mergeViewerSettings(
|
||||
|
||||
export default function App() {
|
||||
const runtime = useMissionRuntime();
|
||||
const environment = useEnvironmentSettings();
|
||||
const { selection } = useDevicePluginHost();
|
||||
const polygonDatasetRoute = useMemo(
|
||||
() => resolvePolygonRunRoute(typeof window === "undefined" ? "" : window.location.search),
|
||||
@@ -162,6 +164,7 @@ export default function App() {
|
||||
const [activeRoot, setActiveRoot] = useState<RootId | null>(
|
||||
polygonDatasetRoute.active ? "data" : null,
|
||||
);
|
||||
const [environmentSettingsOpen, setEnvironmentSettingsOpen] = useState(false);
|
||||
const [sourceUrl, setSourceUrl] = useState("");
|
||||
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
|
||||
const [recordedReplayLabel, setRecordedReplayLabel] = useState<string | null>(null);
|
||||
@@ -423,11 +426,6 @@ export default function App() {
|
||||
|
||||
const selectRoot = (rootId: RootId) => {
|
||||
setActiveRoot(rootId);
|
||||
if (rootId === "polygon") {
|
||||
workspace.openView("lab-archive");
|
||||
workspace.openNavigation();
|
||||
return;
|
||||
}
|
||||
workspace.closeView();
|
||||
workspace.openNavigation();
|
||||
};
|
||||
@@ -626,6 +624,7 @@ export default function App() {
|
||||
const header = (
|
||||
<AppHeader
|
||||
brand={<img src="/nodedc-logo.svg" alt="NODE.DC" />}
|
||||
brandHref="/"
|
||||
brandLabel="NODEDC MISSION CORE"
|
||||
center={
|
||||
<>
|
||||
@@ -633,18 +632,35 @@ export default function App() {
|
||||
<HeaderNavigation
|
||||
label="Архитектурные блоки пункта управления"
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
right={
|
||||
<HeaderProfile>
|
||||
<HeaderProfileButton onClick={() => void runtime.refresh()} title="Обновить локальный контур">
|
||||
<span className="api-dot" data-status={runtime.backendStatus} aria-hidden="true" />
|
||||
{backendLabel(runtime.backendStatus)}
|
||||
</HeaderProfileButton>
|
||||
<HeaderAvatar label="DC" />
|
||||
<UserProfileMenu
|
||||
displayName="DC"
|
||||
subtitle="Mission Core"
|
||||
triggerLabel={null}
|
||||
actions={[
|
||||
{
|
||||
id: "refresh",
|
||||
label: "Обновить контур",
|
||||
icon: "refresh",
|
||||
onSelect: () => void runtime.refresh(),
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
label: "Настройки",
|
||||
icon: "settings",
|
||||
onSelect: () => setEnvironmentSettingsOpen(true),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</HeaderProfile>
|
||||
}
|
||||
/>
|
||||
@@ -668,6 +684,9 @@ export default function App() {
|
||||
backendStatus={runtime.backendStatus}
|
||||
phase={runtime.state?.phase}
|
||||
message={runtime.state?.message}
|
||||
background={
|
||||
environment.settings.backgrounds[activeRoot ?? "home"]
|
||||
}
|
||||
onOpenObservation={() => openView("spatial-scene")}
|
||||
onOpenDevice={() => openView("local-device")}
|
||||
/>
|
||||
@@ -764,7 +783,7 @@ export default function App() {
|
||||
) : activeDefinition.kind === "datasets" ? (
|
||||
<StatusBadge tone="neutral">Offline evaluation</StatusBadge>
|
||||
) : activeDefinition.kind === "lab-archive" ? (
|
||||
<StatusBadge tone="accent">Лаборатория</StatusBadge>
|
||||
null
|
||||
) : (
|
||||
<StatusBadge tone="warning">Интерфейс готов</StatusBadge>
|
||||
)
|
||||
@@ -821,6 +840,16 @@ export default function App() {
|
||||
) : null}
|
||||
/>
|
||||
|
||||
<EnvironmentSettingsWindow
|
||||
open={environmentSettingsOpen}
|
||||
settings={environment.settings}
|
||||
state={environment.state}
|
||||
error={environment.error}
|
||||
onClose={() => setEnvironmentSettingsOpen(false)}
|
||||
onSave={environment.save}
|
||||
onUpload={environment.upload}
|
||||
/>
|
||||
|
||||
<Window
|
||||
open={sceneWorkspaceActive && sourceWindowOpen}
|
||||
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 type { BackendStatus, RuntimePhase } from "../core/runtime/contracts";
|
||||
import type { EnvironmentBackground } from "../core/environment/environmentSettings";
|
||||
import type { RootDefinition } from "../productModel";
|
||||
import { backendLabel, backendTone, phaseLabel, phaseTone } from "../presentation";
|
||||
|
||||
@@ -9,6 +10,7 @@ export interface LandingStageProps {
|
||||
backendStatus: BackendStatus;
|
||||
phase?: RuntimePhase | null;
|
||||
message?: string | null;
|
||||
background: EnvironmentBackground;
|
||||
onOpenObservation: () => void;
|
||||
onOpenDevice: () => void;
|
||||
}
|
||||
@@ -18,11 +20,33 @@ export function LandingStage({
|
||||
backendStatus,
|
||||
phase,
|
||||
message,
|
||||
background,
|
||||
onOpenObservation,
|
||||
onOpenDevice,
|
||||
}: LandingStageProps) {
|
||||
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">
|
||||
<span className="section-eyebrow">{root?.eyebrow ?? "NODEDC / MISSION CORE"}</span>
|
||||
<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/responsive.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);
|
||||
}
|
||||
|
||||
.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 {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
|
||||
@@ -2441,12 +2441,12 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.laboratory-selector label {
|
||||
.laboratory-selector__control {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.laboratory-selector label > span {
|
||||
.laboratory-selector__control > span {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.55rem;
|
||||
font-weight: 650;
|
||||
@@ -2454,26 +2454,9 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.laboratory-selector select {
|
||||
.laboratory-selector__control .nodedc-select-anchor,
|
||||
.laboratory-selector__control .nodedc-select {
|
||||
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 {
|
||||
@@ -2482,16 +2465,14 @@
|
||||
}
|
||||
|
||||
.laboratory-task,
|
||||
.laboratory-result-summary,
|
||||
.laboratory-visual-result {
|
||||
.laboratory-result-summary {
|
||||
border-radius: 1rem;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.laboratory-task > header,
|
||||
.laboratory-result-summary > header,
|
||||
.laboratory-visual-result > header {
|
||||
.laboratory-result-summary > header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
@@ -2502,15 +2483,12 @@
|
||||
.laboratory-task p,
|
||||
.laboratory-task dl,
|
||||
.laboratory-result-summary h2,
|
||||
.laboratory-result-summary p,
|
||||
.laboratory-visual-result h2,
|
||||
.laboratory-visual-result p {
|
||||
.laboratory-result-summary p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.laboratory-task h2,
|
||||
.laboratory-result-summary h2,
|
||||
.laboratory-visual-result h2 {
|
||||
.laboratory-result-summary h2 {
|
||||
margin-top: 0.3rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 1rem;
|
||||
@@ -2518,8 +2496,7 @@
|
||||
}
|
||||
|
||||
.laboratory-task p,
|
||||
.laboratory-result-summary > p,
|
||||
.laboratory-visual-result header p {
|
||||
.laboratory-result-summary > p {
|
||||
max-width: 66rem;
|
||||
margin-top: 0.38rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
@@ -2566,7 +2543,7 @@
|
||||
}
|
||||
|
||||
.lab-result-surface {
|
||||
min-height: 36rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.lab-result-surface .spatial-workspace {
|
||||
@@ -2575,12 +2552,12 @@
|
||||
|
||||
.laboratory-result-pending {
|
||||
display: grid;
|
||||
min-height: 34rem;
|
||||
min-height: 10rem;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 1rem;
|
||||
background: #06070a;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
color: var(--nodedc-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -2596,123 +2573,6 @@
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Button,
|
||||
GlassSurface,
|
||||
Icon,
|
||||
Select,
|
||||
StatusBadge,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
@@ -1218,18 +1219,21 @@ function LaboratorySelector<T extends string>({
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<label>
|
||||
<div className="laboratory-selector__control">
|
||||
<span>{label}</span>
|
||||
<select
|
||||
<Select
|
||||
label={`Выбрать: ${label}`}
|
||||
value={value}
|
||||
options={options.map((option) => ({
|
||||
value: option.id,
|
||||
label: option.label,
|
||||
}))}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.currentTarget.value as T)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.id} value={option.id}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
onChange={(next) => onChange(next)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1268,20 +1272,6 @@ function LaboratoryTask({
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<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">
|
||||
<header>
|
||||
<div>
|
||||
@@ -1575,7 +1510,7 @@ function LabArchiveWorkspace(props: WorkspaceRendererProps) {
|
||||
<LaboratorySelector
|
||||
eyebrow="ЛАБОРАТОРНАЯ РАБОТА"
|
||||
title={workOptions.find((work) => work.id === workId)?.label ?? "Работа не выбрана"}
|
||||
description="Выберите один зафиксированный эксперимент. Ниже откроются его задача, полноразмерное визуальное доказательство и структурированный результат."
|
||||
description="Выберите один зафиксированный эксперимент. Ниже откроются его задача и структурированный результат; viewer появляется только у опубликованного серверного доказательства."
|
||||
label="Работа"
|
||||
value={workId}
|
||||
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,
|
||||
)
|
||||
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.plugin_catalog import DevicePluginCatalog, PluginCatalogError
|
||||
from k1link.web.plugin_runtime import (
|
||||
@@ -398,6 +399,11 @@ app.include_router(
|
||||
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(
|
||||
build_polygon_router(
|
||||
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