From 453d760be4ac6a5f20a002f374eea992188e3cd4 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sun, 26 Jul 2026 14:18:08 +0300 Subject: [PATCH] feat(control-station): add configurable environment shell --- apps/control-station/src/App.tsx | 57 ++- .../components/EnvironmentSettingsWindow.tsx | 291 +++++++++++++ .../src/components/LandingStage.tsx | 26 +- .../core/environment/environmentSettings.ts | 193 +++++++++ .../environment/useEnvironmentSettings.ts | 125 ++++++ apps/control-station/src/styles.css | 1 + .../src/styles/environment-settings.css | 43 ++ apps/control-station/src/styles/shell.css | 28 ++ .../control-station/src/styles/workspaces.css | 164 +------- .../src/workspaces/Workspaces.tsx | 93 +---- .../test/environmentSettings.test.mjs | 114 ++++++ src/k1link/web/app.py | 6 + src/k1link/web/environment_api.py | 381 ++++++++++++++++++ tests/test_environment_api.py | 197 +++++++++ 14 files changed, 1473 insertions(+), 246 deletions(-) create mode 100644 apps/control-station/src/components/EnvironmentSettingsWindow.tsx create mode 100644 apps/control-station/src/core/environment/environmentSettings.ts create mode 100644 apps/control-station/src/core/environment/useEnvironmentSettings.ts create mode 100644 apps/control-station/src/styles/environment-settings.css create mode 100644 apps/control-station/test/environmentSettings.test.mjs create mode 100644 src/k1link/web/environment_api.py create mode 100644 tests/test_environment_api.py diff --git a/apps/control-station/src/App.tsx b/apps/control-station/src/App.tsx index 9936f90..92fb969 100644 --- a/apps/control-station/src/App.tsx +++ b/apps/control-station/src/App.tsx @@ -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( polygonDatasetRoute.active ? "data" : null, ); + const [environmentSettingsOpen, setEnvironmentSettingsOpen] = useState(false); const [sourceUrl, setSourceUrl] = useState(""); const [recordedReplay, setRecordedReplay] = useState(null); const [recordedReplayLabel, setRecordedReplayLabel] = useState(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 = ( } + brandHref="/" brandLabel="NODEDC MISSION CORE" center={ <> @@ -633,18 +632,35 @@ export default function App() { ({ value: root.id, label: root.label }))} + items={visibleRoots.map((root) => ({ + value: root.id, + label: environment.settings.headerLabels[root.id], + }))} onChange={selectRoot} /> } right={ - void runtime.refresh()} title="Обновить локальный контур"> - - + void runtime.refresh(), + }, + { + id: "settings", + label: "Настройки", + icon: "settings", + onSelect: () => setEnvironmentSettingsOpen(true), + }, + ]} + /> } /> @@ -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" ? ( Offline evaluation ) : activeDefinition.kind === "lab-archive" ? ( - Лаборатория + null ) : ( Интерфейс готов ) @@ -821,6 +840,16 @@ export default function App() { ) : null} /> + setEnvironmentSettingsOpen(false)} + onSave={environment.save} + onUpload={environment.upload} + /> + void; + onSave: (settings: EnvironmentSettings) => Promise; + onUpload: ( + surfaceId: EnvironmentSurfaceId, + file: File, + ) => Promise; +} + +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, +): 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("home"); + const [uploading, setUploading] = useState(false); + const [localError, setLocalError] = useState(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, + }); + onClose(); + } catch (reason) { + setLocalError(reason instanceof Error + ? reason.message + : "Не удалось сохранить настройки окружения."); + } + }; + + return ( + undefined} + onClose={onClose} + footer={( + + + + + )} + > +
+ +
+ {headerLabelFields.map((field) => ( + updateHeaderLabel(field.id, event.currentTarget.value)} + /> + ))} +
+
+ + { + if (enabled && !selectedBackground.url) { + setLocalError("Сначала загрузите файл или укажите URL."); + return; + } + setLocalError(null); + setDraft((current) => + patchBackground(current, surfaceId, { enabled })); + }} + /> + )} + > +
+
+ Экран + ({ + value: option.id, + label: option.label, + }))} + variant="split" + menuWidth="anchor" disabled={disabled} - onChange={(event) => onChange(event.currentTarget.value as T)} - > - {options.map((option) => ( - - ))} - - + onChange={(next) => onChange(next)} + /> +
); } @@ -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 ( <> -
-
-
- ВИЗУАЛЬНЫЙ РЕЗУЛЬТАТ -

Покрытие семантических наблюдений геометрией

-

- Это покрытие одного воспроизводимого replay, а не accuracy и не допуск - планировщика. Конфликты сохранены для ручного покадрового разбора. -

-
- 16 конфликтных эпизодов -
- -
-
- {statuses.map((status) => ( - - ))} -
-
- {statuses.map((status) => ( -
- - {status.label} - {status.value.toLocaleString("ru-RU")} - {((status.value / semanticTotal) * 100).toLocaleString("ru-RU", { - maximumFractionDigits: 2, - })}% -
- ))} -
-
- -
-
- НЕЗАВИСИМАЯ ГЕОМЕТРИЯ - 21 321 компонент -

- Незасемантизированные занятые компоненты сохранены отдельным слоем: - им не назначается выдуманный класс и они не считаются свободным местом. -

-
-
- КОНФЛИКТЫ ДЛЯ РАЗБОРА -
- {conflictEpisodes.map((episode) => {episode})} -
-
-
-
-
@@ -1575,7 +1510,7 @@ function LabArchiveWorkspace(props: WorkspaceRendererProps) { work.id === workId)?.label ?? "Работа не выбрана"} - description="Выберите один зафиксированный эксперимент. Ниже откроются его задача, полноразмерное визуальное доказательство и структурированный результат." + description="Выберите один зафиксированный эксперимент. Ниже откроются его задача и структурированный результат; viewer появляется только у опубликованного серверного доказательства." label="Работа" value={workId} options={workOptions} diff --git a/apps/control-station/test/environmentSettings.test.mjs b/apps/control-station/test/environmentSettings.test.mjs new file mode 100644 index 0000000..cb0c621 --- /dev/null +++ b/apps/control-station/test/environmentSettings.test.mjs @@ -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); +}); diff --git a/src/k1link/web/app.py b/src/k1link/web/app.py index 653a80f..fb8bab4 100644 --- a/src/k1link/web/app.py +++ b/src/k1link/web/app.py @@ -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() diff --git a/src/k1link/web/environment_api.py b/src/k1link/web/environment_api.py new file mode 100644 index 0000000..c2123d9 --- /dev/null +++ b/src/k1link/web/environment_api.py @@ -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 diff --git a/tests/test_environment_api.py b/tests/test_environment_api.py new file mode 100644 index 0000000..849d149 --- /dev/null +++ b/tests/test_environment_api.py @@ -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", + }