Reuse canonical home and settings on Node and admit local K1 viewer

This commit is contained in:
DCCONSTRUCTIONS
2026-09-07 22:55:13 +03:00
parent 1832943558
commit a23c5b2005
23 changed files with 891 additions and 1139 deletions
@@ -1,92 +1 @@
import { useEffect, useMemo, useState } from "react";
import type {
EnvironmentBackground,
EnvironmentMediaItem,
} from "../core/environment/environmentSettings";
type ReadyEnvironmentMediaItem = EnvironmentMediaItem & {
url: string;
mediaKind: "image" | "video";
};
function isReadyMediaItem(
item: EnvironmentMediaItem,
): item is ReadyEnvironmentMediaItem {
return Boolean(item.url && item.mediaKind);
}
export function EnvironmentBackgroundMedia({
background,
}: {
background: EnvironmentBackground;
}) {
const items = useMemo(
() => background.items.filter(isReadyMediaItem),
[background.items],
);
const playlistIdentity = items.map((item) => `${item.id}:${item.url}`).join("|");
const [activeIndex, setActiveIndex] = useState(0);
const [failedIds, setFailedIds] = useState<Set<string>>(new Set());
const playableItems = items.filter((item) => !failedIds.has(item.id));
const activeItem = playableItems[activeIndex] ?? playableItems[0] ?? null;
useEffect(() => {
setActiveIndex(0);
setFailedIds(new Set());
}, [playlistIdentity]);
useEffect(() => {
if (
!background.enabled
|| !activeItem
|| activeItem.mediaKind !== "image"
|| playableItems.length < 2
) {
return;
}
const timer = window.setTimeout(() => {
setActiveIndex((current) => (current + 1) % playableItems.length);
}, background.imageDurationSeconds * 1_000);
return () => window.clearTimeout(timer);
}, [
activeItem,
background.enabled,
background.imageDurationSeconds,
playableItems.length,
]);
if (!background.enabled || !activeItem) return null;
return (
<div className="landing-stage__media" aria-hidden="true">
{activeItem.mediaKind === "video" ? (
<video
key={activeItem.url}
src={activeItem.url}
autoPlay
muted
loop={playableItems.length === 1}
playsInline
onEnded={() => setActiveIndex((current) => (
(current + 1) % playableItems.length
))}
onError={() => {
setFailedIds((current) => new Set(current).add(activeItem.id));
setActiveIndex(0);
}}
/>
) : (
<img
key={activeItem.url}
src={activeItem.url}
alt=""
onError={() => {
setFailedIds((current) => new Set(current).add(activeItem.id));
setActiveIndex(0);
}}
/>
)}
</div>
);
}
export { EnvironmentBackgroundMedia } from "@nodedc/ui-react";
@@ -1,248 +1 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
Icon,
IconButton,
MediaSourceField,
RangeControl,
SortableList,
} from "@nodedc/ui-react";
import {
appendEnvironmentMediaItem,
inferEnvironmentMediaKind,
maxEnvironmentMediaItems,
removeEnvironmentMediaItem,
type EnvironmentBackground,
type EnvironmentMediaItem,
type EnvironmentSurfaceId,
type UploadedEnvironmentMedia,
} from "../core/environment/environmentSettings";
interface EnvironmentMediaPlaylistEditorProps {
surfaceId: EnvironmentSurfaceId;
background: EnvironmentBackground;
disabled: boolean;
error: string | null;
onChange: (background: EnvironmentBackground) => void;
onBusyChange: (busy: boolean) => void;
onUpload: (
surfaceId: EnvironmentSurfaceId,
itemId: string,
file: File,
) => Promise<UploadedEnvironmentMedia>;
}
const acceptedEnvironmentMedia = [
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"image/avif",
"video/mp4",
"video/webm",
"video/quicktime",
".png",
".jpg",
".jpeg",
".gif",
".webp",
".avif",
".mp4",
".webm",
".mov",
].join(",");
function patchItem(
background: EnvironmentBackground,
itemId: string,
patch: Partial<EnvironmentMediaItem>,
): EnvironmentBackground {
return {
...background,
items: background.items.map((item) => (
item.id === itemId ? { ...item, ...patch } : item
)),
};
}
export function EnvironmentMediaPlaylistEditor({
surfaceId,
background,
disabled,
error,
onChange,
onBusyChange,
onUpload,
}: EnvironmentMediaPlaylistEditorProps) {
const [uploadingIds, setUploadingIds] = useState<Set<string>>(new Set());
const [itemErrors, setItemErrors] = useState<Record<string, string>>({});
const backgroundRef = useRef(background);
backgroundRef.current = background;
const displayedItems = useMemo(
() => [...background.items].reverse(),
[background.items],
);
useEffect(() => {
onBusyChange(uploadingIds.size > 0);
}, [onBusyChange, uploadingIds.size]);
useEffect(() => () => onBusyChange(false), [onBusyChange]);
const setItemError = (itemId: string, message?: string) => {
setItemErrors((current) => {
const next = { ...current };
if (message) next[itemId] = message;
else delete next[itemId];
return next;
});
};
const uploadFile = async (itemId: string, file?: File) => {
if (!file) return;
setUploadingIds((current) => new Set(current).add(itemId));
setItemError(itemId);
try {
const uploaded = await onUpload(surfaceId, itemId, file);
onChange(patchItem(backgroundRef.current, itemId, {
source: "file",
url: uploaded.url,
mediaKind: uploaded.mediaKind,
fileName: uploaded.fileName,
}));
} catch (reason) {
setItemError(
itemId,
reason instanceof Error
? reason.message
: "Не удалось загрузить медиаконтент.",
);
} finally {
setUploadingIds((current) => {
const next = new Set(current);
next.delete(itemId);
return next;
});
}
};
return (
<div className="environment-media-playlist">
<div className="environment-media-playlist__head">
<div>
<span>Видео / картинка</span>
<p>MP4, WebM, MOV, PNG, JPEG, GIF, WebP или AVIF · до 256 МБ.</p>
</div>
<IconButton
label="Добавить медиаконтент"
disabled={disabled || background.items.length >= maxEnvironmentMediaItems}
onClick={() => onChange(appendEnvironmentMediaItem(background))}
>
<Icon name="plus" />
</IconButton>
</div>
{displayedItems.length ? (
<SortableList
items={displayedItems}
getId={(item) => item.id}
className="environment-media-playlist__items"
onReorder={(items) => onChange({
...background,
items: [...items].reverse(),
})}
>
{(item, { handle }) => {
const playbackIndex = background.items.findIndex(
(candidate) => candidate.id === item.id,
);
return (
<div className="environment-media-playlist__item">
<MediaSourceField
label={`Медиаконтент ${String(playbackIndex + 1).padStart(2, "0")}`}
kindLabel={item.mediaKind ?? "media"}
source={item.source}
url={item.url ?? ""}
fileName={item.fileName}
uploading={uploadingIds.has(item.id)}
previewSrc={item.url}
previewKind={item.mediaKind}
accept={acceptedEnvironmentMedia}
path={`${surfaceId}.background.items[${playbackIndex}] → server environment media`}
hint="Файл сохраняется в Mission Core data root. URL должен вести прямо на media по HTTP(S)."
error={itemErrors[item.id] ?? (
playbackIndex === background.items.length - 1 ? error : null
)}
onSourceChange={(source) => {
if (source === item.source) return;
setItemError(item.id);
onChange(patchItem(background, item.id, {
source,
url: null,
mediaKind: null,
fileName: null,
}));
}}
onUrlChange={(url) => {
setItemError(item.id);
onChange(patchItem(background, item.id, {
source: "url",
url: url || null,
mediaKind: url ? inferEnvironmentMediaKind(url) : null,
fileName: null,
}));
}}
onFileChange={(file) => void uploadFile(item.id, file)}
/>
<div className="environment-media-playlist__item-actions">
<IconButton
label={`Удалить медиаконтент ${playbackIndex + 1}`}
disabled={disabled || uploadingIds.has(item.id)}
onClick={() => {
setItemError(item.id);
onChange(removeEnvironmentMediaItem(background, item.id));
}}
>
<Icon name="trash" />
</IconButton>
{handle}
</div>
</div>
);
}}
</SortableList>
) : (
<>
<p className="environment-media-playlist__empty">
Добавьте первый файл или прямую ссылку на медиаконтент.
</p>
{error ? (
<p className="environment-media-playlist__error" role="alert">
{error}
</p>
) : null}
</>
)}
<div className="environment-media-playlist__timing">
<RangeControl
label="Показывать изображение"
value={background.imageDurationSeconds}
min={1}
max={60}
step={1}
disabled={disabled}
formatValue={(value) => `${value} с`}
onChange={(imageDurationSeconds) => onChange({
...background,
imageDurationSeconds,
})}
/>
<span>
Новые элементы появляются сверху. Воспроизведение начинается снизу;
перетаскивание меняет порядок.
</span>
</div>
</div>
);
}
export { EnvironmentMediaPlaylistEditor } from "@nodedc/ui-react";
@@ -1,358 +1,25 @@
import { useEffect, useMemo, useState } from "react";
import {
Button,
FeatureSettingsWindow,
Select,
SettingsCard,
Switch,
TextAreaField,
TextField,
WindowFooterActions,
} from "@nodedc/ui-react";
import { EnvironmentSettingsWindow as SharedEnvironmentSettingsWindow } from "@nodedc/ui-react";
import { environmentSurfaceIds, type EnvironmentSettings, type EnvironmentSurfaceId, type UploadedEnvironmentMedia } from "../core/environment/environmentSettings";
import { roots, workspaces, workspacesForRoot } from "../productModel";
import {
cloneEnvironmentSettings,
type EnvironmentBackground,
type EnvironmentPage,
type EnvironmentSettings,
type EnvironmentSurfaceId,
type UploadedEnvironmentMedia,
} from "../core/environment/environmentSettings";
import { EnvironmentMediaPlaylistEditor } from "./EnvironmentMediaPlaylistEditor";
import {
roots,
workspaces,
workspacesForRoot,
} from "../productModel";
interface EnvironmentSettingsWindowProps {
interface Props {
open: boolean;
settings: EnvironmentSettings;
state: "loading" | "ready" | "saving" | "error";
error: string | null;
onClose: () => void;
onSave: (settings: EnvironmentSettings) => Promise<EnvironmentSettings>;
onUpload: (
surfaceId: EnvironmentSurfaceId,
itemId: string,
file: File,
) => Promise<UploadedEnvironmentMedia>;
onUpload: (surfaceId: EnvironmentSurfaceId, itemId: string, file: File) => Promise<UploadedEnvironmentMedia>;
}
function patchPage(
draft: EnvironmentSettings,
surfaceId: EnvironmentSurfaceId,
patch: Partial<EnvironmentPage>,
): EnvironmentSettings {
return {
...draft,
pages: {
...draft.pages,
[surfaceId]: {
...draft.pages[surfaceId],
...patch,
},
},
};
}
function patchBackground(
draft: EnvironmentSettings,
surfaceId: EnvironmentSurfaceId,
patch: Partial<EnvironmentBackground>,
): EnvironmentSettings {
const page = draft.pages[surfaceId];
return patchPage(draft, surfaceId, {
background: {
...page.background,
...patch,
},
});
}
export function EnvironmentSettingsWindow({
open,
settings,
state,
error,
onClose,
onSave,
onUpload,
}: EnvironmentSettingsWindowProps) {
const [draft, setDraft] = useState(() => cloneEnvironmentSettings(settings));
const [surfaceId, setSurfaceId] = useState<EnvironmentSurfaceId>("fleet");
const [uploading, setUploading] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setDraft(cloneEnvironmentSettings(settings));
setLocalError(null);
}, [open, settings]);
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 quickActionWorkspaces = useMemo(
() => surfaceId === "home"
? workspaces.filter((workspace) => !workspace.internalOnly)
: workspacesForRoot(surfaceId),
[surfaceId],
);
const quickActionOptions = useMemo(() => [
{
value: "none",
label: "Не показывать",
description: "Кнопка скрыта на стартовом экране",
},
...quickActionWorkspaces.map((workspace) => ({
value: workspace.id,
label: workspace.label,
description: workspace.description,
})),
], [quickActionWorkspaces]);
const dirty = useMemo(
() => JSON.stringify(draft) !== JSON.stringify(settings),
[draft, settings],
);
const busy = state === "saving" || uploading;
const updatePage = (patch: Partial<EnvironmentPage>) => {
setDraft((current) => patchPage(current, surfaceId, patch));
};
const save = async () => {
const invalid = Object.entries(draft.pages).find(([, page]) => (
!page.headerLabel.trim()
|| !page.eyebrow.trim()
|| !page.title.trim()
|| !page.description.trim()
));
if (invalid) {
setLocalError("Название, надзаголовок, заголовок и описание не могут быть пустыми.");
return;
}
if (
selectedPage.primaryWorkspaceId
&& selectedPage.primaryWorkspaceId === selectedPage.secondaryWorkspaceId
) {
setLocalError("Быстрые кнопки должны вести на разные рабочие поверхности.");
return;
}
const invalidMedia = Object.entries(draft.pages).find(([, page]) => (
(page.background.enabled && !page.background.items.length)
|| page.background.items.some((item) => {
if (!item.url || !item.mediaKind) return true;
if (item.source !== "url") return false;
try {
const parsed = new URL(item.url);
return !["http:", "https:"].includes(parsed.protocol);
} catch {
return true;
}
})
));
if (invalidMedia) {
setSurfaceId(invalidMedia[0] as EnvironmentSurfaceId);
setLocalError(
"Каждый элемент фона должен содержать загруженный файл или прямой HTTP(S) URL.",
);
return;
}
setLocalError(null);
try {
await onSave({
...draft,
pages: Object.fromEntries(
Object.entries(draft.pages).map(([id, page]) => [id, {
...page,
headerLabel: page.headerLabel.trim(),
eyebrow: page.eyebrow.trim(),
title: page.title.trim(),
description: page.description.trim(),
}]),
) as EnvironmentSettings["pages"],
});
onClose();
} catch (reason) {
setLocalError(reason instanceof Error
? reason.message
: "Не удалось сохранить настройки окружения.");
}
};
return (
<FeatureSettingsWindow
open={open}
title="Настройки Mission Core"
subtitle="Локальное операторское окружение"
identity={{
title: "DC",
subtitle: "Mission Core",
avatarLabel: "DC",
}}
sections={[
{
id: "environment",
label: "Окружение",
group: "MISSION CORE",
icon: "settings",
},
]}
activeSection="environment"
onSectionChange={() => undefined}
onClose={onClose}
footer={(
<WindowFooterActions>
<Button
variant="secondary"
disabled={!dirty || busy}
onClick={() => {
setDraft(cloneEnvironmentSettings(settings));
setLocalError(null);
}}
>
Сбросить изменения
</Button>
<Button
variant="primary"
disabled={!dirty || busy}
onClick={() => void save()}
>
{state === "saving" ? "Сохраняем…" : "Сохранить"}
</Button>
</WindowFooterActions>
)}
>
<div className="environment-settings">
<SettingsCard
eyebrow="ОКРУЖЕНИЕ"
title="Основные элементы управления"
description="Выберите страницу и настройте её название в шапке, содержание стартового экрана, подложку и быстрые переходы."
actions={(
<Switch
checked={selectedBackground.enabled}
label="Показывать фон"
onChange={(enabled) => {
if (enabled && !selectedBackground.items.length) {
setLocalError("Сначала добавьте медиаконтент.");
return;
}
setLocalError(null);
setDraft((current) =>
patchBackground(current, surfaceId, { enabled }));
}}
/>
)}
>
<div className="environment-settings__editor">
<div className="environment-settings__surface">
<span>Страница</span>
<Select
label="Выбрать страницу окружения"
value={surfaceId}
options={pageOptions}
variant="split"
menuWidth="anchor"
onChange={(value) => {
setSurfaceId(value);
setLocalError(null);
}}
/>
</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>
<EnvironmentMediaPlaylistEditor
surfaceId={surfaceId}
background={selectedBackground}
disabled={busy}
error={localError ?? error}
onBusyChange={setUploading}
onChange={(background) => {
setLocalError(null);
setDraft((current) =>
patchBackground(current, surfaceId, background));
}}
onUpload={onUpload}
/>
</div>
</SettingsCard>
</div>
</FeatureSettingsWindow>
);
const surfaces = environmentSurfaceIds.map(id => ({
id, home: id === "home", description: id === "home" ? "Главная страница продукта" : `Стартовая страница раздела «${roots.find(root => root.id === id)?.label}»`,
actions: id === "home" ? workspaces.filter(item => !item.internalOnly) : workspacesForRoot(id),
}));
export function EnvironmentSettingsWindow(props: Props) {
return <SharedEnvironmentSettingsWindow {...props} productName="Mission Core" surfaces={surfaces} initialSurfaceId="fleet"
onSave={draft => props.onSave({ ...draft, pages: Object.fromEntries(environmentSurfaceIds.map(id => [id, draft.pages[id]])) as EnvironmentSettings["pages"] })}
onUpload={(id, itemId, file) => {
if (!environmentSurfaceIds.includes(id as EnvironmentSurfaceId)) throw new Error("Страница окружения недоступна.");
return props.onUpload(id as EnvironmentSurfaceId, itemId, file);
}} />;
}
@@ -1,10 +1,9 @@
import { Button, Icon, StatusBadge } from "@nodedc/ui-react";
import { LandingStage as SharedLandingStage, StatusBadge } from "@nodedc/ui-react";
import type { BackendStatus, RuntimePhase } from "../core/runtime/contracts";
import type { EnvironmentPage } from "../core/environment/environmentSettings";
import type { RootDefinition, WorkspaceDefinition } from "../productModel";
import { backendLabel, backendTone, phaseLabel, phaseTone } from "../presentation";
import { EnvironmentBackgroundMedia } from "./EnvironmentBackgroundMedia";
export interface LandingStageProps {
root: RootDefinition | null;
@@ -25,40 +24,10 @@ export function LandingStage({
quickActions,
onOpenWorkspace,
}: LandingStageProps) {
const { background } = page;
const hasMedia = background.enabled && background.items.some(
(item) => item.url && item.mediaKind,
);
return (
<section
className="landing-stage"
data-root={root?.id ?? "home"}
data-has-media={hasMedia ? "true" : undefined}
>
<EnvironmentBackgroundMedia background={background} />
<div className="landing-stage__shade" aria-hidden="true" />
<div className="landing-stage__copy">
<span className="section-eyebrow">{page.eyebrow}</span>
<h1>{page.title}</h1>
<p>{page.description}</p>
{quickActions.length ? (
<div className="landing-stage__actions">
{quickActions.map((workspace, index) => (
<Button
key={workspace.id}
variant={index === 0 ? "primary" : "secondary"}
icon={<Icon name={workspace.icon} />}
onClick={() => onOpenWorkspace(workspace.id)}
>
{workspace.label}
</Button>
))}
</div>
) : null}
</div>
<div className="landing-stage__status">
<div>
return <SharedLandingStage page={page} pageId={root?.id ?? "home"}
actions={quickActions.map(workspace => ({ id: workspace.id, label: workspace.label, icon: workspace.icon, onSelect: () => onOpenWorkspace(workspace.id) }))}
status={<>
<div>
<span className="section-eyebrow">ЛОКАЛЬНЫЙ КОНТУР</span>
<StatusBadge tone={backendTone(backendStatus)}>{backendLabel(backendStatus)}</StatusBadge>
</div>
@@ -67,12 +36,9 @@ export function LandingStage({
<StatusBadge tone={phaseTone(phase)}>{phaseLabel(phase)}</StatusBadge>
</div>
<p>{message || "Выберите архитектурный блок в верхней навигации."}</p>
</div>
<footer className="landing-stage__footer">
<span>{root?.accent ?? "НАБЛЮДЕНИЕ · МИССИИ · ДАННЫЕ"}</span>
</>}
footer={<>
<span>{root?.accent ?? "НАБЛЮДЕНИЕ · МИССИИ · ДАННЫЕ"}</span>
<span>01 / ЛОКАЛЬНЫЙ СТЕНД</span>
</footer>
</section>
);
</>} />;
}
@@ -1,32 +1,9 @@
import { roots, type RootId } from "../../productModel";
export type EnvironmentSurfaceId = "home" | RootId;
export type EnvironmentMediaKind = "image" | "video";
export type EnvironmentMediaSource = "file" | "url";
export interface EnvironmentMediaItem {
id: string;
source: EnvironmentMediaSource;
url: string | null;
mediaKind: EnvironmentMediaKind | null;
fileName: string | null;
}
export interface EnvironmentBackground {
enabled: boolean;
imageDurationSeconds: number;
items: EnvironmentMediaItem[];
}
export interface EnvironmentPage {
headerLabel: string;
eyebrow: string;
title: string;
description: string;
primaryWorkspaceId: string | null;
secondaryWorkspaceId: string | null;
background: EnvironmentBackground;
}
import { defaultEnvironmentImageDurationSeconds, maxEnvironmentMediaItems, type EnvironmentBackground, type EnvironmentMediaItem, type EnvironmentMediaKind, type EnvironmentMediaSource, type EnvironmentPage } from "@nodedc/ui-core";
export { createEnvironmentMediaItem, appendEnvironmentMediaItem, removeEnvironmentMediaItem, inferEnvironmentMediaKind, defaultEnvironmentImageDurationSeconds, maxEnvironmentMediaItems } from "@nodedc/ui-core";
export type { EnvironmentBackground, EnvironmentMediaItem, EnvironmentMediaKind, EnvironmentMediaSource, EnvironmentPage } from "@nodedc/ui-core";
export interface EnvironmentSettings {
revision: number;
@@ -54,60 +31,7 @@ const surfaceIds: readonly EnvironmentSurfaceId[] = [
"polygon",
];
export const defaultEnvironmentImageDurationSeconds = 10;
export const maxEnvironmentMediaItems = 24;
function emptyBackground(): EnvironmentBackground {
return {
enabled: false,
imageDurationSeconds: defaultEnvironmentImageDurationSeconds,
items: [],
};
}
export function createEnvironmentMediaItem(): EnvironmentMediaItem {
return {
id: `media-${crypto.randomUUID()}`,
source: "file",
url: null,
mediaKind: null,
fileName: null,
};
}
export function appendEnvironmentMediaItem(
background: EnvironmentBackground,
item: EnvironmentMediaItem = createEnvironmentMediaItem(),
): EnvironmentBackground {
if (
background.items.length >= maxEnvironmentMediaItems
|| background.items.some((candidate) => candidate.id === item.id)
) {
return background;
}
return {
...background,
enabled: background.items.length === 0 ? true : background.enabled,
items: [...background.items, item],
};
}
export function removeEnvironmentMediaItem(
background: EnvironmentBackground,
itemId: string,
): EnvironmentBackground {
const items = background.items.filter((item) => item.id !== itemId);
if (items.length === background.items.length) return background;
return {
...background,
enabled: items.length > 0 && background.enabled,
items,
};
}
export function inferEnvironmentMediaKind(url: string): EnvironmentMediaKind {
return /\.(mp4|webm|mov)(?:[?#].*)?$/i.test(url) ? "video" : "image";
}
function emptyBackground(): EnvironmentBackground { return { enabled: false, imageDurationSeconds: defaultEnvironmentImageDurationSeconds, items: [] }; }
const defaultQuickActions: Record<
EnvironmentSurfaceId,
-1
View File
@@ -22,7 +22,6 @@
@import "./styles/responsive.css";
@import "./styles/observation.css";
@import "./styles/observatory.css";
@import "./styles/environment-settings.css";
@import "./styles/system-telemetry.css";
@import "./styles/artifact-health.css";
@import "./styles/map.css";
@@ -1,141 +0,0 @@
.environment-settings {
display: grid;
gap: 1rem;
}
.environment-settings__copy {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
}
.environment-settings__editor {
display: grid;
gap: 1rem;
}
.environment-settings__surface {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(18rem, 24rem);
align-items: center;
gap: 1rem;
}
.environment-settings__surface > span {
color: var(--nodedc-text-secondary);
font-size: 0.74rem;
font-weight: 650;
}
.environment-settings__surface .nodedc-select-anchor,
.environment-settings__surface .nodedc-select {
width: 100%;
}
.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%;
}
.environment-media-playlist {
display: grid;
min-width: 0;
gap: 0.85rem;
}
.environment-media-playlist__head {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.environment-media-playlist__head > div {
display: grid;
min-width: 0;
gap: 0.22rem;
}
.environment-media-playlist__head span {
color: var(--nodedc-text-secondary);
font-size: var(--nodedc-font-size-sm);
font-weight: var(--nodedc-font-weight-medium);
}
.environment-media-playlist__head p,
.environment-media-playlist__empty,
.environment-media-playlist__timing > span,
.environment-media-playlist__error {
margin: 0;
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
line-height: 1.4;
}
.environment-media-playlist__error {
color: rgb(var(--nodedc-danger-rgb));
}
.environment-media-playlist__items {
display: grid;
min-width: 0;
gap: 0.7rem;
}
.environment-media-playlist__item {
display: grid;
min-width: 0;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: 0.4rem;
}
.environment-media-playlist__item-actions {
display: flex;
align-items: center;
gap: 0.1rem;
padding-top: 1.65rem;
}
.environment-media-playlist__timing {
display: grid;
gap: 0.42rem;
}
@media (max-width: 760px) {
.environment-settings__copy,
.environment-settings__quick-actions {
grid-template-columns: 1fr;
}
.environment-settings__surface {
grid-template-columns: 1fr;
}
.environment-media-playlist__item {
grid-template-columns: minmax(0, 1fr);
}
.environment-media-playlist__item-actions {
justify-self: end;
padding-top: 0;
}
}
@@ -3,9 +3,6 @@
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.landing-stage__copy {
width: min(35rem, 54%);
}
}
@media (max-width: 1280px) {
@@ -190,15 +187,6 @@
text-align: left;
}
.landing-stage__status {
width: 20rem;
max-width: 36vw;
}
.landing-stage__copy h1 {
font-size: clamp(2.7rem, 7vw, 5.6rem);
}
.scene-metrics {
display: none;
}
@@ -424,36 +412,6 @@
display: none;
}
.landing-stage__copy {
top: 42%;
right: 1.2rem;
left: 1.2rem;
width: auto;
}
.landing-stage__copy h1 {
font-size: clamp(2.8rem, 14vw, 5rem);
}
.landing-stage__status {
top: auto;
right: 1rem;
bottom: 3.3rem;
left: 1rem;
width: auto;
max-width: none;
}
.landing-stage__footer {
right: 1rem;
bottom: 1.1rem;
left: 1rem;
}
.landing-stage__footer span:first-child {
display: none;
}
.metrics-grid,
.capability-summary,
.camera-grid,
@@ -645,20 +603,5 @@
}
@media (max-height: 720px) and (min-width: 761px) {
.landing-stage__copy h1 {
font-size: clamp(2.7rem, 6vw, 5rem);
}
.landing-stage__copy p {
font-size: 0.76rem;
}
.landing-stage__actions {
margin-top: 1rem;
}
.landing-stage__status {
top: 1.2rem;
right: 1.2rem;
}
}
-113
View File
@@ -48,116 +48,3 @@
height: 100%;
overflow: hidden;
}
.landing-stage {
position: relative;
min-width: 0;
min-height: 0;
overflow: hidden;
border-radius: var(--nodedc-radius-card);
background: var(--station-stage);
}
.landing-stage__media,
.landing-stage__shade {
position: absolute;
inset: 0;
}
.landing-stage__media img,
.landing-stage__media video {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.landing-stage__shade {
z-index: 1;
background:
linear-gradient(90deg, rgb(5 6 8 / 0.82) 0%, rgb(5 6 8 / 0.54) 46%, rgb(5 6 8 / 0.24) 100%),
linear-gradient(0deg, rgb(5 6 8 / 0.58), transparent 38%);
pointer-events: none;
}
.landing-stage:not([data-has-media="true"]) .landing-stage__shade {
background:
radial-gradient(circle at 68% 42%, rgb(255 255 255 / 0.035), transparent 34%),
linear-gradient(90deg, rgb(5 6 8 / 0.2), transparent 62%);
}
.landing-stage__copy {
position: absolute;
z-index: 2;
top: 50%;
left: clamp(2rem, 5vw, 6rem);
width: min(39rem, 50%);
transform: translateY(-55%);
}
.landing-stage__copy h1 {
margin: 0.75rem 0 1rem;
color: var(--nodedc-text-primary);
font-size: clamp(3rem, 7vw, 7.6rem);
font-weight: 600;
letter-spacing: -0.072em;
line-height: 0.87;
}
.landing-stage__copy p {
max-width: 34rem;
margin: 0;
color: var(--nodedc-text-secondary);
font-size: clamp(0.8rem, 1vw, 1rem);
line-height: 1.6;
}
.landing-stage__actions {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
margin-top: 1.6rem;
}
.landing-stage__status {
position: absolute;
z-index: 2;
top: 2.4rem;
right: 2.4rem;
display: grid;
width: min(24rem, 30vw);
gap: 0.6rem;
border-radius: 1.25rem;
background: rgb(10 10 12 / 0.58);
padding: 1rem;
backdrop-filter: blur(18px);
}
.landing-stage__status > div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.landing-stage__status p {
margin: 0.15rem 0 0;
color: var(--nodedc-text-muted);
font-size: 0.66rem;
line-height: 1.45;
}
.landing-stage__footer {
position: absolute;
z-index: 2;
right: 2.4rem;
bottom: 1.8rem;
left: 2.4rem;
display: flex;
justify-content: space-between;
gap: 1rem;
color: var(--nodedc-text-muted);
font-size: 0.57rem;
font-weight: 780;
letter-spacing: 0.12em;
}
+1
View File
@@ -69,6 +69,7 @@ func run() error {
return err
}
app := &node.Server{Store: store, Assets: assets, Origin: "http://" + *listen, Version: version, Inventory: func() node.Inventory { return node.Host("/") }}
app.Presentation = node.NewPresentationStore(*dir)
pairing, err := node.OpenPairing(store, *dir, version, app.Inventory)
if err != nil {
return err
@@ -0,0 +1,237 @@
package node
import (
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"unicode"
"unicode/utf8"
)
const presentationSchema = "missioncore.node.presentation/v1"
type PresentationMedia struct {
ID string `json:"id"`
Source string `json:"source"`
URL string `json:"url"`
MediaKind string `json:"mediaKind"`
FileName *string `json:"fileName"`
}
type PresentationBackground struct {
Enabled bool `json:"enabled"`
ImageDurationSeconds int `json:"imageDurationSeconds"`
Items []PresentationMedia `json:"items"`
}
type PresentationPage struct {
HeaderLabel string `json:"headerLabel"`
Eyebrow string `json:"eyebrow"`
Title string `json:"title"`
Description string `json:"description"`
PrimaryWorkspaceID *string `json:"primaryWorkspaceId"`
SecondaryWorkspaceID *string `json:"secondaryWorkspaceId"`
Background PresentationBackground `json:"background"`
}
type PresentationSettings struct {
Schema string `json:"schema"`
Revision int64 `json:"revision"`
Pages map[string]PresentationPage `json:"pages"`
}
// Presentation is local product state, separate from identity and device authority.
type PresentationStore struct {
mu sync.Mutex
dir string
uploadMu sync.Mutex
}
func NewPresentationStore(dir string) *PresentationStore { return &PresentationStore{dir: dir} }
func defaultPresentation() PresentationSettings {
primary, secondary := "sensors", "environment"
return PresentationSettings{Schema: presentationSchema, Pages: map[string]PresentationPage{"home": {
HeaderLabel: "Mission Core Node", Eyebrow: "NODEDC / MISSION CORE NODE", Title: "Mission Core Node",
Description: "Подключение устройств, запись и просмотр данных на бортовом компьютере.",
PrimaryWorkspaceID: &primary, SecondaryWorkspaceID: &secondary,
Background: PresentationBackground{ImageDurationSeconds: 10, Items: []PresentationMedia{}},
}}}
}
func (p *PresentationStore) read() (PresentationSettings, error) {
file, err := os.Open(filepath.Join(p.dir, "presentation.json"))
if errors.Is(err, os.ErrNotExist) {
return defaultPresentation(), nil
}
if err != nil {
return PresentationSettings{}, err
}
defer file.Close()
var value PresentationSettings
decoder := json.NewDecoder(io.LimitReader(file, 128*1024+1))
decoder.DisallowUnknownFields()
if err = decoder.Decode(&value); err != nil {
return value, err
}
if decoder.Decode(new(any)) != io.EOF || value.Schema != presentationSchema {
return value, errors.New("invalid presentation document")
}
return value, p.validate(&value)
}
var presentationMediaID = regexp.MustCompile(`^media-[a-z0-9-]{1,58}$`)
var presentationAssetName = regexp.MustCompile(`^[a-f0-9]{64}\.(png|jpg|gif|webp|avif|mp4|webm|mov)$`)
func cleanPresentationText(value *string, limit int, multiline bool) bool {
*value = strings.TrimSpace(*value)
return *value != "" && utf8.ValidString(*value) && utf8.RuneCountInString(*value) <= limit &&
!strings.ContainsFunc(*value, func(r rune) bool { return unicode.IsControl(r) && !(multiline && (r == '\n' || r == '\t')) })
}
func (p *PresentationStore) validate(value *PresentationSettings) error {
invalid := errors.New("Проверьте текст, быстрые переходы и медиаконтент главной страницы.")
page, ok := value.Pages["home"]
if !ok || len(value.Pages) != 1 || value.Revision < 0 || value.Revision >= 1<<53-1 ||
!cleanPresentationText(&page.HeaderLabel, 40, false) || !cleanPresentationText(&page.Eyebrow, 80, false) ||
!cleanPresentationText(&page.Title, 120, false) || !cleanPresentationText(&page.Description, 500, true) {
return invalid
}
actions := map[string]bool{"sensors": true, "environment": true, "overview": true, "network": true, "diagnostics": true, "usb": true, "tailscale": true, "ssh": true, "core": true}
for _, action := range []*string{page.PrimaryWorkspaceID, page.SecondaryWorkspaceID} {
if action != nil && !actions[*action] {
return invalid
}
}
if page.PrimaryWorkspaceID != nil && page.SecondaryWorkspaceID != nil && *page.PrimaryWorkspaceID == *page.SecondaryWorkspaceID {
return invalid
}
bg := page.Background
if bg.ImageDurationSeconds < 1 || bg.ImageDurationSeconds > 60 || bg.Items == nil || len(bg.Items) > 24 || (bg.Enabled && len(bg.Items) == 0) {
return invalid
}
ids := map[string]bool{}
for _, item := range bg.Items {
if !presentationMediaID.MatchString(item.ID) || ids[item.ID] || len(item.URL) > 2048 ||
(item.MediaKind != "image" && item.MediaKind != "video") {
return invalid
}
ids[item.ID] = true
if item.FileName != nil && !cleanPresentationText(item.FileName, 255, false) {
return invalid
}
switch item.Source {
case "url":
u, err := url.Parse(item.URL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" || u.User != nil {
return invalid
}
case "file":
name := strings.TrimPrefix(item.URL, "/api/presentation/media/")
if item.URL != "/api/presentation/media/"+name || !presentationAssetName.MatchString(name) {
return invalid
}
info, err := os.Lstat(filepath.Join(p.dir, "presentation-media", name))
if err != nil || !info.Mode().IsRegular() || mediaKindForExtension(filepath.Ext(name)) != item.MediaKind {
return invalid
}
default:
return invalid
}
}
value.Pages["home"] = page
return nil
}
func (p *PresentationStore) save(value PresentationSettings) error {
data, err := json.Marshal(value)
if err != nil {
return err
}
file, err := os.CreateTemp(p.dir, ".presentation-*")
if err != nil {
return err
}
defer os.Remove(file.Name())
if _, err = file.Write(data); err == nil {
err = file.Sync()
}
closeErr := file.Close()
if err != nil {
return err
}
if closeErr != nil {
return closeErr
}
if err = os.Rename(file.Name(), filepath.Join(p.dir, "presentation.json")); err != nil {
return err
}
dir, err := os.Open(p.dir)
if err != nil {
return err
}
defer dir.Close()
return dir.Sync()
}
func (s *Server) presentationRoutes(mux *http.ServeMux) {
p := s.Presentation
mux.HandleFunc("GET /api/presentation/settings", func(w http.ResponseWriter, r *http.Request) {
if !s.authorized(w, r) {
return
}
p.mu.Lock()
defer p.mu.Unlock()
value, err := p.read()
if err != nil {
reply(w, 500, map[string]string{"error": "Не удалось прочитать оформление главной. Сохранённые настройки не изменены."})
return
}
reply(w, 200, value)
})
mux.HandleFunc("PUT /api/presentation/settings", func(w http.ResponseWriter, r *http.Request) {
if !s.authorized(w, r) {
return
}
if r.Header.Get("Content-Type") != "application/json" {
reply(w, 415, map[string]string{"error": "Ожидался JSON"})
return
}
var value PresentationSettings
d := json.NewDecoder(http.MaxBytesReader(w, r.Body, 128*1024))
d.DisallowUnknownFields()
if d.Decode(&value) != nil || d.Decode(new(any)) != io.EOF || (value.Schema != "" && value.Schema != presentationSchema) {
reply(w, 400, map[string]string{"error": "Некорректные настройки оформления"})
return
}
p.mu.Lock()
defer p.mu.Unlock()
current, err := p.read()
if err != nil {
reply(w, 500, map[string]string{"error": "Не удалось прочитать сохранённые настройки"})
return
}
if value.Revision != current.Revision {
reply(w, 409, map[string]string{"error": "Настройки изменены в другом окне. Обновите их перед сохранением."})
return
}
if err = p.validate(&value); err != nil {
reply(w, 400, map[string]string{"error": err.Error()})
return
}
value.Schema, value.Revision = presentationSchema, current.Revision+1
if err = p.save(value); err != nil {
reply(w, 500, map[string]string{"error": "Не удалось сохранить оформление главной"})
return
}
reply(w, 200, value)
})
p.mediaRoutes(mux, s)
}
@@ -0,0 +1,182 @@
package node
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
)
const maxPresentationMediaBytes = 256 * 1024 * 1024
var presentationMediaTypes = map[string]string{
".png": "image/png", ".jpg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif",
".mp4": "video/mp4", ".webm": "video/webm", ".mov": "video/quicktime",
}
func mediaKindForExtension(extension string) string {
if strings.HasPrefix(presentationMediaTypes[extension], "image/") {
return "image"
}
if strings.HasPrefix(presentationMediaTypes[extension], "video/") {
return "video"
}
return ""
}
func matchesPresentationMedia(head []byte, extension string) bool {
typ := http.DetectContentType(head)
if typ == presentationMediaTypes[extension] {
return true
}
if len(head) >= 12 && string(head[4:8]) == "ftyp" {
brand := string(head[8:12])
switch extension {
case ".avif":
return brand == "avif" || brand == "avis"
case ".mov":
return brand == "qt "
}
}
return extension == ".webm" && bytes.HasPrefix(head, []byte{0x1a, 0x45, 0xdf, 0xa3}) && bytes.Contains(head, []byte("webm"))
}
func (p *PresentationStore) mediaRoutes(mux *http.ServeMux, s *Server) {
mux.HandleFunc("PUT /api/presentation/media/{surface}/{item}", func(w http.ResponseWriter, r *http.Request) {
if !s.authorized(w, r) {
return
}
if r.PathValue("surface") != "home" || !presentationMediaID.MatchString(r.PathValue("item")) {
reply(w, 400, map[string]string{"error": "Страница или медиаконтент недоступны"})
return
}
name, err := url.PathUnescape(r.Header.Get("X-NODEDC-File-Name"))
if err != nil || !cleanPresentationText(&name, 255, false) || strings.ContainsAny(name, `/\`) {
reply(w, 400, map[string]string{"error": "Некорректное имя файла"})
return
}
extension := strings.ToLower(filepath.Ext(name))
if extension == ".jpeg" {
extension = ".jpg"
}
if presentationMediaTypes[extension] == "" {
reply(w, 415, map[string]string{"error": "Поддерживаются MP4, WebM, MOV, PNG, JPEG, GIF, WebP и AVIF."})
return
}
if r.ContentLength > maxPresentationMediaBytes {
reply(w, 413, map[string]string{"error": "Размер файла не должен превышать 256 МБ."})
return
}
if !p.uploadMu.TryLock() {
reply(w, 409, map[string]string{"error": "Дождитесь завершения текущей загрузки файла."})
return
}
defer p.uploadMu.Unlock()
controller := http.NewResponseController(w)
_ = controller.SetReadDeadline(time.Now().Add(5 * time.Minute))
_ = controller.SetWriteDeadline(time.Now().Add(5 * time.Minute))
dir := filepath.Join(p.dir, "presentation-media")
if os.MkdirAll(dir, 0700) != nil {
reply(w, 500, map[string]string{"error": "Хранилище фонов недоступно"})
return
}
// Local media storage is bounded independently of device recordings.
entries, err := os.ReadDir(dir)
if err != nil {
reply(w, 500, map[string]string{"error": "Хранилище фонов недоступно"})
return
}
var total int64
for _, entry := range entries {
if info, e := entry.Info(); e == nil {
total += info.Size()
}
}
if total > 8*1024*1024*1024-maxPresentationMediaBytes {
reply(w, 413, map[string]string{"error": "Хранилище фонов заполнено. Используйте прямую ссылку на медиаконтент."})
return
}
file, err := os.CreateTemp(dir, ".upload-*")
if err != nil {
reply(w, 500, map[string]string{"error": "Не удалось создать файл фона"})
return
}
defer os.Remove(file.Name())
defer file.Close()
body := http.MaxBytesReader(w, r.Body, maxPresentationMediaBytes)
head := make([]byte, 512)
n, err := io.ReadFull(body, head)
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
reply(w, 400, map[string]string{"error": "Загрузка файла прервана"})
return
}
head = head[:n]
if !matchesPresentationMedia(head, extension) {
reply(w, 415, map[string]string{"error": "Содержимое файла не соответствует поддерживаемому изображению или видео."})
return
}
hash := sha256.New()
written, err := io.Copy(io.MultiWriter(file, hash), io.MultiReader(bytes.NewReader(head), body))
if err != nil {
reply(w, 413, map[string]string{"error": "Загрузка прервана или файл превышает 256 МБ."})
return
}
if err = file.Sync(); err != nil {
reply(w, 500, map[string]string{"error": "Не удалось сохранить файл фона"})
return
}
if err = file.Close(); err != nil {
reply(w, 500, map[string]string{"error": "Не удалось сохранить файл фона"})
return
}
digest := hex.EncodeToString(hash.Sum(nil))
asset := digest + extension
if err = os.Rename(file.Name(), filepath.Join(dir, asset)); err != nil {
reply(w, 500, map[string]string{"error": "Не удалось сохранить файл фона"})
return
}
directory, err := os.Open(dir)
if err != nil {
reply(w, 500, map[string]string{"error": "Не удалось подтвердить сохранение файла"})
return
}
err = directory.Sync()
directory.Close()
if err != nil {
reply(w, 500, map[string]string{"error": "Не удалось подтвердить сохранение файла"})
return
}
reply(w, 200, map[string]any{"url": "/api/presentation/media/" + asset, "fileName": name, "mediaKind": mediaKindForExtension(extension), "sha256": digest, "byteLength": written})
})
mux.HandleFunc("GET /api/presentation/media/{asset}", func(w http.ResponseWriter, r *http.Request) {
if !s.authorized(w, r) {
return
}
asset := r.PathValue("asset")
if !presentationAssetName.MatchString(asset) {
http.NotFound(w, r)
return
}
path := filepath.Join(p.dir, "presentation-media", asset)
info, err := os.Lstat(path)
if err != nil || !info.Mode().IsRegular() {
http.NotFound(w, r)
return
}
file, err := os.Open(path)
if err != nil {
http.NotFound(w, r)
return
}
defer file.Close()
_ = http.NewResponseController(w).SetWriteDeadline(time.Now().Add(5 * time.Minute))
w.Header().Set("Content-Type", presentationMediaTypes[filepath.Ext(asset)])
http.ServeContent(w, r, asset, info.ModTime(), file)
})
}
@@ -0,0 +1,238 @@
package node
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"image"
"image/png"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
)
func presentationServer(t *testing.T) (*Server, *http.Cookie) {
t.Helper()
s := newTestServer(t)
s.Presentation = NewPresentationStore(t.TempDir())
return s, login(t, s)
}
func presentationJSON(t *testing.T, value PresentationSettings) string {
t.Helper()
b, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
return string(b)
}
func TestPresentationPersistsAndConcurrentWindowsConflict(t *testing.T) {
s, cookie := presentationServer(t)
value := defaultPresentation()
page := value.Pages["home"]
page.Title = " Главная борта "
value.Pages["home"] = page
body := presentationJSON(t, value)
codes := make(chan int, 2)
var wg sync.WaitGroup
for range 2 {
wg.Add(1)
go func() { defer wg.Done(); codes <- call(s, "PUT", "/api/presentation/settings", body, cookie).Code }()
}
wg.Wait()
close(codes)
counts := map[int]int{}
for code := range codes {
counts[code]++
}
if counts[200] != 1 || counts[409] != 1 {
t.Fatal(counts)
}
reopened := NewPresentationStore(s.Presentation.dir)
got, err := reopened.read()
if err != nil || got.Revision != 1 || got.Pages["home"].Title != "Главная борта" {
t.Fatal(got, err)
}
info, err := os.Stat(filepath.Join(reopened.dir, "presentation.json"))
if err != nil || info.Mode().Perm() != 0600 {
t.Fatal(info, err)
}
files, _ := os.ReadDir(reopened.dir)
if len(files) != 1 {
t.Fatal("temporary files survived", files)
}
}
func TestPresentationRejectsInvalidChangesWithoutReplacingSavedState(t *testing.T) {
s, cookie := presentationServer(t)
initial := defaultPresentation()
if w := call(s, "PUT", "/api/presentation/settings", presentationJSON(t, initial), cookie); w.Code != 200 {
t.Fatal(w.Body.String())
}
path := filepath.Join(s.Presentation.dir, "presentation.json")
before, _ := os.ReadFile(path)
for name, mutate := range map[string]func(*PresentationSettings){
"extra page": func(v *PresentationSettings) { v.Pages["system"] = v.Pages["home"] },
"blank title": func(v *PresentationSettings) { p := v.Pages["home"]; p.Title = " "; v.Pages["home"] = p },
"unknown action": func(v *PresentationSettings) {
p := v.Pages["home"]
action := "start-device"
p.PrimaryWorkspaceID = &action
v.Pages["home"] = p
},
"duplicate action": func(v *PresentationSettings) {
p := v.Pages["home"]
p.SecondaryWorkspaceID = p.PrimaryWorkspaceID
v.Pages["home"] = p
},
"script URL": func(v *PresentationSettings) {
p := v.Pages["home"]
p.Background.Items = []PresentationMedia{{ID: "media-test", Source: "url", URL: "javascript:alert(1)", MediaKind: "image"}}
v.Pages["home"] = p
},
"foreign local file": func(v *PresentationSettings) {
p := v.Pages["home"]
p.Background.Items = []PresentationMedia{{ID: "media-test", Source: "file", URL: "/etc/passwd", MediaKind: "image"}}
v.Pages["home"] = p
},
} {
t.Run(name, func(t *testing.T) {
value := defaultPresentation()
value.Revision = 1
mutate(&value)
if w := call(s, "PUT", "/api/presentation/settings", presentationJSON(t, value), cookie); w.Code != 400 {
t.Fatal(w.Code, w.Body.String())
}
after, _ := os.ReadFile(path)
if !bytes.Equal(before, after) {
t.Fatal("saved state changed")
}
})
}
corrupt := []byte(`{"schema":"broken"}`)
if err := os.WriteFile(path, corrupt, 0600); err != nil {
t.Fatal(err)
}
if w := call(s, "GET", "/api/presentation/settings", "", cookie); w.Code != 500 {
t.Fatal(w.Code)
}
if w := call(s, "PUT", "/api/presentation/settings", presentationJSON(t, initial), cookie); w.Code != 500 {
t.Fatal(w.Code)
}
after, _ := os.ReadFile(path)
if !bytes.Equal(corrupt, after) {
t.Fatal("corrupt evidence was replaced")
}
}
func presentationUpload(s *Server, cookie *http.Cookie, name string, data []byte) *httptest.ResponseRecorder {
r := httptest.NewRequest("PUT", s.Origin+"/api/presentation/media/home/media-test", bytes.NewReader(data))
r.Header.Set("Origin", s.Origin)
r.Header.Set("X-NODEDC-File-Name", name)
r.Header.Set("Content-Type", "application/octet-stream")
if cookie != nil {
r.AddCookie(cookie)
}
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, r)
return w
}
func TestPresentationUploadSurvivesReopenAndSupportsRange(t *testing.T) {
s, cookie := presentationServer(t)
var data bytes.Buffer
if err := png.Encode(&data, image.NewRGBA(image.Rect(0, 0, 2, 2))); err != nil {
t.Fatal(err)
}
w := presentationUpload(s, cookie, "background.png", data.Bytes())
if w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
var asset struct {
URL, FileName, MediaKind string
ByteLength int64
SHA256 string
}
if err := json.Unmarshal(w.Body.Bytes(), &asset); err != nil {
t.Fatal(err)
}
digest := sha256.Sum256(data.Bytes())
if asset.SHA256 != hex.EncodeToString(digest[:]) || asset.ByteLength != int64(data.Len()) || asset.MediaKind != "image" {
t.Fatal(asset)
}
value := defaultPresentation()
page := value.Pages["home"]
page.Background.Enabled = true
page.Background.Items = []PresentationMedia{{ID: "media-test", Source: "file", URL: asset.URL, FileName: &asset.FileName, MediaKind: asset.MediaKind}}
value.Pages["home"] = page
if w := call(s, "PUT", "/api/presentation/settings", presentationJSON(t, value), cookie); w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
s.Presentation = NewPresentationStore(s.Presentation.dir)
if got, err := s.Presentation.read(); err != nil || got.Pages["home"].Background.Items[0].URL != asset.URL {
t.Fatal(got, err)
}
r := httptest.NewRequest("GET", s.Origin+asset.URL, nil)
r.AddCookie(cookie)
r.Header.Set("Range", "bytes=0-7")
w = httptest.NewRecorder()
s.Handler().ServeHTTP(w, r)
if w.Code != 206 || !bytes.Equal(w.Body.Bytes(), data.Bytes()[:8]) || w.Header().Get("Content-Type") != "image/png" {
t.Fatal(w.Code, w.Header(), w.Body.String())
}
if w := call(s, "GET", asset.URL, "", nil); w.Code != 401 {
t.Fatal("anonymous media access", w.Code)
}
if w := presentationUpload(s, cookie, "fake.png", []byte("<html>not an image</html>")); w.Code != 415 {
t.Fatal(w.Code)
}
files, _ := os.ReadDir(filepath.Join(s.Presentation.dir, "presentation-media"))
if len(files) != 1 {
t.Fatal("rejected upload left files", files)
}
}
func TestPresentationRequiresSessionAndSameOrigin(t *testing.T) {
s, cookie := presentationServer(t)
for _, method := range []string{"GET", "PUT"} {
if w := call(s, method, "/api/presentation/settings", presentationJSON(t, defaultPresentation()), nil); w.Code != 401 {
t.Fatal(method, w.Code)
}
}
if w := presentationUpload(s, nil, "x.png", nil); w.Code != 401 {
t.Fatal(w.Code)
}
r := httptest.NewRequest("PUT", s.Origin+"/api/presentation/settings", strings.NewReader(presentationJSON(t, defaultPresentation())))
r.AddCookie(cookie)
r.Header.Set("Origin", "https://unrelated.invalid")
r.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, r)
if w.Code != 403 {
t.Fatal(w.Code)
}
}
func TestLocalViewerCSPAdmitsOnlyItsOwnRuntimeFrame(t *testing.T) {
s := newTestServer(t)
for _, path := range []string{"/", "/rerun-runtime.html", "/assets/runtime.js"} {
w := call(s, "GET", path, "", nil)
csp := w.Header().Get("Content-Security-Policy")
if path == "/rerun-runtime.html" {
if !strings.Contains(csp, "frame-ancestors 'self'") || !strings.Contains(csp, "'wasm-unsafe-eval'") {
t.Fatal(path, csp)
}
} else if !strings.Contains(csp, "frame-ancestors 'none'") || strings.Contains(csp, "'wasm-unsafe-eval'") {
t.Fatal(path, csp)
}
if strings.Contains(csp, "'unsafe-eval'") || strings.Contains(csp, "script-src *") {
t.Fatal(csp)
}
}
}
+9 -1
View File
@@ -24,6 +24,7 @@ type Server struct {
Access *AccessStore
Tailscale func() TailscaleStatus
Environment func() EnvironmentStatus
Presentation *PresentationStore
mu sync.Mutex
logins map[string]time.Time
sessions map[string]time.Time
@@ -80,6 +81,9 @@ func reply(w http.ResponseWriter, status int, v any) {
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
if s.Presentation != nil {
s.presentationRoutes(mux)
}
if s.Sensors != nil {
s.Sensors.Routes(mux, s)
}
@@ -165,7 +169,11 @@ func (s *Server) Handler() http.Handler {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
ancestor, scripts := "'none'", "'self'"
if r.URL.Path == "/rerun-runtime.html" {
ancestor, scripts = "'self'", "'self' 'wasm-unsafe-eval'"
}
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src "+scripts+"; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; media-src 'self' blob: https: http:; connect-src 'self'; frame-src 'self'; worker-src 'self' blob:; frame-ancestors "+ancestor+"; base-uri 'none'; form-action 'self'")
if "http://"+r.Host != s.Origin {
http.Error(w, "Invalid host", http.StatusForbidden)
return
+3 -1
View File
@@ -11,13 +11,15 @@ import sys
from build_deb import build, VERSION, BRAND_SHA256
ROOT = Path(__file__).resolve().parents[1]
DG_COMMIT = "26a1bf72a2a32b002e51f910e8faa300333bb6c3"
DG_COMMIT = "b10fd5d645ddfb8c373ae6105efa0850aef2509c"
def guideline_sources():
dg = ROOT.parents[2] / "NODEDC_DESIGN_GUIDELINE"
paths = list((dg / "packages/ui-react/src").glob("*"))
paths += list((dg / "packages/ui-react/dist").glob("*"))
paths += list((dg / "packages/ui-core/src").glob("*"))
paths += list((dg / "packages/ui-core/dist").glob("*"))
paths += [dg / "packages/ui-core/styles.css", dg / "packages/tokens/tokens.css", dg / "packages/tokens/themes.css"]
return {str(p.relative_to(dg)): hashlib.sha256(p.read_bytes()).hexdigest()
for p in sorted(paths) if p.is_file()}
+1 -1
View File
@@ -11,7 +11,7 @@ import sys
ROOT = Path(__file__).resolve().parents[1]
VERSION = "0.8.13"
VERSION = "0.8.14"
sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging"))
from debian import package
+22
View File
@@ -0,0 +1,22 @@
import { EnvironmentSettingsWindow, LandingStage } from "@nodedc/ui-react";
import type { EnvironmentPage } from "@nodedc/ui-core";
import { views, type ViewId } from "./nodeModel";
import type { usePresentation } from "./usePresentation";
const surfaces = [{ id: "home", home: true, description: "Главная страница продукта", actions: views }];
export function Home({ page, openView }: { page: EnvironmentPage; openView: (id: ViewId) => void }) {
const actions = [page.primaryWorkspaceId, page.secondaryWorkspaceId].flatMap(id => {
const view = views.find(item => item.id === id);
return view ? [{ id: view.id, label: view.label, icon: view.icon, onSelect: () => openView(view.id) }] : [];
});
return <LandingStage page={page} actions={actions} />;
}
export function HomeSettings({ open, onClose, presentation }: {
open: boolean; onClose: () => void; presentation: ReturnType<typeof usePresentation>;
}) {
return <EnvironmentSettingsWindow productName="Mission Core Node" open={open} onClose={onClose}
surfaces={surfaces} settings={presentation.settings} state={presentation.state} error={presentation.error}
onSave={presentation.save} onUpload={presentation.upload} />;
}
+14 -9
View File
@@ -16,19 +16,23 @@ import { EnvironmentView } from "./EnvironmentView";
import { CoreConnectionView } from "./CoreConnectionView";
import "./node.css";
import { NodeSensors } from "./NodeSensors";
import { Home, HomeSettings } from "./Home";
import { usePresentation } from "./usePresentation";
function App() {
const node = useNode();
const { value, pending, locked, refresh, failure } = node;
const environment = useEnvironment(!!value, failure);
const presentation = usePresentation(!!value);
const [settingsOpen, setSettingsOpen] = useState(false);
const refreshAll = () => {if(!environment.running) {void refresh();void environment.refresh();}};
const [root, setRoot] = useState<RootId>("system");
const workspace = useApplicationWorkspace<ViewId>({ activeView: "environment" });
const [root, setRoot] = useState<RootId | null>(null);
const workspace = useApplicationWorkspace<ViewId>();
const [adding, setAdding] = useState(false);
const [theme, setTheme] = useState(() => localStorage.getItem("node-theme") === "light" ? "light" : "dark");
// Theme applies to body portals as well as the application shell.
useEffect(() => { document.documentElement.dataset.nodedcTheme = theme; }, [theme]);
const currentRoot = roots.find(item => item.id === root)!;
const currentRoot = roots.find(item => item.id === root) ?? roots[0];
const currentView = views.find(item => item.id === workspace.activeView);
function openView(id: ViewId) { if(environment.running) return; setAdding(false); setRoot(views.find(item => item.id === id)!.root); workspace.openView(id); }
function selectRoot(id: RootId) { const first = roots.find(item => item.id === id)!.first; if (first) openView(first); }
@@ -41,22 +45,23 @@ function App() {
: workspace.activeView === "tailscale" ? <TailnetAccess failure={failure} revision={value.host.collected_at} />
: workspace.activeView === "ssh" ? <SystemAccess revision={value.host.collected_at} failure={failure} success={node.success} adding={adding} closeAdd={() => setAdding(false)} /> : null;
return <>
<ApplicationShell data-nodedc-ui className="node-app" header={<AppHeader brandMonochrome brand={<img src="/nodedc-logo.svg" alt="NODE.DC" />} brandLabel="Mission Core Node"
center={<HeaderNavigation label="Разделы бортового компьютера" value={root} items={roots.map(item => ({ value: item.id, label: item.label, disabled: !value || !item.first || environment.running }))} onChange={selectRoot} />}
right={<HeaderProfile><UserProfileMenu displayName="Node" subtitle={value?.name ?? "Mission Core Node"} triggerLabel={null} actions={[{ id: "refresh", label: "Обновить сведения", icon: "refresh", onSelect: refreshAll }, { id: "theme", label: theme === "dark" ? "Светлая тема" : "Тёмная тема", icon: "eye", onSelect: () => { const next = theme === "dark" ? "light" : "dark"; setTheme(next); localStorage.setItem("node-theme", next); } }]} /></HeaderProfile>} />}
<ApplicationShell data-nodedc-ui className="node-app" header={<AppHeader brandMonochrome brandHref="/" brand={<img src="/nodedc-logo.svg" alt="NODE.DC" />} brandLabel={presentation.settings.pages.home.headerLabel}
center={<HeaderNavigation label="Разделы бортового компьютера" value={root ?? undefined} items={roots.map(item => ({ value: item.id, label: item.label, disabled: !value || !item.first || environment.running }))} onChange={selectRoot} />}
right={<HeaderProfile><UserProfileMenu displayName="Node" subtitle={value?.name ?? "Mission Core Node"} triggerLabel={null} actions={[{ id: "settings", label: "Настройки", icon: "settings", onSelect: () => { if (value) { void presentation.refresh(); setSettingsOpen(true); } } }, { id: "refresh", label: "Обновить сведения", icon: "refresh", onSelect: refreshAll }, { id: "theme", label: theme === "dark" ? "Светлая тема" : "Тёмная тема", icon: "eye", onSelect: () => { const next = theme === "dark" ? "light" : "dark"; setTheme(next); localStorage.setItem("node-theme", next); } }]} /></HeaderProfile>} />}
navigationOpen={!!value && workspace.navigationOpen} contentOpen={!!value && workspace.contentOpen} contentExpanded={workspace.contentExpanded}
navigation={<AdminNavigationPanel eyebrow="MISSION CORE NODE" title={currentRoot.label} onClose={workspace.closeNavigation} closeLabel="Закрыть навигацию" navigationLabel="Разделы выбранной вкладки"
contexts={value ? [{ id: "board", label: value.name, description: value.host.hostname, icon: <Icon name="activity" />, onSelect: () => openView("overview") }] : []}
items={views.filter(item => item.root === root).map(item => ({ id: item.id, label: item.label, icon: <Icon name={item.icon} /> }))} activeId={workspace.activeView ?? undefined} onItemChange={id => openView(id as ViewId)} footer={<span>Mission Core Node · {value?.version}</span>} />}
content={currentView && <ApplicationPanel title={currentView.label} eyebrow={currentRoot.label} expanded={workspace.contentExpanded} onExpandedChange={workspace.setContentExpanded} onClose={workspace.closeView}
utilityActions={[...(workspace.activeView === "ssh" ? [{ label: "Добавить доверенное устройство", icon: "plus" as const, onClick: () => setAdding(true) }] : []), { label: "Обновить сведения", icon: "refresh", disabled: pending || environment.running, onClick: refreshAll }]}>{content}</ApplicationPanel>}
stage={<div className="node-stage" aria-busy={pending}>
{value ? <SettingsCard title={value.name} eyebrow="MISSION CORE NODE" description={`Бортовой компьютер · ${value.host.architecture}`}><div className="node-home-actions">{roots.map(item => <Button key={item.id} disabled={!item.first} onClick={() => selectRoot(item.id)}>{item.label}</Button>)}</div></SettingsCard> : <SettingsCard className="node-entry" title={pending ? "Подключаемся к ноде" : locked ? "Вход в Mission Core Node" : "Нода недоступна"}>
stage={value ? <Home page={presentation.settings.pages.home} openView={openView} /> : <div className="node-stage" aria-busy={pending}>
<SettingsCard className="node-entry" title={pending ? "Подключаемся к ноде" : locked ? "Вход в Mission Core Node" : "Нода недоступна"}>
{pending ? <ActivityIndicator label="Получение сведений о ноде" /> : <p className="node-note">{locked ? "Подтвердите доступ в системном окне." : "Не удалось связаться со службой. Повторите подключение."}</p>}
<Button disabled={pending} onClick={() => { if (locked) { if (!desktopLogin()) failure(new Error("Откройте установленное приложение Mission Core Node из меню приложений.")); } else void refresh(); }}>{locked ? "Войти" : "Повторить подключение"}</Button>
</SettingsCard>}
</SettingsCard>
</div>} />
<ToastStack items={node.toasts} onDismiss={node.dismiss} />
<HomeSettings open={!!value && settingsOpen} onClose={() => setSettingsOpen(false)} presentation={presentation} />
</>;
}
createRoot(document.getElementById("root")!).render(<App />);
-1
View File
@@ -11,4 +11,3 @@ body { margin: 0; background: var(--nodedc-canvas); color: var(--nodedc-text-pri
.node-form > button { justify-self: start; }
.node-note { margin: 0; color: var(--nodedc-text-secondary); font-size: var(--nodedc-font-size-sm); line-height: 1.6; overflow-wrap: anywhere; }
.node-entry { max-width: 640px; margin: var(--nodedc-space-8) auto; }
.node-home-actions { display: flex; flex-wrap: wrap; gap: var(--nodedc-space-3); }
+57
View File
@@ -0,0 +1,57 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { EnvironmentSettings, UploadedEnvironmentMedia } from "@nodedc/ui-core";
import { APIError, request } from "./api";
export function defaultPresentation(): EnvironmentSettings {
return { revision: 0, pages: { home: {
headerLabel: "Mission Core Node", eyebrow: "NODEDC / MISSION CORE NODE", title: "Mission Core Node",
description: "Подключение устройств, запись и просмотр данных на бортовом компьютере.",
primaryWorkspaceId: "sensors", secondaryWorkspaceId: "environment",
background: { enabled: false, imageDurationSeconds: 10, items: [] },
} } };
}
export function usePresentation(authorized: boolean) {
const [settings, setSettings] = useState(defaultPresentation);
const [state, setState] = useState<"loading" | "ready" | "saving" | "error">("loading");
const [error, setError] = useState<string | null>(null);
const epoch = useRef(0);
const refresh = useCallback(async () => {
if (!authorized) return;
const generation = ++epoch.current;
setState("loading");
try {
const next = await request<EnvironmentSettings & { schema: string }>("/api/presentation/settings");
if (generation !== epoch.current) return;
if (next.schema !== "missioncore.node.presentation/v1" || !next.pages.home || Object.keys(next.pages).length !== 1) throw new Error("Версия оформления главной не поддерживается.");
setSettings(next); setError(null); setState("ready");
} catch (reason) {
if (generation !== epoch.current) return;
setError(reason instanceof Error ? reason.message : "Не удалось загрузить оформление главной."); setState("error");
}
}, [authorized]);
useEffect(() => { void refresh(); return () => { epoch.current += 1; }; }, [refresh]);
const save = useCallback(async (draft: EnvironmentSettings) => {
setState("saving"); setError(null);
try {
const next = await request<EnvironmentSettings>("/api/presentation/settings", "PUT", draft);
setSettings(next); setState("ready"); return next;
} catch (reason) {
const message = reason instanceof Error ? reason.message : "Не удалось сохранить оформление главной.";
setError(message); setState("error"); throw new Error(message);
}
}, []);
const upload = useCallback(async (surfaceId: string, itemId: string, file: File): Promise<UploadedEnvironmentMedia> => {
if (surfaceId !== "home") throw new Error("Оформление доступно только для главной страницы.");
const response = await fetch(`/api/presentation/media/home/${encodeURIComponent(itemId)}`, {
method: "PUT", credentials: "same-origin", signal: AbortSignal.timeout(300000),
headers: { "Content-Type": file.type || "application/octet-stream", "X-NODEDC-File-Name": encodeURIComponent(file.name) }, body: file,
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new APIError(body.error ?? "Не удалось загрузить фон главной страницы.", response.status);
}
return response.json();
}, []);
return { settings, state, error, refresh, save, upload };
}
@@ -0,0 +1,94 @@
# Node home and local K1 interface — R17
## Approved product surface
The owner requests the existing Mission Core home and avatar → Settings →
Environment composition on the onboard application, with the same reusable
components. They explicitly approve moving any missing reusable settings and
background components into the Design Guideline. Only Home has configurable
landing content on Node; System and Devices keep their existing workspaces.
Logo navigation returns to Home. Text, uploaded/URL image/video playlist, order
and image duration are editable. Remove the decorative fallback radial gradient.
Operator job: personalize the onboard home, then enter existing system/device
workflows. Home owns presentation, never acquisition or physical authority.
The existing ApplicationShell stage is the selected placement. A separate
dashboard/root or reimplemented Node settings form would duplicate the approved
job and composition, so neither is selected. Avatar UserProfileMenu already
exists in DG; the reusable EnvironmentSettingsWindow, playlist, background and
LandingStage are extracted there from the existing implementation. Core and
Node supply product pages/actions, storage and permissions through adapters.
Initial state: Home without media. States: loading, ready, local draft, saving,
uploading, saved, validation/storage conflict/error, media unavailable and
unauthorized Node. Settings use FeatureSettingsWindow, SettingsCard, Field,
Select, Switch, SortableList, MediaSourceField, RangeControl and canonical
Button/WindowFooterActions. No new product root or device command is introduced.
## Local K1 findings
Both hosts already consume SensorWorkspace and xgridsK1SensorUi. NodeSensors
adapts authenticated local enrollment/inventory/operation routes; remote Fleet
adapts the same broker/plugin operations. Shared K1 fields, validation, connection
proof and physical command guards remain the same. Local UI reuse alone is not
physical acceptance on the onboard webview.
The primary button inherited the DG theme's default pink accent on Node, whereas
Core overrode primary tokens locally. Shared Button now admits a neutral primary
tone, with white enabled and gray disabled states independent of accent/theme;
the shared K1 start/configuration actions select it.
The local server's CSP also denied frame ancestry for its own Rerun runtime,
WebAssembly compilation and blob video. These are presentation transport gaps,
not a missing second K1 controller. Fixes and tests must preserve same-origin
authentication/control and allow only the existing viewer/runtime capabilities.
## Acceptance
Required: same settings component rendered for Core and Node, only home in Node
page selector, text/media persist across reopening, playlist order/timing,
invalid/stale saves rejected, logo returns home, neutral start states across
themes, local/remote K1 broker parity and unchanged one-shot guards. Synthetic,
browser and owner physical outcomes are recorded separately when obtained.
No physical fault injection, automatic provisioning or START/STOP is authorized
by the visual refactor. The R16 Bridge laboratory checkpoint remains bounded as
recorded in the previous audit.
## Source and validation — 2026-09-07
Design Guideline: `b10fd5d645ddfb8c373ae6105efa0850aef2509c`. Node candidate: 0.8.14;
K1 plugin remains 0.1.13. Builder provenance now includes ui-core source and
compiled shared environment model as well as ui-react and styles.
- Shared DG packages compiled; catalog production build and registry validation
passed (34 components, 48 icons). Three shared media/model/DOM tests passed.
- Core architecture: 4 tests; complete frontend: 809/809; TypeScript and production
build passed. Existing operator-environment v3 API/storage is unchanged.
- Node TypeScript and UI boundary check passed; all Go packages passed. Six new
Go test functions cover persistent trimmed home copy, simultaneous revision
conflict, rejection without overwriting, corrupt-state preservation, validated
PNG upload/hash/reopen/HTTP Range, session/Origin restrictions and scoped CSP.
- Package lifecycle/installer regressions: 14 passed. No physical BLE, network
provisioning, START or STOP was dispatched during this task.
- In-app browser at the canonical 8000 confirms shared Home renders without the
fallback radial gradient. Detailed settings interactions and the Ubuntu
webview require acceptance after installation; do not infer them from builds.
- Core 8000 remains the original durable PID 88424; no 8765 listener and no
temporary test/build/server processes. Only the Docker networking helper is
present; Docker Desktop was not started.
Local and Bridge commands enter the same DeviceEnrollment.Submit and
Sensors.Submit; the boolean distinguishes local vs signed remote authority.
Both use the same SensorWorkspace and xgridsK1SensorUi, including project name,
mount/GNSS settings, one-shot controls and shared scene. The existing SSE path
refreshes its write deadline per delivery. The R17 change admits same-origin
Rerun iframe/WASM/blob media in the local server CSP; it does not change K1
protocol state or the R16 laboratory acceptance boundary.
Local presentation is authenticated, CAS-versioned and atomically persisted
beside Node product state. Uploaded media is content-checked, hashed, private,
limited to 256 MiB/file and 8 GiB total. External images/video are browser-loaded
HTTP(S) URLs. Media cleanup beyond rejected-upload temporary files is not added;
recordings, identity, pairing and the installed credential are not part of this
store. No marketing-image fallback is supplied.
@@ -117,7 +117,7 @@ export function DeviceEnrollmentWindow({transport,onChange,onComplete,renderWind
}
return renderWindow({busy:pending,
actions:confirmed?<Button variant="primary" width="full" disabled={pending}
actions:confirmed?<Button variant="primary" tone="neutral" width="full" disabled={pending}
aria-busy={busy==='opening'} icon={busy==='opening'?<ActivityIndicator size="compact"/>:undefined}
onClick={()=>void configure()}>Настроить устройство</Button>:undefined,
content:<div className="sensor-content" ref={content}>
@@ -48,7 +48,7 @@ export function K1Detail({device,transport,enabled,back,refresh,failure,createRe
actions={manual.connected&&<IconButton label="Настройки устройства" disabled={!!pending} onClick={()=>setTool('display')}><Icon name="settings"/></IconButton>}>
{manual.connected&&!manual.showStop&&<K1AcquisitionFields mountType={mountType} setMountType={setMountType} gnssMode={gnssMode} setGnssMode={setGnssMode}
projectName={projectName} setProjectName={setProjectName} projectNameTouched={projectNameTouched} setProjectNameTouched={setProjectNameTouched} disabled={!!pending}/>}
{manual.connected&&(!manual.showStop||pending==='start')&&<Button variant="primary" disabled={!!pending||!manual.canStart||!!project.error} aria-busy={pending==='start'}
{manual.connected&&(!manual.showStop||pending==='start')&&<Button variant="primary" tone="neutral" disabled={!!pending||!manual.canStart||!!project.error} aria-busy={pending==='start'}
icon={pending==='start'?<ActivityIndicator size="compact"/>:undefined} onClick={()=>void act('start')}>{pending==='start'?'Запускаем устройство':'Инициировать запуск'}</Button>}
{manual.showStop&&pending!=='start'&&<Button disabled={!manual.connected||!!pending||(!streaming&&!device.control?.can_stop)} aria-busy={pending==='stop'}
icon={pending==='stop'?<ActivityIndicator size="compact"/>:undefined} onClick={()=>void act('stop')}>{pending==='stop'?'Останавливаем устройство':'Остановить устройство'}</Button>}