feat(control-station): configure page-specific shell
This commit is contained in:
@@ -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>,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user