Reuse canonical home and settings on Node and admit local K1 viewer
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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 />);
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user