feat(control-station): configure page-specific shell

This commit is contained in:
DCCONSTRUCTIONS
2026-07-26 17:06:04 +03:00
parent dc55ff16c9
commit 360ec39ff3
9 changed files with 619 additions and 259 deletions
+15 -6
View File
@@ -201,6 +201,17 @@ export default function App() {
const currentRoot = rootById(activeRoot);
const visibleRoots = roots;
const activeEnvironmentSurface = activeRoot ?? "home";
const landingPage = environment.settings.pages[activeEnvironmentSurface];
const landingQuickActions = [
landingPage.primaryWorkspaceId,
landingPage.secondaryWorkspaceId,
].flatMap((workspaceId) => {
const definition = workspaceById(workspaceId);
if (!definition) return [];
if (activeRoot !== null && definition.root !== activeRoot) return [];
return [definition];
});
const activeDefinition = workspaceById(workspace.activeView);
const sceneWorkspaceActive = Boolean(
workspace.contentOpen
@@ -634,7 +645,7 @@ export default function App() {
value={activeRoot ?? undefined}
items={visibleRoots.map((root) => ({
value: root.id,
label: environment.settings.headerLabels[root.id],
label: environment.settings.pages[root.id].headerLabel,
}))}
onChange={selectRoot}
/>
@@ -684,11 +695,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")}
page={landingPage}
quickActions={landingQuickActions}
onOpenWorkspace={openView}
/>
}
navigation={currentRoot ? (
@@ -6,6 +6,7 @@ import {
Select,
SettingsCard,
Switch,
TextAreaField,
TextField,
WindowFooterActions,
} from "@nodedc/ui-react";
@@ -14,11 +15,16 @@ import {
cloneEnvironmentSettings,
type EnvironmentBackground,
type EnvironmentMediaKind,
type EnvironmentPage,
type EnvironmentSettings,
type EnvironmentSurfaceId,
type UploadedEnvironmentMedia,
} from "../core/environment/environmentSettings";
import { roots, type RootId } from "../productModel";
import {
roots,
workspaces,
workspacesForRoot,
} from "../productModel";
interface EnvironmentSettingsWindowProps {
open: boolean;
@@ -33,52 +39,39 @@ interface EnvironmentSettingsWindowProps {
) => 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 patchPage(
draft: EnvironmentSettings,
surfaceId: EnvironmentSurfaceId,
patch: Partial<EnvironmentPage>,
): EnvironmentSettings {
return {
...draft,
pages: {
...draft.pages,
[surfaceId]: {
...draft.pages[surfaceId],
...patch,
},
},
};
}
function patchBackground(
draft: EnvironmentSettings,
surfaceId: EnvironmentSurfaceId,
patch: Partial<EnvironmentBackground>,
): EnvironmentSettings {
return {
...draft,
backgrounds: {
...draft.backgrounds,
[surfaceId]: {
...draft.backgrounds[surfaceId],
...patch,
},
const page = draft.pages[surfaceId];
return patchPage(draft, surfaceId, {
background: {
...page.background,
...patch,
},
};
});
}
export function EnvironmentSettingsWindow({
@@ -91,7 +84,7 @@ export function EnvironmentSettingsWindow({
onUpload,
}: EnvironmentSettingsWindowProps) {
const [draft, setDraft] = useState(() => cloneEnvironmentSettings(settings));
const [surfaceId, setSurfaceId] = useState<EnvironmentSurfaceId>("home");
const [surfaceId, setSurfaceId] = useState<EnvironmentSurfaceId>("fleet");
const [uploading, setUploading] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
@@ -101,20 +94,48 @@ export function EnvironmentSettingsWindow({
setLocalError(null);
}, [open, settings]);
const selectedBackground = draft.backgrounds[surfaceId];
const selectedPage = draft.pages[surfaceId];
const selectedBackground = selectedPage.background;
const pageOptions = useMemo(() => [
{
value: "home" as const,
label: draft.pages.home.headerLabel,
description: "Главная страница продукта",
},
...roots.map((root) => ({
value: root.id,
label: draft.pages[root.id].headerLabel,
description: `Стартовая страница раздела «${draft.pages[root.id].title}»`,
})),
], [draft.pages]);
const previewKind = selectedBackground.mediaKind
?? (selectedBackground.url ? inferMediaKind(selectedBackground.url) : null);
const quickActionWorkspaces = useMemo(
() => surfaceId === "home"
? workspaces.filter((workspace) => !workspace.internalOnly)
: workspacesForRoot(surfaceId),
[surfaceId],
);
const quickActionOptions = useMemo(() => [
{
value: "none",
label: "Не показывать",
description: "Кнопка скрыта на стартовом экране",
},
...quickActionWorkspaces.map((workspace) => ({
value: workspace.id,
label: workspace.label,
description: workspace.description,
})),
], [quickActionWorkspaces]);
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 updatePage = (patch: Partial<EnvironmentPage>) => {
setDraft((current) => patchPage(current, surfaceId, patch));
};
const uploadFile = async (file?: File) => {
@@ -140,19 +161,36 @@ export function EnvironmentSettingsWindow({
};
const save = async () => {
const blank = headerLabelFields.find(({ id }) => !draft.headerLabels[id].trim());
if (blank) {
setLocalError(`Название «${blank.label}» не может быть пустым.`);
const invalid = Object.entries(draft.pages).find(([, page]) => (
!page.headerLabel.trim()
|| !page.eyebrow.trim()
|| !page.title.trim()
|| !page.description.trim()
));
if (invalid) {
setLocalError("Название, надзаголовок, заголовок и описание не могут быть пустыми.");
return;
}
if (
selectedPage.primaryWorkspaceId
&& selectedPage.primaryWorkspaceId === selectedPage.secondaryWorkspaceId
) {
setLocalError("Быстрые кнопки должны вести на разные рабочие поверхности.");
return;
}
setLocalError(null);
try {
await onSave({
...draft,
headerLabels: Object.fromEntries(headerLabelFields.map(({ id }) => [
id,
draft.headerLabels[id].trim(),
])) as Record<RootId, string>,
pages: Object.fromEntries(
Object.entries(draft.pages).map(([id, page]) => [id, {
...page,
headerLabel: page.headerLabel.trim(),
eyebrow: page.eyebrow.trim(),
title: page.title.trim(),
description: page.description.trim(),
}]),
) as EnvironmentSettings["pages"],
});
onClose();
} catch (reason) {
@@ -207,31 +245,13 @@ export function EnvironmentSettingsWindow({
>
<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 не затрагиваются."
eyebrow="ОКРУЖЕНИЕ"
title="Основные элементы управления"
description="Выберите страницу и настройте её название в шапке, содержание стартового экрана, подложку и быстрые переходы."
actions={(
<Switch
checked={selectedBackground.enabled}
label="Показывать"
label="Показывать фон"
onChange={(enabled) => {
if (enabled && !selectedBackground.url) {
setLocalError("Сначала загрузите файл или укажите URL.");
@@ -244,13 +264,13 @@ export function EnvironmentSettingsWindow({
/>
)}
>
<div className="environment-settings__media">
<div className="environment-settings__editor">
<div className="environment-settings__surface">
<span>Экран</span>
<span>Страница</span>
<Select
label="Выбрать стартовую страницу"
label="Выбрать страницу окружения"
value={surfaceId}
options={backgroundSurfaceOptions}
options={pageOptions}
variant="split"
menuWidth="anchor"
onChange={(value) => {
@@ -259,6 +279,72 @@ export function EnvironmentSettingsWindow({
}}
/>
</div>
<div className="environment-settings__copy">
<TextField
label={surfaceId === "home" ? "Название продукта" : "Название в шапке"}
value={selectedPage.headerLabel}
maxLength={40}
onChange={(event) => updatePage({
headerLabel: event.currentTarget.value,
})}
/>
<TextField
label="Надзаголовок"
value={selectedPage.eyebrow}
maxLength={80}
onChange={(event) => updatePage({
eyebrow: event.currentTarget.value,
})}
/>
<TextField
label="Основной заголовок"
value={selectedPage.title}
maxLength={120}
onChange={(event) => updatePage({
title: event.currentTarget.value,
})}
/>
<TextAreaField
label="Описание"
value={selectedPage.description}
maxLength={500}
rows={3}
onChange={(event) => updatePage({
description: event.currentTarget.value,
})}
/>
</div>
<div className="environment-settings__quick-actions">
<div>
<span>Кнопка 1</span>
<Select
label="Выбрать первую быструю кнопку"
value={selectedPage.primaryWorkspaceId ?? "none"}
options={quickActionOptions}
variant="split"
menuWidth="anchor"
onChange={(value) => updatePage({
primaryWorkspaceId: value === "none" ? null : value,
})}
/>
</div>
<div>
<span>Кнопка 2</span>
<Select
label="Выбрать вторую быструю кнопку"
value={selectedPage.secondaryWorkspaceId ?? "none"}
options={quickActionOptions}
variant="split"
menuWidth="anchor"
onChange={(value) => updatePage({
secondaryWorkspaceId: value === "none" ? null : value,
})}
/>
</div>
</div>
<MediaSourceField
label="Видео / картинка"
kindLabel={previewKind ?? "media"}
@@ -1,8 +1,8 @@
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 type { EnvironmentPage } from "../core/environment/environmentSettings";
import type { RootDefinition, WorkspaceDefinition } from "../productModel";
import { backendLabel, backendTone, phaseLabel, phaseTone } from "../presentation";
export interface LandingStageProps {
@@ -10,9 +10,9 @@ export interface LandingStageProps {
backendStatus: BackendStatus;
phase?: RuntimePhase | null;
message?: string | null;
background: EnvironmentBackground;
onOpenObservation: () => void;
onOpenDevice: () => void;
page: EnvironmentPage;
quickActions: readonly WorkspaceDefinition[];
onOpenWorkspace: (workspaceId: string) => void;
}
export function LandingStage({
@@ -20,10 +20,11 @@ export function LandingStage({
backendStatus,
phase,
message,
background,
onOpenObservation,
onOpenDevice,
page,
quickActions,
onOpenWorkspace,
}: LandingStageProps) {
const { background } = page;
return (
<section
className="landing-stage"
@@ -48,20 +49,23 @@ export function LandingStage({
) : 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>
<p>
{root?.statement ??
"Наблюдение, планирование и корректировка миссий в одной модульной рабочей области."}
</p>
<div className="landing-stage__actions">
<Button variant="primary" icon={<Icon name="globe" />} onClick={onOpenObservation}>
Пространственная сцена
</Button>
<Button variant="secondary" icon={<Icon name="network" />} onClick={onOpenDevice}>
Локальное устройство
</Button>
</div>
<span className="section-eyebrow">{page.eyebrow}</span>
<h1>{page.title}</h1>
<p>{page.description}</p>
{quickActions.length ? (
<div className="landing-stage__actions">
{quickActions.map((workspace, index) => (
<Button
key={workspace.id}
variant={index === 0 ? "primary" : "secondary"}
icon={<Icon name={workspace.icon} />}
onClick={() => onOpenWorkspace(workspace.id)}
>
{workspace.label}
</Button>
))}
</div>
) : null}
</div>
<div className="landing-stage__status">
@@ -12,10 +12,19 @@ export interface EnvironmentBackground {
fileName: string | null;
}
export interface EnvironmentPage {
headerLabel: string;
eyebrow: string;
title: string;
description: string;
primaryWorkspaceId: string | null;
secondaryWorkspaceId: string | null;
background: EnvironmentBackground;
}
export interface EnvironmentSettings {
revision: number;
headerLabels: Record<RootId, string>;
backgrounds: Record<EnvironmentSurfaceId, EnvironmentBackground>;
pages: Record<EnvironmentSurfaceId, EnvironmentPage>;
}
export interface UploadedEnvironmentMedia {
@@ -30,7 +39,6 @@ export interface UploadedEnvironmentMedia {
const surfaceIds: readonly EnvironmentSurfaceId[] = [
"home",
"center",
"fleet",
"observation",
"missions",
@@ -39,8 +47,6 @@ const surfaceIds: readonly EnvironmentSurfaceId[] = [
"polygon",
];
const rootIds = surfaceIds.filter((value): value is RootId => value !== "home");
function emptyBackground(): EnvironmentBackground {
return {
enabled: false,
@@ -51,15 +57,37 @@ function emptyBackground(): EnvironmentBackground {
};
}
const defaultQuickActions: Record<
EnvironmentSurfaceId,
readonly [string | null, string | null]
> = {
home: ["spatial-scene", "local-device"],
fleet: ["contour-health", "local-device"],
observation: ["spatial-scene", "cameras"],
missions: ["mission-planner", null],
data: ["recordings", "datasets"],
system: ["modules", "integrations"],
polygon: ["lab-archive", null],
};
export function defaultEnvironmentSettings(): EnvironmentSettings {
const rootById = new Map(roots.map((root) => [root.id, root]));
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>,
pages: Object.fromEntries(surfaceIds.map((surfaceId) => {
const root = surfaceId === "home" ? null : rootById.get(surfaceId) ?? null;
const [primaryWorkspaceId, secondaryWorkspaceId] = defaultQuickActions[surfaceId];
return [surfaceId, {
headerLabel: root?.label ?? "Mission Core",
eyebrow: root?.eyebrow ?? "NODEDC / MISSION CORE",
title: root?.title ?? "Mission Core",
description: root?.statement
?? "Наблюдение, планирование и корректировка миссий в одной модульной рабочей области.",
primaryWorkspaceId,
secondaryWorkspaceId,
background: emptyBackground(),
}];
})) as Record<EnvironmentSurfaceId, EnvironmentPage>,
};
}
@@ -70,7 +98,11 @@ function requireRecord(value: unknown, path: string): Record<string, unknown> {
return value as Record<string, unknown>;
}
function requireString(value: unknown, path: string, nullable = false): string | null {
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} должен быть непустой строкой.`);
@@ -80,7 +112,9 @@ function requireString(value: unknown, path: string, nullable = false): string |
function decodeBackground(value: unknown, path: string): EnvironmentBackground {
const record = requireRecord(value, path);
if (typeof record.enabled !== "boolean") throw new Error(`${path}.enabled должен быть boolean.`);
if (typeof record.enabled !== "boolean") {
throw new Error(`${path}.enabled должен быть boolean.`);
}
if (!["file", "url"].includes(String(record.source))) {
throw new Error(`${path}.source не поддерживается.`);
}
@@ -99,9 +133,30 @@ function decodeBackground(value: unknown, path: string): EnvironmentBackground {
};
}
function decodePage(value: unknown, path: string): EnvironmentPage {
const record = requireRecord(value, path);
return {
headerLabel: requireString(record.header_label, `${path}.header_label`)!,
eyebrow: requireString(record.eyebrow, `${path}.eyebrow`)!,
title: requireString(record.title, `${path}.title`)!,
description: requireString(record.description, `${path}.description`)!,
primaryWorkspaceId: requireString(
record.primary_workspace_id,
`${path}.primary_workspace_id`,
true,
),
secondaryWorkspaceId: requireString(
record.secondary_workspace_id,
`${path}.secondary_workspace_id`,
true,
),
background: decodeBackground(record.background, `${path}.background`),
};
}
export function decodeEnvironmentSettings(value: unknown): EnvironmentSettings {
const record = requireRecord(value, "environment");
if (record.schema_version !== "missioncore.operator-environment/v1") {
if (record.schema_version !== "missioncore.operator-environment/v2") {
throw new Error("Версия настроек окружения не поддерживается.");
}
if (
@@ -111,36 +166,35 @@ export function decodeEnvironmentSettings(value: unknown): EnvironmentSettings {
) {
throw new Error("Ревизия настроек окружения некорректна.");
}
const labels = requireRecord(record.header_labels, "environment.header_labels");
const backgrounds = requireRecord(record.backgrounds, "environment.backgrounds");
const pages = requireRecord(record.pages, "environment.pages");
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) => [
pages: Object.fromEntries(surfaceIds.map((surfaceId) => [
surfaceId,
decodeBackground(
backgrounds[surfaceId],
`environment.backgrounds.${surfaceId}`,
),
])) as Record<EnvironmentSurfaceId, EnvironmentBackground>,
decodePage(pages[surfaceId], `environment.pages.${surfaceId}`),
])) as Record<EnvironmentSurfaceId, EnvironmentPage>,
};
}
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];
pages: Object.fromEntries(surfaceIds.map((surfaceId) => {
const page = settings.pages[surfaceId];
return [surfaceId, {
enabled: background.enabled,
source: background.source,
url: background.url,
media_kind: background.mediaKind,
file_name: background.fileName,
header_label: page.headerLabel,
eyebrow: page.eyebrow,
title: page.title,
description: page.description,
primary_workspace_id: page.primaryWorkspaceId,
secondary_workspace_id: page.secondaryWorkspaceId,
background: {
enabled: page.background.enabled,
source: page.background.source,
url: page.background.url,
media_kind: page.background.mediaKind,
file_name: page.background.fileName,
},
}];
})),
};
@@ -182,11 +236,13 @@ export function cloneEnvironmentSettings(
): EnvironmentSettings {
return {
revision: settings.revision,
headerLabels: { ...settings.headerLabels },
backgrounds: Object.fromEntries(surfaceIds.map((surfaceId) => [
pages: Object.fromEntries(surfaceIds.map((surfaceId) => [
surfaceId,
{ ...settings.backgrounds[surfaceId] },
])) as Record<EnvironmentSurfaceId, EnvironmentBackground>,
{
...settings.pages[surfaceId],
background: { ...settings.pages[surfaceId].background },
},
])) as Record<EnvironmentSurfaceId, EnvironmentPage>,
};
}
+2 -12
View File
@@ -1,7 +1,6 @@
import type { IconName } from "@nodedc/ui-react";
export type RootId =
| "center"
| "fleet"
| "observation"
| "missions"
@@ -84,15 +83,6 @@ const later = (label: string, description: string): Capability => ({
});
export const roots: RootDefinition[] = [
{
id: "center",
label: "Центр",
title: "Центр управления",
eyebrow: "ПУНКТ УПРАВЛЕНИЯ",
description: "Единая оперативная картина, состояние контура и события оператора.",
statement: "Наблюдать весь контур, быстро видеть отклонения и переходить к действию.",
accent: "ОПЕРАТИВНЫЙ КОНТУР",
},
{
id: "fleet",
label: "Парк",
@@ -163,10 +153,10 @@ export const workspaces: WorkspaceDefinition[] = [
},
{
id: "contour-health",
root: "center",
root: "fleet",
label: "Состояние контура",
title: "Состояние контура",
eyebrow: "ЦЕНТР / СОСТОЯНИЕ",
eyebrow: "ПАРК / СОСТОЯНИЕ",
description: "Узлы, процессы, сеть и подключённые устройства по данным живого контура.",
icon: "shield",
kind: "contour-health",
@@ -3,13 +3,13 @@
gap: 1rem;
}
.environment-settings__labels {
.environment-settings__copy {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
}
.environment-settings__media {
.environment-settings__editor {
display: grid;
gap: 1rem;
}
@@ -32,8 +32,31 @@
width: 100%;
}
.environment-settings__quick-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
}
.environment-settings__quick-actions > div {
display: grid;
gap: 0.4rem;
}
.environment-settings__quick-actions span {
color: var(--nodedc-text-secondary);
font-size: 0.68rem;
font-weight: 650;
}
.environment-settings__quick-actions .nodedc-select-anchor,
.environment-settings__quick-actions .nodedc-select {
width: 100%;
}
@media (max-width: 760px) {
.environment-settings__labels {
.environment-settings__copy,
.environment-settings__quick-actions {
grid-template-columns: 1fr;
}
@@ -24,18 +24,25 @@ after(async () => {
function serverDocument(overrides = {}) {
const defaults = environment.defaultEnvironmentSettings();
return {
schema_version: "missioncore.operator-environment/v1",
schema_version: "missioncore.operator-environment/v2",
revision: defaults.revision,
header_labels: defaults.headerLabels,
backgrounds: Object.fromEntries(
Object.entries(defaults.backgrounds).map(([surfaceId, background]) => [
pages: Object.fromEntries(
Object.entries(defaults.pages).map(([surfaceId, page]) => [
surfaceId,
{
enabled: background.enabled,
source: background.source,
url: background.url,
media_kind: background.mediaKind,
file_name: background.fileName,
header_label: page.headerLabel,
eyebrow: page.eyebrow,
title: page.title,
description: page.description,
primary_workspace_id: page.primaryWorkspaceId,
secondary_workspace_id: page.secondaryWorkspaceId,
background: {
enabled: page.background.enabled,
source: page.background.source,
url: page.background.url,
media_kind: page.background.mediaKind,
file_name: page.background.fileName,
},
},
]),
),
@@ -43,20 +50,10 @@ function serverDocument(overrides = {}) {
};
}
test("default environment exposes every header and landing surface", () => {
test("default environment exposes every current product page", () => {
const defaults = environment.defaultEnvironmentSettings();
assert.deepEqual(Object.keys(defaults.headerLabels), [
"center",
"fleet",
"observation",
"missions",
"data",
"system",
"polygon",
]);
assert.deepEqual(Object.keys(defaults.backgrounds), [
assert.deepEqual(Object.keys(defaults.pages), [
"home",
"center",
"fleet",
"observation",
"missions",
@@ -64,23 +61,31 @@ test("default environment exposes every header and landing surface", () => {
"system",
"polygon",
]);
assert.equal(defaults.pages.fleet.primaryWorkspaceId, "contour-health");
assert.equal(defaults.pages.observation.primaryWorkspaceId, "spatial-scene");
});
test("server document decodes and re-encodes without leaking schema internals", () => {
test("server document decodes and re-encodes the complete page contract", () => {
const base = serverDocument();
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",
pages: {
...base.pages,
observation: {
...base.pages.observation,
header_label: "Контроль",
title: "Контроль пространства",
},
fleet: {
...base.pages.fleet,
primary_workspace_id: "contour-health",
background: {
enabled: true,
source: "file",
url: `/api/v1/environment/media/fleet?generation=${"a".repeat(64)}`,
media_kind: "video",
file_name: "stage.mp4",
},
},
},
});
@@ -88,9 +93,11 @@ test("server document decodes and re-encodes without leaking schema internals",
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(decoded.pages.observation.headerLabel, "Контроль");
assert.equal(decoded.pages.observation.title, "Контроль пространства");
assert.equal(decoded.pages.fleet.background.mediaKind, "video");
assert.equal(encoded.pages.fleet.background.media_kind, "video");
assert.equal(encoded.pages.fleet.primary_workspace_id, "contour-health");
assert.equal("schema_version" in encoded, false);
});
@@ -103,12 +110,12 @@ test("decoder fails closed on an unknown environment schema", () => {
);
});
test("editing a cloned environment cannot mutate the accepted settings", () => {
test("editing a cloned page cannot mutate accepted settings", () => {
const accepted = environment.decodeEnvironmentSettings(serverDocument());
const draft = environment.cloneEnvironmentSettings(accepted);
draft.headerLabels.center = "Изменено";
draft.backgrounds.home.enabled = true;
draft.pages.observation.headerLabel = "Изменено";
draft.pages.fleet.background.enabled = true;
assert.equal(accepted.headerLabels.center, "Центр");
assert.equal(accepted.backgrounds.home.enabled, false);
assert.equal(accepted.pages.observation.headerLabel, "Наблюдение");
assert.equal(accepted.pages.fleet.background.enabled, false);
});
+160 -47
View File
@@ -16,7 +16,6 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator
EnvironmentSurfaceId = Literal[
"home",
"center",
"fleet",
"observation",
"missions",
@@ -27,8 +26,8 @@ EnvironmentSurfaceId = Literal[
EnvironmentMediaKind = Literal["image", "video"]
EnvironmentMediaSource = Literal["file", "url"]
ENVIRONMENT_SCHEMA_VERSION: Literal["missioncore.operator-environment/v1"] = (
"missioncore.operator-environment/v1"
ENVIRONMENT_SCHEMA_VERSION: Literal["missioncore.operator-environment/v2"] = (
"missioncore.operator-environment/v2"
)
ENVIRONMENT_MEDIA_SCHEMA_VERSION: Literal[
"missioncore.operator-environment-media/v1"
@@ -51,16 +50,6 @@ 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"
@@ -83,25 +72,50 @@ class EnvironmentBackground(StrictApiModel):
return self
class EnvironmentBackgrounds(StrictApiModel):
home: EnvironmentBackground
center: EnvironmentBackground
fleet: EnvironmentBackground
observation: EnvironmentBackground
missions: EnvironmentBackground
data: EnvironmentBackground
system: EnvironmentBackground
polygon: EnvironmentBackground
class EnvironmentPage(StrictApiModel):
header_label: str = Field(min_length=1, max_length=40)
eyebrow: str = Field(min_length=1, max_length=80)
title: str = Field(min_length=1, max_length=120)
description: str = Field(min_length=1, max_length=500)
primary_workspace_id: str | None = Field(
default=None,
max_length=80,
pattern=r"^[a-z0-9-]+$",
)
secondary_workspace_id: str | None = Field(
default=None,
max_length=80,
pattern=r"^[a-z0-9-]+$",
)
background: EnvironmentBackground
@model_validator(mode="after")
def validate_quick_actions(self) -> EnvironmentPage:
if (
self.primary_workspace_id is not None
and self.primary_workspace_id == self.secondary_workspace_id
):
raise ValueError("quick actions must target different workspaces")
return self
class EnvironmentPages(StrictApiModel):
home: EnvironmentPage
fleet: EnvironmentPage
observation: EnvironmentPage
missions: EnvironmentPage
data: EnvironmentPage
system: EnvironmentPage
polygon: EnvironmentPage
class EnvironmentSettingsPut(StrictApiModel):
revision: int = Field(ge=0)
header_labels: EnvironmentHeaderLabels
backgrounds: EnvironmentBackgrounds
pages: EnvironmentPages
class EnvironmentSettingsDocument(EnvironmentSettingsPut):
schema_version: Literal["missioncore.operator-environment/v1"] = ENVIRONMENT_SCHEMA_VERSION
schema_version: Literal["missioncore.operator-environment/v2"] = ENVIRONMENT_SCHEMA_VERSION
class EnvironmentMediaDocument(StrictApiModel):
@@ -121,28 +135,128 @@ 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(),
pages=EnvironmentPages(
home=EnvironmentPage(
header_label="Mission Core",
eyebrow="NODEDC / MISSION CORE",
title="Mission Core",
description=(
"Наблюдение, планирование и корректировка миссий "
"в одной модульной рабочей области."
),
primary_workspace_id="spatial-scene",
secondary_workspace_id="local-device",
background=background.model_copy(),
),
fleet=EnvironmentPage(
header_label="Парк",
eyebrow="ПАРК / УСТРОЙСТВА",
title="Аппараты и устройства",
description=(
"Одинаково подключать одиночный стенд, наземную платформу "
"и будущий рой."
),
primary_workspace_id="contour-health",
secondary_workspace_id="local-device",
background=background.model_copy(),
),
observation=EnvironmentPage(
header_label="Наблюдение",
eyebrow="СИТУАЦИОННАЯ ОСВЕДОМЛЁННОСТЬ",
title="Наблюдение",
description=(
"Свести все сенсоры в синхронную и управляемую операторскую картину."
),
primary_workspace_id="spatial-scene",
secondary_workspace_id="cameras",
background=background.model_copy(),
),
missions=EnvironmentPage(
header_label="Миссии",
eyebrow="УПРАВЛЕНИЕ МИССИЯМИ",
title="Миссии",
description=(
"Собрать задачу из точек, ограничений и действий до передачи на борт."
),
primary_workspace_id="mission-planner",
secondary_workspace_id=None,
background=background.model_copy(),
),
data=EnvironmentPage(
header_label="Данные",
eyebrow="РАБОЧАЯ ОБЛАСТЬ ДАННЫХ",
title="Данные и записи",
description=(
"Хранить живой контур и воспроизводимый эксперимент "
"как одну модель данных."
),
primary_workspace_id="recordings",
secondary_workspace_id="datasets",
background=background.model_copy(),
),
system=EnvironmentPage(
header_label="Система",
eyebrow="УПРАВЛЕНИЕ ПЛАТФОРМОЙ",
title="Система",
description=(
"Подключать новые возможности модульно, "
"не связывая интерфейс с одним устройством."
),
primary_workspace_id="modules",
secondary_workspace_id="integrations",
background=background.model_copy(),
),
polygon=EnvironmentPage(
header_label="Тестировочный контур",
eyebrow="ЛАБОРАТОРНЫЕ ИССЛЕДОВАНИЯ",
title="Тестировочный контур",
description=(
"Фиксировать каждый эксперимент как проверяемую лабораторную работу."
),
primary_workspace_id="lab-archive",
secondary_workspace_id=None,
background=background.model_copy(),
),
),
)
def _upgrade_v1_environment(payload: object) -> EnvironmentSettingsDocument:
if not isinstance(payload, dict):
raise ValueError("environment document must be an object")
if payload.get("schema_version") != "missioncore.operator-environment/v1":
return EnvironmentSettingsDocument.model_validate(payload)
revision = payload.get("revision")
labels = payload.get("header_labels")
backgrounds = payload.get("backgrounds")
if not isinstance(revision, int) or not isinstance(labels, dict) or not isinstance(
backgrounds,
dict,
):
raise ValueError("legacy environment document is incomplete")
defaults = default_environment_settings()
pages: dict[str, EnvironmentPage] = {}
for surface_id, default_page in defaults.pages:
legacy_label = labels.get(surface_id)
legacy_background = backgrounds.get(surface_id)
pages[surface_id] = default_page.model_copy(
update={
"header_label": (
legacy_label
if isinstance(legacy_label, str) and legacy_label.strip()
else default_page.header_label
),
"background": EnvironmentBackground.model_validate(
legacy_background
),
},
)
return EnvironmentSettingsDocument(
revision=revision,
pages=EnvironmentPages.model_validate(pages),
)
class EnvironmentSettingsStore:
def __init__(self, root: Path) -> None:
self.root = root.expanduser().resolve()
@@ -156,7 +270,7 @@ class EnvironmentSettingsStore:
return default_environment_settings()
try:
payload = json.loads(self.settings_path.read_text(encoding="utf-8"))
return EnvironmentSettingsDocument.model_validate(payload)
return _upgrade_v1_environment(payload)
except (OSError, ValueError) as exc:
raise RuntimeError("operator environment settings are corrupt") from exc
@@ -167,8 +281,7 @@ class EnvironmentSettingsStore:
raise RuntimeError("operator environment settings revision changed")
document = EnvironmentSettingsDocument(
revision=current.revision + 1,
header_labels=request.header_labels,
backgrounds=request.backgrounds,
pages=request.pages,
)
self.root.mkdir(mode=0o700, parents=True, exist_ok=True)
temporary = self.settings_path.with_suffix(".json.tmp")
@@ -189,7 +302,7 @@ class EnvironmentSettingsStore:
return default_environment_settings()
try:
payload = json.loads(self.settings_path.read_text(encoding="utf-8"))
return EnvironmentSettingsDocument.model_validate(payload)
return _upgrade_v1_environment(payload)
except (OSError, ValueError) as exc:
raise RuntimeError("operator environment settings are corrupt") from exc
+84 -12
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio
import hashlib
import json
from collections.abc import Callable
from pathlib import Path
from typing import Any
@@ -71,24 +72,34 @@ def _streaming_request(payload: bytes, media_type: str) -> Request:
)
def test_environment_settings_are_versioned_and_persist_header_labels(tmp_path: Path) -> None:
def test_environment_settings_are_versioned_and_persist_page_controls(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
assert initial.pages.polygon.header_label == "Тестировочный контур"
assert initial.pages.fleet.primary_workspace_id == "contour-health"
assert initial.pages.home.background.enabled is False
request = EnvironmentSettingsPut(
revision=initial.revision,
header_labels=initial.header_labels.model_copy(update={"center": "Командный центр"}),
backgrounds=initial.backgrounds,
pages=initial.pages.model_copy(
update={
"observation": initial.pages.observation.model_copy(
update={
"header_label": "Контроль",
"title": "Контроль пространства",
}
)
}
),
)
saved = store.save(request)
restored = EnvironmentSettingsStore(store.root).read()
assert saved.revision == 1
assert restored == saved
assert restored.header_labels.center == "Командный центр"
assert restored.pages.observation.header_label == "Контроль"
assert restored.pages.observation.title == "Контроль пространства"
assert str(tmp_path) not in restored.model_dump_json()
@@ -98,8 +109,7 @@ def test_environment_settings_reject_stale_revision(tmp_path: Path) -> None:
store.save(
EnvironmentSettingsPut(
revision=0,
header_labels=initial.header_labels,
backgrounds=initial.backgrounds,
pages=initial.pages,
)
)
@@ -107,8 +117,7 @@ def test_environment_settings_reject_stale_revision(tmp_path: Path) -> None:
store.save(
EnvironmentSettingsPut(
revision=0,
header_labels=initial.header_labels,
backgrounds=initial.backgrounds,
pages=initial.pages,
)
)
@@ -185,9 +194,8 @@ def test_environment_media_upload_route_streams_and_publishes_safe_metadata(
def test_default_environment_has_every_product_surface() -> None:
document = default_environment_settings()
assert set(document.backgrounds.model_dump()) == {
assert set(document.pages.model_dump()) == {
"home",
"center",
"fleet",
"observation",
"missions",
@@ -195,3 +203,67 @@ def test_default_environment_has_every_product_surface() -> None:
"system",
"polygon",
}
assert document.schema_version == "missioncore.operator-environment/v2"
assert document.pages.fleet.primary_workspace_id == "contour-health"
def test_legacy_v1_environment_is_migrated_without_losing_operator_media(
tmp_path: Path,
) -> None:
store = EnvironmentSettingsStore(tmp_path / "operator-environment")
store.root.mkdir(parents=True)
empty_background = {
"enabled": False,
"source": "file",
"url": None,
"media_kind": None,
"file_name": None,
}
store.settings_path.write_text(
json.dumps(
{
"schema_version": "missioncore.operator-environment/v1",
"revision": 10,
"header_labels": {
"center": "Центр",
"fleet": "Парк",
"observation": "Контроль",
"missions": "Миссии",
"data": "Данные",
"system": "Система",
"polygon": "Тестировочный контур",
},
"backgrounds": {
"home": empty_background,
"center": empty_background,
"fleet": {
"enabled": True,
"source": "file",
"url": (
"/api/v1/environment/media/fleet?generation="
+ "a" * 64
),
"media_kind": "image",
"file_name": "park.png",
},
"observation": empty_background,
"missions": empty_background,
"data": empty_background,
"system": empty_background,
"polygon": empty_background,
},
},
ensure_ascii=False,
indent=2,
)
+ "\n",
encoding="utf-8",
)
migrated = store.read()
assert migrated.revision == 10
assert migrated.pages.observation.header_label == "Контроль"
assert migrated.pages.fleet.background.file_name == "park.png"
assert migrated.pages.fleet.primary_workspace_id == "contour-health"
assert "center" not in migrated.pages.model_dump()