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 currentRoot = rootById(activeRoot);
const visibleRoots = roots; 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 activeDefinition = workspaceById(workspace.activeView);
const sceneWorkspaceActive = Boolean( const sceneWorkspaceActive = Boolean(
workspace.contentOpen workspace.contentOpen
@@ -634,7 +645,7 @@ export default function App() {
value={activeRoot ?? undefined} value={activeRoot ?? undefined}
items={visibleRoots.map((root) => ({ items={visibleRoots.map((root) => ({
value: root.id, value: root.id,
label: environment.settings.headerLabels[root.id], label: environment.settings.pages[root.id].headerLabel,
}))} }))}
onChange={selectRoot} onChange={selectRoot}
/> />
@@ -684,11 +695,9 @@ export default function App() {
backendStatus={runtime.backendStatus} backendStatus={runtime.backendStatus}
phase={runtime.state?.phase} phase={runtime.state?.phase}
message={runtime.state?.message} message={runtime.state?.message}
background={ page={landingPage}
environment.settings.backgrounds[activeRoot ?? "home"] quickActions={landingQuickActions}
} onOpenWorkspace={openView}
onOpenObservation={() => openView("spatial-scene")}
onOpenDevice={() => openView("local-device")}
/> />
} }
navigation={currentRoot ? ( navigation={currentRoot ? (
@@ -6,6 +6,7 @@ import {
Select, Select,
SettingsCard, SettingsCard,
Switch, Switch,
TextAreaField,
TextField, TextField,
WindowFooterActions, WindowFooterActions,
} from "@nodedc/ui-react"; } from "@nodedc/ui-react";
@@ -14,11 +15,16 @@ import {
cloneEnvironmentSettings, cloneEnvironmentSettings,
type EnvironmentBackground, type EnvironmentBackground,
type EnvironmentMediaKind, type EnvironmentMediaKind,
type EnvironmentPage,
type EnvironmentSettings, type EnvironmentSettings,
type EnvironmentSurfaceId, type EnvironmentSurfaceId,
type UploadedEnvironmentMedia, type UploadedEnvironmentMedia,
} from "../core/environment/environmentSettings"; } from "../core/environment/environmentSettings";
import { roots, type RootId } from "../productModel"; import {
roots,
workspaces,
workspacesForRoot,
} from "../productModel";
interface EnvironmentSettingsWindowProps { interface EnvironmentSettingsWindowProps {
open: boolean; open: boolean;
@@ -33,52 +39,39 @@ interface EnvironmentSettingsWindowProps {
) => Promise<UploadedEnvironmentMedia>; ) => 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 { function inferMediaKind(url: string): EnvironmentMediaKind {
return /\.(mp4|webm|mov|m4v)(?:[?#].*)?$/i.test(url) ? "video" : "image"; 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( function patchBackground(
draft: EnvironmentSettings, draft: EnvironmentSettings,
surfaceId: EnvironmentSurfaceId, surfaceId: EnvironmentSurfaceId,
patch: Partial<EnvironmentBackground>, patch: Partial<EnvironmentBackground>,
): EnvironmentSettings { ): EnvironmentSettings {
return { const page = draft.pages[surfaceId];
...draft, return patchPage(draft, surfaceId, {
backgrounds: { background: {
...draft.backgrounds, ...page.background,
[surfaceId]: { ...patch,
...draft.backgrounds[surfaceId],
...patch,
},
}, },
}; });
} }
export function EnvironmentSettingsWindow({ export function EnvironmentSettingsWindow({
@@ -91,7 +84,7 @@ export function EnvironmentSettingsWindow({
onUpload, onUpload,
}: EnvironmentSettingsWindowProps) { }: EnvironmentSettingsWindowProps) {
const [draft, setDraft] = useState(() => cloneEnvironmentSettings(settings)); const [draft, setDraft] = useState(() => cloneEnvironmentSettings(settings));
const [surfaceId, setSurfaceId] = useState<EnvironmentSurfaceId>("home"); const [surfaceId, setSurfaceId] = useState<EnvironmentSurfaceId>("fleet");
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [localError, setLocalError] = useState<string | null>(null); const [localError, setLocalError] = useState<string | null>(null);
@@ -101,20 +94,48 @@ export function EnvironmentSettingsWindow({
setLocalError(null); setLocalError(null);
}, [open, settings]); }, [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 const previewKind = selectedBackground.mediaKind
?? (selectedBackground.url ? inferMediaKind(selectedBackground.url) : null); ?? (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( const dirty = useMemo(
() => JSON.stringify(draft) !== JSON.stringify(settings), () => JSON.stringify(draft) !== JSON.stringify(settings),
[draft, settings], [draft, settings],
); );
const busy = state === "saving" || uploading; const busy = state === "saving" || uploading;
const updateHeaderLabel = (rootId: RootId, value: string) => { const updatePage = (patch: Partial<EnvironmentPage>) => {
setDraft((current) => ({ setDraft((current) => patchPage(current, surfaceId, patch));
...current,
headerLabels: { ...current.headerLabels, [rootId]: value },
}));
}; };
const uploadFile = async (file?: File) => { const uploadFile = async (file?: File) => {
@@ -140,19 +161,36 @@ export function EnvironmentSettingsWindow({
}; };
const save = async () => { const save = async () => {
const blank = headerLabelFields.find(({ id }) => !draft.headerLabels[id].trim()); const invalid = Object.entries(draft.pages).find(([, page]) => (
if (blank) { !page.headerLabel.trim()
setLocalError(`Название «${blank.label}» не может быть пустым.`); || !page.eyebrow.trim()
|| !page.title.trim()
|| !page.description.trim()
));
if (invalid) {
setLocalError("Название, надзаголовок, заголовок и описание не могут быть пустыми.");
return;
}
if (
selectedPage.primaryWorkspaceId
&& selectedPage.primaryWorkspaceId === selectedPage.secondaryWorkspaceId
) {
setLocalError("Быстрые кнопки должны вести на разные рабочие поверхности.");
return; return;
} }
setLocalError(null); setLocalError(null);
try { try {
await onSave({ await onSave({
...draft, ...draft,
headerLabels: Object.fromEntries(headerLabelFields.map(({ id }) => [ pages: Object.fromEntries(
id, Object.entries(draft.pages).map(([id, page]) => [id, {
draft.headerLabels[id].trim(), ...page,
])) as Record<RootId, string>, headerLabel: page.headerLabel.trim(),
eyebrow: page.eyebrow.trim(),
title: page.title.trim(),
description: page.description.trim(),
}]),
) as EnvironmentSettings["pages"],
}); });
onClose(); onClose();
} catch (reason) { } catch (reason) {
@@ -207,31 +245,13 @@ export function EnvironmentSettingsWindow({
> >
<div className="environment-settings"> <div className="environment-settings">
<SettingsCard <SettingsCard
eyebrow="ШАПКА" eyebrow="ОКРУЖЕНИЕ"
title="Названия разделов" title="Основные элементы управления"
description="Подписи применяются к верхней навигации. Продуктовые идентификаторы и маршруты не меняются." 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={( actions={(
<Switch <Switch
checked={selectedBackground.enabled} checked={selectedBackground.enabled}
label="Показывать" label="Показывать фон"
onChange={(enabled) => { onChange={(enabled) => {
if (enabled && !selectedBackground.url) { if (enabled && !selectedBackground.url) {
setLocalError("Сначала загрузите файл или укажите 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"> <div className="environment-settings__surface">
<span>Экран</span> <span>Страница</span>
<Select <Select
label="Выбрать стартовую страницу" label="Выбрать страницу окружения"
value={surfaceId} value={surfaceId}
options={backgroundSurfaceOptions} options={pageOptions}
variant="split" variant="split"
menuWidth="anchor" menuWidth="anchor"
onChange={(value) => { onChange={(value) => {
@@ -259,6 +279,72 @@ export function EnvironmentSettingsWindow({
}} }}
/> />
</div> </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 <MediaSourceField
label="Видео / картинка" label="Видео / картинка"
kindLabel={previewKind ?? "media"} kindLabel={previewKind ?? "media"}
@@ -1,8 +1,8 @@
import { Button, Icon, StatusBadge } from "@nodedc/ui-react"; import { Button, Icon, StatusBadge } from "@nodedc/ui-react";
import type { BackendStatus, RuntimePhase } from "../core/runtime/contracts"; import type { BackendStatus, RuntimePhase } from "../core/runtime/contracts";
import type { EnvironmentBackground } from "../core/environment/environmentSettings"; import type { EnvironmentPage } from "../core/environment/environmentSettings";
import type { RootDefinition } from "../productModel"; import type { RootDefinition, WorkspaceDefinition } from "../productModel";
import { backendLabel, backendTone, phaseLabel, phaseTone } from "../presentation"; import { backendLabel, backendTone, phaseLabel, phaseTone } from "../presentation";
export interface LandingStageProps { export interface LandingStageProps {
@@ -10,9 +10,9 @@ export interface LandingStageProps {
backendStatus: BackendStatus; backendStatus: BackendStatus;
phase?: RuntimePhase | null; phase?: RuntimePhase | null;
message?: string | null; message?: string | null;
background: EnvironmentBackground; page: EnvironmentPage;
onOpenObservation: () => void; quickActions: readonly WorkspaceDefinition[];
onOpenDevice: () => void; onOpenWorkspace: (workspaceId: string) => void;
} }
export function LandingStage({ export function LandingStage({
@@ -20,10 +20,11 @@ export function LandingStage({
backendStatus, backendStatus,
phase, phase,
message, message,
background, page,
onOpenObservation, quickActions,
onOpenDevice, onOpenWorkspace,
}: LandingStageProps) { }: LandingStageProps) {
const { background } = page;
return ( return (
<section <section
className="landing-stage" className="landing-stage"
@@ -48,20 +49,23 @@ export function LandingStage({
) : null} ) : null}
<div className="landing-stage__shade" aria-hidden="true" /> <div className="landing-stage__shade" aria-hidden="true" />
<div className="landing-stage__copy"> <div className="landing-stage__copy">
<span className="section-eyebrow">{root?.eyebrow ?? "NODEDC / MISSION CORE"}</span> <span className="section-eyebrow">{page.eyebrow}</span>
<h1>{root?.title ?? "Mission Core"}</h1> <h1>{page.title}</h1>
<p> <p>{page.description}</p>
{root?.statement ?? {quickActions.length ? (
"Наблюдение, планирование и корректировка миссий в одной модульной рабочей области."} <div className="landing-stage__actions">
</p> {quickActions.map((workspace, index) => (
<div className="landing-stage__actions"> <Button
<Button variant="primary" icon={<Icon name="globe" />} onClick={onOpenObservation}> key={workspace.id}
Пространственная сцена variant={index === 0 ? "primary" : "secondary"}
</Button> icon={<Icon name={workspace.icon} />}
<Button variant="secondary" icon={<Icon name="network" />} onClick={onOpenDevice}> onClick={() => onOpenWorkspace(workspace.id)}
Локальное устройство >
</Button> {workspace.label}
</div> </Button>
))}
</div>
) : null}
</div> </div>
<div className="landing-stage__status"> <div className="landing-stage__status">
@@ -12,10 +12,19 @@ export interface EnvironmentBackground {
fileName: string | null; 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 { export interface EnvironmentSettings {
revision: number; revision: number;
headerLabels: Record<RootId, string>; pages: Record<EnvironmentSurfaceId, EnvironmentPage>;
backgrounds: Record<EnvironmentSurfaceId, EnvironmentBackground>;
} }
export interface UploadedEnvironmentMedia { export interface UploadedEnvironmentMedia {
@@ -30,7 +39,6 @@ export interface UploadedEnvironmentMedia {
const surfaceIds: readonly EnvironmentSurfaceId[] = [ const surfaceIds: readonly EnvironmentSurfaceId[] = [
"home", "home",
"center",
"fleet", "fleet",
"observation", "observation",
"missions", "missions",
@@ -39,8 +47,6 @@ const surfaceIds: readonly EnvironmentSurfaceId[] = [
"polygon", "polygon",
]; ];
const rootIds = surfaceIds.filter((value): value is RootId => value !== "home");
function emptyBackground(): EnvironmentBackground { function emptyBackground(): EnvironmentBackground {
return { return {
enabled: false, 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 { export function defaultEnvironmentSettings(): EnvironmentSettings {
const rootById = new Map(roots.map((root) => [root.id, root]));
return { return {
revision: 0, revision: 0,
headerLabels: Object.fromEntries( pages: Object.fromEntries(surfaceIds.map((surfaceId) => {
roots.map((root) => [root.id, root.label]), const root = surfaceId === "home" ? null : rootById.get(surfaceId) ?? null;
) as Record<RootId, string>, const [primaryWorkspaceId, secondaryWorkspaceId] = defaultQuickActions[surfaceId];
backgrounds: Object.fromEntries( return [surfaceId, {
surfaceIds.map((surfaceId) => [surfaceId, emptyBackground()]), headerLabel: root?.label ?? "Mission Core",
) as Record<EnvironmentSurfaceId, EnvironmentBackground>, 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>; 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 (nullable && value === null) return null;
if (typeof value !== "string" || !value.trim()) { if (typeof value !== "string" || !value.trim()) {
throw new Error(`${path} должен быть непустой строкой.`); throw new Error(`${path} должен быть непустой строкой.`);
@@ -80,7 +112,9 @@ function requireString(value: unknown, path: string, nullable = false): string |
function decodeBackground(value: unknown, path: string): EnvironmentBackground { function decodeBackground(value: unknown, path: string): EnvironmentBackground {
const record = requireRecord(value, path); 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))) { if (!["file", "url"].includes(String(record.source))) {
throw new Error(`${path}.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 { export function decodeEnvironmentSettings(value: unknown): EnvironmentSettings {
const record = requireRecord(value, "environment"); const record = requireRecord(value, "environment");
if (record.schema_version !== "missioncore.operator-environment/v1") { if (record.schema_version !== "missioncore.operator-environment/v2") {
throw new Error("Версия настроек окружения не поддерживается."); throw new Error("Версия настроек окружения не поддерживается.");
} }
if ( if (
@@ -111,36 +166,35 @@ export function decodeEnvironmentSettings(value: unknown): EnvironmentSettings {
) { ) {
throw new Error("Ревизия настроек окружения некорректна."); throw new Error("Ревизия настроек окружения некорректна.");
} }
const labels = requireRecord(record.header_labels, "environment.header_labels"); const pages = requireRecord(record.pages, "environment.pages");
const backgrounds = requireRecord(record.backgrounds, "environment.backgrounds");
return { return {
revision: record.revision, revision: record.revision,
headerLabels: Object.fromEntries(rootIds.map((rootId) => [ pages: Object.fromEntries(surfaceIds.map((surfaceId) => [
rootId,
requireString(labels[rootId], `environment.header_labels.${rootId}`),
])) as Record<RootId, string>,
backgrounds: Object.fromEntries(surfaceIds.map((surfaceId) => [
surfaceId, surfaceId,
decodeBackground( decodePage(pages[surfaceId], `environment.pages.${surfaceId}`),
backgrounds[surfaceId], ])) as Record<EnvironmentSurfaceId, EnvironmentPage>,
`environment.backgrounds.${surfaceId}`,
),
])) as Record<EnvironmentSurfaceId, EnvironmentBackground>,
}; };
} }
export function encodeEnvironmentSettings(settings: EnvironmentSettings): unknown { export function encodeEnvironmentSettings(settings: EnvironmentSettings): unknown {
return { return {
revision: settings.revision, revision: settings.revision,
header_labels: settings.headerLabels, pages: Object.fromEntries(surfaceIds.map((surfaceId) => {
backgrounds: Object.fromEntries(surfaceIds.map((surfaceId) => { const page = settings.pages[surfaceId];
const background = settings.backgrounds[surfaceId];
return [surfaceId, { return [surfaceId, {
enabled: background.enabled, header_label: page.headerLabel,
source: background.source, eyebrow: page.eyebrow,
url: background.url, title: page.title,
media_kind: background.mediaKind, description: page.description,
file_name: background.fileName, 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 { ): EnvironmentSettings {
return { return {
revision: settings.revision, revision: settings.revision,
headerLabels: { ...settings.headerLabels }, pages: Object.fromEntries(surfaceIds.map((surfaceId) => [
backgrounds: Object.fromEntries(surfaceIds.map((surfaceId) => [
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"; import type { IconName } from "@nodedc/ui-react";
export type RootId = export type RootId =
| "center"
| "fleet" | "fleet"
| "observation" | "observation"
| "missions" | "missions"
@@ -84,15 +83,6 @@ const later = (label: string, description: string): Capability => ({
}); });
export const roots: RootDefinition[] = [ export const roots: RootDefinition[] = [
{
id: "center",
label: "Центр",
title: "Центр управления",
eyebrow: "ПУНКТ УПРАВЛЕНИЯ",
description: "Единая оперативная картина, состояние контура и события оператора.",
statement: "Наблюдать весь контур, быстро видеть отклонения и переходить к действию.",
accent: "ОПЕРАТИВНЫЙ КОНТУР",
},
{ {
id: "fleet", id: "fleet",
label: "Парк", label: "Парк",
@@ -163,10 +153,10 @@ export const workspaces: WorkspaceDefinition[] = [
}, },
{ {
id: "contour-health", id: "contour-health",
root: "center", root: "fleet",
label: "Состояние контура", label: "Состояние контура",
title: "Состояние контура", title: "Состояние контура",
eyebrow: "ЦЕНТР / СОСТОЯНИЕ", eyebrow: "ПАРК / СОСТОЯНИЕ",
description: "Узлы, процессы, сеть и подключённые устройства по данным живого контура.", description: "Узлы, процессы, сеть и подключённые устройства по данным живого контура.",
icon: "shield", icon: "shield",
kind: "contour-health", kind: "contour-health",
@@ -3,13 +3,13 @@
gap: 1rem; gap: 1rem;
} }
.environment-settings__labels { .environment-settings__copy {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem; gap: 0.85rem;
} }
.environment-settings__media { .environment-settings__editor {
display: grid; display: grid;
gap: 1rem; gap: 1rem;
} }
@@ -32,8 +32,31 @@
width: 100%; 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) { @media (max-width: 760px) {
.environment-settings__labels { .environment-settings__copy,
.environment-settings__quick-actions {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -24,18 +24,25 @@ after(async () => {
function serverDocument(overrides = {}) { function serverDocument(overrides = {}) {
const defaults = environment.defaultEnvironmentSettings(); const defaults = environment.defaultEnvironmentSettings();
return { return {
schema_version: "missioncore.operator-environment/v1", schema_version: "missioncore.operator-environment/v2",
revision: defaults.revision, revision: defaults.revision,
header_labels: defaults.headerLabels, pages: Object.fromEntries(
backgrounds: Object.fromEntries( Object.entries(defaults.pages).map(([surfaceId, page]) => [
Object.entries(defaults.backgrounds).map(([surfaceId, background]) => [
surfaceId, surfaceId,
{ {
enabled: background.enabled, header_label: page.headerLabel,
source: background.source, eyebrow: page.eyebrow,
url: background.url, title: page.title,
media_kind: background.mediaKind, description: page.description,
file_name: background.fileName, 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(); const defaults = environment.defaultEnvironmentSettings();
assert.deepEqual(Object.keys(defaults.headerLabels), [ assert.deepEqual(Object.keys(defaults.pages), [
"center",
"fleet",
"observation",
"missions",
"data",
"system",
"polygon",
]);
assert.deepEqual(Object.keys(defaults.backgrounds), [
"home", "home",
"center",
"fleet", "fleet",
"observation", "observation",
"missions", "missions",
@@ -64,23 +61,31 @@ test("default environment exposes every header and landing surface", () => {
"system", "system",
"polygon", "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({ const payload = serverDocument({
revision: 4, revision: 4,
header_labels: { pages: {
...serverDocument().header_labels, ...base.pages,
center: "Командный центр", observation: {
}, ...base.pages.observation,
backgrounds: { header_label: "Контроль",
...serverDocument().backgrounds, title: "Контроль пространства",
home: { },
enabled: true, fleet: {
source: "file", ...base.pages.fleet,
url: `/api/v1/environment/media/home?generation=${"a".repeat(64)}`, primary_workspace_id: "contour-health",
media_kind: "video", background: {
file_name: "stage.mp4", 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); const encoded = environment.encodeEnvironmentSettings(decoded);
assert.equal(decoded.revision, 4); assert.equal(decoded.revision, 4);
assert.equal(decoded.headerLabels.center, "Командный центр"); assert.equal(decoded.pages.observation.headerLabel, "Контроль");
assert.equal(decoded.backgrounds.home.mediaKind, "video"); assert.equal(decoded.pages.observation.title, "Контроль пространства");
assert.equal(encoded.backgrounds.home.media_kind, "video"); 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); 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 accepted = environment.decodeEnvironmentSettings(serverDocument());
const draft = environment.cloneEnvironmentSettings(accepted); const draft = environment.cloneEnvironmentSettings(accepted);
draft.headerLabels.center = "Изменено"; draft.pages.observation.headerLabel = "Изменено";
draft.backgrounds.home.enabled = true; draft.pages.fleet.background.enabled = true;
assert.equal(accepted.headerLabels.center, "Центр"); assert.equal(accepted.pages.observation.headerLabel, "Наблюдение");
assert.equal(accepted.backgrounds.home.enabled, false); 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[ EnvironmentSurfaceId = Literal[
"home", "home",
"center",
"fleet", "fleet",
"observation", "observation",
"missions", "missions",
@@ -27,8 +26,8 @@ EnvironmentSurfaceId = Literal[
EnvironmentMediaKind = Literal["image", "video"] EnvironmentMediaKind = Literal["image", "video"]
EnvironmentMediaSource = Literal["file", "url"] EnvironmentMediaSource = Literal["file", "url"]
ENVIRONMENT_SCHEMA_VERSION: Literal["missioncore.operator-environment/v1"] = ( ENVIRONMENT_SCHEMA_VERSION: Literal["missioncore.operator-environment/v2"] = (
"missioncore.operator-environment/v1" "missioncore.operator-environment/v2"
) )
ENVIRONMENT_MEDIA_SCHEMA_VERSION: Literal[ ENVIRONMENT_MEDIA_SCHEMA_VERSION: Literal[
"missioncore.operator-environment-media/v1" "missioncore.operator-environment-media/v1"
@@ -51,16 +50,6 @@ class StrictApiModel(BaseModel):
model_config = ConfigDict(extra="forbid") 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): class EnvironmentBackground(StrictApiModel):
enabled: bool = False enabled: bool = False
source: EnvironmentMediaSource = "file" source: EnvironmentMediaSource = "file"
@@ -83,25 +72,50 @@ class EnvironmentBackground(StrictApiModel):
return self return self
class EnvironmentBackgrounds(StrictApiModel): class EnvironmentPage(StrictApiModel):
home: EnvironmentBackground header_label: str = Field(min_length=1, max_length=40)
center: EnvironmentBackground eyebrow: str = Field(min_length=1, max_length=80)
fleet: EnvironmentBackground title: str = Field(min_length=1, max_length=120)
observation: EnvironmentBackground description: str = Field(min_length=1, max_length=500)
missions: EnvironmentBackground primary_workspace_id: str | None = Field(
data: EnvironmentBackground default=None,
system: EnvironmentBackground max_length=80,
polygon: EnvironmentBackground 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): class EnvironmentSettingsPut(StrictApiModel):
revision: int = Field(ge=0) revision: int = Field(ge=0)
header_labels: EnvironmentHeaderLabels pages: EnvironmentPages
backgrounds: EnvironmentBackgrounds
class EnvironmentSettingsDocument(EnvironmentSettingsPut): 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): class EnvironmentMediaDocument(StrictApiModel):
@@ -121,28 +135,128 @@ def default_environment_settings() -> EnvironmentSettingsDocument:
background = EnvironmentBackground() background = EnvironmentBackground()
return EnvironmentSettingsDocument( return EnvironmentSettingsDocument(
revision=0, revision=0,
header_labels=EnvironmentHeaderLabels( pages=EnvironmentPages(
center="Центр", home=EnvironmentPage(
fleet="Парк", header_label="Mission Core",
observation="Наблюдение", eyebrow="NODEDC / MISSION CORE",
missions="Миссии", title="Mission Core",
data="Данные", description=(
system="Система", "Наблюдение, планирование и корректировка миссий "
polygon="Тестировочный контур", "в одной модульной рабочей области."
), ),
backgrounds=EnvironmentBackgrounds( primary_workspace_id="spatial-scene",
home=background.model_copy(), secondary_workspace_id="local-device",
center=background.model_copy(), background=background.model_copy(),
fleet=background.model_copy(), ),
observation=background.model_copy(), fleet=EnvironmentPage(
missions=background.model_copy(), header_label="Парк",
data=background.model_copy(), eyebrow="ПАРК / УСТРОЙСТВА",
system=background.model_copy(), title="Аппараты и устройства",
polygon=background.model_copy(), 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: class EnvironmentSettingsStore:
def __init__(self, root: Path) -> None: def __init__(self, root: Path) -> None:
self.root = root.expanduser().resolve() self.root = root.expanduser().resolve()
@@ -156,7 +270,7 @@ class EnvironmentSettingsStore:
return default_environment_settings() return default_environment_settings()
try: try:
payload = json.loads(self.settings_path.read_text(encoding="utf-8")) 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: except (OSError, ValueError) as exc:
raise RuntimeError("operator environment settings are corrupt") from exc raise RuntimeError("operator environment settings are corrupt") from exc
@@ -167,8 +281,7 @@ class EnvironmentSettingsStore:
raise RuntimeError("operator environment settings revision changed") raise RuntimeError("operator environment settings revision changed")
document = EnvironmentSettingsDocument( document = EnvironmentSettingsDocument(
revision=current.revision + 1, revision=current.revision + 1,
header_labels=request.header_labels, pages=request.pages,
backgrounds=request.backgrounds,
) )
self.root.mkdir(mode=0o700, parents=True, exist_ok=True) self.root.mkdir(mode=0o700, parents=True, exist_ok=True)
temporary = self.settings_path.with_suffix(".json.tmp") temporary = self.settings_path.with_suffix(".json.tmp")
@@ -189,7 +302,7 @@ class EnvironmentSettingsStore:
return default_environment_settings() return default_environment_settings()
try: try:
payload = json.loads(self.settings_path.read_text(encoding="utf-8")) 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: except (OSError, ValueError) as exc:
raise RuntimeError("operator environment settings are corrupt") from 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 asyncio
import hashlib import hashlib
import json
from collections.abc import Callable from collections.abc import Callable
from pathlib import Path from pathlib import Path
from typing import Any 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") store = EnvironmentSettingsStore(tmp_path / "operator-environment")
initial = store.read() initial = store.read()
assert initial.revision == 0 assert initial.revision == 0
assert initial.header_labels.polygon == "Тестировочный контур" assert initial.pages.polygon.header_label == "Тестировочный контур"
assert initial.backgrounds.home.enabled is False assert initial.pages.fleet.primary_workspace_id == "contour-health"
assert initial.pages.home.background.enabled is False
request = EnvironmentSettingsPut( request = EnvironmentSettingsPut(
revision=initial.revision, revision=initial.revision,
header_labels=initial.header_labels.model_copy(update={"center": "Командный центр"}), pages=initial.pages.model_copy(
backgrounds=initial.backgrounds, update={
"observation": initial.pages.observation.model_copy(
update={
"header_label": "Контроль",
"title": "Контроль пространства",
}
)
}
),
) )
saved = store.save(request) saved = store.save(request)
restored = EnvironmentSettingsStore(store.root).read() restored = EnvironmentSettingsStore(store.root).read()
assert saved.revision == 1 assert saved.revision == 1
assert restored == saved 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() 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( store.save(
EnvironmentSettingsPut( EnvironmentSettingsPut(
revision=0, revision=0,
header_labels=initial.header_labels, pages=initial.pages,
backgrounds=initial.backgrounds,
) )
) )
@@ -107,8 +117,7 @@ def test_environment_settings_reject_stale_revision(tmp_path: Path) -> None:
store.save( store.save(
EnvironmentSettingsPut( EnvironmentSettingsPut(
revision=0, revision=0,
header_labels=initial.header_labels, pages=initial.pages,
backgrounds=initial.backgrounds,
) )
) )
@@ -185,9 +194,8 @@ def test_environment_media_upload_route_streams_and_publishes_safe_metadata(
def test_default_environment_has_every_product_surface() -> None: def test_default_environment_has_every_product_surface() -> None:
document = default_environment_settings() document = default_environment_settings()
assert set(document.backgrounds.model_dump()) == { assert set(document.pages.model_dump()) == {
"home", "home",
"center",
"fleet", "fleet",
"observation", "observation",
"missions", "missions",
@@ -195,3 +203,67 @@ def test_default_environment_has_every_product_surface() -> None:
"system", "system",
"polygon", "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()