feat(control-station): add configurable environment shell

This commit is contained in:
DCCONSTRUCTIONS
2026-07-26 14:18:08 +03:00
parent 42041b37cd
commit 453d760be4
14 changed files with 1473 additions and 246 deletions
+43 -14
View File
@@ -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 };
}
+1
View File
@@ -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;
}
}
+28
View File
@@ -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;
+12 -152
View File
@@ -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,79971,398 с",
"track 470 · 215,324218,708 с",
"track 679 · 282,569283,281 с",
"track 1011 · 354,937355,242 с",
"track 1276 · 402,606402,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);
});
+6
View File
@@ -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()
+381
View File
@@ -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
+197
View File
@@ -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",
}