Compare commits
66
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be58d589e2 | ||
|
|
5f75afb720 | ||
|
|
99f29218d7 | ||
|
|
db14ee5435 | ||
|
|
81dd44906d | ||
|
|
d45b329de8 | ||
|
|
f164606828 | ||
|
|
556e376a62 | ||
|
|
1a2de8df65 | ||
|
|
b536f0049a | ||
|
|
3b85299fd9 | ||
|
|
505844adc3 | ||
|
|
89b0eeca3f | ||
|
|
1488a46794 | ||
|
|
e4339f4e88 | ||
|
|
7e257275ab | ||
|
|
a3c15e11e9 | ||
|
|
54a85fdf50 | ||
|
|
14f8375713 | ||
|
|
6492ebcdee | ||
|
|
e57511f975 | ||
|
|
f0802d2713 | ||
|
|
d8afb61229 | ||
|
|
421a8c515a | ||
|
|
a23c5b2005 | ||
|
|
1832943558 | ||
|
|
cc90709919 | ||
|
|
ee5d760c30 | ||
|
|
0e9faa6824 | ||
|
|
c6693d6f44 | ||
|
|
ed9a77ba73 | ||
|
|
7dd2c1c836 | ||
|
|
762d77ef95 | ||
|
|
9c4d70b0e9 | ||
|
|
eed53dbc0b | ||
|
|
4f333fc11c | ||
|
|
35a517f921 | ||
|
|
9bba44f7c4 | ||
|
|
9b6534287a | ||
|
|
9e03404f23 | ||
|
|
fa8ac760a1 | ||
|
|
309bdf759e | ||
|
|
ff00851216 | ||
|
|
ad24518b71 | ||
|
|
7ce09eabcd | ||
|
|
92b60de945 | ||
|
|
67398fef10 | ||
|
|
a93f1f1b8b | ||
|
|
102e93796b | ||
|
|
4f715e41d2 | ||
|
|
8f17a7e282 | ||
|
|
7187fd86bd | ||
|
|
e6521e855a | ||
|
|
06d216ac4a | ||
|
|
d7986cb62f | ||
|
|
a0732386f1 | ||
|
|
5c4546a2a6 | ||
|
|
ef1c1f50de | ||
|
|
2f80507bc0 | ||
|
|
d2dbfc9c85 | ||
|
|
3dffe39c89 | ||
|
|
77acb377e2 | ||
|
|
2fdf1d3cc4 | ||
|
|
d8cc5367c4 | ||
|
|
111fe3dcfb | ||
|
|
63ea2bed67 |
@@ -62,6 +62,40 @@ and the boundary between Mission Core and vendor-specific integration code.
|
||||
- Synthetic or explicitly redacted fixtures may be committed under
|
||||
`tests/fixtures/`.
|
||||
|
||||
## Insta360 and clean-host installation — owner requirement, 2026-09-08
|
||||
|
||||
- The current X4 starting point is USB enumeration only. SDK installation,
|
||||
camera initialization and live video have not been accepted.
|
||||
- Every Ubuntu change needed for Insta360 must execute through the shipped
|
||||
installer or the application's versioned device-preparation workflow from
|
||||
the first board experiment. Do not repair the board with an ad-hoc apt/pip
|
||||
install, copied library, chmod, environment override, service edit or root
|
||||
invocation and promise to package it later. Fix the product artifact first,
|
||||
then rerun that artifact. Read-only engineering inspection remains allowed.
|
||||
- Document every relevant action and its before/after evidence in
|
||||
`docs/node/07_INSTA360_X4_INSTALLATION_LEDGER.md`; record the artifact version,
|
||||
owning installer step, dependencies, idempotency, failure behavior and rollback.
|
||||
Keep private logs/identifiers/media out of Git and redact their public summary.
|
||||
- A working prepared Mini is not clean-Ubuntu acceptance. Qualify dependency
|
||||
closure on the declared clean OS image and actual USB operation separately;
|
||||
installer, preparation and GUI acceptance must use the same shipped code.
|
||||
- X4 is operator live video and camera control (settings, photo, recording
|
||||
start/stop and file access), per the owner’s subsequent scope expansion.
|
||||
Preview and recording are separate operations. No stitching, AI, navigation
|
||||
or vehicle-control integration is admitted. Model support and physical device instances are separate: several
|
||||
cameras of the same model must retain independent identity and lifecycle.
|
||||
- Follow the scoped implementation plan in
|
||||
`docs/node/06_INSTA360_X4_AND_MULTI_CAMERA_PLAN.md`. It is a proposed plan,
|
||||
not evidence that SDK compatibility, hardware capacity or installation passed.
|
||||
- Subsequent owner direction: the existing Ubuntu Mini is the available build
|
||||
and qualification machine; do not depend on Worker 006 or another host for
|
||||
this X4 work. Its existing GCC may compile through a versioned build artifact
|
||||
in bounded temporary staging. This is a build step, not runtime installation:
|
||||
no system changes, SDK execution or camera access during compilation. The
|
||||
final installer must carry the compiled payload so a clean operator Ubuntu
|
||||
does not inherit an undeclared compiler/build-cache prerequisite. All board
|
||||
staging/build/install/test actions remain documented and artifact-owned.
|
||||
|
||||
## Implementation order
|
||||
|
||||
Follow the gates in `docs/01_IMPLEMENTATION_PLAN.md`. Do not build heavy
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import {SceneDisplayControls} from "../../../packages/spatial-ui/src/SceneDisplayControls";
|
||||
import {SceneLayerControls} from "../../../packages/spatial-ui/src/SceneLayerControls";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
AdminNavigationPanel,
|
||||
@@ -5,15 +7,11 @@ import {
|
||||
ApplicationPanel,
|
||||
ApplicationShell,
|
||||
Button,
|
||||
Checker,
|
||||
ColorField,
|
||||
ControlRow,
|
||||
HeaderNavigation,
|
||||
HeaderProfile,
|
||||
Icon,
|
||||
Inspector,
|
||||
RangeControl,
|
||||
Select,
|
||||
StatusBadge,
|
||||
TextField,
|
||||
UserProfileMenu,
|
||||
@@ -66,8 +64,6 @@ import {
|
||||
import { backendLabel, localConnectionPhaseLabel, phaseTone } from "./presentation";
|
||||
import {
|
||||
defaultSceneSettings,
|
||||
type PointColorMode,
|
||||
type PointPalette,
|
||||
type SceneSettings,
|
||||
} from "./sceneSettings";
|
||||
import { DeviceWorkspace } from "./workspaces/DeviceWorkspace";
|
||||
@@ -77,22 +73,6 @@ import "./styles/scene-windows.css";
|
||||
type SceneToolWindowId = "sources" | "display" | "layers";
|
||||
const viewerSettingsQuietPeriodMs = 750;
|
||||
|
||||
const colorModeOptions: Array<{ value: PointColorMode; label: string; description: string }> = [
|
||||
{ value: "intensity", label: "Интенсивность", description: "Значение отражённого сигнала" },
|
||||
{ value: "height", label: "Высота Z", description: "Градиент по вертикальной координате" },
|
||||
{ value: "distance", label: "Расстояние", description: "Дальность точки от сенсора" },
|
||||
{ value: "rgb", label: "RGB", description: "Цвет, если он присутствует в данных" },
|
||||
{ value: "class", label: "Класс", description: "Категория внешнего модуля восприятия" },
|
||||
];
|
||||
|
||||
const paletteOptions: Array<{ value: PointPalette; label: string; description: string }> = [
|
||||
{ value: "turbo", label: "Turbo", description: "Контрастный спектральный градиент" },
|
||||
{ value: "viridis", label: "Viridis", description: "Равномерный перцептивный градиент" },
|
||||
{ value: "plasma", label: "Plasma", description: "Тёплый контрастный градиент" },
|
||||
{ value: "grayscale", label: "Серый", description: "Монохромное отображение" },
|
||||
{ value: "custom", label: "Свой цвет", description: "Один назначенный цвет" },
|
||||
];
|
||||
|
||||
interface LivePerceptionLayers {
|
||||
detections2d: boolean;
|
||||
segmentation: boolean;
|
||||
@@ -1029,132 +1009,8 @@ export default function App() {
|
||||
onPointerDown={() => activateSceneWindow("display")}
|
||||
onClose={() => closeSceneWindow("display")}
|
||||
>
|
||||
<Inspector
|
||||
defaultOpen={["points"]}
|
||||
singleOpen
|
||||
sections={[
|
||||
{
|
||||
id: "points",
|
||||
label: "Облако точек",
|
||||
description: "Размер и способ окрашивания",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<div
|
||||
className="scene-settings-commit-field"
|
||||
onPointerUp={flushDisplaySettings}
|
||||
onKeyUp={flushDisplaySettings}
|
||||
onBlur={flushDisplaySettings}
|
||||
>
|
||||
<RangeControl
|
||||
label="Размер точки"
|
||||
value={displayDraft.pointSize}
|
||||
min={0.5}
|
||||
max={12}
|
||||
step={0.5}
|
||||
formatValue={(value) => `${value.toFixed(1)} пкс`}
|
||||
onChange={(pointSize) => stageDisplayPatch({ pointSize })}
|
||||
/>
|
||||
</div>
|
||||
<div className="scene-settings-commit-field" onBlur={flushDisplaySettings}>
|
||||
<ControlRow label="Атрибут цвета">
|
||||
<Select
|
||||
variant="split"
|
||||
label="Атрибут цвета"
|
||||
value={displayDraft.colorMode}
|
||||
options={colorModeOptions}
|
||||
onChange={(colorMode) => stageDisplayPatch({ colorMode })}
|
||||
/>
|
||||
</ControlRow>
|
||||
</div>
|
||||
<div className="scene-settings-commit-field" onBlur={flushDisplaySettings}>
|
||||
<ControlRow label="Палитра">
|
||||
<Select
|
||||
variant="split"
|
||||
label="Палитра"
|
||||
value={displayDraft.palette}
|
||||
options={paletteOptions}
|
||||
onChange={(palette) => stageDisplayPatch({ palette })}
|
||||
/>
|
||||
</ControlRow>
|
||||
</div>
|
||||
{displayDraft.palette === "custom" || displayDraft.colorMode === "class" ? (
|
||||
<div
|
||||
className="scene-settings-commit-field"
|
||||
onPointerUp={flushDisplaySettings}
|
||||
onKeyUp={flushDisplaySettings}
|
||||
onBlur={flushDisplaySettings}
|
||||
>
|
||||
<ControlRow label="Цвет точек">
|
||||
<ColorField
|
||||
label="Цвет точек"
|
||||
value={displayDraft.customColor}
|
||||
onChange={(customColor) => stageDisplayPatch({ customColor })}
|
||||
/>
|
||||
</ControlRow>
|
||||
</div>
|
||||
) : null}
|
||||
{replayPresented ? (
|
||||
<p className="scene-window-note">
|
||||
Первый новый режим читает индекс архивных точек. Следующие палитры
|
||||
переключаются из подготовленного цветового кэша без переэкспорта геометрии.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "history",
|
||||
label: "Накопление и время",
|
||||
description: "История облака и траектория",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<div
|
||||
className="scene-settings-commit-field"
|
||||
onPointerUp={flushDisplaySettings}
|
||||
onKeyUp={flushDisplaySettings}
|
||||
onBlur={flushDisplaySettings}
|
||||
>
|
||||
<RangeControl
|
||||
label="Окно накопления"
|
||||
value={displayDraft.accumulationSeconds}
|
||||
min={0}
|
||||
max={120}
|
||||
step={1}
|
||||
formatValue={(value) => (value === 0 ? "Только кадр" : `${value} с`)}
|
||||
onChange={(accumulationSeconds) => stageDisplayPatch({ accumulationSeconds })}
|
||||
/>
|
||||
</div>
|
||||
<div className="nodedc-field">
|
||||
<span className="nodedc-field__description">Линия пути устройства в координатах сцены</span>
|
||||
<Checker
|
||||
checked={displayDraft.showTrajectory}
|
||||
label="Показывать траекторию"
|
||||
onChange={(showTrajectory) => commitDisplayPatch({ showTrajectory })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "scene",
|
||||
label: "Окружение сцены",
|
||||
description: "Сетка, подписи и камеры",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<Checker checked={displayDraft.showGrid} label="Сетка и оси" onChange={(showGrid) => commitDisplayPatch({ showGrid })} />
|
||||
<div className="nodedc-field">
|
||||
<span className="nodedc-field__description">Появятся после подключения семантических сущностей</span>
|
||||
<Checker checked={false} disabled label="Подписи сущностей" onChange={() => undefined} />
|
||||
</div>
|
||||
<div className="nodedc-field">
|
||||
<span className="nodedc-field__description">Камерный канал пока не подключён</span>
|
||||
<Checker checked={false} disabled label="Области обзора камер" onChange={() => undefined} />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<SceneDisplayControls displayDraft={displayDraft} stageDisplayPatch={stageDisplayPatch}
|
||||
commitDisplayPatch={commitDisplayPatch} flushDisplaySettings={flushDisplaySettings} replayPresented={replayPresented}/>
|
||||
</Window>
|
||||
|
||||
<Window
|
||||
@@ -1180,64 +1036,7 @@ export default function App() {
|
||||
</span>
|
||||
<p>Структура повторяет продуктовые сущности, а не внутренние панели визуального движка.</p>
|
||||
</div>
|
||||
<Inspector
|
||||
defaultOpen={["geometry"]}
|
||||
singleOpen
|
||||
sections={[
|
||||
{
|
||||
id: "geometry",
|
||||
label: "Геометрия",
|
||||
description: "Облако точек и путь",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<div className="nodedc-field">
|
||||
<span className="nodedc-field__description">Основной поток геометрии лидара</span>
|
||||
<Checker disabled={runtime.pendingAction === "viewer"} checked={sceneSettings.showPoints} label="Облако точек" onChange={(showPoints) => void applyScenePatch({ showPoints })} />
|
||||
</div>
|
||||
<div className="nodedc-field">
|
||||
<span className="nodedc-field__description">Положение и ориентация устройства во времени</span>
|
||||
<Checker disabled={runtime.pendingAction === "viewer"} checked={sceneSettings.showTrajectory} label="Траектория" onChange={(showTrajectory) => void applyScenePatch({ showTrajectory })} />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "frames",
|
||||
label: "Координаты и камеры",
|
||||
description: "Преобразования и области обзора",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<Checker disabled={runtime.pendingAction === "viewer"} checked={sceneSettings.showGrid} label="Сетка и оси" onChange={(showGrid) => void applyScenePatch({ showGrid })} />
|
||||
<div className="nodedc-field">
|
||||
<span className="nodedc-field__description">Камерный канал пока не подключён</span>
|
||||
<Checker checked={false} disabled label="Области обзора камер" onChange={() => undefined} />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "perception",
|
||||
label: "Объекты и маски",
|
||||
description: "Ожидают внешние обработчики",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<div className="nodedc-field">
|
||||
<span className="nodedc-field__description">Внешний обработчик не подключён</span>
|
||||
<Checker checked={false} disabled label="Рамки 2D и 3D" onChange={() => undefined} />
|
||||
</div>
|
||||
<div className="nodedc-field">
|
||||
<span className="nodedc-field__description">Внешний обработчик не подключён</span>
|
||||
<Checker checked={false} disabled label="Маски сегментации" onChange={() => undefined} />
|
||||
</div>
|
||||
<div className="nodedc-field">
|
||||
<span className="nodedc-field__description">Внешний обработчик не подключён</span>
|
||||
<Checker checked={false} disabled label="Ключевые точки и треки" onChange={() => undefined} />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<SceneLayerControls sceneSettings={sceneSettings} pending={runtime.pendingAction === "viewer"} applyScenePatch={applyScenePatch}/>
|
||||
</Window>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
import { useLayoutEffect, useState, type RefObject } from "react";
|
||||
import { WorkspaceWindow } from "@nodedc/ui-react";
|
||||
import type {RefObject} from "react";
|
||||
import {FloatingMediaWindow} from "../../../../packages/spatial-ui/src/FloatingMediaWindow";
|
||||
export {initialObservationWindowRect,shouldCaptureWorkspacePointer} from "../../../../packages/spatial-ui/src/FloatingMediaWindow";
|
||||
|
||||
import type { ObservationWindowRect } from "../core/observation/useObservationLayout";
|
||||
import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
|
||||
@@ -10,60 +11,6 @@ import type {
|
||||
RecordedCameraAdmissionState,
|
||||
} from "../core/observation/recordedSessionAdmission";
|
||||
|
||||
const WINDOW_WIDTH = 336;
|
||||
const WINDOW_HEIGHT = 210;
|
||||
const WINDOW_GAP = 16;
|
||||
const WINDOW_INSET = 18;
|
||||
const TIMELINE_CLEARANCE = 64;
|
||||
|
||||
type ClosestTarget = { closest: (selectors: string) => unknown };
|
||||
|
||||
export function shouldCaptureWorkspacePointer(
|
||||
button: number,
|
||||
target: ClosestTarget | null,
|
||||
): boolean {
|
||||
if (button !== 0 || !target) return false;
|
||||
if (target.closest(".nodedc-workspace-window__resize")) return true;
|
||||
if (!target.closest(".nodedc-workspace-window__head")) return false;
|
||||
return !target.closest("button, input, select, textarea, a");
|
||||
}
|
||||
|
||||
export function initialObservationWindowRect(
|
||||
index: number,
|
||||
count = 1,
|
||||
bounds: { width: number; height: number } = { width: 1280, height: 720 },
|
||||
): ObservationWindowRect {
|
||||
const availableWidth = Math.max(0, bounds.width - WINDOW_INSET * 2);
|
||||
const width = Math.max(1, Math.min(WINDOW_WIDTH, availableWidth || WINDOW_WIDTH));
|
||||
const availableHeight = Math.max(0, bounds.height - WINDOW_INSET - TIMELINE_CLEARANCE);
|
||||
const maxColumns = Math.max(
|
||||
1,
|
||||
Math.floor((availableWidth + WINDOW_GAP) / (width + WINDOW_GAP)),
|
||||
);
|
||||
const columns = Math.max(1, Math.min(Math.max(1, count), maxColumns));
|
||||
const rows = Math.max(1, Math.ceil(Math.max(1, count) / columns));
|
||||
const rowHeight = Math.max(
|
||||
1,
|
||||
(availableHeight - Math.max(0, rows - 1) * WINDOW_GAP) / rows,
|
||||
);
|
||||
const height = Math.max(1, Math.min(WINDOW_HEIGHT, rowHeight));
|
||||
const row = Math.floor(index / columns);
|
||||
const column = index % columns;
|
||||
const rowItemCount = Math.min(columns, Math.max(1, count - row * columns));
|
||||
const rowWidth = rowItemCount * width + Math.max(0, rowItemCount - 1) * WINDOW_GAP;
|
||||
const rowStart = Math.max(0, bounds.width - WINDOW_INSET - rowWidth);
|
||||
|
||||
return {
|
||||
x: rowStart + Math.min(column, rowItemCount - 1) * (width + WINDOW_GAP),
|
||||
y: Math.max(
|
||||
0,
|
||||
bounds.height - TIMELINE_CLEARANCE - height - row * (height + WINDOW_GAP),
|
||||
),
|
||||
width,
|
||||
height,
|
||||
};
|
||||
}
|
||||
|
||||
export function FloatingObservationWindow({
|
||||
source,
|
||||
index,
|
||||
@@ -104,76 +51,12 @@ export function FloatingObservationWindow({
|
||||
state: RecordedCameraAdmissionState,
|
||||
) => void;
|
||||
}) {
|
||||
const [bounds, setBounds] = useState<{ width: number; height: number } | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const element = boundsRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const measure = () => {
|
||||
const next = { width: element.clientWidth, height: element.clientHeight };
|
||||
setBounds((current) => (
|
||||
current?.width === next.width && current.height === next.height ? current : next
|
||||
));
|
||||
};
|
||||
|
||||
measure();
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [boundsRef]);
|
||||
|
||||
// Camera media admission must not wait for the viewport observer. A source
|
||||
// can arrive in the same commit as a workspace transition; mounting it with
|
||||
// the canonical fallback geometry keeps the browser transport alive until
|
||||
// the real bounds are measured immediately afterward.
|
||||
const initialRect = initialObservationWindowRect(index, count, bounds ?? undefined);
|
||||
const windowRect = rect ?? initialRect;
|
||||
|
||||
return (
|
||||
<WorkspaceWindow
|
||||
boundsRef={boundsRef}
|
||||
rect={windowRect}
|
||||
onRectChange={onRectChange}
|
||||
maximized={maximized}
|
||||
onMaximizedChange={onMaximizedChange}
|
||||
onActivate={onActivate}
|
||||
onClose={onClose}
|
||||
title={source.label}
|
||||
subtitle={source.description}
|
||||
status={(
|
||||
<span className="floating-observation-window__status">
|
||||
<i data-availability={source.availability} aria-hidden="true" />
|
||||
{observationSourceStatusLabel(source)}
|
||||
</span>
|
||||
)}
|
||||
footer={(
|
||||
<span className="floating-observation-window__footer">
|
||||
<span>{source.endpointLabel || source.transport}</span>
|
||||
<span>{source.capabilities.timelineMode === "live-only" ? "Эфир без буфера" : "Временная шкала"}</span>
|
||||
</span>
|
||||
)}
|
||||
minWidth={Math.min(280, windowRect.width)}
|
||||
minHeight={Math.min(190, windowRect.height)}
|
||||
resizable={source.capabilities.resizable}
|
||||
active={active}
|
||||
zIndex={maximized ? 15 : active ? 9 : 7}
|
||||
className={`floating-observation-window${hidden ? " floating-observation-window--hidden" : ""}`}
|
||||
onPointerDownCapture={(event) => {
|
||||
if (!shouldCaptureWorkspacePointer(event.button, event.target as HTMLElement)) return;
|
||||
try {
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// Pointer capture can fail if the browser ended the pointer between
|
||||
// dispatch and capture. The donor's window listeners remain fallback.
|
||||
}
|
||||
}}
|
||||
closeLabel={`Закрыть ${source.label}`}
|
||||
maximizeLabel={`Развернуть ${source.label}`}
|
||||
restoreLabel={`Восстановить ${source.label}`}
|
||||
moveLabel={`Переместить ${source.label}`}
|
||||
resizeLabel={`Изменить размер ${source.label}`}
|
||||
>
|
||||
return <FloatingMediaWindow title={source.label} subtitle={source.description}
|
||||
index={index} count={count} boundsRef={boundsRef} rect={rect} maximized={maximized}
|
||||
active={active} hidden={hidden} onRectChange={onRectChange} onMaximizedChange={onMaximizedChange}
|
||||
onActivate={onActivate} onClose={onClose} resizable={source.capabilities.resizable}
|
||||
status={<span className="floating-observation-window__status"><i data-availability={source.availability} aria-hidden="true"/>{observationSourceStatusLabel(source)}</span>}
|
||||
footer={<span className="floating-observation-window__footer"><span>{source.endpointLabel || source.transport}</span><span>{source.capabilities.timelineMode === "live-only" ? "Эфир без буфера" : "Временная шкала"}</span></span>}>
|
||||
<ObservationMedia
|
||||
source={source}
|
||||
playback={playback}
|
||||
@@ -182,6 +65,5 @@ export function FloatingObservationWindow({
|
||||
recordedAdmissionKey={recordedAdmissionKey}
|
||||
onRecordedAdmissionChange={onRecordedAdmissionChange}
|
||||
/>
|
||||
</WorkspaceWindow>
|
||||
);
|
||||
</FloatingMediaWindow>;
|
||||
}
|
||||
|
||||
@@ -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,9 +1,9 @@
|
||||
import { Dropdown, Icon, type IconName } from "@nodedc/ui-react";
|
||||
import {sourceIcon,observationSourceStatusLabel} from "../../../../packages/spatial-ui/src/ObservationSourcePicker";
|
||||
export {ObservationSourcePicker,observationSourceStatusLabel} from "../../../../packages/spatial-ui/src/ObservationSourcePicker";
|
||||
import { Icon } from "@nodedc/ui-react";
|
||||
|
||||
import type {
|
||||
ObservationSourceAvailability,
|
||||
ObservationSourceDescriptor,
|
||||
ObservationSourceModality,
|
||||
} from "../core/runtime/contracts";
|
||||
import { MseFmp4WebSocketPlayer } from "./MseFmp4WebSocketPlayer";
|
||||
import {
|
||||
@@ -16,28 +16,6 @@ import type {
|
||||
} from "../core/observation/recordedSessionAdmission";
|
||||
import { liveCameraPlaybackAuthorityIdentity } from "../core/observation/liveCameraRecovery";
|
||||
|
||||
const sourceIcon: Record<ObservationSourceModality, IconName> = {
|
||||
"point-cloud": "globe",
|
||||
video: "video",
|
||||
image: "image",
|
||||
depth: "image",
|
||||
};
|
||||
|
||||
const availabilityCopy: Record<ObservationSourceAvailability, string> = {
|
||||
unverified: "Не подтверждён",
|
||||
declared: "Канал объявлен",
|
||||
available: "Доступен",
|
||||
connecting: "Подключение",
|
||||
streaming: "Эфир",
|
||||
degraded: "Нестабильно",
|
||||
unavailable: "Недоступен",
|
||||
error: "Ошибка",
|
||||
};
|
||||
|
||||
export function observationSourceStatusLabel(source: ObservationSourceDescriptor): string {
|
||||
return availabilityCopy[source.availability];
|
||||
}
|
||||
|
||||
export function ObservationMedia({
|
||||
source,
|
||||
playback,
|
||||
@@ -159,105 +137,3 @@ export function ObservationMedia({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ObservationSourcePicker({
|
||||
sources,
|
||||
visibleSourceIds,
|
||||
pendingSourceIds,
|
||||
onToggle,
|
||||
}: {
|
||||
sources: readonly ObservationSourceDescriptor[];
|
||||
visibleSourceIds: ReadonlySet<string>;
|
||||
pendingSourceIds?: ReadonlySet<string>;
|
||||
onToggle: (sourceId: string) => void | Promise<boolean>;
|
||||
}) {
|
||||
const visibleCount = sources.filter((source) => visibleSourceIds.has(source.id)).length;
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
className="scene-source-picker"
|
||||
placement="bottom-start"
|
||||
width={340}
|
||||
offset={10}
|
||||
surfaceRole="dialog"
|
||||
surfaceClassName="observation-source-menu"
|
||||
trigger={({ open, toggle, setAnchorRef, setTriggerRef, surfaceId }) => (
|
||||
<button
|
||||
ref={(node) => {
|
||||
setAnchorRef(node);
|
||||
setTriggerRef(node);
|
||||
}}
|
||||
type="button"
|
||||
className="scene-source-picker__trigger"
|
||||
data-active={open || visibleCount > 0 ? "true" : undefined}
|
||||
aria-label="Источники данных сцены"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
aria-controls={surfaceId}
|
||||
onClick={toggle}
|
||||
>
|
||||
<Icon name="database" size={17} />
|
||||
{sources.length > 0 ? <span>{visibleCount}</span> : null}
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<header className="observation-source-menu__head">
|
||||
<div>
|
||||
<span className="section-eyebrow">НАБЛЮДЕНИЕ</span>
|
||||
<strong>Источники данных</strong>
|
||||
</div>
|
||||
<small>{sources.length ? `${visibleCount} из ${sources.length}` : "нет источников"}</small>
|
||||
</header>
|
||||
{sources.length ? (
|
||||
<div className="observation-source-menu__list">
|
||||
{sources.map((source) => {
|
||||
const selected = visibleSourceIds.has(source.id);
|
||||
const pending = pendingSourceIds?.has(source.id) ?? false;
|
||||
const canOpen = Boolean(
|
||||
selected ||
|
||||
source.modality === "point-cloud" ||
|
||||
source.previewUrl ||
|
||||
source.delivery ||
|
||||
source.activation?.controllable,
|
||||
);
|
||||
return (
|
||||
<button
|
||||
key={source.id}
|
||||
type="button"
|
||||
className="nodedc-dropdown-option observation-source-option"
|
||||
data-selected={selected ? "true" : undefined}
|
||||
aria-pressed={selected}
|
||||
disabled={pending || !canOpen}
|
||||
onClick={() => void onToggle(source.id)}
|
||||
>
|
||||
<span className="nodedc-dropdown-option__icon">
|
||||
<Icon name={sourceIcon[source.modality]} size={16} />
|
||||
</span>
|
||||
<span className="nodedc-dropdown-option__body">
|
||||
<span className="nodedc-dropdown-option__label">{source.label}</span>
|
||||
<span className="nodedc-dropdown-option__description">
|
||||
<i data-availability={source.availability} aria-hidden="true" />
|
||||
{pending ? "Переключение" : observationSourceStatusLabel(source)} · {source.endpointLabel || source.transport}
|
||||
</span>
|
||||
</span>
|
||||
<span className="nodedc-dropdown-option__check">
|
||||
{selected ? <Icon name="check" size={15} /> : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="observation-source-menu__empty">
|
||||
<Icon name="database" size={18} />
|
||||
<strong>Каталог пока пуст</strong>
|
||||
<span>Выберите профиль устройства — его плагин опубликует доступные каналы.</span>
|
||||
</div>
|
||||
)}
|
||||
<footer className="observation-source-menu__foot">
|
||||
Каналы приходят из активного контура или выбранной сохранённой сессии; сцена не знает
|
||||
модель оборудования.
|
||||
</footer>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,197 +1 @@
|
||||
import { Button, Icon, Select } from "@nodedc/ui-react";
|
||||
|
||||
import type { ObservationTimelineMode } from "../core/runtime/contracts";
|
||||
|
||||
export function ObservationTimeline({
|
||||
active,
|
||||
sourceCount,
|
||||
mode = "live-only",
|
||||
seekable = false,
|
||||
synchronization = "host-arrival-best-effort",
|
||||
rangeNs = null,
|
||||
currentNs = null,
|
||||
playing = false,
|
||||
onSeek,
|
||||
onPlayingChange,
|
||||
onJumpToEnd,
|
||||
showJumpToEnd = true,
|
||||
playbackRate,
|
||||
onPlaybackRateChange,
|
||||
accumulationSeconds,
|
||||
onAccumulationChange,
|
||||
onAccumulationCommit,
|
||||
className = "",
|
||||
}: {
|
||||
active: boolean;
|
||||
sourceCount: number;
|
||||
mode?: ObservationTimelineMode;
|
||||
seekable?: boolean;
|
||||
synchronization?: "host-arrival-best-effort" | "shared-clock" | "frame-accurate";
|
||||
rangeNs?: { min: number; max: number } | null;
|
||||
currentNs?: number | null;
|
||||
playing?: boolean;
|
||||
onSeek?: (timeNs: number) => void;
|
||||
onPlayingChange?: (playing: boolean) => void;
|
||||
onJumpToEnd?: () => void;
|
||||
showJumpToEnd?: boolean;
|
||||
playbackRate?: number;
|
||||
onPlaybackRateChange?: (rate: number) => void;
|
||||
accumulationSeconds?: number;
|
||||
onAccumulationChange?: (value: number) => void;
|
||||
onAccumulationCommit?: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const playbackRange = normalizeTimelineRange(rangeNs);
|
||||
const buffered = Boolean(
|
||||
seekable &&
|
||||
(mode === "buffered" || mode === "recorded") &&
|
||||
playbackRange,
|
||||
);
|
||||
const elapsedSeconds = playbackRange
|
||||
? timelineOffsetSeconds(playbackRange, currentNs ?? playbackRange.min)
|
||||
: 0;
|
||||
const durationSeconds = playbackRange
|
||||
? Math.max(0, (playbackRange.max - playbackRange.min) / 1_000_000_000)
|
||||
: 0;
|
||||
const synchronizationLabel = {
|
||||
"host-arrival-best-effort": "Синхронизация по приходу",
|
||||
"shared-clock": "Общие часы",
|
||||
"frame-accurate": "Покадровая синхронизация",
|
||||
}[synchronization];
|
||||
const accumulationValue = accumulationSeconds === undefined
|
||||
? null
|
||||
: normalizeAccumulationSeconds(accumulationSeconds);
|
||||
return (
|
||||
<div
|
||||
className={`observation-timeline ${className}`.trim()}
|
||||
data-active={active ? "true" : undefined}
|
||||
data-mode={mode}
|
||||
data-accumulation={accumulationValue !== null ? "true" : undefined}
|
||||
>
|
||||
{accumulationValue !== null ? (
|
||||
<div className="observation-timeline__accumulation">
|
||||
<span>Накопление</span>
|
||||
<input
|
||||
className="observation-timeline__track"
|
||||
type="range"
|
||||
min={0}
|
||||
max={120}
|
||||
step={1}
|
||||
value={accumulationValue}
|
||||
aria-label="Окно накопления облака точек"
|
||||
aria-valuetext={formatAccumulationDuration(accumulationValue)}
|
||||
onChange={(event) =>
|
||||
onAccumulationChange?.(normalizeAccumulationSeconds(Number(event.target.value)))}
|
||||
onPointerUp={onAccumulationCommit}
|
||||
onTouchEnd={onAccumulationCommit}
|
||||
onKeyUp={onAccumulationCommit}
|
||||
onBlur={onAccumulationCommit}
|
||||
/>
|
||||
<code>{formatAccumulationDuration(accumulationValue)}</code>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="observation-timeline__playback">
|
||||
<Button
|
||||
size="compact"
|
||||
variant="ghost"
|
||||
disabled={!buffered || !onSeek}
|
||||
aria-label="Перейти к началу"
|
||||
onClick={() => playbackRange && onSeek?.(playbackRange.min)}
|
||||
>
|
||||
<Icon name="chevron-left" />
|
||||
</Button>
|
||||
<Button
|
||||
className="observation-timeline__transport"
|
||||
size="compact"
|
||||
variant="ghost"
|
||||
disabled={!buffered || !onPlayingChange}
|
||||
aria-label={buffered ? (playing ? "Пауза" : "Воспроизвести") : "Только эфир"}
|
||||
icon={<Icon name={playing ? "stop" : "play"} />}
|
||||
onClick={() => onPlayingChange?.(!playing)}
|
||||
/>
|
||||
{buffered && playbackRate !== undefined && onPlaybackRateChange ? (
|
||||
<Select
|
||||
label="Скорость воспроизведения"
|
||||
value={String(playbackRate)}
|
||||
options={[
|
||||
{ value: "0.5", label: "0,5×" },
|
||||
{ value: "1", label: "1×" },
|
||||
{ value: "2", label: "2×" },
|
||||
]}
|
||||
variant="inline"
|
||||
menuWidth="anchor"
|
||||
onChange={(value) => onPlaybackRateChange(Number(value))}
|
||||
/>
|
||||
) : null}
|
||||
<input
|
||||
className="observation-timeline__track"
|
||||
type="range"
|
||||
min={0}
|
||||
max={Math.max(durationSeconds, 0.001)}
|
||||
step={0.01}
|
||||
value={Math.min(elapsedSeconds, durationSeconds)}
|
||||
disabled={!buffered || !onSeek}
|
||||
aria-label="Позиция воспроизведения"
|
||||
onChange={(event) => {
|
||||
if (!playbackRange) return;
|
||||
onSeek?.(playbackRange.min + Number(event.target.value) * 1_000_000_000);
|
||||
}}
|
||||
/>
|
||||
<div className="observation-timeline__meta">
|
||||
<code>
|
||||
{buffered
|
||||
? `${formatTimelineDuration(elapsedSeconds)} / ${formatTimelineDuration(durationSeconds)}`
|
||||
: active ? "LIVE" : "—:—:—.———"}
|
||||
</code>
|
||||
<small>
|
||||
{buffered ? `${sourceCount} каналов` : `${synchronizationLabel} · буфер не включён`}
|
||||
</small>
|
||||
</div>
|
||||
{showJumpToEnd ? (
|
||||
<button
|
||||
type="button"
|
||||
className="observation-timeline__follow"
|
||||
data-active={!buffered && active ? "true" : undefined}
|
||||
disabled={buffered ? !onJumpToEnd : true}
|
||||
onClick={onJumpToEnd}
|
||||
>
|
||||
{buffered ? "К КОНЦУ" : "ЭФИР"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeAccumulationSeconds(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.min(120, Math.max(0, Math.round(value)));
|
||||
}
|
||||
|
||||
export function formatAccumulationDuration(value: number): string {
|
||||
const seconds = normalizeAccumulationSeconds(value);
|
||||
return seconds === 0 ? "Кадр" : `${seconds} с`;
|
||||
}
|
||||
|
||||
export function normalizeTimelineRange(
|
||||
range: { min: number; max: number } | null | undefined,
|
||||
): { min: number; max: number } | null {
|
||||
if (!range || !Number.isFinite(range.min) || !Number.isFinite(range.max)) return null;
|
||||
if (range.max <= range.min) return null;
|
||||
return range;
|
||||
}
|
||||
|
||||
export function timelineOffsetSeconds(
|
||||
range: { min: number; max: number },
|
||||
currentNs: number,
|
||||
): number {
|
||||
const clamped = Math.min(Math.max(currentNs, range.min), range.max);
|
||||
return Math.max(0, (clamped - range.min) / 1_000_000_000);
|
||||
}
|
||||
|
||||
export function formatTimelineDuration(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return "00:00.000";
|
||||
const wholeMinutes = Math.floor(seconds / 60);
|
||||
const remaining = seconds - wholeMinutes * 60;
|
||||
return `${String(wholeMinutes).padStart(2, "0")}:${remaining.toFixed(3).padStart(6, "0")}`;
|
||||
}
|
||||
export * from "../../../../packages/spatial-ui/src/ObservationTimeline";
|
||||
|
||||
@@ -762,7 +762,15 @@ export async function probeRecordedPerceptionViewerSource(
|
||||
return { sourceUrl: endpoint.href, byteLength: declaredLength };
|
||||
}
|
||||
|
||||
export function RerunViewport({
|
||||
export function RerunViewport(props: RerunViewportProps) {
|
||||
// Even when source URLs coincide, another profile cannot inherit a mounted
|
||||
// viewer's playback, blueprint channels, recovery state or scene draft.
|
||||
const identity = props.profile.kind === "laboratory-result"
|
||||
? `${props.profile.kind}:${props.profile.resultId}` : props.profile.kind;
|
||||
return <RerunViewportInstance key={identity} {...props} />;
|
||||
}
|
||||
|
||||
function RerunViewportInstance({
|
||||
profile,
|
||||
onStatusChange,
|
||||
onSelectionChange,
|
||||
@@ -772,7 +780,7 @@ export function RerunViewport({
|
||||
onPerceptionLoadChange,
|
||||
onPointColorLoadChange,
|
||||
}: RerunViewportProps) {
|
||||
const recordedProfile = profile.kind === "recorded-session" ? profile : null;
|
||||
const recordedProfile = profile.kind === "recorded-session" || profile.kind === "laboratory-result" ? profile : null;
|
||||
const liveProfile = profile.kind === "live-acquisition" ? profile : null;
|
||||
const sourceUrl = profile.sourceUrl;
|
||||
const recordedArtifact = recordedProfile?.artifact ?? null;
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
} from "../laboratory/CanonicalRecordedLabReplay";
|
||||
import type { CanonicalLabReplayDescriptor } from "../../core/laboratory/canonicalLabReplay";
|
||||
import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive";
|
||||
import { recordedSessionRerunProfile } from "../../core/observation/viewerProfile";
|
||||
import { laboratoryResultRerunProfile } from "../../core/observation/viewerProfile";
|
||||
import { resolveObservationSessionReplay } from "../../core/observation/useObservationSessions";
|
||||
import type { AIViewerLayer } from "../../core/observatory/aiComposition";
|
||||
import {
|
||||
@@ -305,7 +305,8 @@ export function CanonicalResultRerunReplay({
|
||||
accumulationSeconds: showLocalSlam ? sceneDraft.accumulationSeconds : 0,
|
||||
}), [sceneDraft, showLocalSlam, showSourcePoints, spatialMode]);
|
||||
const semanticVisible = showDDRNet || showEoMT;
|
||||
const profile = launch ? recordedSessionRerunProfile({
|
||||
const profile = launch ? laboratoryResultRerunProfile({
|
||||
resultId,
|
||||
sourceUrl: launch.replay.sourceUrl,
|
||||
artifact: {
|
||||
sourceUrl: launch.replay.sourceUrl,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import type {MapVisualSettings} from '../../core/map/mapView';
|
||||
import type {CesiumMapRendererProps} from '@nodedc/map-cesium-react';
|
||||
|
||||
export function mapPresentation(settings:MapVisualSettings):CesiumMapRendererProps['settings'] {
|
||||
return {
|
||||
atmosphere_enabled: settings.atmosphereEnabled,
|
||||
atmosphere_hue: settings.atmosphereHue,
|
||||
atmosphere_saturation:
|
||||
settings.atmosphereSaturation,
|
||||
atmosphere_brightness:
|
||||
settings.atmosphereBrightness,
|
||||
fog_enabled: settings.fogEnabled,
|
||||
fog_density: settings.fogDensity,
|
||||
sun_enabled: settings.sunEnabled,
|
||||
sun_hour: settings.sunHour,
|
||||
sun_intensity: settings.sunIntensity,
|
||||
shadows_enabled: settings.shadowsEnabled,
|
||||
monochrome_enabled: settings.monochromeEnabled,
|
||||
monochrome_color: settings.monochromeColor,
|
||||
imagery_brightness: settings.imageryBrightness,
|
||||
imagery_contrast: settings.imageryContrast,
|
||||
imagery_saturation: settings.imagerySaturation,
|
||||
imagery_gamma: settings.imageryGamma,
|
||||
imagery_hue: settings.imageryHue,
|
||||
imagery_alpha: settings.imageryAlpha,
|
||||
globe_color: settings.globeColor,
|
||||
background_color: settings.backgroundColor,
|
||||
terrain_exaggeration:
|
||||
settings.terrainExaggeration,
|
||||
buildings_color: settings.buildingsColor,
|
||||
buildings_opacity: settings.buildingsOpacity,
|
||||
buildings_maximum_screen_space_error:
|
||||
settings.buildingsMaximumScreenSpaceError,
|
||||
grid_lod_enabled: settings.gridLodEnabled,
|
||||
grid_height_meters: settings.gridHeightMeters,
|
||||
grid_lod_1_max_height_km:
|
||||
settings.gridLod1MaxHeightKm,
|
||||
grid_lod_1_step_km:
|
||||
settings.gridLod1StepKm,
|
||||
grid_lod_2_max_height_km:
|
||||
settings.gridLod2MaxHeightKm,
|
||||
grid_lod_2_step_km:
|
||||
settings.gridLod2StepKm,
|
||||
grid_lod_3_step_km:
|
||||
settings.gridLod3StepKm,
|
||||
grid_radius_km: settings.gridRadiusKm,
|
||||
grid_line_width: settings.gridLineWidth,
|
||||
grid_color: settings.gridColor,
|
||||
grid_opacity: settings.gridOpacity,
|
||||
grid_dots_enabled:
|
||||
settings.gridDotsEnabled,
|
||||
grid_dots_size: settings.gridDotsSize,
|
||||
grid_dots_color: settings.gridDotsColor,
|
||||
grid_dots_opacity:
|
||||
settings.gridDotsOpacity,
|
||||
camera_animation_enabled:
|
||||
settings.cameraAnimationEnabled,
|
||||
};
|
||||
}
|
||||
@@ -6,19 +6,18 @@ interface TelemetrySeriesProps {
|
||||
ceiling?: number;
|
||||
}
|
||||
|
||||
function linePoints(values: Array<number | null>, ceiling?: number): string {
|
||||
const finite = values.filter((value): value is number =>
|
||||
value !== null && Number.isFinite(value),
|
||||
);
|
||||
if (finite.length === 0) return "";
|
||||
const maximum = Math.max(ceiling ?? 0, ...finite, 1);
|
||||
const denominator = Math.max(1, values.length - 1);
|
||||
return values.map((value, index) => {
|
||||
const x = index / denominator * 100;
|
||||
const normalized = value === null ? 0 : Math.max(0, Math.min(maximum, value));
|
||||
const y = 36 - normalized / maximum * 34;
|
||||
return `${x.toFixed(2)},${y.toFixed(2)}`;
|
||||
}).join(" ");
|
||||
export function lineSegments(values: Array<number | null>, ceiling?: number): string[] {
|
||||
const finite = values.filter((value): value is number => value !== null && Number.isFinite(value));
|
||||
if (!finite.length) return [];
|
||||
const maximum = Math.max(ceiling ?? 0, ...finite, 1), denominator = Math.max(1, values.length - 1);
|
||||
const minimum = Math.min(0, ...finite), span = maximum - minimum;
|
||||
const segments:string[]=[];let segment:string[]=[];
|
||||
values.forEach((value,index)=>{
|
||||
if(value===null||!Number.isFinite(value)){if(segment.length)segments.push(segment.join(' '));segment=[];return;}
|
||||
segment.push(`${(index/denominator*100).toFixed(2)},${(36-(Math.min(maximum,value)-minimum)/span*34).toFixed(2)}`);
|
||||
});
|
||||
if(segment.length)segments.push(segment.join(' '));
|
||||
return segments;
|
||||
}
|
||||
|
||||
export function TelemetrySeries({
|
||||
@@ -28,7 +27,7 @@ export function TelemetrySeries({
|
||||
resource,
|
||||
ceiling,
|
||||
}: TelemetrySeriesProps) {
|
||||
const points = linePoints(values, ceiling);
|
||||
const segments = lineSegments(values, ceiling);
|
||||
return (
|
||||
<div className="system-telemetry-series">
|
||||
<div>
|
||||
@@ -45,7 +44,7 @@ export function TelemetrySeries({
|
||||
aria-label={`${label}: ${value}`}
|
||||
>
|
||||
<path d="M0 37 H100" />
|
||||
{points ? <polyline points={points} /> : null}
|
||||
{segments.map((points,index)=><polyline key={index} points={points} />)}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { DeviceUiPlugin } from "../core/device-plugins/contracts";
|
||||
import { xgridsK1Plugin } from "@xgrids-k1/frontend/plugin";
|
||||
import {insta360X4SensorUi} from '../../../../plugins/insta360-x4/frontend/src/plugin';
|
||||
|
||||
// Composition root: this is the only place where Mission Core chooses which
|
||||
// statically reviewed device plugins are shipped in the current build.
|
||||
export const installedDevicePlugins: readonly DeviceUiPlugin[] = Object.freeze([
|
||||
xgridsK1Plugin,
|
||||
]);
|
||||
|
||||
// Node-executed camera controls use the existing sensor contribution contract;
|
||||
// they do not register a desktop capture/AI runtime or a new product workspace.
|
||||
export const installedNodeSensorContributions = Object.freeze([insta360X4SensorUi]);
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
import { IdleMissionRuntimeProvider } from "../runtime/MissionRuntimeContext";
|
||||
import type { DeviceUiPlugin, RegisteredDeviceModel } from "./contracts";
|
||||
import { createDevicePluginRegistry, type DevicePluginRegistry } from "./registry";
|
||||
import type {SensorUiContribution} from '../../../../../packages/sensor-ui/src/extensions';
|
||||
const NO_NODE_SENSORS: readonly SensorUiContribution[] = Object.freeze([]);
|
||||
|
||||
interface DevicePluginHostValue {
|
||||
registry: DevicePluginRegistry;
|
||||
@@ -83,12 +85,14 @@ export function commitPersistedDeviceModelId(
|
||||
|
||||
export function DevicePluginHostProvider({
|
||||
plugins,
|
||||
nodeSensorContributions = NO_NODE_SENSORS,
|
||||
children,
|
||||
}: {
|
||||
plugins: readonly DeviceUiPlugin[];
|
||||
nodeSensorContributions?: readonly SensorUiContribution[];
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const registry = useMemo(() => createDevicePluginRegistry(plugins), [plugins]);
|
||||
const registry = useMemo(() => createDevicePluginRegistry(plugins, nodeSensorContributions), [plugins, nodeSensorContributions]);
|
||||
const selectionStorage = useMemo(browserDeviceModelSelectionStorage, []);
|
||||
const [selectedModelId, setSelectedModelId] = useState<string | null>(() =>
|
||||
restorePersistedDeviceModelId(registry, selectionStorage)
|
||||
|
||||
@@ -103,6 +103,9 @@ export interface DevicePluginConnectionProps {
|
||||
}
|
||||
|
||||
export interface DeviceUiPlugin {
|
||||
sensorUi?: {
|
||||
contributions: readonly import('../../../../../packages/sensor-ui/src/extensions').SensorUiContribution[];
|
||||
};
|
||||
manifest: DevicePluginManifest;
|
||||
RuntimeProvider: ComponentType<{
|
||||
activeModel: DeviceModelDefinition | null;
|
||||
|
||||
@@ -6,8 +6,10 @@ import {
|
||||
type DeviceUiPlugin,
|
||||
type RegisteredDeviceModel,
|
||||
} from "./contracts";
|
||||
import type {SensorUiContribution} from '../../../../../packages/sensor-ui/src/extensions';
|
||||
|
||||
export interface DevicePluginRegistry {
|
||||
readonly sensorContributions: readonly SensorUiContribution[];
|
||||
readonly plugins: readonly DeviceUiPlugin[];
|
||||
readonly models: readonly RegisteredDeviceModel[];
|
||||
resolveModel: (modelId: string) => RegisteredDeviceModel | null;
|
||||
@@ -15,6 +17,7 @@ export interface DevicePluginRegistry {
|
||||
|
||||
export function createDevicePluginRegistry(
|
||||
installedPlugins: readonly DeviceUiPlugin[],
|
||||
nodeSensorContributions: readonly SensorUiContribution[] = [],
|
||||
): DevicePluginRegistry {
|
||||
const pluginIds = new Set<string>();
|
||||
const modelIds = new Set<string>();
|
||||
@@ -133,7 +136,15 @@ export function createDevicePluginRegistry(
|
||||
}
|
||||
|
||||
const modelById = new Map(models.map((registered) => [registered.model.id, registered]));
|
||||
const sensorContributions = [
|
||||
...installedPlugins.flatMap(plugin => plugin.sensorUi?.contributions ?? []),
|
||||
...nodeSensorContributions,
|
||||
];
|
||||
if (new Set(sensorContributions.map(value => value.kind)).size !== sensorContributions.length) {
|
||||
throw new Error('Плагины повторяют модель устройства БК.');
|
||||
}
|
||||
return {
|
||||
sensorContributions: Object.freeze(sensorContributions),
|
||||
plugins: Object.freeze([...installedPlugins]),
|
||||
models: Object.freeze(models),
|
||||
resolveModel: (modelId) => modelById.get(modelId) ?? null,
|
||||
|
||||
@@ -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,72 +31,19 @@ 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,
|
||||
readonly [string | null, string | null]
|
||||
> = {
|
||||
home: ["spatial-scene", "local-device"],
|
||||
fleet: ["contour-health", "local-device"],
|
||||
observation: ["spatial-scene", "cameras"],
|
||||
home: ["spatial-scene", "vehicles"],
|
||||
fleet: ["vehicles", "contour-health"],
|
||||
observation: ["cameras", "world-map"],
|
||||
missions: ["mission-planner", null],
|
||||
data: ["recordings", "datasets"],
|
||||
system: ["modules", "integrations"],
|
||||
polygon: ["lab-archive", null],
|
||||
polygon: ["lab-archive", "local-device"],
|
||||
};
|
||||
|
||||
export function defaultEnvironmentSettings(): EnvironmentSettings {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import {useEffect,useState} from 'react';
|
||||
|
||||
export interface MetricDefinition {label:string;group:string;resource:string;unit:string;reason:string|null}
|
||||
export interface BoardMonitor {
|
||||
available:boolean;fresh:boolean;storage:string;source_id:string;
|
||||
latest:{seq:number;at:number;boot_id:string;values:Record<string,number|null>}|null;
|
||||
definitions:Record<string,MetricDefinition>;received_at:number;backlog:number;
|
||||
database_bytes:number|null;database_budget_bytes:number;archive_since:number|null;
|
||||
start:number;end:number;bucket_seconds:number;
|
||||
series:{at:number;min:number|null;max:number|null;mean:number|null;count:number}[];
|
||||
events:{at:number;code:string;kind:string;locations:string[]}[];
|
||||
}
|
||||
export function useBoardMonitor(vehicle:string,metric:string,window:number,end:number|null){
|
||||
const [value,setValue]=useState<BoardMonitor|null>(null),[error,setError]=useState('');
|
||||
useEffect(()=>{
|
||||
let active=true;let pending:AbortController|undefined;let timer:ReturnType<typeof setTimeout>|undefined;
|
||||
setValue(null);setError('');
|
||||
const read=async()=>{
|
||||
const controller=new AbortController();pending=controller;
|
||||
const deadline=setTimeout(()=>controller.abort(),12000);
|
||||
try {
|
||||
const query=new URLSearchParams({metric,window:String(window),...(end===null?{}:{end:String(end)})});
|
||||
const response=await fetch(`/api/v1/fleet/${encodeURIComponent(vehicle)}/monitor?${query}`,{credentials:'same-origin',cache:'no-store',signal:controller.signal});
|
||||
if(!response.ok)throw new Error('Мониторинг БК недоступен.');
|
||||
const next=await response.json() as BoardMonitor;
|
||||
if(active){setValue(next);setError('');}
|
||||
}catch {if(active)setError('Не удалось обновить мониторинг БК.');}
|
||||
finally {clearTimeout(deadline);if(active)timer=setTimeout(()=>void read(),5000);}
|
||||
};
|
||||
void read();return()=>{active=false;pending?.abort();clearTimeout(timer);};
|
||||
},[vehicle,metric,window,end]);
|
||||
return {value,error,dismissError:()=>setError('')};
|
||||
}
|
||||
export function monitorSeries(value:BoardMonitor,mode:'min'|'max'|'mean') {
|
||||
const count=Math.ceil((value.end-value.start)/value.bucket_seconds)+1;
|
||||
const values:Array<number|null>=Array(Math.min(count,302)).fill(null);
|
||||
const first=Math.floor(value.start/value.bucket_seconds)*value.bucket_seconds;
|
||||
for(const point of value.series){const index=Math.round((point.at-first)/value.bucket_seconds);if(index>=0&&index<values.length)values[index]=point[mode];}
|
||||
return values;
|
||||
}
|
||||
export function monitorValue(value:number|null|undefined,unit=''){
|
||||
if(value==null||!Number.isFinite(value))return 'Нет измерения';
|
||||
if(unit==='bytes'||unit==='bytes/s'){
|
||||
const labels=unit==='bytes'?['Б','КиБ','МиБ','ГиБ']:['Б/с','КиБ/с','МиБ/с','ГиБ/с'];let index=0;
|
||||
while(value>=1024&&index<3){value/=1024;index++;}
|
||||
return `${value.toLocaleString('ru-RU',{maximumFractionDigits:1})} ${labels[index]}`;
|
||||
}
|
||||
return `${value.toLocaleString('ru-RU',{maximumFractionDigits:1})}${unit?' '+unit:''}`;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export interface ObservationLayout {
|
||||
version:1; order:string[]; hidden:string[]; layers:Record<string,string>;
|
||||
splits:Record<string,number>; arrangement:'auto'|'columns'|'rows';
|
||||
}
|
||||
export const emptyObservationLayout=():ObservationLayout=>({version:1,order:[],hidden:[],layers:{},splits:{},arrangement:'auto'});
|
||||
const record=(value:unknown):Record<string,unknown>=>value!==null&&typeof value==='object'&&!Array.isArray(value)?value as Record<string,unknown>:{};
|
||||
export function decodeObservationLayout(value:unknown):ObservationLayout {
|
||||
const source=record(value),result=emptyObservationLayout();if(source.version!==1)return result;
|
||||
const ids=(value:unknown)=>Array.isArray(value)?[...new Set(value.filter((v):v is string=>typeof v==='string'&&v.length<512))].slice(0,128):[];
|
||||
result.order=ids(source.order);result.hidden=ids(source.hidden);
|
||||
result.layers=Object.fromEntries(Object.entries(record(source.layers)).filter(([k,v])=>k.length<512&&typeof v==='string'&&v.length<128).slice(0,128)) as Record<string,string>;
|
||||
result.splits=Object.fromEntries(Object.entries(record(source.splits)).filter(([k,v])=>k.length<4096&&typeof v==='number'&&Number.isFinite(v)).slice(0,256).map(([k,v])=>[k,Math.min(80,Math.max(20,v as number))]));
|
||||
if(source.arrangement==='rows'||source.arrangement==='columns')result.arrangement=source.arrangement;
|
||||
return result;
|
||||
}
|
||||
export function orderedObservationIDs(layout:ObservationLayout,available:string[]):string[]{
|
||||
const present=new Set(available);return [...layout.order.filter(id=>present.has(id)),...available.filter(id=>!layout.order.includes(id))];
|
||||
}
|
||||
export function moveObservation(layout:ObservationLayout,available:string[],from:string,to:string):ObservationLayout {
|
||||
const order=orderedObservationIDs(layout,available);const source=order.indexOf(from),target=order.indexOf(to);
|
||||
if(source<0||target<0||source===target)return layout;
|
||||
order.splice(source,1);order.splice(target,0,from);return {...layout,order};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type {SensorInventory, SensorTransport} from '../../../../../packages/sensor-ui/src/contracts';
|
||||
import {fleetRequest} from './useFleet';
|
||||
|
||||
export function createFleetSensorTransport(vehicleID:string):SensorTransport {
|
||||
const path=`/${encodeURIComponent(vehicleID)}/devices`;
|
||||
const inventory=(fleet:{items:{id:string;connectivity:string;enrollment:string;sensor_state:SensorInventory}[]})=>{
|
||||
const value=fleet.items.find(v=>v.id===vehicleID);
|
||||
if(!value)throw new Error('Аппарат не найден.');
|
||||
return {...value.sensor_state,fresh:value.connectivity==='online'&&value.enrollment==='paired'};
|
||||
};
|
||||
return {
|
||||
enrollment:{state:()=>fleetRequest(`${path}/enrollment`),submit:value=>fleetRequest(`${path}/enrollment/operations`,'POST',value),operation:id=>fleetRequest(`${path}/enrollment/operations/${encodeURIComponent(id)}`)},
|
||||
inventory:async()=>inventory(await fleetRequest()),
|
||||
subscribe:(receive,unavailable)=>{const events=new EventSource('/api/v1/fleet/events');events.onmessage=e=>{try{receive(inventory(JSON.parse(e.data)));}catch{unavailable();}};events.onerror=unavailable;return()=>events.close();},
|
||||
submit:value=>fleetRequest(`${path}/operations`,'POST',value),
|
||||
operation:id=>fleetRequest(`${path}/operations/${encodeURIComponent(id)}`),
|
||||
};
|
||||
}
|
||||
@@ -85,6 +85,13 @@ export interface RecordedSessionRerunProfile {
|
||||
lockPerceptionCameraInteraction: boolean;
|
||||
}
|
||||
|
||||
/** A LAB owns its result presentation; it does not inherit session-view settings. */
|
||||
export interface LaboratoryResultRerunProfile
|
||||
extends Omit<RecordedSessionRerunProfile, "kind"> {
|
||||
kind: "laboratory-result";
|
||||
resultId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deprecated comparison-only contract for LAB artifacts that have not yet
|
||||
* been republished as a native Rerun sidecar. It must never be selected by a
|
||||
@@ -101,7 +108,8 @@ export interface LaboratoryRecordedEvidenceViewerProfile {
|
||||
|
||||
export type RerunViewerProfile =
|
||||
| LiveAcquisitionRerunProfile
|
||||
| RecordedSessionRerunProfile;
|
||||
| RecordedSessionRerunProfile
|
||||
| LaboratoryResultRerunProfile;
|
||||
|
||||
export type ObservationViewerProfile =
|
||||
| RerunViewerProfile
|
||||
@@ -119,11 +127,18 @@ export const LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE = Object.freeze({
|
||||
export function liveAcquisitionRerunProfile(
|
||||
input: Omit<LiveAcquisitionRerunProfile, "kind" | "clock">,
|
||||
): LiveAcquisitionRerunProfile {
|
||||
return { kind: "live-acquisition", clock: "stream_time", ...input };
|
||||
return { ...input, kind: "live-acquisition", clock: "stream_time" };
|
||||
}
|
||||
|
||||
export function recordedSessionRerunProfile(
|
||||
input: Omit<RecordedSessionRerunProfile, "kind" | "clock">,
|
||||
): RecordedSessionRerunProfile {
|
||||
return { kind: "recorded-session", clock: "session_time", ...input };
|
||||
return { ...input, kind: "recorded-session", clock: "session_time" };
|
||||
}
|
||||
|
||||
export function laboratoryResultRerunProfile(
|
||||
input: Omit<LaboratoryResultRerunProfile, "kind" | "clock">,
|
||||
): LaboratoryResultRerunProfile {
|
||||
if (!input.resultId.trim()) throw new Error("LAB result identity is required");
|
||||
return { ...input, kind: "laboratory-result", clock: "session_time" };
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import "@nodedc/tokens/themes.css";
|
||||
import "@nodedc/ui-core/styles.css";
|
||||
|
||||
import App from "./App";
|
||||
import { installedDevicePlugins } from "./composition/devicePlugins";
|
||||
import { installedDevicePlugins, installedNodeSensorContributions } from "./composition/devicePlugins";
|
||||
import { DevicePluginHostProvider } from "./core/device-plugins/DevicePluginHost";
|
||||
import { ComputeContourProvider } from "./core/system/ComputeContourContext";
|
||||
import "./styles.css";
|
||||
@@ -23,7 +23,7 @@ applyNodedcTheme(rootElement, { theme: "dark" });
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<DevicePluginHostProvider plugins={installedDevicePlugins}>
|
||||
<DevicePluginHostProvider plugins={installedDevicePlugins} nodeSensorContributions={installedNodeSensorContributions}>
|
||||
<ComputeContourProvider>
|
||||
<App />
|
||||
</ComputeContourProvider>
|
||||
|
||||
@@ -94,7 +94,7 @@ export const roots: RootDefinition[] = [
|
||||
label: "Парк",
|
||||
title: "Аппараты и устройства",
|
||||
eyebrow: "ПАРК / УСТРОЙСТВА",
|
||||
description: "Реестр аппаратов, локальное подключение, сенсоры и конфигурации борта.",
|
||||
description: "Реестр аппаратов, бортовые компьютеры, сенсоры и конфигурации борта.",
|
||||
statement: "Одинаково подключать одиночный стенд, наземную платформу и будущий рой.",
|
||||
accent: "АППАРАТЫ И ПОЛЕЗНАЯ НАГРУЗКА",
|
||||
},
|
||||
@@ -203,11 +203,11 @@ export const workspaces: WorkspaceDefinition[] = [
|
||||
},
|
||||
{
|
||||
id: "local-device",
|
||||
root: "fleet",
|
||||
label: "Подключение",
|
||||
title: "Подключение",
|
||||
eyebrow: "ПАРК / ТЕКУЩИЙ АДАПТЕР",
|
||||
description: "Выбор модели, сценарий установленного плагина и запуск доступного потока.",
|
||||
root: "polygon",
|
||||
label: "Тестовые устройства",
|
||||
title: "Тестовые устройства",
|
||||
eyebrow: "LAB / ТЕСТОВЫЕ УСТРОЙСТВА",
|
||||
description: "Подключение устройств к компьютеру оператора для испытаний и записи.",
|
||||
icon: "network",
|
||||
kind: "device",
|
||||
groups: [],
|
||||
@@ -258,11 +258,11 @@ export const workspaces: WorkspaceDefinition[] = [
|
||||
},
|
||||
{
|
||||
id: "spatial-scene",
|
||||
root: "observation",
|
||||
root: "polygon",
|
||||
label: "Пространственная сцена",
|
||||
title: "Пространственная сцена",
|
||||
eyebrow: "НАБЛЮДЕНИЕ / ЭФИР",
|
||||
description: "Облако точек, траектория, преобразования и пространственные слои в единой 3D-сцене.",
|
||||
eyebrow: "LAB / ПРОСТРАНСТВЕННАЯ СЦЕНА",
|
||||
description: "Облако точек, камеры и траектория тестового устройства в реальном времени.",
|
||||
icon: "globe",
|
||||
kind: "spatial",
|
||||
groups: [
|
||||
|
||||
@@ -1,31 +1 @@
|
||||
export type SceneProjection = "3d" | "2d" | "map";
|
||||
export type PointColorMode = "intensity" | "height" | "distance" | "rgb" | "class";
|
||||
export type PointPalette = "turbo" | "viridis" | "plasma" | "grayscale" | "custom";
|
||||
|
||||
export interface SceneSettings {
|
||||
projection: SceneProjection;
|
||||
pointSize: number;
|
||||
colorMode: PointColorMode;
|
||||
palette: PointPalette;
|
||||
customColor: string;
|
||||
accumulationSeconds: number;
|
||||
showPoints: boolean;
|
||||
showTrajectory: boolean;
|
||||
showGrid: boolean;
|
||||
showLabels: boolean;
|
||||
showCameraFrustums: boolean;
|
||||
}
|
||||
|
||||
export const defaultSceneSettings: SceneSettings = {
|
||||
projection: "3d",
|
||||
pointSize: 2.5,
|
||||
colorMode: "intensity",
|
||||
palette: "turbo",
|
||||
customColor: "#35d7c1",
|
||||
accumulationSeconds: 12,
|
||||
showPoints: true,
|
||||
showTrajectory: true,
|
||||
showGrid: true,
|
||||
showLabels: false,
|
||||
showCameraFrustums: true,
|
||||
};
|
||||
export * from "../../../packages/spatial-ui/src/sceneSettings";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,659 +1 @@
|
||||
.scene-source-controls {
|
||||
position: absolute;
|
||||
z-index: 12;
|
||||
top: 0.85rem;
|
||||
left: 0.85rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.scene-device-controls {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
top: 0.85rem;
|
||||
left: 50%;
|
||||
max-width: calc(100% - 9rem);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.scene-source-picker__trigger,
|
||||
.scene-source-control,
|
||||
.scene-focus-exit {
|
||||
display: inline-grid;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
place-items: center;
|
||||
border: 1px solid rgb(255 255 255 / 0.09);
|
||||
border-radius: 50%;
|
||||
background: rgb(12 13 16 / 0.82);
|
||||
color: var(--nodedc-text-secondary);
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.scene-navigation-hint {
|
||||
position: absolute;
|
||||
z-index: 11;
|
||||
right: 0.85rem;
|
||||
bottom: 4.9rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
border: 1px solid rgb(255 255 255 / 0.07);
|
||||
border-radius: 999px;
|
||||
background: rgb(9 10 13 / 0.7);
|
||||
color: var(--nodedc-text-muted);
|
||||
padding: 0.42rem 0.65rem;
|
||||
font-size: 0.52rem;
|
||||
font-weight: 680;
|
||||
pointer-events: none;
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.scene-source-picker__trigger {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.scene-source-picker__trigger:hover,
|
||||
.scene-source-control:hover,
|
||||
.scene-focus-exit:hover,
|
||||
.scene-source-picker__trigger[data-active="true"] {
|
||||
border-color: rgb(255 255 255 / 0.18);
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.scene-source-picker__trigger > span {
|
||||
position: absolute;
|
||||
top: -0.2rem;
|
||||
right: -0.2rem;
|
||||
display: grid;
|
||||
min-width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
background: var(--nodedc-text-primary);
|
||||
color: #090a0c;
|
||||
padding: 0 0.25rem;
|
||||
font-size: 0.5rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.scene-focus-exit {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
top: 0.85rem;
|
||||
right: 0.85rem;
|
||||
}
|
||||
|
||||
.scene-status--top-left {
|
||||
left: 7.1rem;
|
||||
}
|
||||
|
||||
.scene-status[aria-hidden="true"],
|
||||
.scene-metrics[aria-hidden="true"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.observation-source-menu {
|
||||
overflow: hidden;
|
||||
font-family: var(--nodedc-font-family);
|
||||
}
|
||||
|
||||
.observation-source-menu__head,
|
||||
.observation-source-menu__foot {
|
||||
padding: 0.85rem 0.9rem;
|
||||
}
|
||||
|
||||
.observation-source-menu__head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.observation-source-menu__head strong,
|
||||
.observation-source-menu__head span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.observation-source-menu__head strong {
|
||||
margin-top: 0.34rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: var(--nodedc-font-size-md);
|
||||
}
|
||||
|
||||
.observation-source-menu__head small,
|
||||
.observation-source-menu__foot {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.observation-source-menu__list {
|
||||
display: grid;
|
||||
max-height: min(28rem, 60vh);
|
||||
overflow: auto;
|
||||
padding: 0.35rem;
|
||||
}
|
||||
|
||||
.observation-source-option .nodedc-dropdown-option__description {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.observation-source-option i,
|
||||
.floating-observation-window i,
|
||||
.camera-slot__status i {
|
||||
width: 0.42rem;
|
||||
height: 0.42rem;
|
||||
flex: 0 0 0.42rem;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
i[data-availability="available"],
|
||||
i[data-availability="streaming"] {
|
||||
background: rgb(var(--nodedc-success-rgb));
|
||||
}
|
||||
|
||||
i[data-availability="connecting"],
|
||||
i[data-availability="declared"] {
|
||||
background: rgb(var(--nodedc-warning-rgb));
|
||||
}
|
||||
|
||||
i[data-availability="unverified"] {
|
||||
background: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
i[data-availability="degraded"],
|
||||
i[data-availability="error"] {
|
||||
background: rgb(var(--nodedc-danger-rgb));
|
||||
}
|
||||
|
||||
.observation-source-menu__empty,
|
||||
.camera-grid__empty,
|
||||
.observation-media__empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.observation-source-menu__empty {
|
||||
min-height: 10rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.observation-source-menu__empty strong,
|
||||
.observation-media__empty strong,
|
||||
.camera-grid__empty strong {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.observation-source-menu__empty span,
|
||||
.observation-media__empty span,
|
||||
.camera-grid__empty span {
|
||||
max-width: 24rem;
|
||||
font-size: 0.59rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.observation-source-menu__foot {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.observation-media__asset,
|
||||
.observation-media__empty {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.observation-media__asset {
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
background: #050608;
|
||||
}
|
||||
|
||||
.recorded-media-player {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #070809;
|
||||
}
|
||||
|
||||
.recorded-media-player:not([data-state="ready"]) .observation-media__asset {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.recorded-media-player__notice {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 18px;
|
||||
color: rgba(247, 248, 244, 0.72);
|
||||
background: #070809;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mse-fmp4-player {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 9rem;
|
||||
overflow: hidden;
|
||||
background: #050608;
|
||||
}
|
||||
|
||||
.mse-fmp4-player__status {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
background: rgb(5 6 8 / 0.88);
|
||||
color: var(--nodedc-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mse-fmp4-player__status strong {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.mse-fmp4-player__status span {
|
||||
max-width: 24rem;
|
||||
font-size: 0.59rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.mse-fmp4-player__status button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.25rem;
|
||||
border: 1px solid rgb(255 255 255 / 0.12);
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 0.06);
|
||||
color: var(--nodedc-text-primary);
|
||||
padding: 0.48rem 0.72rem;
|
||||
font: inherit;
|
||||
font-size: 0.58rem;
|
||||
font-weight: 760;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mse-fmp4-player__status button:hover {
|
||||
background: rgb(255 255 255 / 0.11);
|
||||
}
|
||||
|
||||
.observation-media__empty {
|
||||
min-height: 9rem;
|
||||
background: #07080a;
|
||||
}
|
||||
|
||||
.floating-observation-window__status,
|
||||
.floating-observation-window__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.camera-slot__head-actions button {
|
||||
display: grid;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: rgb(255 255 255 / 0.05);
|
||||
color: var(--nodedc-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.camera-slot__head-actions button:hover {
|
||||
background: rgb(255 255 255 / 0.1);
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.camera-slot__head-actions .camera-slot__activation {
|
||||
width: auto;
|
||||
min-width: 5.6rem;
|
||||
height: 1.9rem;
|
||||
border: 1px solid rgb(255 255 255 / 0.09);
|
||||
border-radius: 999px;
|
||||
padding: 0 0.65rem;
|
||||
font: inherit;
|
||||
font-size: 0.53rem;
|
||||
font-weight: 760;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.camera-slot__head-actions .camera-slot__activation:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
.floating-observation-window__status {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.54rem;
|
||||
}
|
||||
|
||||
.floating-observation-window__footer {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.53rem;
|
||||
}
|
||||
|
||||
.floating-observation-window .nodedc-workspace-window__body {
|
||||
background: #06070a;
|
||||
}
|
||||
|
||||
.floating-observation-window.nodedc-material-rim::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.floating-observation-window[data-active="true"] {
|
||||
box-shadow: 0 1.5rem 4rem rgb(0 0 0 / 0.34);
|
||||
}
|
||||
|
||||
.floating-observation-window--hidden {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.observation-timeline {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
border-radius: 0.9rem;
|
||||
background: rgb(9 10 13 / 0.78);
|
||||
padding: 0.4rem;
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.observation-timeline[data-accumulation="true"] {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.observation-timeline__playback {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: auto auto minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.observation-timeline__playback > .nodedc-select-anchor {
|
||||
display: inline-flex;
|
||||
width: auto;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.observation-timeline__transport {
|
||||
width: var(--nodedc-control-height-compact);
|
||||
padding: 0;
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
}
|
||||
|
||||
.observation-timeline__accumulation {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
padding: 0 0.45rem;
|
||||
}
|
||||
|
||||
.observation-timeline__accumulation span,
|
||||
.observation-timeline__accumulation code {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.55rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.observation-timeline__accumulation code {
|
||||
min-width: 2.65rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.observation-timeline__track {
|
||||
width: 100%;
|
||||
height: 0.3rem;
|
||||
appearance: none;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 0.08);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.observation-timeline__track::-webkit-slider-thumb {
|
||||
width: 0.72rem;
|
||||
height: 0.72rem;
|
||||
appearance: none;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.observation-timeline__track::-moz-range-thumb {
|
||||
width: 0.72rem;
|
||||
height: 0.72rem;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.observation-timeline__track:disabled {
|
||||
opacity: 0.46;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.observation-timeline__meta {
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
gap: 0.12rem;
|
||||
}
|
||||
|
||||
.observation-timeline__meta code,
|
||||
.observation-timeline__meta small {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.52rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.observation-timeline__follow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.38rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0.3rem 0.45rem;
|
||||
font-size: 0.52rem;
|
||||
font-weight: 820;
|
||||
}
|
||||
|
||||
.observation-timeline__follow:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.observation-timeline__follow::before {
|
||||
width: 0.4rem;
|
||||
height: 0.4rem;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-text-muted);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.observation-timeline__follow[data-active="true"] {
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.observation-timeline__follow[data-active="true"]::before {
|
||||
background: rgb(var(--nodedc-success-rgb));
|
||||
}
|
||||
|
||||
.scene-timeline {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
right: 0.75rem;
|
||||
bottom: 0.75rem;
|
||||
left: 0.75rem;
|
||||
}
|
||||
|
||||
.spatial-workspace[data-focused="true"] {
|
||||
min-height: 0;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.spatial-workspace[data-focused="true"] > .spatial-toolbar,
|
||||
.spatial-workspace[data-focused="true"] > .spatial-contract-strip {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cameras-workspace {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
||||
overflow: hidden;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.camera-workspace__catalog {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.8rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
.camera-grid {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(24rem, 100%), 1fr));
|
||||
grid-template-rows: none;
|
||||
grid-auto-rows: minmax(16rem, 1fr);
|
||||
gap: 0.75rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.camera-grid[data-count="1"] {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.camera-grid__empty {
|
||||
min-height: 18rem;
|
||||
border-radius: 1rem;
|
||||
background: #07080a;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.camera-slot {
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.camera-slot header,
|
||||
.camera-slot footer {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.camera-slot__head-actions,
|
||||
.camera-slot__status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.camera-slot__status {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.56rem;
|
||||
}
|
||||
|
||||
.camera-slot__body {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.camera-slot footer {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.camera-slot footer span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.camera-slot footer small {
|
||||
flex: 0 0 auto;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.53rem;
|
||||
}
|
||||
|
||||
.camera-timeline {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cameras-workspace[data-focused="true"] {
|
||||
height: 100%;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.cameras-workspace[data-focused="true"] > .workspace-lead,
|
||||
.cameras-workspace[data-focused="true"] > .camera-workspace__catalog {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cameras-workspace[data-focused="true"] .camera-grid {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.scene-status--top-left {
|
||||
top: 4.2rem;
|
||||
left: 0.6rem;
|
||||
}
|
||||
|
||||
.observation-timeline[data-accumulation="true"] {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.observation-timeline__playback {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.observation-timeline__playback > .nodedc-button:first-child,
|
||||
.observation-timeline__meta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.camera-grid {
|
||||
grid-template-columns: 1fr;
|
||||
grid-auto-rows: minmax(14rem, 1fr);
|
||||
}
|
||||
}
|
||||
@import "../../../../packages/spatial-ui/src/observation.css";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,83 +1 @@
|
||||
.scene-tool-window {
|
||||
--scene-tool-window-top: 5.75rem;
|
||||
--scene-tool-window-right: 27rem;
|
||||
position: fixed;
|
||||
top: var(--scene-tool-window-top);
|
||||
right: var(--scene-tool-window-right);
|
||||
max-height: calc(100vh - var(--scene-tool-window-top) - 1.5rem);
|
||||
}
|
||||
|
||||
.scene-tool-window--display {
|
||||
--scene-tool-window-top: 7.75rem;
|
||||
--scene-tool-window-right: 28.5rem;
|
||||
}
|
||||
|
||||
.scene-tool-window--layers {
|
||||
--scene-tool-window-top: 9.75rem;
|
||||
--scene-tool-window-right: 30rem;
|
||||
}
|
||||
|
||||
.scene-tool-window--layout {
|
||||
--scene-tool-window-top: 11.75rem;
|
||||
--scene-tool-window-right: 31.5rem;
|
||||
}
|
||||
|
||||
.nodedc-overlay:has(> .scene-tool-window[data-scene-active="true"]) {
|
||||
z-index: calc(var(--nodedc-layer-overlay) + 1);
|
||||
}
|
||||
|
||||
.nodedc-overlay[data-placement="center"] {
|
||||
z-index: calc(var(--nodedc-layer-overlay) + 2);
|
||||
}
|
||||
|
||||
.scene-window-state {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.scene-window-code {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
border-radius: var(--nodedc-radius-control-compact);
|
||||
background: var(--nodedc-field-bg);
|
||||
color: var(--nodedc-text-secondary);
|
||||
padding: 0.72rem 0.8rem;
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.scene-window-note {
|
||||
margin: 0;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 44rem) {
|
||||
.scene-tool-window,
|
||||
.scene-tool-window--display,
|
||||
.scene-tool-window--layers,
|
||||
.scene-tool-window--layout {
|
||||
--scene-tool-window-top: 4.75rem;
|
||||
--scene-tool-window-right: 0.875rem;
|
||||
max-height: calc(100vh - 5.625rem);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 44.01rem) and (max-width: 70rem) {
|
||||
.scene-tool-window,
|
||||
.scene-tool-window--display,
|
||||
.scene-tool-window--layers,
|
||||
.scene-tool-window--layout {
|
||||
--scene-tool-window-top: 13.5rem;
|
||||
--scene-tool-window-right: 1.5rem;
|
||||
max-height: calc(100vh - 15rem);
|
||||
}
|
||||
}
|
||||
@import "../../../../packages/spatial-ui/src/scene-windows.css";
|
||||
|
||||
@@ -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,484 +1 @@
|
||||
.spatial-workspace {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 34rem;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
gap: 0.65rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.spatial-toolbar {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.spatial-toolbar__actions {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.spatial-toolbar__view-switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
border: 1px solid rgb(255 255 255 / 0.07);
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
padding: 0.2rem;
|
||||
}
|
||||
|
||||
.spatial-toolbar__view-switch .nodedc-button {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.spatial-viewport-shell {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 1rem;
|
||||
background: #06070a;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.rerun-viewport,
|
||||
.rerun-viewport__canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.rerun-viewport__canvas[data-presented="false"] {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.recorded-session-preloaders {
|
||||
position: fixed;
|
||||
left: -10000px;
|
||||
top: -10000px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.rerun-viewport {
|
||||
--rerun-native-chrome-height: 72px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* The upstream Rerun canvas keeps three fixed 24px rows even after its
|
||||
panels are overridden: the native top row, recording tab and view tab.
|
||||
They are drawn inside WASM and cannot be styled independently, so crop the
|
||||
fixed native chrome while keeping the actual 3D viewport full-height. */
|
||||
.rerun-viewport__canvas {
|
||||
top: calc(-1 * var(--rerun-native-chrome-height));
|
||||
bottom: auto;
|
||||
height: calc(100% + var(--rerun-native-chrome-height));
|
||||
}
|
||||
|
||||
.rerun-viewport__runtime {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.rerun-viewport__canvas canvas {
|
||||
display: block;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.rerun-viewport__camera-lock {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: calc(46% - 2px);
|
||||
cursor: default;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.rerun-viewport:is([data-status="loading"], [data-status="error"])
|
||||
.rerun-viewport__canvas {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.rerun-viewport__notice {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
border: 0;
|
||||
border-radius: 1rem;
|
||||
background: rgb(10 11 14 / 0.82);
|
||||
padding: 0.9rem 1rem;
|
||||
transform: translate(-50%, -50%);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.rerun-viewport__notice strong,
|
||||
.rerun-viewport__notice span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.rerun-viewport__notice strong {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.rerun-viewport__notice span {
|
||||
margin-top: 0.18rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.61rem;
|
||||
}
|
||||
|
||||
.rerun-viewport__notice--error {
|
||||
background: rgb(10 11 14 / 0.9);
|
||||
}
|
||||
|
||||
.rerun-viewport__retry {
|
||||
margin-top: 0.65rem;
|
||||
border: 1px solid var(--station-hairline-strong);
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 0.08);
|
||||
padding: 0.42rem 0.7rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
font: inherit;
|
||||
font-size: 0.61rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rerun-viewport__retry:hover,
|
||||
.rerun-viewport__retry:focus-visible {
|
||||
background: rgb(255 255 255 / 0.14);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.busy-indicator {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
flex: 0 0 1rem;
|
||||
border: 2px solid rgb(255 255 255 / 0.12);
|
||||
border-top-color: var(--nodedc-text-primary);
|
||||
border-radius: 50%;
|
||||
animation: viewer-spin 900ms linear infinite;
|
||||
}
|
||||
|
||||
@keyframes viewer-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.empty-spatial-stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
background: #06070a;
|
||||
}
|
||||
|
||||
.empty-spatial-stage__grid {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.spatial-axis {
|
||||
position: absolute;
|
||||
right: 2rem;
|
||||
bottom: 5.2rem;
|
||||
width: 4.4rem;
|
||||
height: 4.4rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.53rem;
|
||||
}
|
||||
|
||||
.spatial-axis::before,
|
||||
.spatial-axis::after {
|
||||
position: absolute;
|
||||
bottom: 0.65rem;
|
||||
left: 0.75rem;
|
||||
width: 2.7rem;
|
||||
height: 1px;
|
||||
background: rgb(255 255 255 / 0.2);
|
||||
content: "";
|
||||
transform-origin: left center;
|
||||
}
|
||||
|
||||
.spatial-axis::before { transform: rotate(-24deg); }
|
||||
.spatial-axis::after { transform: rotate(-90deg); }
|
||||
.spatial-axis span { position: absolute; }
|
||||
.spatial-axis span[data-axis="x"] { right: 0; bottom: 0; color: var(--nodedc-text-muted); }
|
||||
.spatial-axis span[data-axis="y"] { top: 0; left: 0.55rem; color: var(--nodedc-text-muted); }
|
||||
.spatial-axis span[data-axis="z"] { right: 0.55rem; bottom: 2.2rem; color: var(--nodedc-text-muted); }
|
||||
|
||||
.empty-spatial-stage__message {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
display: grid;
|
||||
max-width: 25rem;
|
||||
justify-items: center;
|
||||
gap: 0.62rem;
|
||||
transform: translate(-50%, -58%);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-spatial-stage__icon {
|
||||
display: grid;
|
||||
width: 3.4rem;
|
||||
height: 3.4rem;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: rgb(255 255 255 / 0.035);
|
||||
color: var(--nodedc-text-secondary);
|
||||
}
|
||||
|
||||
.empty-spatial-stage__message strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.empty-spatial-stage__message p {
|
||||
margin: 0;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.67rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.scene-status,
|
||||
.scene-metrics,
|
||||
.scene-adapter-note,
|
||||
.scene-selection,
|
||||
.scene-timeline {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
border: 0;
|
||||
background: rgb(9 10 13 / 0.74);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.scene-status--top-left {
|
||||
top: 0.85rem;
|
||||
left: 0.85rem;
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
gap: 0.42rem;
|
||||
border-radius: 0.9rem;
|
||||
padding: 0.7rem;
|
||||
}
|
||||
|
||||
.scene-status small {
|
||||
max-width: 18rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.57rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.scene-metrics {
|
||||
top: 0.85rem;
|
||||
right: 0.85rem;
|
||||
display: grid;
|
||||
min-width: 10rem;
|
||||
border-radius: 0.9rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
}
|
||||
|
||||
.scene-metrics > div {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
border-bottom: 0;
|
||||
padding: 0.38rem 0;
|
||||
}
|
||||
|
||||
.scene-metrics > div:last-child { border-bottom: 0; }
|
||||
.scene-metrics span { color: var(--nodedc-text-muted); font-size: 0.56rem; }
|
||||
.scene-metrics strong { color: var(--nodedc-text-primary); font-size: 0.72rem; }
|
||||
.scene-metrics small { color: var(--nodedc-text-muted); font-size: 0.52rem; font-weight: 500; }
|
||||
|
||||
.scene-adapter-note {
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
display: flex;
|
||||
max-width: 34rem;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
border-radius: 0.9rem;
|
||||
color: var(--nodedc-text-secondary);
|
||||
padding: 0.75rem 0.9rem;
|
||||
font-size: 0.63rem;
|
||||
line-height: 1.4;
|
||||
transform: translate(-50%, 5.2rem);
|
||||
}
|
||||
|
||||
.scene-adapter-note > svg { flex: 0 0 auto; color: var(--nodedc-text-secondary); }
|
||||
|
||||
.scene-selection {
|
||||
right: 0.85rem;
|
||||
bottom: 4.6rem;
|
||||
display: grid;
|
||||
max-width: 19rem;
|
||||
gap: 0.2rem;
|
||||
border-radius: 0.85rem;
|
||||
padding: 0.65rem 0.75rem;
|
||||
}
|
||||
|
||||
.scene-selection span,
|
||||
.scene-selection small { color: var(--nodedc-text-muted); font-size: 0.55rem; }
|
||||
.scene-selection strong { overflow: hidden; font-size: 0.66rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.scene-operation-status-stack {
|
||||
position: absolute;
|
||||
z-index: 11;
|
||||
left: 0.85rem;
|
||||
bottom: 7.15rem;
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.scene-operation-status {
|
||||
display: inline-flex;
|
||||
max-width: min(24rem, calc(100% - 1.7rem));
|
||||
align-items: center;
|
||||
gap: 0.42rem;
|
||||
border: 1px solid rgb(255 255 255 / 0.07);
|
||||
border-radius: 999px;
|
||||
background: rgb(9 10 13 / 0.7);
|
||||
color: var(--nodedc-text-muted);
|
||||
padding: 0.42rem 0.65rem;
|
||||
font: inherit;
|
||||
font-size: 0.52rem;
|
||||
font-weight: 680;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.scene-operation-status .busy-indicator {
|
||||
width: 0.66rem;
|
||||
height: 0.66rem;
|
||||
flex-basis: 0.66rem;
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
.scene-operation-status--action {
|
||||
color: var(--nodedc-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.scene-operation-status--action:hover,
|
||||
.scene-operation-status--action:focus-visible {
|
||||
border-color: rgb(255 255 255 / 0.14);
|
||||
background: rgb(20 21 25 / 0.82);
|
||||
color: var(--nodedc-text-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.scene-operation-status--error {
|
||||
color: rgb(var(--nodedc-danger-rgb));
|
||||
}
|
||||
|
||||
.scene-timeline {
|
||||
right: 0.75rem;
|
||||
bottom: 0.75rem;
|
||||
left: 0.75rem;
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: auto auto minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
border-radius: 0.9rem;
|
||||
padding: 0.4rem;
|
||||
}
|
||||
|
||||
.scene-timeline__track {
|
||||
height: 0.3rem;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 0.08);
|
||||
}
|
||||
|
||||
.scene-timeline__track span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.scene-timeline__track[data-disabled="true"] { opacity: 0.46; }
|
||||
.scene-timeline code { color: var(--nodedc-text-muted); font-size: 0.57rem; }
|
||||
|
||||
.scene-timeline__follow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.38rem;
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
background: transparent;
|
||||
color: var(--nodedc-text-muted);
|
||||
padding: 0.3rem 0.45rem;
|
||||
font-size: 0.52rem;
|
||||
font-weight: 820;
|
||||
}
|
||||
|
||||
.scene-timeline__follow::before {
|
||||
width: 0.4rem;
|
||||
height: 0.4rem;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-text-muted);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.scene-timeline__follow[data-active="true"] {
|
||||
background: transparent;
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.scene-timeline__follow[data-active="true"]::before {
|
||||
background: rgb(var(--nodedc-success-rgb));
|
||||
}
|
||||
|
||||
.spatial-contract-strip {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.45rem 1rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.57rem;
|
||||
}
|
||||
|
||||
.spatial-contract-strip span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.38rem;
|
||||
}
|
||||
|
||||
.spatial-contract-strip i {
|
||||
width: 0.42rem;
|
||||
height: 0.42rem;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
.spatial-contract-strip i[data-state="ready"] { background: var(--nodedc-text-primary); }
|
||||
.spatial-contract-strip i[data-state="contract"] { background: rgb(var(--nodedc-warning-rgb)); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.busy-indicator { animation: none; }
|
||||
}
|
||||
@import "../../../../packages/spatial-ui/src/spatial.css";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import {SpatialScene, EmptySpatialStage} from '../../../../packages/spatial-ui/src';
|
||||
import {SpatialToolbarActions} from "../../../../packages/spatial-ui/src/SpatialToolbarActions";
|
||||
import { VehiclesWorkspace } from "./fleet/VehiclesWorkspace";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button, GlassSurface, Icon, StatusBadge } from "@nodedc/ui-react";
|
||||
@@ -32,7 +34,6 @@ import {
|
||||
type WorkspaceDefinition,
|
||||
} from "../productModel";
|
||||
import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from "../presentation";
|
||||
import type { SceneSettings } from "../sceneSettings";
|
||||
import type { WorkspaceRendererProps } from "./contracts";
|
||||
import { DatasetGatewayWorkspace } from "./DatasetGatewayWorkspace";
|
||||
import { ArtifactHealthWorkspace } from "./data/ArtifactHealthWorkspace";
|
||||
@@ -90,26 +91,6 @@ function WorkspaceLead({ definition, note }: { definition: WorkspaceDefinition;
|
||||
</section>
|
||||
);
|
||||
}
|
||||
function EmptySpatialStage({ settings }: { settings: SceneSettings }) {
|
||||
return (
|
||||
<div className="empty-spatial-stage" data-grid={settings.showGrid ? "true" : undefined}>
|
||||
<div className="empty-spatial-stage__grid" aria-hidden="true" />
|
||||
<div className="spatial-axis" aria-hidden="true">
|
||||
<span data-axis="x">X</span>
|
||||
<span data-axis="z">Z</span>
|
||||
</div>
|
||||
<div className="empty-spatial-stage__message">
|
||||
<span className="empty-spatial-stage__icon"><Icon name="globe" size={20} /></span>
|
||||
<strong>Визуальный источник не назначен</strong>
|
||||
<p>
|
||||
Область сцены не подставляет демонстрационные точки. Назначьте RRD или Rerun gRPC, чтобы
|
||||
открыть реальные данные.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SpatialWorkspace({
|
||||
state,
|
||||
sourceUrl,
|
||||
@@ -411,14 +392,9 @@ function SpatialWorkspace({
|
||||
: null,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="spatial-workspace"
|
||||
data-focused={pointCloudFocused || floatingSourceMaximized ? "true" : undefined}
|
||||
>
|
||||
<div className="spatial-toolbar" data-viewer-controls="v1">
|
||||
<div className="spatial-toolbar__actions">
|
||||
{recordedPerceptionSupported || livePerceptionAvailable ? (
|
||||
return <SpatialScene viewportRef={viewportRef} focused={pointCloudFocused||floatingSourceMaximized}
|
||||
primaryFocused={pointCloudFocused} mediaMaximized={floatingSourceMaximized}
|
||||
toolbar={<> {recordedPerceptionSupported || livePerceptionAvailable ? (
|
||||
<div className="spatial-toolbar__view-switch" role="group" aria-label="Слои распознавания сцены">
|
||||
<Button
|
||||
size="compact"
|
||||
@@ -498,25 +474,7 @@ function SpatialWorkspace({
|
||||
Сброс вида
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="compact" variant="secondary" icon={<Icon name="network" />} onClick={navigation.openSource}>
|
||||
Движок
|
||||
</Button>
|
||||
<Button size="compact" variant="secondary" icon={<Icon name="list" />} onClick={navigation.openLayers}>
|
||||
Слои
|
||||
</Button>
|
||||
<Button size="compact" variant="secondary" icon={<Icon name="sliders" />} onClick={navigation.openDisplay}>
|
||||
Отображение
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={viewportRef}
|
||||
className="spatial-viewport-shell"
|
||||
data-primary-focused={pointCloudFocused ? "true" : undefined}
|
||||
data-media-maximized={floatingSourceMaximized ? "true" : undefined}
|
||||
>
|
||||
{sourceUrl.trim() && pointCloudVisible && !intentionalSourceEnd ? (
|
||||
<SpatialToolbarActions openSource={navigation.openSource} openLayers={navigation.openLayers} openDisplay={navigation.openDisplay}/></>} renderer={<> {sourceUrl.trim() && pointCloudVisible && !intentionalSourceEnd ? (
|
||||
<RerunViewport
|
||||
profile={rerunViewerProfile}
|
||||
sceneSettings={sceneSettings}
|
||||
@@ -530,20 +488,15 @@ function SpatialWorkspace({
|
||||
) : (
|
||||
<EmptySpatialStage settings={sceneSettings} />
|
||||
)}
|
||||
|
||||
{spatialControls && !recordedSource ? (
|
||||
<div className="scene-device-controls">
|
||||
<spatialControls.View
|
||||
</>}
|
||||
deviceControls={spatialControls&&!recordedSource?( <spatialControls.View
|
||||
model={spatialControls.model}
|
||||
host={{
|
||||
openSpatialScene: () => navigation.openView("spatial-scene"),
|
||||
activateAutomaticSpatialSource: navigation.activateAutomaticSpatialSource,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{pointCloudFocused ? (
|
||||
/>):null}
|
||||
sourceControls={<> {pointCloudFocused ? (
|
||||
<button
|
||||
type="button"
|
||||
className="scene-focus-exit"
|
||||
@@ -574,22 +527,9 @@ function SpatialWorkspace({
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className="scene-status scene-status--top-left"
|
||||
aria-hidden={pointCloudFocused || floatingSourceMaximized}
|
||||
>
|
||||
<span className="section-eyebrow">ВИЗУАЛЬНЫЙ ДВИЖОК</span>
|
||||
<StatusBadge tone={viewerStatusTone}>{viewerStatusLabel}</StatusBadge>
|
||||
{!intentionalSourceEnd && viewerMessage ? <small>{viewerMessage}</small> : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="scene-metrics"
|
||||
aria-label="Метрики пространственной сцены"
|
||||
aria-hidden={pointCloudFocused || floatingSourceMaximized}
|
||||
>
|
||||
<div>
|
||||
</>}
|
||||
status={{label:viewerStatusLabel,tone:viewerStatusTone,message:!intentionalSourceEnd?viewerMessage:undefined}}
|
||||
metrics={<> <div>
|
||||
<span>КАДР/С</span>
|
||||
<strong>{formatNumber(frameRate)}</strong>
|
||||
</div>
|
||||
@@ -610,10 +550,7 @@ function SpatialWorkspace({
|
||||
{aiFrameRate === null ? null : <small> · {formatNumber(aiFrameRate)} Гц</small>}
|
||||
</strong>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!pointCloudFocused && !floatingSourceMaximized && state?.sourceMode && state.sourceMode !== "idle" && !sourceUrl.trim() ? (
|
||||
) : null}</>} overlays={<> {!pointCloudFocused && !floatingSourceMaximized && state?.sourceMode && state.sourceMode !== "idle" && !sourceUrl.trim() ? (
|
||||
<div className="scene-adapter-note">
|
||||
<Icon name="alert" />
|
||||
<span>
|
||||
@@ -668,14 +605,8 @@ function SpatialWorkspace({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{presentedViewerStatus === "ready" && !floatingSourceMaximized ? (
|
||||
<div className="scene-navigation-hint" aria-label="Навигация по 3D-сцене">
|
||||
<span>Колесо · зум к курсору</span>
|
||||
<span>WASD · свободный проход</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!pointCloudFocused && !floatingSourceMaximized && recordedPlaybackReady ? (
|
||||
</>}
|
||||
navigationReady={presentedViewerStatus==='ready'} timeline={<> {!pointCloudFocused && !floatingSourceMaximized && recordedPlaybackReady ? (
|
||||
<ObservationTimeline
|
||||
active={presentedViewerStatus === "ready"}
|
||||
sourceCount={Math.max(1, (unifiedPerception ? 2 : 1) + presentedMediaSourceCount)}
|
||||
@@ -698,8 +629,8 @@ function SpatialWorkspace({
|
||||
className="scene-timeline"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{visibleMediaSources.map((source, index) => (
|
||||
</>}
|
||||
media={<> {visibleMediaSources.map((source, index) => (
|
||||
<FloatingObservationWindow
|
||||
key={source.id}
|
||||
source={source}
|
||||
@@ -750,10 +681,7 @@ function SpatialWorkspace({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="spatial-contract-strip">
|
||||
) : null}</>} footer={ <div className="spatial-contract-strip">
|
||||
<span><i data-state="ready" />Облако точек</span>
|
||||
<span><i data-state="ready" />Траектория</span>
|
||||
<span><i data-state="ready" />Преобразования</span>
|
||||
@@ -762,9 +690,7 @@ function SpatialWorkspace({
|
||||
<span><i data-state={segmentationActive ? "ready" : "contract"} />Сегментация</span>
|
||||
<span><i data-state={cuboids3dActive ? "ready" : "contract"} />Кубы 3D</span>
|
||||
<span><i data-state="contract" />Компоновка</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
</div>}/>;
|
||||
}
|
||||
|
||||
function CameraSourceCard({
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import {useState} from 'react';
|
||||
import {ActivityIndicator,Button,Icon,ResourceList,ResourceRow,Select,SettingsCard,StatusBadge,TextField,ToastStack} from '@nodedc/ui-react';
|
||||
import {useBoardMonitor,monitorSeries,monitorValue} from '../../core/fleet/boardMonitor';
|
||||
import {TelemetrySeries} from '../../components/system/TelemetrySeries';
|
||||
import '../../styles/system-telemetry.css';
|
||||
import './board-monitor.css';
|
||||
|
||||
const eventLabels:Record<string,string>={'ui-error':'Ошибка интерфейса','ui-rejection':'Ошибка асинхронной операции','ui-render-error':'Ошибка представления','system-oom':'Нехватка памяти','gpu-reset':'Сброс GPU','disk-error':'Ошибка диска','usb-disconnected':'Отключение USB','usb-error':'Ошибка USB','service-failed':'Сбой службы','ui-process-exit':'Завершение процесса окна','ui-load-failed':'Ошибка загрузки окна'};
|
||||
const time=(value:number)=>new Date(value*1000).toLocaleString('ru-RU');
|
||||
export function BoardMonitorView({vehicle,name,back}:{vehicle:string;name:string;back:()=>void}) {
|
||||
const [metric,setMetric]=useState('cpu.usage'),[group,setGroup]=useState('CPU'),[window,setWindow]=useState(900),[end,setEnd]=useState<number|null>(null),[mode,setMode]=useState<'min'|'max'|'mean'>('max');
|
||||
const {value,error,dismissError}=useBoardMonitor(vehicle,metric,window,end);
|
||||
const definitions=value?.definitions??{},definition=definitions[metric];
|
||||
const groups=[...new Set(Object.values(definitions).map(item=>item.group))];
|
||||
const metrics=Object.entries(definitions).filter(([,item])=>item.group===group);
|
||||
const latest=value?.latest;
|
||||
return <div className="board-monitor">
|
||||
<div><Button onClick={back}><Icon name="chevron-left"/>К аппарату</Button></div>
|
||||
<SettingsCard title="Мониторинг системы БК" description={name} actions={<StatusBadge tone={value?.fresh&&!error?'success':'neutral'}>{value?.fresh&&!error?'Данные поступают':'Нет свежих данных'}</StatusBadge>}>
|
||||
{latest&&<p>Измерено на БК: {time(latest.at)}. {value?.storage==='ready'?'Локальная история записывается.':'Запись локальной истории недоступна.'} {value?.backlog?'История догружается в Mission Core.':''}</p>}
|
||||
{value?.database_bytes!=null&&<p>Архив на БК: {monitorValue(value.database_bytes,'bytes')} из {monitorValue(value.database_budget_bytes,'bytes')}. {value.archive_since?`Копия в Mission Core с ${time(value.archive_since)}.`:''}</p>}
|
||||
</SettingsCard>
|
||||
{!value?<ActivityIndicator label="Получение мониторинга БК"/>:!value.available?<SettingsCard title="История БК пока не получена" description="Данные появятся после подключения бортового сборщика телеметрии."/>:<>
|
||||
<SettingsCard title="История нагрузки">
|
||||
<div className="board-monitor-controls">
|
||||
<Select label="Ресурс" value={group} options={groups.map(label=>({value:label,label}))} onChange={next=>{setGroup(next);const first=Object.entries(definitions).find(([,item])=>item.group===next);if(first)setMetric(first[0]);}}/>
|
||||
<Select label="Показатель" value={metric} options={metrics.map(([key,item])=>({value:key,label:`${item.resource} · ${item.label}`}))} onChange={setMetric}/>
|
||||
<Select label="Период" value={String(window)} options={[{value:'900',label:'15 минут'},{value:'3600',label:'1 час'},{value:'21600',label:'6 часов'},{value:'86400',label:'24 часа'},{value:'604800',label:'7 дней'}]} onChange={next=>setWindow(Number(next))}/>
|
||||
<Select label="Значение в интервале" value={mode} options={[{value:'max',label:'Максимум'},{value:'mean',label:'Среднее'},{value:'min',label:'Минимум'}]} onChange={next=>setMode(next as typeof mode)}/>
|
||||
</div>
|
||||
<div className="board-monitor-controls"><Button onClick={()=>setEnd(end===null?Date.now()/1000:null)}>{end===null?'Зафиксировать время':'В реальном времени'}</Button>{end!==null&&<TextField label="Конец периода" type="datetime-local" step="1" value={new Date((end-new Date(end*1000).getTimezoneOffset()*60)*1000).toISOString().slice(0,19)} onChange={event=>{const next=new Date(event.target.value).getTime()/1000;if(Number.isFinite(next))setEnd(next);}}/>}</div>
|
||||
<TelemetrySeries label={definition?`${definition.resource} · ${definition.label}`:'Загрузка CPU'} resource="Последний интервал периода" value={monitorValue(value.series.at(-1)?.[mode],definition?.unit)} values={monitorSeries(value,mode)} ceiling={definition?.unit==='%'?100:undefined}/>
|
||||
<p>{time(value.start)} — {time(value.end)} · интервал {value.bucket_seconds} с. Разрывы означают отсутствие измерений.</p>
|
||||
{!value.series.length&&<p>За выбранный период измерений нет.</p>}
|
||||
</SettingsCard>
|
||||
<SettingsCard title={`Последние измерения · ${group}`} description={latest?time(latest.at):'Ожидание измерений'}>
|
||||
<ResourceList aria-label="Показатели системы">{metrics.map(([key,item])=><li key={key}><ResourceRow title={`${item.resource} · ${item.label}`} description={latest?.values[key]==null?item.reason??'Счётчик недоступен':undefined} metadata={monitorValue(latest?.values[key],item.unit)}/></li>)}</ResourceList>
|
||||
</SettingsCard>
|
||||
<SettingsCard title="События системы и приложения" description="За выбранный период">
|
||||
{value.events.length?<ResourceList aria-label="События БК">{value.events.map((event,index)=><li key={`${event.at}-${index}`}><ResourceRow title={eventLabels[event.code]??'Событие системы'} description={time(event.at)}/></li>)}</ResourceList>:<p>Событий не получено.</p>}
|
||||
</SettingsCard>
|
||||
</>}
|
||||
<ToastStack items={error?[{id:'monitor-error',title:error,tone:'error',durationMs:null}]:[]} onDismiss={dismissError}/>
|
||||
</div>;
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
import {useMemo} from 'react';
|
||||
import {useDevicePluginHost} from '../../core/device-plugins/DevicePluginHost';
|
||||
import {createIsolatedRerunHost} from '../../components/rerun/isolatedRerunHost';
|
||||
import {SensorWorkspace} from '../../../../../packages/sensor-ui/src/SensorWorkspace';
|
||||
import type {SensorInventory,SensorTransport} from '../../../../../packages/sensor-ui/src/contracts';
|
||||
import {fleetRequest} from '../../core/fleet/useFleet';
|
||||
import {createFleetSensorTransport} from '../../core/fleet/sensorTransport';
|
||||
export function VehicleSensors({vehicleID,enabled,onDetailChange}:{vehicleID:string;enabled:boolean;onDetailChange:(open:boolean)=>void}){
|
||||
const transport=useMemo<SensorTransport>(()=>({
|
||||
inventory:async()=>{const fleet=await fleetRequest<{items:{id:string;connectivity:string;sensor_state:SensorInventory}[]}>();const value=fleet.items.find(v=>v.id===vehicleID);if(!value)throw new Error('Аппарат не найден.');return {...value.sensor_state,fresh:value.connectivity==='online'};},
|
||||
subscribe:(receive,unavailable)=>{const events=new EventSource('/api/v1/fleet/events');events.onmessage=e=>{try{const value=JSON.parse(e.data).items.find((v:{id:string})=>v.id===vehicleID);if(value)receive({...value.sensor_state,fresh:value.connectivity==='online'});else unavailable();}catch{unavailable();}};events.onerror=unavailable;return()=>events.close();},
|
||||
submit:value=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/operations`,'POST',value),
|
||||
operation:id=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/operations/${encodeURIComponent(id)}`),
|
||||
}),[vehicleID]);
|
||||
return <SensorWorkspace key={vehicleID} transport={transport} enabled={enabled} onDetailChange={onDetailChange}/>;
|
||||
const {registry}=useDevicePluginHost();
|
||||
const sensorContributions=registry.sensorContributions;
|
||||
const transport=useMemo(()=>createFleetSensorTransport(vehicleID),[vehicleID]);
|
||||
return <SensorWorkspace contributions={sensorContributions} createRerunHost={createIsolatedRerunHost} key={vehicleID} transport={transport} enabled={enabled} onDetailChange={onDetailChange}/>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {BoardObservationCenter} from './observation/BoardObservationCenter';
|
||||
import {BoardMonitorView} from './BoardMonitorView';
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ActivityIndicator, Button, ConfirmationModal, Icon, IconButton, ResourceList, ResourceRow, Select, SettingsCard, StatusBadge, TextAreaField, TextField, Window, WindowFooterActions } from "@nodedc/ui-react";
|
||||
import { LoadingRegion, Button, ConfirmationModal, Icon, IconButton, ResourceList, ResourceRow, Select, SettingsCard, StatusBadge, TextAreaField, TextField, Window, WindowFooterActions } from "@nodedc/ui-react";
|
||||
import { fleetRequest, useFleet, type FleetPreview, type Vehicle } from "../../core/fleet/useFleet";
|
||||
import "./fleet.css";
|
||||
import { VehicleSensors } from "./VehicleSensors";
|
||||
@@ -19,6 +21,8 @@ export function VehiclesWorkspace({ createRequest = 0 }: { createRequest?: numbe
|
||||
const [error, setError] = useState("");
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [sensorOpen, setSensorOpen] = useState(false);
|
||||
const [monitorOpen,setMonitorOpen]=useState(false);
|
||||
const [observationOpen,setObservationOpen]=useState(false);
|
||||
const [revoking, setRevoking] = useState<Vehicle | null>(null);
|
||||
const lastCreateRequest = useRef(createRequest);
|
||||
useEffect(() => { if (createRequest !== lastCreateRequest.current) { lastCreateRequest.current = createRequest; setAdding(true); setError(""); } }, [createRequest]);
|
||||
@@ -40,6 +44,8 @@ export function VehiclesWorkspace({ createRequest = 0 }: { createRequest?: numbe
|
||||
finally { setPending(false); }
|
||||
}
|
||||
const detail = fleet.items?.find(item => item.id === selected);
|
||||
if(detail&&observationOpen)return <BoardObservationCenter key={detail.id} vehicleID={detail.id} name={detail.name} enabled={!fleet.error&&detail.enrollment==="paired"&&detail.connectivity==="online"} back={()=>{setObservationOpen(false);setSensorOpen(false);}}/>;
|
||||
if(detail&&monitorOpen)return <BoardMonitorView vehicle={detail.id} name={detail.name} back={()=>setMonitorOpen(false)}/>;
|
||||
return <div className="fleet-workspace">
|
||||
{fleet.error && <p role="alert">{fleet.error}</p>}
|
||||
{!adding && error && <p role="alert">{error}</p>}
|
||||
@@ -51,11 +57,13 @@ export function VehiclesWorkspace({ createRequest = 0 }: { createRequest?: numbe
|
||||
<div><dt>Последняя связь</dt><dd>{detail.last_seen ? new Date(detail.last_seen * 1000).toLocaleString("ru-RU") : "Соединение ещё не получено"}</dd></div>
|
||||
{detail.host && <><div><dt>Имя БК в системе</dt><dd>{detail.host.hostname}</dd></div><div><dt>Операционная система</dt><dd>{detail.host.os}</dd></div><div><dt>Архитектура</dt><dd>{detail.host.architecture}</dd></div><div><dt>Процессоры</dt><dd>{detail.host.cpus}</dd></div><div><dt>Память</dt><dd>{detail.host.memory_kib ? `${(detail.host.memory_kib / 1024 / 1024).toFixed(1)} ГиБ` : "Нет сведений"}</dd></div></>}
|
||||
</dl>
|
||||
{detail.enrollment !== "revoked" && <Button onClick={() => setRevoking(detail)}>Отозвать привязку БК</Button>}
|
||||
<div className="fleet-board-actions"><Button onClick={()=>setMonitorOpen(true)}><Icon name="activity"/>Мониторинг системы БК</Button>
|
||||
<Button onClick={()=>setObservationOpen(true)}><Icon name="eye"/>Центр наблюдения</Button>
|
||||
{detail.enrollment !== "revoked" && <Button onClick={() => setRevoking(detail)}>Отозвать привязку БК</Button>}</div>
|
||||
</SettingsCard>}
|
||||
<SettingsCard title="Устройства аппарата"><VehicleSensors onDetailChange={setSensorOpen} vehicleID={detail.id} enabled={!fleet.error && detail.enrollment === "paired" && detail.connectivity === "online"} /></SettingsCard>
|
||||
</> : !fleet.items ? <ActivityIndicator label="Получаем аппараты" /> : fleet.items.length === 0 ? <SettingsCard title="Аппаратов пока нет" description="Добавьте аппарат по приглашению из Mission Core Node на его бортовом компьютере."><Button onClick={() => setAdding(true)}>Добавить аппарат</Button></SettingsCard> : <ResourceList aria-label="Аппараты">{fleet.items.map(item => <li key={item.id}><ResourceRow icon={<Icon name="apps" />} title={item.name} description={`${platformLabel(item.platform)} · бортовой компьютер`} status={<StatusBadge tone={!fleet.error && item.enrollment === "paired" && item.connectivity === "online" ? "success" : "neutral"}>{fleet.error ? "Нет свежих данных" : statusLabel(item)}</StatusBadge>} actions={<IconButton label={`Конфигурация: ${item.name}`} onClick={() => setSelected(item.id)}><Icon name="eye" /></IconButton>} /></li>)}</ResourceList>}
|
||||
<Window open={adding} title="Добавить аппарат" subtitle="Подключить бортовой компьютер по приглашению Node" size="md" closeOnBackdrop={false} closeOnEscape={!pending} onClose={close} footer={<WindowFooterActions><Button disabled={pending} onClick={close}>Отмена</Button>{preview ? <Button disabled={pending || !name.trim()} onClick={() => void add()}>{pending ? "Добавляем…" : "Добавить аппарат"}</Button> : <Button type="submit" form="fleet-invitation" disabled={pending || !code.trim()}>{pending ? "Проверяем БК…" : "Проверить БК"}</Button>}</WindowFooterActions>}>
|
||||
</> : !fleet.items ? <LoadingRegion loading label="Получение аппаратов" /> : fleet.items.length === 0 ? <SettingsCard title="Аппаратов пока нет" description="Добавьте аппарат по приглашению из Mission Core Node на его бортовом компьютере."><Button onClick={() => setAdding(true)}>Добавить аппарат</Button></SettingsCard> : <ResourceList aria-label="Аппараты">{fleet.items.map(item => <li key={item.id}><ResourceRow icon={<Icon name="apps" />} title={item.name} description={`${platformLabel(item.platform)} · бортовой компьютер`} status={<StatusBadge tone={!fleet.error && item.enrollment === "paired" && item.connectivity === "online" ? "success" : "neutral"}>{fleet.error ? "Нет свежих данных" : statusLabel(item)}</StatusBadge>} actions={<><IconButton label={`Конфигурация: ${item.name}`} onClick={() => setSelected(item.id)}><Icon name="sliders" /></IconButton><IconButton label={`Центр наблюдения: ${item.name}`} onClick={() => {setSelected(item.id);setObservationOpen(true);}}><Icon name="eye" /></IconButton></>} /></li>)}</ResourceList>}
|
||||
<Window open={adding} title="Добавить аппарат" subtitle="Подключить бортовой компьютер по приглашению Node" size="md" closeOnBackdrop={false} closeOnEscape={!pending} onClose={close} footer={<WindowFooterActions><Button disabled={pending} onClick={close}>Отмена</Button>{preview ? <Button disabled={pending || !name.trim()} onClick={() => void add()}>{pending ? "Добавление…" : "Добавить аппарат"}</Button> : <Button type="submit" form="fleet-invitation" disabled={pending || !code.trim()}>{pending ? "Проверка БК…" : "Проверить БК"}</Button>}</WindowFooterActions>}>
|
||||
<form id="fleet-invitation" className="fleet-form" onSubmit={inspect} aria-busy={pending}>
|
||||
<Select label="Способ подключения" value="node" options={[{ value: "node", label: "С бортовым компьютером" }]} onChange={() => undefined} disabled={pending} />
|
||||
<Select label="Класс аппарата" value={platform} options={platforms} onChange={setPlatform} disabled={pending} />
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.board-monitor { display: grid; gap: var(--nodedc-space-4); min-width: 0; }
|
||||
.board-monitor-controls { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: var(--nodedc-space-3); align-items: end; margin-bottom: var(--nodedc-space-3); }
|
||||
.board-monitor p { font-size: var(--nodedc-font-size-sm); color: var(--nodedc-text-secondary); }
|
||||
@@ -7,3 +7,7 @@
|
||||
.fleet-facts { font-size: var(--nodedc-font-size-sm); line-height: 1.5; }
|
||||
.fleet-facts dt { color: var(--nodedc-text-secondary); }
|
||||
.fleet-facts dd { color: var(--nodedc-text-muted); }
|
||||
|
||||
.fleet-board-actions { display:flex; gap:var(--nodedc-space-3); }
|
||||
.fleet-board-actions > * { flex:1; }
|
||||
@media(max-width:600px) { .fleet-board-actions { flex-wrap:wrap; } }
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import {useEffect,useLayoutEffect,useMemo,useRef,useState} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
import {Button,DragDropRoot,DraggableItem,DropZone,GlassSurface,Icon,IconButton,LoadingRegion,Select,StatusBadge,Switch,Window} from '@nodedc/ui-react';
|
||||
import {useDevicePluginHost} from '../../../core/device-plugins/DevicePluginHost';
|
||||
import {createFleetSensorTransport} from '../../../core/fleet/sensorTransport';
|
||||
import {decodeObservationLayout,emptyObservationLayout,moveObservation,orderedObservationIDs} from '../../../core/fleet/observationLayout';
|
||||
import {createIsolatedRerunHost} from '../../../components/rerun/isolatedRerunHost';
|
||||
import {CameraObservation} from '../../../../../../packages/sensor-ui/src/CameraObservation';
|
||||
import {cameraObservationViews,observationKey,type SensorObservationView,type ObservationHeaderTargets} from '../../../../../../packages/sensor-ui/src/observation';
|
||||
import {ObservationDeck,ObservationMount} from './ObservationDeck';
|
||||
import {ObservationMap} from './ObservationMap';
|
||||
import {useObservationInventory} from './useObservationInventory';
|
||||
import './observation.css';
|
||||
|
||||
export function BoardObservationCenter({vehicleID,name,enabled,back}:{vehicleID:string;name:string;enabled:boolean;back:()=>void}){
|
||||
const {registry}=useDevicePluginHost(),transport=useMemo(()=>createFleetSensorTransport(vehicleID),[vehicleID]);
|
||||
const inventory=useObservationInventory(transport),storageKey=`missioncore.observation.v1:${vehicleID}`;
|
||||
const [layout,setLayout]=useState(()=>{try{return decodeObservationLayout(JSON.parse(localStorage.getItem(storageKey)??'null'));}catch{return emptyObservationLayout();}});
|
||||
const [layersOpen,setLayersOpen]=useState(false),[focused,setFocused]=useState<string|null>(null);
|
||||
const [full,setFull]=useState<string|null>(null),[expanded,setExpanded]=useState(false);
|
||||
const frame=useRef<HTMLDivElement>(null),[portal]=useState(()=>document.createElement('div'));
|
||||
useLayoutEffect(()=>{(expanded?document.body:frame.current!).append(portal);return()=>portal.remove();},[expanded,portal]);
|
||||
const mounts=useRef(new Map<string,HTMLElement>()),media=useRef(new Map<string,HTMLElement>()),headers=useRef(new Map<string,ObservationHeaderTargets>());
|
||||
const groups=(inventory?.items??[]).flatMap(device=>{
|
||||
const contribution=registry.sensorContributions.find(item=>item.kind===device.kind)?.observation;
|
||||
const views=contribution?.views(device)??cameraObservationViews(device);
|
||||
return views.length?[{device,views,Session:contribution?.Session??CameraObservation}]:[];
|
||||
});
|
||||
const sources:{key:string;deviceID?:string;view:SensorObservationView}[]=groups.flatMap(group=>group.views.map(view=>({key:observationKey(group.device.id,view.id),deviceID:group.device.id,view})));
|
||||
sources.push({key:'map',view:{id:'map',label:'Карта'}});
|
||||
for(const source of sources){if(!mounts.current.has(source.key)){mounts.current.set(source.key,document.createElement('div'));media.current.set(source.key,document.createElement('div'));headers.current.set(source.key,{statusTarget:document.createElement('div'),actionsTarget:document.createElement('div')});mounts.current.get(source.key)!.className='observation-panel-mount';media.current.get(source.key)!.className='observation-media-mount';}}
|
||||
const ids=orderedObservationIDs(layout,sources.map(source=>source.key)),visible=ids.filter(id=>!layout.hidden.includes(id));
|
||||
const effectiveFull=full&&visible.includes(full)?full:null;
|
||||
const active=sources.find(source=>source.key===focused);
|
||||
useEffect(()=>{try{localStorage.setItem(storageKey,JSON.stringify(layout));}catch{/* The workspace remains usable without browser storage. */}},[storageKey,layout]);
|
||||
useEffect(()=>{const escape=(event:KeyboardEvent)=>{if(event.key!=='Escape'||layersOpen||document.querySelector('[role=dialog], [role=listbox]'))return;if(effectiveFull){setFull(null);event.stopImmediatePropagation();}else if(expanded){setExpanded(false);event.stopImmediatePropagation();}};window.addEventListener('keydown',escape,true);return()=>window.removeEventListener('keydown',escape,true);},[effectiveFull,expanded,layersOpen]);
|
||||
const setVisible=(id:string,show:boolean)=>setLayout(value=>({...value,hidden:show?value.hidden.filter(key=>key!==id):[...new Set([...value.hidden,id])]}));
|
||||
const layerFor=(key:string,view:SensorObservationView)=>view.layers?.find(layer=>layer.value===layout.layers[key])?.value??view.layers?.[0]?.value??view.id;
|
||||
return <><div ref={frame}/>{createPortal(<section className={`observation-center${expanded?' observation-center--expanded':''}`} aria-label={`Центр наблюдения · ${name}`}>
|
||||
<header className="observation-toolbar"><div><h2>Центр наблюдения</h2><span>{name}</span></div><div className="observation-actions"><Button onClick={back}><Icon name="sliders"/>Конфигуратор</Button><Button onClick={()=>{setFocused(null);setLayersOpen(true);}}><Icon name="grid"/>Доступные слои</Button><IconButton label={expanded?'Восстановить размер центра':'Развернуть центр наблюдения'} onClick={()=>setExpanded(value=>!value)}><Icon name={expanded?'minimize':'expand'}/></IconButton></div></header>
|
||||
<div className="observation-summary"><StatusBadge tone={enabled&&inventory&&inventory.fresh!==false?'success':'neutral'}>{!inventory?'Получение источников':!enabled||inventory.fresh===false?'Нет свежих данных с БК':`Устройства: ${groups.length} · Окна: ${visible.length}`}</StatusBadge>{effectiveFull&&<Button onClick={()=>setFull(null)}>Все окна</Button>}</div>
|
||||
<DragDropRoot onDragEnd={({activeId,overId})=>{if(overId)setLayout(value=>moveObservation(value,ids,activeId,overId));}}>
|
||||
<div className="observation-deck">{!inventory?<LoadingRegion loading label="Получение визуальных источников"/>:visible.length?<ObservationDeck ids={effectiveFull?[effectiveFull]:visible} mounts={mounts.current} layout={layout} onSplit={(key,value)=>setLayout(current=>({...current,splits:{...current.splits,[key]:value}}))}/>:<div className="observation-empty"><p>Все окна скрыты.</p><Button onClick={()=>{setFocused(null);setLayersOpen(true);}}>Доступные слои</Button></div>}</div>
|
||||
{sources.filter(source=>visible.includes(source.key)).map(source=>createPortal(
|
||||
<DropZone id={source.key} className="observation-panel">
|
||||
<DraggableItem id={source.key} className="observation-panel-drag">{({handle})=>
|
||||
<GlassSurface tone="soft" radius="panel" padding="none" materialRim={false} className="observation-panel__surface">
|
||||
<header className="observation-panel__header">
|
||||
{handle}
|
||||
<div className="observation-panel__title"><h3 title={source.view.label}>{source.view.label}</h3><ObservationMount className="observation-header-slot" element={headers.current.get(source.key)!.statusTarget}/></div>
|
||||
<div className="observation-actions">
|
||||
{source.view.layers&&<Select className="observation-layer-select" label={`Слой · ${source.view.label}`} value={layerFor(source.key,source.view)} options={source.view.layers} onChange={value=>setLayout(current=>({...current,layers:{...current.layers,[source.key]:value}}))} variant="inline"/>}
|
||||
<ObservationMount className="observation-header-slot" element={headers.current.get(source.key)!.actionsTarget}/>
|
||||
{source.key!=='map'&&<IconButton label={`Настройки · ${source.view.label}`} onClick={()=>{setFocused(source.key);setLayersOpen(true);}}><Icon name="settings"/></IconButton>}
|
||||
<IconButton label={`${effectiveFull===source.key?'Восстановить':'Развернуть'} · ${source.view.label}`} onClick={()=>setFull(value=>value===source.key?null:source.key)}><Icon name={effectiveFull===source.key?'minimize':'expand'}/></IconButton>
|
||||
<IconButton label={`Скрыть · ${source.view.label}`} onClick={()=>setVisible(source.key,false)}><Icon name="close"/></IconButton>
|
||||
</div>
|
||||
</header>
|
||||
<ObservationMount element={media.current.get(source.key)!}/>
|
||||
</GlassSurface>
|
||||
}</DraggableItem>
|
||||
</DropZone>,mounts.current.get(source.key)!,source.key))}
|
||||
</DragDropRoot>
|
||||
{groups.map(({device,views,Session})=>visible.some(key=>views.some(view=>key===observationKey(device.id,view.id)))&&<Session key={device.id} device={device} transport={transport} enabled={enabled&&inventory?.fresh!==false&&device.online} createRerunHost={createIsolatedRerunHost} views={views.map(view=>({id:view.id,layer:layerFor(observationKey(device.id,view.id),view),target:media.current.get(observationKey(device.id,view.id))!,...headers.current.get(observationKey(device.id,view.id))!}))}/>)}
|
||||
{visible.includes('map')&&createPortal(<ObservationMap vehicleID={vehicleID} header={headers.current.get('map')!}/>,media.current.get('map')!,'map-session')}
|
||||
<Window open={layersOpen} onClose={()=>setLayersOpen(false)} title={active?active.view.label:'Доступные слои'} size="md"><div className="observation-settings">
|
||||
{!active&&<Select label="Расположение окон" value={layout.arrangement} options={[{value:'auto',label:'Автоматически'},{value:'columns',label:'В ряд'},{value:'rows',label:'Друг под другом'}]} onChange={value=>setLayout(current=>({...current,arrangement:value as typeof layout.arrangement}))}/>}
|
||||
{ids.filter(id=>!active||id===active.key).map(id=>{const source=sources.find(item=>item.key===id)!;return <div className="observation-layer" key={id}><Switch label={source.view.label} checked={visible.includes(id)} onChange={value=>setVisible(id,value)}/><Select label={`Позиция · ${source.view.label}`} value={String(ids.indexOf(id))} options={ids.map((_,i)=>({value:String(i),label:`Окно ${i+1}`}))} onChange={value=>setLayout(current=>moveObservation(current,ids,id,ids[Number(value)]))}/></div>;})}
|
||||
{active?.view.layers&&<Select label="Слой источника" value={layerFor(active.key,active.view)} options={active.view.layers} onChange={value=>setLayout(current=>({...current,layers:{...current.layers,[active.key]:value}}))}/>}
|
||||
<Button onClick={()=>{setLayout(emptyObservationLayout());setFull(null);}}>Восстановить расположение</Button>
|
||||
</div></Window>
|
||||
</section>,portal)}</>;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import {useLayoutEffect,useRef} from 'react';
|
||||
import {SplitPane} from '@nodedc/ui-react';
|
||||
import type {ObservationLayout} from '../../../core/fleet/observationLayout';
|
||||
|
||||
/** Moving a mount preserves media elements, peer lifetimes and native renderer state. */
|
||||
export function ObservationMount({element,className='observation-mount'}:{element:HTMLElement;className?:string}){
|
||||
const ref=useRef<HTMLDivElement>(null);
|
||||
useLayoutEffect(()=>{const host=ref.current!;host.append(element);return()=>{if(element.parentElement===host)element.remove();};},[element]);
|
||||
return <div ref={ref} className={className}/>;
|
||||
}
|
||||
export function ObservationDeck({ids,mounts,layout,onSplit,depth=0}:{ids:string[];mounts:Map<string,HTMLElement>;layout:ObservationLayout;onSplit:(key:string,value:number)=>void;depth?:number}){
|
||||
if(!ids.length)return null;
|
||||
if(ids.length===1)return <ObservationMount element={mounts.get(ids[0])!}/>;
|
||||
const mid=Math.ceil(ids.length/2),key=JSON.stringify(ids),orientation=layout.arrangement==='rows'?'horizontal':layout.arrangement==='columns'?'vertical':depth%2?'horizontal':'vertical';
|
||||
return <SplitPane className="observation-split" orientation={orientation} primarySize={layout.splits[key]??50} onPrimarySizeChange={value=>onSplit(key,value)} separatorLabel="Изменить размеры окон" primary={<ObservationDeck ids={ids.slice(0,mid)} mounts={mounts} layout={layout} onSplit={onSplit} depth={depth+1}/>} secondary={<ObservationDeck ids={ids.slice(mid)} mounts={mounts} layout={layout} onSplit={onSplit} depth={depth+1}/>}/>;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import {useMemo,useRef,useState} from 'react';
|
||||
import {CesiumMapRenderer,initialMapRuntimeState,type CesiumMapRendererHandle,type MapCamera} from '@nodedc/map-cesium-react';
|
||||
import {Button,Icon,IconButton,Switch,Window} from '@nodedc/ui-react';
|
||||
import {ObservationHeader} from '../../../../../../packages/sensor-ui/src/ObservationHeader';
|
||||
import type {ObservationHeaderTargets} from '../../../../../../packages/sensor-ui/src/observation';
|
||||
import {defaultMapViewDocument} from '../../../core/map/mapView';
|
||||
import {mapPresentation} from '../../../components/map/mapPresentation';
|
||||
|
||||
export function ObservationMap({vehicleID,header}:{vehicleID:string;header:ObservationHeaderTargets}){
|
||||
const defaults=useMemo(()=>defaultMapViewDocument().view,[]),renderer=useRef<CesiumMapRendererHandle>(null);
|
||||
const [settings,setSettings]=useState(false),[state,setState]=useState(initialMapRuntimeState),[generation,setGeneration]=useState(0);
|
||||
const storageKey=`missioncore.observation-map.v1:${vehicleID}`;
|
||||
const [camera]=useState(()=>{try{const value=JSON.parse(localStorage.getItem(storageKey)??'null') as MapCamera|null;return value&&['longitude','latitude','height','heading','pitch','roll'].every(key=>Number.isFinite(value[key as keyof MapCamera]))?value:defaults.camera;}catch{return defaults.camera;}});
|
||||
const [layers,setLayers]=useState({...defaults.layerVisibility,grid:false,targets:false});
|
||||
const ready=state.phase==='ready';
|
||||
const available=Object.values(state.providers).some(provider=>provider.phase==='ready');
|
||||
return <div className="observation-source">
|
||||
<ObservationHeader targets={header} live={ready} label={ready?'Карта доступна':state.phase==='loading'?'Загрузка карты':state.phase==='degraded'?'Часть слоёв недоступна':'Карта недоступна'}><IconButton label="Слои карты" onClick={()=>setSettings(true)}><Icon name="layers"/></IconButton></ObservationHeader>
|
||||
<div className="observation-source__media observation-map" data-available={available}><CesiumMapRenderer key={generation} ref={renderer} runtimeConfigUrl="/api/v1/map/runtime-config" camera={camera} layers={layers} settings={mapPresentation(defaults.visualSettings)} cacheIntent={{enabled:true,no_overwrite:true}} onRuntimeStateChange={setState} onCameraChange={value=>{try{localStorage.setItem(storageKey,JSON.stringify(value));}catch{/* Browser storage may be unavailable. */}}}/></div>
|
||||
{!available&&state.phase!=='loading'&&<div className="observation-source__notice"><p>Не удалось загрузить картографические слои.</p><Button onClick={()=>setGeneration(v=>v+1)}>Повторить загрузку карты</Button></div>}
|
||||
<Window open={settings} onClose={()=>setSettings(false)} title="Слои карты" size="sm">
|
||||
<div className="observation-settings">{([{id:'imagery',name:'Спутниковые снимки'},{id:'terrain',name:'Рельеф'},{id:'buildings',name:'Здания OSM'}] as const).map(item=><Switch key={item.id} label={item.name} checked={layers[item.id]} onChange={value=>setLayers(current=>({...current,[item.id]:value}))}/>)}
|
||||
</div>
|
||||
</Window>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
.observation-center { display:flex; flex-direction:column; gap:var(--nodedc-space-3); padding:var(--nodedc-space-4); min-width:0; height:max(600px, calc(100dvh - 260px)); }
|
||||
.observation-center--expanded { position:fixed; inset:0; z-index:var(--nodedc-layer-overlay); background:var(--nodedc-canvas); height:100dvh; box-sizing:border-box; }
|
||||
.observation-toolbar, .observation-summary, .observation-actions { display:flex; align-items:center; gap:var(--nodedc-space-2); }
|
||||
.observation-toolbar { justify-content:space-between; flex-wrap:wrap; }
|
||||
.observation-toolbar h2, .observation-panel h3 { margin:0; }
|
||||
.observation-toolbar span { color:var(--nodedc-text-muted); font-size:var(--nodedc-font-size-sm); }
|
||||
.observation-actions { flex-shrink:0; }
|
||||
.observation-deck { flex:1; min-height:0; min-width:0; }
|
||||
.observation-mount, .observation-panel-mount, .observation-media-mount, .observation-panel, .observation-split { height:100%; min-height:0; min-width:0; }
|
||||
.observation-panel__surface { display:flex; flex-direction:column; height:100%; overflow:hidden; container-type:inline-size; }
|
||||
.observation-panel__surface > .observation-mount { flex:1; }
|
||||
.observation-panel__header { display:flex; align-items:center; gap:var(--nodedc-space-2); padding:var(--nodedc-space-2); flex-shrink:0; }
|
||||
.observation-panel__header h3 { min-width:0; font-size:var(--nodedc-font-size-sm); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.observation-source { position:relative; display:flex; flex-direction:column; height:100%; min-height:0; }
|
||||
.observation-panel__title { display:flex; align-items:center; gap:var(--nodedc-space-2); flex:1; min-width:0; }
|
||||
.observation-header-slot, .observation-header-slot > div { display:flex; align-items:center; gap:var(--nodedc-space-2); flex-shrink:0; }
|
||||
.observation-header-slot:has(> div:empty) { display:none; }
|
||||
.observation-layer-select { min-width:0; max-width:120px; }
|
||||
.observation-layer-select button { max-width:100%; }
|
||||
.observation-layer-select button > span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.observation-source__media { position:relative; flex:1; min-height:0; overflow:hidden; display:flex; align-items:center; justify-content:center; }
|
||||
.observation-source__media video, .observation-source__media canvas { width:100%; height:100%; object-fit:contain; }
|
||||
.observation-source__media[data-live="false"] > *, .observation-source__media [data-live="false"] { visibility:hidden; }
|
||||
.observation-source__notice { position:absolute; inset:30% var(--nodedc-space-4) auto; text-align:center; color:var(--nodedc-text-secondary); font-size:calc(var(--nodedc-font-size-lg) * .7); }
|
||||
.observation-rerun, .observation-rerun > div, .observation-map > div { width:100%; height:100%; }
|
||||
.observation-settings { display:flex; flex-direction:column; gap:var(--nodedc-space-4); }
|
||||
.observation-layer { display:grid; grid-template-columns:minmax(0, 1fr) 50%; align-items:center; gap:var(--nodedc-space-3); }
|
||||
.observation-layer > * { min-width:0; width:100%; }
|
||||
.observation-layer > .nodedc-switch { text-align:left; }
|
||||
.observation-empty { display:grid; place-content:center; height:100%; text-align:center; }
|
||||
.observation-decoder { position:absolute; width:1px; height:1px; opacity:0; pointer-events:none; }
|
||||
@media(max-width:700px) { .observation-toolbar .observation-actions { flex-wrap:wrap; } .observation-center { height:max(650px, calc(100dvh - 200px)); } .observation-center--expanded { height:100dvh; } }
|
||||
|
||||
.observation-map[data-available="false"] > [data-nodedc-map-renderer] { visibility:hidden; }
|
||||
|
||||
/* Transform the live panel as one item; reveal it across nested split boundaries. */
|
||||
.observation-panel-drag { position:relative; height:100%; min-height:0; }
|
||||
.observation-panel-drag.is-dragging { z-index:var(--nodedc-layer-panel); }
|
||||
.observation-split:has(.observation-panel-drag.is-dragging),
|
||||
.observation-split > .nodedc-split-pane__panel:has(.observation-panel-drag.is-dragging) { position:relative; overflow:visible; z-index:var(--nodedc-layer-panel); }
|
||||
@container (max-width:340px) {
|
||||
.observation-panel__header { flex-wrap:wrap; }
|
||||
.observation-panel__title { min-width:80px; }
|
||||
.observation-panel__header > .observation-actions { max-width:100%; flex-wrap:wrap; justify-content:flex-end; margin-left:auto; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import {useEffect,useState} from 'react';
|
||||
import type {SensorInventory,SensorTransport} from '../../../../../../packages/sensor-ui/src/contracts';
|
||||
|
||||
export function useObservationInventory(transport:SensorTransport){
|
||||
const [inventory,setInventory]=useState<SensorInventory|null>(null);
|
||||
useEffect(()=>{
|
||||
let active=true,running=false,revision=0,last=0;
|
||||
const receive=(value:SensorInventory)=>{if(active){revision++;last=Date.now();setInventory(value);}};
|
||||
// A lost SSE connection is not evidence that a working camera stopped.
|
||||
// Retain the last inventory for one bounded heartbeat window and verify via HTTP.
|
||||
const unavailable=()=>{if(active&&Date.now()-last>15000)setInventory(value=>value?{...value,fresh:false}:value);};
|
||||
const poll=async()=>{if(running||Date.now()-last<4000)return;running=true;const before=revision;try{const value=await transport.inventory();if(before===revision)receive(value);}catch{if(before===revision)unavailable();}finally{running=false;}};
|
||||
void poll();const unsubscribe=transport.subscribe?.(receive,()=>{unavailable();void poll();});const timer=setInterval(()=>{unavailable();void poll();},5000);
|
||||
return()=>{active=false;clearInterval(timer);unsubscribe?.();};
|
||||
},[transport]);
|
||||
return inventory;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import {mapPresentation} from '../../components/map/mapPresentation';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -207,60 +208,7 @@ export function WorldMapWorkspace({
|
||||
runtimeConfigUrl="/api/v1/map/runtime-config"
|
||||
camera={draft.view.camera}
|
||||
layers={draft.view.layerVisibility}
|
||||
settings={{
|
||||
atmosphere_enabled: draft.view.visualSettings.atmosphereEnabled,
|
||||
atmosphere_hue: draft.view.visualSettings.atmosphereHue,
|
||||
atmosphere_saturation:
|
||||
draft.view.visualSettings.atmosphereSaturation,
|
||||
atmosphere_brightness:
|
||||
draft.view.visualSettings.atmosphereBrightness,
|
||||
fog_enabled: draft.view.visualSettings.fogEnabled,
|
||||
fog_density: draft.view.visualSettings.fogDensity,
|
||||
sun_enabled: draft.view.visualSettings.sunEnabled,
|
||||
sun_hour: draft.view.visualSettings.sunHour,
|
||||
sun_intensity: draft.view.visualSettings.sunIntensity,
|
||||
shadows_enabled: draft.view.visualSettings.shadowsEnabled,
|
||||
monochrome_enabled: draft.view.visualSettings.monochromeEnabled,
|
||||
monochrome_color: draft.view.visualSettings.monochromeColor,
|
||||
imagery_brightness: draft.view.visualSettings.imageryBrightness,
|
||||
imagery_contrast: draft.view.visualSettings.imageryContrast,
|
||||
imagery_saturation: draft.view.visualSettings.imagerySaturation,
|
||||
imagery_gamma: draft.view.visualSettings.imageryGamma,
|
||||
imagery_hue: draft.view.visualSettings.imageryHue,
|
||||
imagery_alpha: draft.view.visualSettings.imageryAlpha,
|
||||
globe_color: draft.view.visualSettings.globeColor,
|
||||
background_color: draft.view.visualSettings.backgroundColor,
|
||||
terrain_exaggeration:
|
||||
draft.view.visualSettings.terrainExaggeration,
|
||||
buildings_color: draft.view.visualSettings.buildingsColor,
|
||||
buildings_opacity: draft.view.visualSettings.buildingsOpacity,
|
||||
buildings_maximum_screen_space_error:
|
||||
draft.view.visualSettings.buildingsMaximumScreenSpaceError,
|
||||
grid_lod_enabled: draft.view.visualSettings.gridLodEnabled,
|
||||
grid_height_meters: draft.view.visualSettings.gridHeightMeters,
|
||||
grid_lod_1_max_height_km:
|
||||
draft.view.visualSettings.gridLod1MaxHeightKm,
|
||||
grid_lod_1_step_km:
|
||||
draft.view.visualSettings.gridLod1StepKm,
|
||||
grid_lod_2_max_height_km:
|
||||
draft.view.visualSettings.gridLod2MaxHeightKm,
|
||||
grid_lod_2_step_km:
|
||||
draft.view.visualSettings.gridLod2StepKm,
|
||||
grid_lod_3_step_km:
|
||||
draft.view.visualSettings.gridLod3StepKm,
|
||||
grid_radius_km: draft.view.visualSettings.gridRadiusKm,
|
||||
grid_line_width: draft.view.visualSettings.gridLineWidth,
|
||||
grid_color: draft.view.visualSettings.gridColor,
|
||||
grid_opacity: draft.view.visualSettings.gridOpacity,
|
||||
grid_dots_enabled:
|
||||
draft.view.visualSettings.gridDotsEnabled,
|
||||
grid_dots_size: draft.view.visualSettings.gridDotsSize,
|
||||
grid_dots_color: draft.view.visualSettings.gridDotsColor,
|
||||
grid_dots_opacity:
|
||||
draft.view.visualSettings.gridDotsOpacity,
|
||||
camera_animation_enabled:
|
||||
draft.view.visualSettings.cameraAnimationEnabled,
|
||||
}}
|
||||
settings={mapPresentation(draft.view.visualSettings)}
|
||||
cacheIntent={{
|
||||
enabled: draft.view.cacheIntent.enabled,
|
||||
no_overwrite: draft.view.cacheIntent.noOverwrite,
|
||||
|
||||
@@ -143,7 +143,7 @@ test("Observatory owns a bounded explicit-open lifecycle around the shared recor
|
||||
/advanced-index|resolveObservationSessionReplay|resolveCanonicalLabReplay|RerunViewport/,
|
||||
);
|
||||
assert.equal(sharedReplay.match(/<RerunViewport\b/g)?.length, 1);
|
||||
assert.match(sharedReplay, /recordedSessionRerunProfile/);
|
||||
assert.match(sharedReplay, /laboratoryResultRerunProfile/);
|
||||
assert.match(sharedReplay, /useState<0 \| 1>\(hasTgs \? 1 : 0\)/);
|
||||
assert.match(sharedReplay, /hasTgs \? 0\.000001 : 0/);
|
||||
for (const adapter of ["CanonicalVegetationRerunReplay", "PortableResultReplay"]) {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {before,after,test} from 'node:test';
|
||||
import {createServer} from 'vite';
|
||||
let server,series,segments,frames,localPreview,MEDIA_PROTOCOL;
|
||||
before(async()=>{
|
||||
server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});
|
||||
({monitorSeries:series}=await server.ssrLoadModule('/src/core/fleet/boardMonitor.ts'));
|
||||
({lineSegments:segments}=await server.ssrLoadModule('/src/components/system/TelemetrySeries.tsx'));
|
||||
({MEDIA_PROTOCOL}=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/previewFrames.ts'));
|
||||
({localPreviewFrames:frames,localPreview}=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/localPreview.ts'));
|
||||
});
|
||||
after(async()=>{await server?.close();});
|
||||
test('archive gaps break the plot instead of manufacturing zero or connecting missing time',()=>{
|
||||
const values=series({start:0,end:20,bucket_seconds:5,series:[{at:0,max:80},{at:5,max:90},{at:20,max:10}]},'max');
|
||||
assert.deepEqual(values,[80,90,null,null,10]);
|
||||
const result=segments(values,100);
|
||||
assert.equal(result.length,2);
|
||||
assert.match(result[0],/^0.00,8.80 25.00,5.40$/);
|
||||
assert.deepEqual(segments([null,NaN,null]),[]);
|
||||
assert.notEqual(segments([-10,0,10])[0].split(' ')[0].split(',')[1],segments([-10,0,10])[0].split(' ')[1].split(',')[1]);
|
||||
});
|
||||
test('HTTP media framing survives every byte boundary and refuses oversized frames',()=>{
|
||||
const output=[],receive=frames((kind,bytes)=>output.push([kind,[...bytes]]));
|
||||
const encoded=Uint8Array.of(2,0,0,0,3,4,5,6,3,0,0,0,2,7,8);
|
||||
for(const byte of encoded)receive(Uint8Array.of(byte));
|
||||
assert.deepEqual(output,[[2,[4,5,6]],[3,[7,8]]]);
|
||||
assert.throws(()=>frames(()=>{})(Uint8Array.of(2,0,1,0,0)));
|
||||
assert.throws(()=>frames(()=>{})(Uint8Array.of(7,0,0,0,0)));
|
||||
});
|
||||
|
||||
test('local carrier delivers registered channels and ACKs through completed responses',async()=>{
|
||||
const encode=(kind,data)=>{const bytes=typeof data==='string'?new TextEncoder().encode(data):Uint8Array.from(data);const out=new Uint8Array(bytes.length+5);out[0]=kind;new DataView(out.buffer).setUint32(1,bytes.length);out.set(bytes,5);return out;};
|
||||
const peer='peer_'+ 'a'.repeat(32), received=[], closed=[];let preview;
|
||||
const device={id:'synthetic-k1',snapshot:{context:{session_id:'synthetic-session'}}};
|
||||
const transport={
|
||||
submit:async request=>{closed.push(request);return {state:'complete'};},
|
||||
localPreview:{
|
||||
open:async request=>{assert.equal(request.action_id,'offer');return new Response(encode(0,JSON.stringify({peer_id:peer,media_protocol:MEDIA_PROTOCOL})));},
|
||||
read:async(id,after)=>{
|
||||
assert.equal(id,peer);
|
||||
if(after===0)return new Response(new Uint8Array([...encode(2,[8,9]),...encode(3,'camera-ready')]));
|
||||
assert.equal(after,7);preview.close();return new Response();
|
||||
}
|
||||
}
|
||||
};
|
||||
preview=localPreview(device,transport,{},()=>assert.fail('carrier failed'));
|
||||
preview.rrd.onmessage=event=>{received.push([...new Uint8Array(event.data)]);preview.rrd.send(JSON.stringify({ack:7}));};
|
||||
preview.camera.onmessage=event=>received.push(event.data);
|
||||
await preview.start();
|
||||
assert.deepEqual(received,[[8,9],'camera-ready']);
|
||||
assert.equal(preview.rrd.readyState,'closed');assert.equal(closed.length,1);
|
||||
assert.equal(closed[0].action_id,'close-peer');assert.equal(closed[0].parameters.peer_id,peer);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {after,before,test} from 'node:test';
|
||||
import {createServer} from 'vite';
|
||||
let server,layout,source,fanout;
|
||||
before(async()=>{
|
||||
server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});
|
||||
layout=await server.ssrLoadModule('/src/core/fleet/observationLayout.ts');
|
||||
source=await server.ssrLoadModule('../../packages/sensor-ui/src/observation.ts');
|
||||
({observationRerun:fanout}=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/observationRerun.ts'));
|
||||
});
|
||||
after(async()=>{await server?.close();});
|
||||
test('layout keeps independent identities and restores absent devices without confusing equal model names',()=>{
|
||||
const a=source.observationKey('camera-1','left'),b=source.observationKey('camera-2','left'),c=source.observationKey('camera-1','right');
|
||||
assert.notEqual(a,b);assert.notEqual(a,c);
|
||||
const value={...layout.emptyObservationLayout(),order:[b,a],hidden:[a],layers:{[a]:'depth'}};
|
||||
assert.deepEqual(layout.orderedObservationIDs(value,[a,c]),[a,c]);
|
||||
assert.deepEqual(layout.orderedObservationIDs(value,[a,b,c]),[b,a,c]);
|
||||
assert.deepEqual(layout.moveObservation(value,[a,b,c],c,b).order,[c,b,a]);
|
||||
assert.deepEqual(value.hidden,[a]);
|
||||
});
|
||||
test('untrusted browser layout is bounded and validates revisions and split numbers',()=>{
|
||||
const decoded=layout.decodeObservationLayout({version:1,order:['a','a',null,8],hidden:'all',layers:{a:'depth',b:[]},splits:{a:Infinity,b:-8,c:98},arrangement:'bad'});
|
||||
assert.deepEqual(decoded.order,['a']);assert.deepEqual(decoded.hidden,[]);assert.deepEqual(decoded.splits,{b:20,c:80});assert.deepEqual(decoded.layers,{a:'depth'});assert.equal(decoded.arrangement,'auto');
|
||||
assert.deepEqual(layout.decodeObservationLayout({version:2}),layout.emptyObservationLayout());
|
||||
});
|
||||
test('camera observation exposes only advertised layers with one viewport and RGB default',()=>{
|
||||
const views=source.cameraObservationViews({name:'RealSense',layers:['depth','private-channel','color','points']});
|
||||
assert.equal(views.length,1);assert.deepEqual(views[0].layers.map(v=>v.value),['color','depth','points']);
|
||||
assert.deepEqual(source.cameraObservationViews({name:'unknown',layers:[]}),[]);
|
||||
});
|
||||
test('two Rerun views share bytes, apply independent blueprints once and dispose both hosts',async()=>{
|
||||
const fetchBefore=globalThis.fetch;globalThis.fetch=async url=>new Response(Uint8Array.of(String(url).includes('plan')?2:1));
|
||||
const sent=[[],[]],closed=[],started=[];let count=0;
|
||||
const factory=mount=>{const index=count++;return {ready:Promise.resolve({mount,viewer:{start:async()=>started.push(index),open_channel:name=>({ready:true,send_rrd:bytes=>sent[index].push([name,[...bytes]]),close:()=>{}}),get_active_recording_id:()=> 'recording-1',override_panel_state:()=>{},set_active_timeline:()=>{}}}),dispose:()=>closed.push(index)};};
|
||||
try{
|
||||
const host=fanout(factory,{current:{id:'top'}})({id:'3d'});const {viewer,mount}=await host.ready;
|
||||
await viewer.start(null,mount,{});const channel=viewer.open_channel('rrd');channel.send_rrd(Uint8Array.of(9,8));
|
||||
assert.deepEqual(sent[0],[['rrd',[9,8]]]);assert.deepEqual(sent[1],[['rrd',[9,8]]]);
|
||||
viewer.get_active_recording_id();viewer.get_active_recording_id();
|
||||
assert.deepEqual(sent[0].at(-1),['observation-perspective',[1]]);assert.deepEqual(sent[1].at(-1),['observation-plan',[2]]);
|
||||
assert.equal(sent[0].length,2);assert.deepEqual(started,[0,1]);host.dispose();assert.deepEqual(closed,[0,1]);
|
||||
}finally{globalThis.fetch=fetchBefore;}
|
||||
});
|
||||
@@ -1676,7 +1676,7 @@ test("physical-command guidance projects backend policy without exposing reason
|
||||
);
|
||||
});
|
||||
|
||||
test("restart-recovery guidance describes automatic recovery without protocol ceremony", () => {
|
||||
test("restart-recovery guidance offers an explicit read-only recovery", () => {
|
||||
const deniedFresh = {
|
||||
connection_policy: {
|
||||
schema_version: "missioncore.xgrids-k1-connection-policy/v1",
|
||||
@@ -1712,7 +1712,7 @@ test("restart-recovery guidance describes automatic recovery without protocol ce
|
||||
);
|
||||
assert.deepEqual(durableGuidance, {
|
||||
reason: "В свежем Bluetooth-поиске не найден K1, связанный с незавершённой записью.",
|
||||
nextAction: "Дождитесь автоматического восстановления сохранённого подключения K1.",
|
||||
nextAction: "Нажмите «Переподключиться», чтобы проверить сохранённое подключение K1.",
|
||||
});
|
||||
assert.doesNotMatch(
|
||||
`${durableGuidance.reason} ${durableGuidance.nextAction}`,
|
||||
@@ -1725,7 +1725,7 @@ test("restart-recovery guidance describes automatic recovery without protocol ce
|
||||
deniedFresh,
|
||||
"observe-fresh-device-network",
|
||||
).nextAction,
|
||||
"Дождитесь автоматического восстановления связи с тем же K1.",
|
||||
"Нажмите «Переподключиться», чтобы проверить связь с тем же K1.",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1991,7 +1991,7 @@ test("observed SCANNING recovery is a successful STOP-only connection outcome",
|
||||
);
|
||||
assert.ok(
|
||||
acquisitionSource.indexOf("terminalPhysicalStopObserved ? (")
|
||||
< acquisitionSource.indexOf("<div className=\"scan-configuration-grid\">"),
|
||||
< acquisitionSource.indexOf("<K1AcquisitionFields"),
|
||||
"recovered SCANNING must render the STOP-only branch before project/START controls",
|
||||
);
|
||||
});
|
||||
@@ -3461,7 +3461,7 @@ test("each terminal explicit provisioning click starts a fresh operation identit
|
||||
|
||||
assert.match(
|
||||
connectRecovery,
|
||||
/failedOperation\?\.status === "succeeded"[\s\S]*?!exactAppliedNetworkIntentCompleted\([\s\S]*?&& !hasExactConnectionReady/,
|
||||
/observeProvisioningRequest\([\s\S]*?observedState = observation\.state[\s\S]*?const exactNetworkIntentCompleted = exactAppliedNetworkIntentCompleted\(/,
|
||||
);
|
||||
assert.match(
|
||||
connectRecovery,
|
||||
@@ -4009,7 +4009,7 @@ test("failed network-profile writes close the UI session with a fresh explicit n
|
||||
|
||||
assert.equal(
|
||||
message,
|
||||
"Bluetooth-периферия завершила сетевую операцию ошибкой. Команда могла быть принята K1; итог текущей попытки не подтверждён. Автоматический повтор команды K1 не отправлялся. Сессия подключения в интерфейсе сброшена. Выполните новый поиск Bluetooth, выберите K1 и запустите подключение ещё раз.",
|
||||
"Bluetooth-периферия завершила сетевую операцию ошибкой. Команда могла быть принята K1; итог текущей попытки не подтверждён. Автоматический повтор команды K1 не отправлялся. Проверьте состояние K1 через «Переподключиться» или начните новое подключение через поиск Bluetooth.",
|
||||
);
|
||||
assert.doesNotMatch(message, /Bleak|GATT|ATT/i);
|
||||
|
||||
@@ -4024,8 +4024,8 @@ test("failed network-profile writes close the UI session with a fresh explicit n
|
||||
});
|
||||
assert.match(attMessage, /ATT 4 INVALID_PDU/);
|
||||
assert.match(attMessage, /Автоматический повтор команды K1 не отправлялся/);
|
||||
assert.match(attMessage, /Сессия подключения в интерфейсе сброшена/);
|
||||
assert.match(attMessage, /новый поиск Bluetooth, выберите K1/);
|
||||
assert.match(attMessage, /Проверьте состояние K1 через «Переподключиться»/);
|
||||
assert.match(attMessage, /новое подключение через поиск Bluetooth/);
|
||||
|
||||
const preWriteAttMessage = networkProvisionFailureMessage({
|
||||
action: "network.provision",
|
||||
@@ -4059,7 +4059,7 @@ test("post-dispatch ambiguity resets the UI session without an automatic retry",
|
||||
|
||||
assert.equal(
|
||||
message,
|
||||
"После BLE-команды K1 вернул сетевой статус, неотличимый от исходного; итог текущей попытки подключения не подтверждён. Автоматический повтор команды K1 не отправлялся. Сессия подключения в интерфейсе сброшена. Выполните новый поиск Bluetooth, выберите K1 и запустите подключение ещё раз.",
|
||||
"После BLE-команды K1 вернул сетевой статус, неотличимый от исходного; итог текущей попытки подключения не подтверждён. Автоматический повтор команды K1 не отправлялся. Проверьте состояние K1 через «Переподключиться» или начните новое подключение через поиск Bluetooth.",
|
||||
);
|
||||
assert.doesNotMatch(message, /ручн|read-only|защитный барьер/i);
|
||||
});
|
||||
@@ -4247,7 +4247,7 @@ test("host Wi-Fi failures close the attempt and require a fresh explicit connect
|
||||
|
||||
assert.equal(
|
||||
operationTimeout,
|
||||
"Локальная операция подготовки Wi‑Fi не завершилась вовремя; итог текущей попытки подключения не подтверждён. Автоматический повтор команды K1 не отправлялся. Сессия подключения в интерфейсе сброшена. Выполните новый поиск Bluetooth, выберите K1 и запустите подключение ещё раз.",
|
||||
"Локальная операция подготовки Wi‑Fi не завершилась вовремя; итог текущей попытки подключения не подтверждён. Автоматический повтор команды K1 не отправлялся. Проверьте состояние K1 через «Переподключиться» или начните новое подключение через поиск Bluetooth.",
|
||||
);
|
||||
assert.doesNotMatch(operationTimeout, /получите пароль|пароль отсутствует/i);
|
||||
assert.match(missingNetwork, /K1 принял команду Quick Connect/);
|
||||
|
||||
@@ -191,11 +191,13 @@ test("XGRIDS frontend is physically plugin-owned and split by operator pipeline"
|
||||
assert.match(spatialControls, /role="alert"/);
|
||||
assert.doesNotMatch(spatialControls, /Повторить остановку/);
|
||||
assert.match(spatialControls, /stopLocalReceiver/);
|
||||
assert.match(spatialControls, /<ActivityIndicator size="compact"/);
|
||||
assert.match(spatialControls, /aria-busy=\{phase\.busy\}/);
|
||||
assert.match(spatialControls, /K1 калибруется и готовит облако точек/);
|
||||
assert.match(spatialControls, /Первые данные могут появиться через десятки секунд/);
|
||||
assert.match(spatialControls, /Не перемещайте устройство/);
|
||||
assert.match(spatialControls, /<K1SpatialSession/);
|
||||
const spatialSession = readFileSync(join(pluginFrontendRoot, "components/K1SpatialSession.tsx"), "utf8");
|
||||
assert.match(spatialSession, /<ActivityIndicator size="compact"/);
|
||||
assert.match(spatialSession, /aria-busy=\{phase\.busy\}/);
|
||||
assert.match(spatialSession, /K1 калибруется и готовит облако точек/);
|
||||
assert.match(spatialSession, /Первые данные могут появиться через десятки секунд/);
|
||||
assert.match(spatialSession, /Не перемещайте устройство/);
|
||||
|
||||
const styles = readFileSync(join(pluginFrontendRoot, "styles.css"), "utf8");
|
||||
assert.doesNotMatch(styles, /@keyframes xgrids-k1-spin/);
|
||||
@@ -218,8 +220,8 @@ test("K1 connection surface enforces one scan, local selection, and one Apply",
|
||||
);
|
||||
assert.match(provisioning, /scanSecondsRemaining/);
|
||||
assert.match(provisioning, /setInterval\(updateCountdown, 250\)/);
|
||||
assert.match(provisioning, /Поиск Bluetooth · \{scanSecondsRemaining \?\? 6\} с/);
|
||||
assert.match(provisioning, /scanWithResult\(\{[^}]*durationSeconds:\s*6/);
|
||||
assert.match(provisioning, /Поиск Bluetooth · до \{scanSecondsRemaining \?\? BLE_DISCOVERY_TIMEOUT_SECONDS\} с/);
|
||||
assert.match(provisioning, /scanWithResult\(\{[^}]*durationSeconds:\s*BLE_DISCOVERY_TIMEOUT_SECONDS/);
|
||||
assert.doesNotMatch(provisioning, /K1 уже доступен|Сетевой адрес K1 доступен/);
|
||||
assert.doesNotMatch(
|
||||
provisioning,
|
||||
@@ -258,7 +260,8 @@ test("K1 connection surface enforces one scan, local selection, and one Apply",
|
||||
assert.doesNotMatch(provisioning, /allow_host_wifi_switch/);
|
||||
assert.doesNotMatch(provisioning, /(?:color|background(?:-color)?):\s*(?:#[0-9a-f]{3,8}|rgba?\()/i);
|
||||
for (const sharedControl of ["Button", "IconButton", "TextField", "ActivityIndicator", "StatusBadge"]) {
|
||||
assert.match(provisioning, new RegExp(`<${sharedControl}\\b`), sharedControl);
|
||||
const password = readFileSync(join(pluginFrontendRoot, "components/K1WifiPasswordField.tsx"), "utf8");
|
||||
assert.match(provisioning + password, new RegExp(`<${sharedControl}\\b`), sharedControl);
|
||||
}
|
||||
assert.doesNotMatch(
|
||||
provisioning,
|
||||
@@ -284,7 +287,7 @@ test("K1 click-owned actions are fenced without hidden frontend continuations",
|
||||
provisioning.indexOf("const verifyAppliedNetwork"),
|
||||
);
|
||||
assert.equal((search.match(/scanWithResult\(/g) ?? []).length, 1);
|
||||
assert.match(search, /durationSeconds:\s*6/);
|
||||
assert.match(search, /durationSeconds:\s*BLE_DISCOVERY_TIMEOUT_SECONDS/);
|
||||
assert.equal((apply.match(/await connect\(/g) ?? []).length, 1);
|
||||
assert.doesNotMatch(
|
||||
apply,
|
||||
@@ -680,7 +683,7 @@ test("automatic K1 live start opens the selected delivered camera despite an old
|
||||
assert.match(layout, /source\.capabilities\.overlay/);
|
||||
});
|
||||
|
||||
test("K1 connect errors reset the UI session without exposing reconciliation ceremony", () => {
|
||||
test("K1 connect errors preserve the result and expose explicit recovery", () => {
|
||||
const runtime = readFileSync(
|
||||
join(pluginFrontendRoot, "useXgridsK1Runtime.ts"),
|
||||
"utf8",
|
||||
@@ -691,15 +694,17 @@ test("K1 connect errors reset the UI session without exposing reconciliation cer
|
||||
assert.notEqual(connectEnd, -1);
|
||||
const connectFlow = runtime.slice(connectStart, connectEnd);
|
||||
|
||||
assert.match(connectFlow, /operationByIdempotencyKey\(/);
|
||||
assert.match(connectFlow, /failedOperation\?\.status === "succeeded"/);
|
||||
assert.match(connectFlow, /resetConnectSessionMessage\(/);
|
||||
assert.match(connectFlow, /observeProvisioningRequest\(/);
|
||||
assert.match(connectFlow, /operation\?\.status !== "succeeded"/);
|
||||
assert.match(connectFlow, /networkProvisionFailureMessage\(/);
|
||||
assert.doesNotMatch(
|
||||
connectFlow,
|
||||
/требует ручной проверки|измените параметры только после проверки устройства|Сохранён тот же ключ/,
|
||||
);
|
||||
assert.match(runtime, /Сессия подключения в интерфейсе сброшена/);
|
||||
assert.match(runtime, /новый поиск Bluetooth, выберите K1 и запустите подключение ещё раз/);
|
||||
const networkFlow = readFileSync(join(pluginFrontendRoot, "networkProvisioning.ts"), "utf8");
|
||||
assert.match(networkFlow, /Проверьте состояние K1 через «Переподключиться»/);
|
||||
assert.match(networkFlow, /новое подключение через поиск Bluetooth/);
|
||||
assert.equal((networkFlow.match(/await context\.send\(/g) ?? []).length, 1);
|
||||
const verifyStart = runtime.indexOf("const verifyConnection = useCallback(");
|
||||
const verifyEnd = runtime.indexOf("const probeConfiguredEndpoint = useCallback(", verifyStart);
|
||||
const verifyFlow = runtime.slice(verifyStart, verifyEnd);
|
||||
@@ -729,10 +734,10 @@ test("K1 provisioning keeps the operator draft separate from the backend lease",
|
||||
assert.match(selection, /requestExplicitProvisioning\(device\.device_id/);
|
||||
assert.equal((apply.match(/await connect\(/g) ?? []).length, 1);
|
||||
assert.doesNotMatch(apply, /scanWithResult\(|verifyConnection\(|candidateRefresh/);
|
||||
assert.match(provisioning, /scanWithResult\(\{ durationSeconds: 6 \}\)/);
|
||||
assert.match(provisioning, /scanWithResult\(\{ durationSeconds: BLE_DISCOVERY_TIMEOUT_SECONDS \}\)/);
|
||||
assert.match(
|
||||
provisioning,
|
||||
/onChange=\{\(event\) => setPassword\(event\.target\.value\)\}/,
|
||||
/<K1WifiPasswordField[\s\S]*?onChange=\{setPassword\}/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -65,8 +65,8 @@ test("default environment exposes every current product page", () => {
|
||||
"system",
|
||||
"polygon",
|
||||
]);
|
||||
assert.equal(defaults.pages.fleet.primaryWorkspaceId, "contour-health");
|
||||
assert.equal(defaults.pages.observation.primaryWorkspaceId, "spatial-scene");
|
||||
assert.equal(defaults.pages.fleet.primaryWorkspaceId, "vehicles");
|
||||
assert.equal(defaults.pages.observation.primaryWorkspaceId, "cameras");
|
||||
});
|
||||
|
||||
test("server document decodes and re-encodes the complete page contract", () => {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import ts from 'typescript';
|
||||
|
||||
const source=readFileSync(new URL('../../../plugins/insta360-x4/frontend/src/model.ts',import.meta.url),'utf8');
|
||||
const code=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.ESNext}}).outputText;
|
||||
const {cameraLabel,cameraStatus,resolutionLabel,cameraRowActions}=await import('data:text/javascript;base64,'+Buffer.from(code).toString('base64'));
|
||||
const camera={id:'instax4_synthetic_a',online:true,configured:true,prepared:true,snapshot:{enrollment:'enrolled'},camera_status:{connected:true,recording:0,preview:0,function_mode:7}};
|
||||
|
||||
test('SD recording remains visible after preview closes, independently for each camera',()=>{
|
||||
const recording={...camera,camera_status:{...camera.camera_status,recording:1}};
|
||||
assert.equal(cameraLabel(recording,true).label,'Запись на карту камеры');
|
||||
assert.equal(cameraLabel({...camera,id:'instax4_synthetic_b'},true).label,'Подключена');
|
||||
assert.equal(cameraStatus(recording).preview,0);
|
||||
});
|
||||
test('stale and unconfirmed camera state never appears ready or actively recording',()=>{
|
||||
assert.equal(cameraLabel({...camera,camera_status:{...camera.camera_status,recording:1}},false).tone,'neutral');
|
||||
assert.equal(cameraLabel({...camera,camera_status:{}},true).tone,'warning');
|
||||
assert.equal(cameraLabel({...camera,camera_status:{...camera.camera_status,preview:-1}},true).tone,'warning');
|
||||
assert.equal(cameraStatus({...camera,camera_status:{preview:true,recording:'1',connected:1}}).preview,null);
|
||||
});
|
||||
test('installed model support does not enroll a new physical camera',()=>{
|
||||
assert.equal(cameraLabel({...camera,configured:false},true).label,'Требуется подготовка');
|
||||
assert.equal(cameraLabel({...camera,initializable:false},true).tone,'warning');
|
||||
});
|
||||
test('SDK resolution names are presented with their actual dimensions and frame rate',()=>{
|
||||
assert.equal(resolutionLabel('3840_1920_30'),'3840 × 1920 · 30 кадр/с');
|
||||
});
|
||||
|
||||
test('manual wake is available for an enrolled offline camera with automatic recovery disabled',()=>{
|
||||
const offline={...camera,online:false,camera_status:{recovery:{supported:true,enabled:false,wake_available:true}}};
|
||||
assert.equal(cameraRowActions(offline)[0].actionId,'power.wake');
|
||||
assert.equal(cameraRowActions(offline)[0].disabled,false);
|
||||
assert.deepEqual(cameraRowActions({...offline,online:true}),[]);
|
||||
assert.equal(cameraRowActions({...offline,configured:false})[0].disabled,true);
|
||||
assert.equal(cameraRowActions({...offline,prepared:false})[0].disabled,true);
|
||||
assert.equal(offline.camera_status.recovery.enabled,false);
|
||||
});
|
||||
test('old drivers and unknown wake identities never offer an active power command',()=>{
|
||||
assert.deepEqual(cameraRowActions(camera),[]);
|
||||
assert.equal(cameraRowActions({...camera,online:false,camera_status:{recovery:{supported:true}}})[0].disabled,true);
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {before,after,test} from 'node:test';
|
||||
import {createElement} from 'react';
|
||||
import {renderToStaticMarkup} from 'react-dom/server';
|
||||
import {createServer} from 'vite';
|
||||
|
||||
let server,Detail,presentation,enrolledDevice;
|
||||
before(async()=>{
|
||||
server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});
|
||||
({K1Detail:Detail}=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/K1Detail.tsx'));
|
||||
presentation=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/presentation.ts');
|
||||
({enrolledDevice}=await server.ssrLoadModule('../../packages/sensor-ui/src/contracts.ts'));
|
||||
});
|
||||
after(async()=>{await server?.close();});
|
||||
const device={id:'synthetic',kind:'k1',name:'K1 test',online:true,verified:true,prepared:true,
|
||||
control:{can_start:true,can_stop:false,can_verify:true,generation:1,revision:1,acquisition_id:null},
|
||||
snapshot:{context:{session_id:'exact-session',execution:{node_id:'test'}},acquisition:'idle'}};
|
||||
const render=(value,enabled=true)=>renderToStaticMarkup(createElement(Detail,{device:value,enabled,
|
||||
transport:{},back:()=>{},refresh:async()=>{},failure:()=>{},createRerunHost:()=>assert.fail('SSR must not start a viewer or hardware')}));
|
||||
|
||||
test('manual K1 surface admits one START only after current control proof',()=>{
|
||||
const markup=render(device);
|
||||
assert.match(markup,/Инициировать запуск/);
|
||||
const start=(markup.match(/<button\b[\s\S]*?<\/button>/g)??[]).filter(button=>button.replace(/<[^>]+>/g,'')==='Инициировать запуск');
|
||||
assert.equal(start.length,1);
|
||||
assert.match(start[0],/<button[^>]*disabled=""/);
|
||||
for(const label of ['Тип установки / носитель','Режим GNSS','Название проекта'])assert.ok(markup.includes(label));
|
||||
assert.match(markup,/Настройки устройства/);
|
||||
assert.doesNotMatch(markup,/Остановить устройство|Обновить просмотр|sensor-live-layout/);
|
||||
const waiting={...device,online:false,verified:false,control:{...device.control,can_start:false,network_applied:true,reason_code:'application_authority_unavailable'}};
|
||||
const pending=render(waiting);
|
||||
assert.doesNotMatch(pending,/Инициировать запуск|Настройки устройства|sensor-live-layout/);
|
||||
assert.match(pending,/Проверить состояние K1/);
|
||||
assert.match(pending,/авторизовать/);
|
||||
assert.equal(presentation.k1Status(waiting,true).label,'Нет связи с K1');
|
||||
assert.equal(presentation.k1ManualState(device,false).canStart,false);
|
||||
});
|
||||
test('active acquisition exposes STOP and Rerun without another START',()=>{
|
||||
const active={...device,control:{...device.control,can_start:false,can_stop:true},snapshot:{...device.snapshot,acquisition:'streaming'}};
|
||||
const markup=render(active);
|
||||
assert.match(markup,/Остановить устройство/);
|
||||
assert.match(markup,/rerun-viewport__canvas/);
|
||||
assert.doesNotMatch(markup,/>Статус</);
|
||||
assert.match(markup,/>Пространственная сцена</);
|
||||
for(const label of ['Движок','Слои','Отображение','Камера K1','Окно накопления облака точек'])assert.ok(markup.includes(label));
|
||||
assert.ok(markup.includes('data-presented="false"'));
|
||||
assert.ok(!markup.includes('Loading Rerun'));
|
||||
assert.ok(!markup.includes('The data layer'));
|
||||
assert.ok(!markup.includes('data-align="center"'));
|
||||
|
||||
assert.doesNotMatch(markup,/Восстановить просмотр|nodedc-activity-indicator/);
|
||||
assert.doesNotMatch(markup,/Инициировать запуск|Обновить просмотр/);
|
||||
assert.equal(presentation.k1ManualState(active,false).canStart,false);
|
||||
assert.equal(presentation.k1ManualState(active,false).showStop,true);
|
||||
});
|
||||
test('calibration loader stays inside the primary action',()=>{
|
||||
const starting={...device,control:{...device.control,can_start:false},snapshot:{...device.snapshot,acquisition:'starting'}};
|
||||
const markup=render(starting);
|
||||
const start=(markup.match(/<button\b[\s\S]*?<\/button>/g)??[]).filter(button=>button.replace(/<[^>]+>/g,'')==='Запускаем устройство');
|
||||
assert.equal(start.length,1);
|
||||
assert.match(start[0],/<button[^>]*aria-busy="true"/);
|
||||
assert.equal((start[0].match(/class="nodedc-activity-indicator"/g)??[]).length,1);
|
||||
assert.match(markup,/K1 калибруется и готовит облако точек/);
|
||||
assert.doesNotMatch(markup,/Обновить просмотр/);
|
||||
});
|
||||
test('connection loss is visible independently of completed STOP',()=>{
|
||||
const stopped={...device,online:false,verified:false,control:{...device.control,phase:'completed',can_start:false,network_applied:true}};
|
||||
const markup=render(stopped);
|
||||
assert.match(markup,/Нет связи с K1/);
|
||||
assert.match(markup,/Проверить состояние K1/);
|
||||
assert.doesNotMatch(markup,/Инициировать запуск|нет управления/);
|
||||
});
|
||||
test('enrollment handoff opens only the verified current session',()=>{
|
||||
const inventory={fresh:true,items:[device],operations:[]};
|
||||
assert.equal(enrolledDevice(inventory,'exact-session'),device);
|
||||
assert.equal(enrolledDevice(inventory,'other-session'),null);
|
||||
assert.equal(enrolledDevice({...inventory,fresh:false},'exact-session'),null);
|
||||
assert.equal(enrolledDevice({...inventory,items:[{...device,verified:false}]},'exact-session'),null);
|
||||
assert.equal(enrolledDevice({...inventory,items:[device,device]},'exact-session'),null);
|
||||
});
|
||||
|
||||
test('completed scan with healthy control remains connected and permits a named next scan',()=>{
|
||||
const stopped={...device,control:{...device.control,phase:'completed'}};
|
||||
const markup=render(stopped);
|
||||
assert.equal(presentation.k1Status(stopped,true).label,'Подключён');
|
||||
assert.equal(presentation.k1ManualState(stopped,true).canStart,true);
|
||||
assert.match(markup,/Инициировать запуск/);
|
||||
assert.doesNotMatch(markup,/Проверить состояние K1|Подключить устройство|Устройство остановлено/);
|
||||
assert.equal(presentation.k1Status(stopped,false).tone,'neutral');
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {before,after,test} from 'node:test';
|
||||
import {execFileSync} from 'node:child_process';
|
||||
import {createServer} from 'vite';
|
||||
let server,previewFrames,assertRrd,previewFreshness,previewCamera;
|
||||
before(async()=>{
|
||||
server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});
|
||||
({previewCamera}=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/previewCamera.ts'));
|
||||
({previewFreshness}=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/previewFreshness.ts'));
|
||||
({previewFrames,assertRrd}=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/previewFrames.ts'));
|
||||
});
|
||||
after(async()=>{await server?.close();});
|
||||
const array=bytes=>Uint8Array.from(bytes).buffer;
|
||||
const header=size=>{const value=new Uint8Array(8);value.set([77,67,70,49]);new DataView(value.buffer).setUint32(4,size);return value.buffer;};
|
||||
test('native RRD crosses many RTC fragments and reaches decoder once, byte-for-byte',()=>{
|
||||
const payload=execFileSync('../../.venv/bin/python',['-c',`import rerun as rr, numpy as np, sys
|
||||
r=rr.RecordingStream('synthetic-r10-js'); b=r.binary_stream()
|
||||
r.log('points',rr.Points3D(np.random.default_rng(42).random((5000,3))))
|
||||
sys.stdout.buffer.write(b.read())`]);
|
||||
assert.ok(payload.length>16384);
|
||||
const calls=[];
|
||||
const receiver=previewFrames(value=>{assertRrd(value);calls.push(value);});
|
||||
for(let record=0;record<2;record++){
|
||||
receiver.push(header(payload.length));
|
||||
for(let offset=0;offset<payload.length;offset+=16384){
|
||||
receiver.push(array(payload.subarray(offset,offset+16384)));
|
||||
if(offset+16384<payload.length)assert.equal(calls.length,record);
|
||||
}
|
||||
assert.deepEqual(Buffer.from(calls[record]),payload);
|
||||
}
|
||||
assert.equal(calls.length,2);
|
||||
receiver.close();
|
||||
});
|
||||
test('partial, oversized, unframed and invalid records never enter the decoder',()=>{
|
||||
let calls=0;
|
||||
const receiver=previewFrames(value=>{assertRrd(value);calls++;});
|
||||
assert.throws(()=>receiver.push(array([1,2,3])));
|
||||
assert.throws(()=>receiver.push(header(8*1024*1024+1)));
|
||||
assert.throws(()=>receiver.push(header(0)));
|
||||
receiver.push(header(20));receiver.push(array([1,2]));receiver.close();
|
||||
assert.equal(calls,0);
|
||||
receiver.push(header(12));assert.throws(()=>receiver.push(array(new Uint8Array(13))));receiver.close();
|
||||
receiver.push(header(12));assert.throws(()=>receiver.push(array(new Uint8Array(12))));
|
||||
assert.equal(calls,0);
|
||||
});
|
||||
|
||||
test('source age expires even when metadata repeats, old frames never look live',()=>{
|
||||
let now=10000;const gate=previewFreshness(()=>now);
|
||||
assert.equal(gate.fresh,false);
|
||||
gate.receive({type:'lidar-state',sequence:1,age_ms:250,points:3});
|
||||
assert.equal(gate.fresh,true);assert.equal(gate.points,3);
|
||||
now+=1800;gate.receive({type:'lidar-state',sequence:1,age_ms:0,points:3});
|
||||
assert.equal(gate.fresh,false);
|
||||
gate.receive({type:'lidar-state',sequence:2,age_ms:9000,points:3});
|
||||
assert.equal(gate.fresh,false);
|
||||
gate.receive({type:'lidar-state',sequence:3,age_ms:0,points:4});
|
||||
assert.equal(gate.fresh,true);assert.equal(gate.points,4);
|
||||
assert.throws(()=>gate.receive({type:'lidar-state',sequence:4,age_ms:-1,points:4}));
|
||||
});
|
||||
|
||||
test('onboard camera admits immediate sourceopen and ignores callbacks after close',()=>{
|
||||
const saved={media:globalThis.MediaSource,create:URL.createObjectURL,revoke:URL.revokeObjectURL};
|
||||
let source,buffer,appended=0,ready=0,failed=0;
|
||||
class Buffer extends EventTarget {updating=false;buffered={length:0};appendBuffer(){appended++;}}
|
||||
class Media extends EventTarget {
|
||||
readyState='open';static isTypeSupported(){return true;}
|
||||
constructor(){super();source=this;}
|
||||
addSourceBuffer(){buffer=new Buffer();return buffer;}
|
||||
}
|
||||
class Video extends EventTarget {currentTime=0;set src(_){source.dispatchEvent(new Event('sourceopen'));}pause(){}load(){}removeAttribute(){}play(){return Promise.resolve();}}
|
||||
globalThis.MediaSource=Media;URL.createObjectURL=()=> 'blob:synthetic';URL.revokeObjectURL=()=>{};
|
||||
try{
|
||||
const video=new Video(),decoder=previewCamera(video,()=>ready++,()=>failed++);
|
||||
decoder.open('video/mp4');decoder.push(new Uint8Array([1,2,3]));
|
||||
assert.equal(appended,1);
|
||||
video.dispatchEvent(new Event('loadeddata'));assert.equal(ready,1);
|
||||
decoder.close();video.dispatchEvent(new Event('loadeddata'));buffer.dispatchEvent(new Event('error'));
|
||||
assert.equal(ready,1);assert.equal(failed,0);
|
||||
}finally{globalThis.MediaSource=saved.media;URL.createObjectURL=saved.create;URL.revokeObjectURL=saved.revoke;}
|
||||
});
|
||||
|
||||
test('a recovered media peer resumes a recording without reinserting old RRD batches',async()=>{
|
||||
const {previewRecording}=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/previewFrames.ts');
|
||||
const sent=[];const cursor=previewRecording(value=>sent.push(value));
|
||||
const payload=new Uint8Array(12);payload.set([82,82,70,50]);
|
||||
assert.equal(cursor.accept(1,payload),true);
|
||||
// The ACK was lost, so the new peer resends the exact complete batch.
|
||||
assert.equal(cursor.accept(1,payload),false);
|
||||
assert.equal(cursor.after,1);
|
||||
assert.throws(()=>cursor.accept(3,payload),/cursor gap/);
|
||||
assert.equal(cursor.accept(2,payload),true);
|
||||
assert.equal(sent.length,2);
|
||||
});
|
||||
@@ -16,6 +16,7 @@ let K1ConnectionPipelines;
|
||||
let physicalRecoveryConnectionDetail;
|
||||
let shouldRenderK1OperationalPanels;
|
||||
let K1ProvisioningPipeline;
|
||||
let K1WifiPasswordField;
|
||||
let RuntimeActionFenceTestContext;
|
||||
let LOCAL_OPERATION_PROGRESS_DELAY_MILLISECONDS;
|
||||
let emptySearchPresentation;
|
||||
@@ -102,6 +103,9 @@ before(async () => {
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({K1WifiPasswordField} = await server.ssrLoadModule(
|
||||
"@xgrids-k1/frontend/components/K1WifiPasswordField.tsx",
|
||||
));
|
||||
({ normalizeXgridsK1MissionState } = await server.ssrLoadModule(
|
||||
"@xgrids-k1/frontend/runtimeContext.tsx",
|
||||
));
|
||||
@@ -250,7 +254,7 @@ test("K1 one-intent source contract makes mode reset explicit and keeps device I
|
||||
"const chooseAnother",
|
||||
);
|
||||
|
||||
assert.match(search, /scanWithResult\(\{[^}]*durationSeconds:\s*6/);
|
||||
assert.match(search, /scanWithResult\(\{[^}]*durationSeconds:\s*BLE_DISCOVERY_TIMEOUT_SECONDS/);
|
||||
assert.equal((search.match(/scanWithResult\(/g) ?? []).length, 1);
|
||||
assert.doesNotMatch(search, /\b(?:connect|verifyConnection|submitConnect)\s*\(/);
|
||||
|
||||
@@ -277,7 +281,7 @@ test("K1 one-intent source contract makes mode reset explicit and keeps device I
|
||||
assert.match(resultRows, /onSelect=\{\(\) => selectCandidate\(device\)\}/);
|
||||
assert.doesNotMatch(resultRows, /Переподключиться|reopen|verifyConnection/);
|
||||
assert.match(source, /onChange=\{\(event\) => setSsid\(event\.target\.value\)\}/);
|
||||
assert.match(source, /onChange=\{\(event\) => setPassword\(event\.target\.value\)\}/);
|
||||
assert.match(source, /<K1WifiPasswordField[\s\S]*?onChange=\{setPassword\}/);
|
||||
|
||||
assert.equal((apply.match(/await connect\(/g) ?? []).length, 1);
|
||||
assert.doesNotMatch(
|
||||
@@ -290,12 +294,16 @@ test("K1 one-intent source contract makes mode reset explicit and keeps device I
|
||||
source,
|
||||
/(?:color|background(?:-color)?):\s*(?:#[0-9a-f]{3,8}|rgba?\()/i,
|
||||
);
|
||||
const passwordSource = readFileSync(new URL(
|
||||
"../../../plugins/xgrids-k1/frontend/src/components/K1WifiPasswordField.tsx", import.meta.url,
|
||||
), "utf8");
|
||||
for (const sharedControl of ["Button", "IconButton", "TextField", "ActivityIndicator", "StatusBadge"]) {
|
||||
assert.match(source, new RegExp(`<${sharedControl}\\b`), sharedControl);
|
||||
assert.match(source + passwordSource, new RegExp(`<${sharedControl}\\b`), sharedControl);
|
||||
}
|
||||
assert.doesNotMatch(passwordSource, /<(?:button|input)\b/);
|
||||
assert.equal((source.match(/buttonLabel:\s*"Применить"/g) ?? []).length, 3);
|
||||
assert.match(source, /устарел|stale/i);
|
||||
assert.match(source, /неизвест|outcome-unknown|safe_to_retry/i);
|
||||
assert.match(source, /network_outcome_unknown|safe_to_retry/i);
|
||||
});
|
||||
|
||||
test("exact network-applied REST proof completes frontend Apply before control is ready", () => {
|
||||
@@ -377,7 +385,7 @@ test("exact network-applied REST proof completes frontend Apply before control i
|
||||
);
|
||||
assert.match(
|
||||
connectFlow,
|
||||
/failedOperation\?\.status === "succeeded"[\s\S]*?!exactAppliedNetworkIntentCompleted\([\s\S]*?&& !hasExactConnectionReady/,
|
||||
/observeProvisioningRequest\([\s\S]*?observedState = observation\.state[\s\S]*?const exactNetworkIntentCompleted = exactAppliedNetworkIntentCompleted\(/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
connectFlow,
|
||||
@@ -1181,7 +1189,7 @@ function elementByProp(node, propName, expectedValue) {
|
||||
return elementByProp(node.props.children, propName, expectedValue);
|
||||
}
|
||||
|
||||
function createStatefulProvisioningHarness(initialProps) {
|
||||
function createStatefulProvisioningHarness(initialProps, Component = K1ProvisioningPipeline) {
|
||||
const hookSlots = [];
|
||||
let currentProps = initialProps;
|
||||
let capturedTree = null;
|
||||
@@ -1296,7 +1304,7 @@ function createStatefulProvisioningHarness(initialProps) {
|
||||
};
|
||||
|
||||
try {
|
||||
capturedTree = K1ProvisioningPipeline(currentProps);
|
||||
capturedTree = Component(currentProps);
|
||||
} finally {
|
||||
dispatcher.useState = originals.useState;
|
||||
dispatcher.useRef = originals.useRef;
|
||||
@@ -1359,7 +1367,7 @@ function actionByLabel(node, label) {
|
||||
return actionByLabel(node.props.children, label);
|
||||
}
|
||||
|
||||
function renderProvisioningWithAttempt(props, presentation) {
|
||||
function renderProvisioningWithAttempt(props, presentation, afterSearch = false) {
|
||||
function AttemptHarness() {
|
||||
const internals =
|
||||
React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
|
||||
@@ -1367,6 +1375,12 @@ function renderProvisioningWithAttempt(props, presentation) {
|
||||
assert.equal(typeof dispatcher?.useState, "function");
|
||||
const originalUseState = dispatcher.useState;
|
||||
dispatcher.useState = (initialState) => {
|
||||
if (afterSearch && initialState === emptySearchPresentation) {
|
||||
const state = props.controller.state;
|
||||
return [{ sequence: 1, snapshotRuntimeId: state.snapshot_runtime_id,
|
||||
connectionMode: props.desiredMode, desiredModeRevision: state.desired_connection_mode_revision,
|
||||
active: false, completedDiscoveryGeneration: state.ble_discovery_generation }, () => undefined];
|
||||
}
|
||||
if (initialState === emptyProvisioningAttemptPresentation) {
|
||||
return [presentation, () => undefined];
|
||||
}
|
||||
@@ -2487,10 +2501,11 @@ test("toolbar reset in one runtime rejects late Scan A and admits exactly one Sc
|
||||
}
|
||||
});
|
||||
|
||||
test("local connection navigation and selector copy use process nouns", () => {
|
||||
test("operator-local connection belongs to LAB and retains all test modes", () => {
|
||||
const localConnection = workspaces.find((workspace) => workspace.id === "local-device");
|
||||
assert.equal(localConnection?.label, "Подключение");
|
||||
assert.equal(localConnection?.title, "Подключение");
|
||||
assert.equal(localConnection?.root, "polygon");
|
||||
assert.equal(localConnection?.label, "Тестовые устройства");
|
||||
assert.equal(localConnection?.title, "Тестовые устройства");
|
||||
assert.equal(model().displayName, "XGRIDS LixelKity K1");
|
||||
assert.deepEqual(
|
||||
connectionModeOptions.map(({ label }) => label),
|
||||
@@ -2841,8 +2856,9 @@ test("compact connection error can expose only its contextual recovery path", ()
|
||||
}));
|
||||
|
||||
assert.match(markup, /Подключение не завершено/);
|
||||
assert.match(markup, />Подключить новый K1<\/button>/);
|
||||
assert.doesNotMatch(markup, /Проверить состояние|>Закрыть<\/button>/);
|
||||
assert.equal(buttonMarkupWithText(markup, "Подключить новый K1").length, 1);
|
||||
assert.doesNotMatch(markup, /Проверить состояние/);
|
||||
assert.equal(buttonMarkupWithText(markup, "Закрыть").length, 0);
|
||||
});
|
||||
|
||||
test("connection attempt network phase copy distinguishes absent, applied and unknown outcomes", () => {
|
||||
@@ -4341,16 +4357,16 @@ test("fresh Scan keeps retired audit rows selectable without recovery I/O", () =
|
||||
assert.match(markup, /Повторить поиск Bluetooth/);
|
||||
assert.doesNotMatch(markup, /old-stop-operation/);
|
||||
const priorAction = deviceRowActionButton(markup, "ble-k1-001");
|
||||
assert.match(priorAction, />Выбрать<\/button>/);
|
||||
assert.equal(priorAction.replace(/<[^>]+>/g, ""), "Выбрать");
|
||||
assert.doesNotMatch(priorAction, /\bdisabled(?:=|\s|>)/);
|
||||
const replacementAction = deviceRowActionButton(markup, "ble-k1-new");
|
||||
assert.match(replacementAction, />Выбрать<\/button>/);
|
||||
assert.equal(replacementAction.replace(/<[^>]+>/g, ""), "Выбрать");
|
||||
assert.doesNotMatch(replacementAction, /\bdisabled(?:=|\s|>)/);
|
||||
assert.equal(
|
||||
buttonMarkupWithText(markup, "Подключить новый K1").length,
|
||||
0,
|
||||
);
|
||||
assert.doesNotMatch(markup, /<button[^>]*disabled[^>]*>Недоступно<\/button>/);
|
||||
assert.equal(buttonMarkupWithText(markup, "Недоступно").filter(button => /<button[^>]*disabled/.test(button)).length, 0);
|
||||
assert.doesNotMatch(
|
||||
markup,
|
||||
/Попытка настройки завершена|Проверить подключение без изменения сети|Управление не подтверждено/,
|
||||
@@ -4373,7 +4389,7 @@ test("fresh Scan keeps retired audit rows selectable without recovery I/O", () =
|
||||
/Подходящих K1 не найдено\. Повторите поиск\./,
|
||||
);
|
||||
const onlyPriorAction = deviceRowActionButton(onlyPriorMarkup, "ble-k1-001");
|
||||
assert.match(onlyPriorAction, />Выбрать<\/button>/);
|
||||
assert.equal(onlyPriorAction.replace(/<[^>]+>/g, ""), "Выбрать");
|
||||
assert.doesNotMatch(onlyPriorAction, /\bdisabled(?:=|\s|>)/);
|
||||
assert.match(onlyPriorMarkup, /Результатов: 1/);
|
||||
assert.doesNotMatch(onlyPriorMarkup, />Недоступно|Переподключиться/);
|
||||
@@ -4400,7 +4416,7 @@ test("fresh Scan keeps retired audit rows selectable without recovery I/O", () =
|
||||
historicalAuditMarkup,
|
||||
"ble-k1-001",
|
||||
);
|
||||
assert.match(historicallyRetiredAction, />Выбрать<\/button>/);
|
||||
assert.equal(historicallyRetiredAction.replace(/<[^>]+>/g, ""), "Выбрать");
|
||||
assert.doesNotMatch(
|
||||
historicallyRetiredAction,
|
||||
/\bdisabled(?:=|\s|>)/,
|
||||
@@ -4490,7 +4506,7 @@ test("an exact prior CoreBluetooth UUID remains a normal fresh selection across
|
||||
markup,
|
||||
"f89438fa-55ed-85ad-eed7-734ac84746d8",
|
||||
);
|
||||
assert.match(priorDeviceAction, />Выбрать<\/button>/);
|
||||
assert.equal(priorDeviceAction.replace(/<[^>]+>/g, ""), "Выбрать");
|
||||
assert.doesNotMatch(priorDeviceAction, /\bdisabled(?:=|\s|>)/);
|
||||
assert.equal(buttonMarkupWithText(markup, "Переподключиться").length, 0);
|
||||
assertCanonicalConnectionCopy(markup);
|
||||
@@ -4662,23 +4678,11 @@ test("post-reset Scan selects the exact prior UUID locally and opens network fie
|
||||
"local selection must not manufacture a backend Apply attempt",
|
||||
);
|
||||
|
||||
const passwordField = elementByProp(tree, "label", "Пароль Wi‑Fi");
|
||||
const passwordField = elementByProp(tree, "placeholder", "Введите пароль");
|
||||
assert.ok(passwordField);
|
||||
assert.equal(passwordField.props.type, "password");
|
||||
passwordField.props.onChange({ target: { value: "test-only-password" } });
|
||||
passwordField.props.onChange("test-only-password");
|
||||
tree = harness.render(props());
|
||||
harness.flushEffects();
|
||||
const showPassword = elementByProp(tree, "label", "Показать пароль");
|
||||
assert.ok(showPassword);
|
||||
assert.equal(showPassword.props.disabled, false);
|
||||
showPassword.props.onClick();
|
||||
tree = harness.render(props());
|
||||
harness.flushEffects();
|
||||
assert.equal(
|
||||
elementByProp(tree, "label", "Пароль Wi‑Fi").props.type,
|
||||
"text",
|
||||
);
|
||||
assert.ok(elementByProp(tree, "label", "Скрыть пароль"));
|
||||
|
||||
const chooseAnother = actionByLabel(tree, "Выбрать другое");
|
||||
assert.ok(chooseAnother);
|
||||
@@ -6109,7 +6113,7 @@ test("an explicit network action keeps one disabled Step-2 form with an in-place
|
||||
assert.match(markup, /Пароль передан/);
|
||||
assert.doesNotMatch(markup, /secret/);
|
||||
assert.equal((markup.match(/<input[^>]*disabled=""/g) ?? []).length, 2);
|
||||
assert.doesNotMatch(markup, />Применить<\/button>/);
|
||||
assert.equal(buttonMarkupWithText(markup, "Применить").length, 0);
|
||||
assertCanonicalConnectionCopy(markup);
|
||||
});
|
||||
|
||||
@@ -6294,8 +6298,8 @@ test("current Bridge bootstrap stays visible over an older unresolved STOP check
|
||||
assert.match(markup, /<span>02<\/span>/);
|
||||
assert.match(markup, /nodedc-activity-indicator/);
|
||||
assert.doesNotMatch(markup, /Прежнее подключение не подтверждено/);
|
||||
assert.doesNotMatch(markup, />Переподключиться<\/button>/);
|
||||
assert.doesNotMatch(markup, />Подключить новый K1<\/button>/);
|
||||
assert.equal(buttonMarkupWithText(markup, "Переподключиться").length, 0);
|
||||
assert.equal(buttonMarkupWithText(markup, "Подключить новый K1").length, 0);
|
||||
|
||||
assert.equal(isConnectionControlBootstrapSettling({
|
||||
...state,
|
||||
@@ -6451,7 +6455,8 @@ test("correlated terminal recovery resets before exposing a separate clean Scan"
|
||||
buttonMarkupWithText(markup, "Проверить прежнее подключение").length,
|
||||
0,
|
||||
);
|
||||
assert.doesNotMatch(markup, /Проверить состояние|>Закрыть<\/button>/);
|
||||
assert.doesNotMatch(markup, /Проверить состояние/);
|
||||
assert.equal(buttonMarkupWithText(markup, "Закрыть").length, 0);
|
||||
assert.doesNotMatch(markup, /Выбрать другое|Пароль Wi‑Fi|Пароль передан|>Применить</);
|
||||
assert.doesNotMatch(markup, /<span>02<\/span>|private transport exception/);
|
||||
|
||||
@@ -7135,7 +7140,7 @@ test("a stale reopen settlement cannot own the current wizard surface", () => {
|
||||
assert.match(markup, /<span>01<\/span>/);
|
||||
assert.doesNotMatch(markup, /Подключение…|aria-busy="true"/);
|
||||
assert.doesNotMatch(markup, /<span>02<\/span>|<span>03<\/span>/);
|
||||
assert.match(markup, />Найти по Bluetooth<\/button>/);
|
||||
assert.equal(buttonMarkupWithText(markup, "Найти по Bluetooth").length, 1);
|
||||
const modeToggle = markup.match(
|
||||
/<button[^>]*aria-label="Способ подключения"[^>]*>/,
|
||||
)?.[0];
|
||||
@@ -7185,7 +7190,7 @@ test("a current-authority Bluetooth scan owns Step 01 and no network step", () =
|
||||
});
|
||||
|
||||
assert.match(markup, /<span>01<\/span>/);
|
||||
assert.match(markup, /Поиск Bluetooth · 6 с/);
|
||||
assert.match(markup, /Поиск Bluetooth · до 20 с/);
|
||||
assert.equal(
|
||||
(markup.match(/class="nodedc-activity-indicator"/g) ?? []).length,
|
||||
1,
|
||||
@@ -7255,7 +7260,7 @@ test("stale raw Bluetooth results stay hidden until an explicit search", () => {
|
||||
|
||||
assert.match(markup, /<span>01<\/span>/);
|
||||
assert.doesNotMatch(markup, /Replacement K1/);
|
||||
assert.match(markup, />Найти по Bluetooth<\/button>/);
|
||||
assert.equal(buttonMarkupWithText(markup, "Найти по Bluetooth").length, 1);
|
||||
assert.doesNotMatch(markup, /aria-busy="true"/);
|
||||
assert.doesNotMatch(markup, /<span>02<\/span>|<span>03<\/span>/);
|
||||
assert.doesNotMatch(markup, /Завершаем предыдущее действие|Завершаем ранее начатое действие/);
|
||||
@@ -7326,7 +7331,7 @@ test("Step-2 credentials and Apply share one canonical field stack", () => {
|
||||
|
||||
assert.match(
|
||||
form,
|
||||
/:\s*\(\s*<div className="field-stack"[\s\S]*?label="Пароль Wi‑Fi"[\s\S]*?\{applyAction\}\s*<\/div>/,
|
||||
/:\s*\(\s*<div className="field-stack"[\s\S]*?<K1WifiPasswordField[\s\S]*?\{applyAction\}\s*<\/div>/,
|
||||
);
|
||||
assert.match(form, /disabled=\{provisioningFieldsDisabled\}/);
|
||||
assert.match(form, /value=\{connectionAttemptOwnsDraft \? "" : password\}/);
|
||||
@@ -7478,7 +7483,7 @@ test("applied-network recovery spends the old intent and admits only explicit se
|
||||
search,
|
||||
/const recoveryScanAttempt = appliedRecoveryEscape[\s\S]*?canScanAppliedRecovery[\s\S]*?unresolvedAppliedAttempt/,
|
||||
);
|
||||
assert.match(search, /scanResult = await scanWithResult\(\{ durationSeconds: 6 \}\)/);
|
||||
assert.match(search, /scanResult = await scanWithResult\(\{ durationSeconds: BLE_DISCOVERY_TIMEOUT_SECONDS \}\)/);
|
||||
assert.match(
|
||||
search,
|
||||
/if \(scanResult\.succeeded && recoveryAttemptKey\) \{\s*setEscapedAppliedAttemptKey\(recoveryAttemptKey\)/,
|
||||
@@ -7708,3 +7713,80 @@ test("contour health never promotes selection or replay metrics to live authorit
|
||||
});
|
||||
assert.equal(replay.aiActive, false);
|
||||
});
|
||||
|
||||
|
||||
test("the recovery panel explains the reviewed K1 network-not-found reply", () => {
|
||||
const state = terminalConnectionRecoveryState();
|
||||
state.connection_attempt.public_error_code = "k1-wifi-network-not-found";
|
||||
const markup = renderToStaticMarkup(createElement(K1OperatorError, {
|
||||
attempt: state.connection_attempt, onRefresh() {}, onClear() {},
|
||||
}));
|
||||
assert.match(markup, /<strong>Не удалось подключить K1 к Wi‑Fi<\/strong>/);
|
||||
assert.match(markup, /указанная сеть не найдена/);
|
||||
assert.match(markup, /Проверьте название сети и пароль/);
|
||||
assert.doesNotMatch(markup, /ошибкой Bluetooth|INVALID_PDU/);
|
||||
});
|
||||
|
||||
test("a reviewed Wi-Fi rejection offers explicit network setup for the same K1", () => {
|
||||
const state = terminalConnectionRecoveryState();
|
||||
state.connection_attempt.public_error_code = "k1-wifi-network-not-found";
|
||||
const controller = {
|
||||
...provisioningController(state), error: "private transport error",
|
||||
errorCorrelation: {
|
||||
action: "connect", runtimeId: state.snapshot_runtime_id, leaseGeneration: 0,
|
||||
connectionAttemptId: state.connection_attempt.attempt_id,
|
||||
},
|
||||
getConnectionRecoveryObservationTarget: () => recommendedConnectionRecoveryObservationTarget(state),
|
||||
};
|
||||
const markup = renderProvisioningWithAttempt({ controller, desiredMode: "bridge" },
|
||||
provisioningAttemptPresentation({ snapshotRuntimeId: state.snapshot_runtime_id,
|
||||
attemptId: state.connection_attempt.attempt_id, localPhase: "failed" }), true);
|
||||
assert.equal(buttonMarkupWithText(markup, "Указать сеть Wi‑Fi заново").length, 1);
|
||||
assert.doesNotMatch(markup, /Подключить новый K1|private transport error/);
|
||||
});
|
||||
|
||||
test("a failed Connect after completed Bluetooth search exposes recovery instead of a locked form", () => {
|
||||
const state = terminalConnectionRecoveryState();
|
||||
state.ble_discovery_generation = 6;
|
||||
state.connection_attempt.public_error_code = "BleakGATTProtocolError";
|
||||
state.connection_attempt.stage = "gatt-write-failed";
|
||||
const controller = {
|
||||
...provisioningController(state),
|
||||
error: "private transport exception must stay hidden",
|
||||
errorCorrelation: {
|
||||
action: "connect", runtimeId: state.snapshot_runtime_id, leaseGeneration: 0,
|
||||
connectionAttemptId: state.connection_attempt.attempt_id,
|
||||
},
|
||||
getConnectionRecoveryObservationTarget: () => recommendedConnectionRecoveryObservationTarget(state),
|
||||
};
|
||||
const markup = renderProvisioningWithAttempt({ controller, desiredMode: "bridge" },
|
||||
provisioningAttemptPresentation({ snapshotRuntimeId: state.snapshot_runtime_id,
|
||||
attemptId: state.connection_attempt.attempt_id, localPhase: "failed" }), true);
|
||||
assert.equal(buttonMarkupWithText(markup, "Переподключиться").length, 1);
|
||||
assert.equal(buttonMarkupWithText(markup, "Подключить новый K1").length, 1);
|
||||
assert.match(markup, /K1 ответил ошибкой Bluetooth/);
|
||||
assert.doesNotMatch(markup, /Подключение не выполнено|дождитесь, пока система|private transport exception|Пароль передан/);
|
||||
});
|
||||
|
||||
|
||||
test("shared K1 password field reveals on demand and hides after clearing", () => {
|
||||
let value = "";
|
||||
const props = () => ({value, onChange:next=>{value=next;}, disabled:false});
|
||||
const harness = createStatefulProvisioningHarness(props(), K1WifiPasswordField);
|
||||
try {
|
||||
let tree = harness.render(props());
|
||||
assert.equal(elementByProp(tree, "label", "Показать пароль").props.disabled, true);
|
||||
elementByProp(tree, "label", "Пароль Wi‑Fi").props.onChange({target:{value:"test-only-value"}});
|
||||
tree = harness.render(props()); harness.flushEffects();
|
||||
elementByProp(tree, "label", "Показать пароль").props.onClick();
|
||||
tree = harness.render(props());
|
||||
assert.equal(elementByProp(tree, "label", "Пароль Wi‑Fi").props.type, "text");
|
||||
assert.equal(elementByProp(tree, "label", "Скрыть пароль").props['aria-pressed'], true);
|
||||
value = "";
|
||||
tree = harness.render(props()); harness.flushEffects();
|
||||
tree = harness.render(props());
|
||||
assert.equal(elementByProp(tree, "label", "Пароль Wi‑Fi").props.type, "password");
|
||||
assert.equal(elementByProp(tree, "label", "Показать пароль").props.disabled, true);
|
||||
assert.equal(elementByProp(tree, "label", "Пароль Wi‑Fi").props.autoComplete, "off");
|
||||
} finally { harness.dispose(); }
|
||||
});
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { before, after, test } from "node:test";
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let observeProvisioningRequest;
|
||||
let networkProvisionFailureMessage;
|
||||
let ApiError;
|
||||
before(async () => {
|
||||
server = await createServer({ appType: "custom", logLevel: "silent", server: { middlewareMode: true } });
|
||||
({ observeProvisioningRequest, networkProvisionFailureMessage } = await server.ssrLoadModule("@xgrids-k1/frontend/networkProvisioning.ts"));
|
||||
({ ApiError } = await server.ssrLoadModule("@xgrids-k1/frontend/api.ts"));
|
||||
});
|
||||
after(async () => { await server?.close(); });
|
||||
|
||||
test("reviewed station errors explain Wi-Fi recovery without claiming a Bluetooth failure", () => {
|
||||
for (const [code, pattern] of [
|
||||
["k1-wifi-network-not-found", /указанная сеть не найдена/],
|
||||
["k1-wifi-credentials-required", /устройство запросило учётные данные сети/],
|
||||
]) {
|
||||
const message = networkProvisionFailureMessage({ status: "failed", error: { code } });
|
||||
assert.match(message, pattern);
|
||||
assert.match(message, /Проверьте название сети и пароль/);
|
||||
assert.doesNotMatch(message, /INVALID_PDU|ошибкой Bluetooth|Переподключиться/);
|
||||
}
|
||||
assert.match(networkProvisionFailureMessage({ status: "failed", error: {
|
||||
code: "BleakGATTProtocolError", ble_att_error_code: 4, ble_att_error_name: "INVALID_PDU",
|
||||
} }), /INVALID_PDU/);
|
||||
assert.match(networkProvisionFailureMessage({ status: "failed", error: {
|
||||
code: "network-not-found",
|
||||
} }), /macOS/);
|
||||
});
|
||||
|
||||
const request = { device_id: "synthetic-k1", connection_mode: "bridge", idempotency_key: "explicit-one" };
|
||||
function snapshot(status, revision = 2) {
|
||||
return {
|
||||
snapshot_runtime_id: "runtime-one",
|
||||
snapshot_runtime_started_at_utc: "2026-09-06T00:00:00Z",
|
||||
snapshot_revision: revision,
|
||||
operations: status ? [{
|
||||
operation_id: "op-one", action: "network.provision", idempotency_key: request.idempotency_key,
|
||||
status, error: status === "failed" ? {
|
||||
code: "BleakGATTProtocolError", ble_att_error_code: 4, ble_att_error_name: "INVALID_PDU",
|
||||
device_write_attempted: true, device_write_confirmed: false,
|
||||
safe_to_retry: false, side_effect_status: "unknown",
|
||||
} : null,
|
||||
}] : [],
|
||||
};
|
||||
}
|
||||
function context(overrides = {}) {
|
||||
let current = snapshot(null, 1);
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
initialState: current,
|
||||
send: async (input) => { calls.push(["send", input]); throw new ApiError("transport", 502); },
|
||||
readState: async () => { calls.push(["read"]); return snapshot("failed"); },
|
||||
acceptState: (incoming) => { if (incoming.snapshot_revision >= current.snapshot_revision) current = incoming; },
|
||||
currentState: () => current,
|
||||
assertCurrent() {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("HTTP 502 retains the exact failed snapshot and ATT facts after one submission", async () => {
|
||||
const ctx = context();
|
||||
const result = await observeProvisioningRequest(request, ctx);
|
||||
assert.deepEqual(ctx.calls.map(([kind]) => kind), ["send", "read"]);
|
||||
assert.equal(ctx.calls[0][1], request);
|
||||
assert.equal(result.operation.operation_id, "op-one");
|
||||
assert.equal(result.operation.error.ble_att_error_name, "INVALID_PDU");
|
||||
assert.equal(result.state.operations[0], result.operation);
|
||||
assert.equal(result.transportError.status, 502);
|
||||
});
|
||||
|
||||
test("an existing failed or successful intent is never resubmitted", async () => {
|
||||
for (const status of ["failed", "succeeded"]) {
|
||||
const ctx = context({ initialState: snapshot(status) });
|
||||
const result = await observeProvisioningRequest(request, ctx);
|
||||
assert.equal(result.operation.status, status);
|
||||
assert.equal(ctx.calls.length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("lost HTTP response can observe journal success without another command", async () => {
|
||||
const ctx = context({ readState: async () => snapshot("succeeded") });
|
||||
const result = await observeProvisioningRequest(request, ctx);
|
||||
assert.equal(result.operation.status, "succeeded");
|
||||
assert.equal(ctx.calls.filter(([kind]) => kind === "send").length, 1);
|
||||
});
|
||||
|
||||
test("an admitted pending intent is followed only by bounded local state reads", async () => {
|
||||
let now = 0;
|
||||
const ctx = context({
|
||||
initialState: snapshot("running"),
|
||||
settlementOptions: { now: () => now, wait: async (ms) => { now += ms; } },
|
||||
});
|
||||
const result = await observeProvisioningRequest(request, ctx);
|
||||
assert.equal(result.operation.status, "failed");
|
||||
assert.deepEqual(ctx.calls.map(([kind]) => kind), ["read"]);
|
||||
});
|
||||
|
||||
test("newer WebSocket failure outranks a stale running REST result", async () => {
|
||||
const newer = snapshot("failed", 7);
|
||||
const ctx = context({
|
||||
send: async () => snapshot("running", 5),
|
||||
currentState: () => newer,
|
||||
});
|
||||
const result = await observeProvisioningRequest(request, ctx);
|
||||
assert.equal(result.state, newer);
|
||||
assert.equal(result.operation.status, "failed");
|
||||
assert.equal(ctx.calls.length, 0);
|
||||
});
|
||||
|
||||
test("superseding an intent after dispatch stops continuation without replay", async () => {
|
||||
let active = true;
|
||||
const ctx = context({
|
||||
send: async () => { active = false; throw new ApiError("lost", 502); },
|
||||
assertCurrent: () => { if (!active) throw new Error("superseded"); },
|
||||
});
|
||||
await assert.rejects(observeProvisioningRequest(request, ctx), /superseded/);
|
||||
assert.equal(ctx.calls.length, 0);
|
||||
});
|
||||
|
||||
test("an unrelated journal row cannot confirm the current command", async () => {
|
||||
const unrelated = snapshot("succeeded");
|
||||
unrelated.operations[0].idempotency_key = "another-intent";
|
||||
const ctx = context({ readState: async () => unrelated });
|
||||
const result = await observeProvisioningRequest(request, ctx);
|
||||
assert.equal(result.operation, null);
|
||||
assert.equal(result.transportError.status, 502);
|
||||
assert.equal(ctx.calls.length, 1);
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {after,before,test} from 'node:test';
|
||||
import {createServer} from 'vite';
|
||||
let server,activation,x4;
|
||||
before(async()=>{
|
||||
server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});
|
||||
activation=await server.ssrLoadModule('../../packages/sensor-ui/src/observationStart.ts');
|
||||
x4=await server.ssrLoadModule('../../plugins/insta360-x4/frontend/src/observationStart.ts');
|
||||
});
|
||||
after(async()=>{await server?.close();});
|
||||
const device=(id='camera-1')=>({id,online:true,prepared:true,layers:['color'],snapshot:{acquisition:'idle',context:{session_id:'session-1',execution:{node_id:'board-1'}}},camera_status:{connected:true,preview:0,recording:0}});
|
||||
function transport(items,submit=()=>({state:'complete'})){
|
||||
const commands=[];return {commands,inventory:async()=>({fresh:true,items}),submit:async c=>{commands.push(c);return submit(c);},operation:async()=>({state:'complete'})};
|
||||
}
|
||||
test('opening two views shares one start; another camera keeps its own lifecycle',async()=>{
|
||||
const a=device(),b=device('camera-2'),t=transport([a,b]);
|
||||
await Promise.all([activation.startCameraObservation(a,t),activation.startCameraObservation(a,t),activation.startCameraObservation(b,t)]);
|
||||
assert.equal(t.commands.length,2);
|
||||
assert.deepEqual(t.commands.map(c=>c.session.device_id).sort(),[a.id,b.id]);
|
||||
for(const c of t.commands){assert.equal(c.action_id,'start');assert.deepEqual(c.parameters,{record:false});}
|
||||
});
|
||||
test('opening observer cannot replace active capture, recording or playback',async()=>{
|
||||
for(const value of [{snapshot:{acquisition:'streaming'}},{snapshot:{acquisition:'starting'}},{snapshot:{acquisition:'stopping'}},{playback_id:'recorded-source'}]){
|
||||
const a=device();Object.assign(a,{...value,snapshot:{...a.snapshot,...value.snapshot}});const t=transport([a]);
|
||||
await activation.startCameraObservation(a,t);assert.equal(t.commands.length,0);
|
||||
}
|
||||
});
|
||||
test('stale inventory and replaced sessions never receive starts',async()=>{
|
||||
const original=device(),replacement=device();replacement.snapshot.context.session_id='new-session';
|
||||
for(const t of [transport([replacement]),{...transport([original]),inventory:async()=>({fresh:false,items:[original]})}]){
|
||||
await assert.rejects(activation.startCameraObservation(original,t));assert.equal(t.commands.length,0);
|
||||
}
|
||||
});
|
||||
test('X4 auto start uses preview only and attaches without restarting an active camera',async()=>{
|
||||
const a=device(),t=transport([a]);await x4.startX4Observation(a,t);assert.deepEqual(t.commands.map(c=>c.action_id),['preview.start']);
|
||||
a.camera_status.preview=1;t.commands.length=0;await x4.startX4Observation(a,t);assert.equal(t.commands.length,0);
|
||||
});
|
||||
test('stalled X4 preview recovers once for both lenses without any recording action',async()=>{
|
||||
const a=device();a.camera_status.preview=1;const t=transport([a]);
|
||||
await Promise.all([x4.startX4Observation(a,t,true),x4.startX4Observation(a,t,true)]);
|
||||
assert.deepEqual(t.commands.map(c=>c.action_id),['preview.stop','preview.start']);
|
||||
});
|
||||
test('X4 recovery requires confirmed stopped recording and successful preview stop',async()=>{
|
||||
for(const recording of [1,null,-1]){const a=device();a.camera_status={connected:true,preview:1,recording};const t=transport([a]);await assert.rejects(x4.startX4Observation(a,t,true));assert.equal(t.commands.length,0);}
|
||||
const a=device();a.camera_status.preview=1;const t=transport([a],()=>({state:'unknown'}));
|
||||
await assert.rejects(x4.startX4Observation(a,t,true));assert.deepEqual(t.commands.map(c=>c.action_id),['preview.stop']);
|
||||
});
|
||||
test('recovery cannot start a replacement camera session after its stop',async()=>{
|
||||
const a=device();a.camera_status.preview=1;const b=device();b.snapshot.context.session_id='replacement';
|
||||
const items=[a],t=transport(items,()=>{items[0]=b;return {state:'complete'};});
|
||||
await assert.rejects(x4.startX4Observation(a,t,true));assert.deepEqual(t.commands.map(c=>c.action_id),['preview.stop']);
|
||||
});
|
||||
@@ -414,7 +414,7 @@ test("data recordings keep the compact session dropdown and laboratory results s
|
||||
|
||||
test("recording preparation statuses share the viewer's left alignment", async () => {
|
||||
const spatialStyles = await readFile(
|
||||
new URL("../src/styles/spatial.css", import.meta.url),
|
||||
new URL("../../../packages/spatial-ui/src/spatial.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const statusStack = spatialStyles.slice(
|
||||
|
||||
@@ -27,7 +27,7 @@ async function read(relativePath) {
|
||||
test("Observatory is the third independent Polygon workspace", () => {
|
||||
assert.deepEqual(
|
||||
productModel.workspacesForRoot("polygon").map(({ id }) => id),
|
||||
["lab-archive", "simulations", "observatory"],
|
||||
["lab-archive", "simulations", "observatory", "local-device", "spatial-scene"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
productModel.workspaceById("observatory"),
|
||||
@@ -138,7 +138,7 @@ test("Observatory mounts the one shared canonical replay only after explicit adm
|
||||
/RerunViewport|ObservationSessionSelect|resolveObservationSessionReplay|resolveCanonicalLabReplay|deleteObservationSession|setInterval|setTimeout|useObservationSessions|useAdvancedLaboratoryCatalog|advanced-index|prefetch|preload/,
|
||||
);
|
||||
assert.equal(sharedReplay.match(/<RerunViewport\b/g)?.length, 1);
|
||||
assert.match(sharedReplay, /recordedSessionRerunProfile/);
|
||||
assert.match(sharedReplay, /laboratoryResultRerunProfile/);
|
||||
assert.match(sharedReplay, /timelineStartSeconds/);
|
||||
assert.doesNotMatch(sharedReplay, /live-acquisition|lab-recorded-evidence/);
|
||||
assert.match(
|
||||
|
||||
@@ -324,7 +324,7 @@ test("Polygon exposes one dataset surface and keeps legacy links compatible", ()
|
||||
assert.equal(workspaceById("datasets").kind, "datasets");
|
||||
assert.deepEqual(
|
||||
workspacesForRoot("polygon").map(({ id }) => id),
|
||||
["lab-archive", "simulations", "observatory"],
|
||||
["lab-archive", "simulations", "observatory", "local-device", "spatial-scene"],
|
||||
);
|
||||
assert.equal(
|
||||
workspacesForRoot("system").some(({ id }) => id === "polygon-run"),
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {after,before,test} from 'node:test';
|
||||
import {createServer} from 'vite';
|
||||
let server,PreviewLiveness,previewPlayback;
|
||||
before(async()=>{
|
||||
server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});
|
||||
({PreviewLiveness}=await server.ssrLoadModule('../../packages/sensor-ui/src/previewLiveness.ts'));
|
||||
({previewPlayback}=await server.ssrLoadModule('../../packages/sensor-ui/src/previewPlayback.ts'));
|
||||
});
|
||||
after(async()=>{await server?.close();});
|
||||
test('offscreen RTP decode progress keeps a viewer live without DOM frame callbacks',()=>{
|
||||
const h=new PreviewLiveness(0);
|
||||
for(let now=2000;now<180000;now+=2000){h.frame(now);assert.equal(h.state(now),'live');}
|
||||
});
|
||||
test('one stale telemetry sample signals delay, never a camera restart',()=>{
|
||||
const h=new PreviewLiveness(0);h.frame(1000);h.source(false,1100);
|
||||
assert.equal(h.state(1100),'delayed');h.source(true,1500);h.frame(1500);assert.equal(h.state(1500),'live');
|
||||
});
|
||||
test('capture recovery requires sustained fresh reports that the source stopped',()=>{
|
||||
const h=new PreviewLiveness(0);h.frame(1000);
|
||||
for(let now=2000;now<=12000;now+=1000){h.source(false,now);assert.equal(h.state(now),'delayed');}
|
||||
h.source(false,14000);assert.equal(h.state(14000),'source-stalled');
|
||||
h.source(true,14500);h.frame(14500);assert.equal(h.state(14500),'live');
|
||||
});
|
||||
test('missing telemetry and a stalled peer cannot be treated as proof to restart capture',()=>{
|
||||
const h=new PreviewLiveness(0);h.frame(100);h.source(false,200);
|
||||
assert.equal(h.state(13000),'peer-stalled');
|
||||
});
|
||||
test('negotiation time does not consume the first-frame grace period',()=>{
|
||||
const h=new PreviewLiveness(0);h.connected(45000);
|
||||
assert.equal(h.state(45001),'waiting');assert.equal(h.state(53001),'peer-stalled');
|
||||
});
|
||||
test('a browser-paused panel resumes the same stream and coalesces pending playback',async()=>{
|
||||
const stream={},attempts=[];
|
||||
const video={srcObject:stream,muted:false,paused:true,play(){return new Promise(resolve=>attempts.push(resolve));}};
|
||||
const playback=previewPlayback(video);
|
||||
playback.resume();playback.resume();assert.equal(attempts.length,1);assert.equal(video.muted,true);
|
||||
video.paused=false;attempts[0]();await new Promise(resolve=>setImmediate(resolve));
|
||||
playback.resume();assert.equal(attempts.length,1);
|
||||
video.paused=true;playback.resume();assert.equal(attempts.length,2);assert.equal(video.srcObject,stream);
|
||||
playback.dispose();attempts[1]();await new Promise(resolve=>setImmediate(resolve));
|
||||
playback.resume();assert.equal(attempts.length,2);
|
||||
});
|
||||
test('hidden-panel play rejection is retried on visibility without replacing the stream',async()=>{
|
||||
const stream={};let attempts=0;
|
||||
const video={srcObject:stream,muted:true,paused:true,play(){attempts++;return Promise.reject(new Error('offscreen'));}};
|
||||
const playback=previewPlayback(video);
|
||||
playback.resume();await new Promise(resolve=>setImmediate(resolve));
|
||||
playback.resume();await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(attempts,2);assert.equal(video.srcObject,stream);
|
||||
video.srcObject=null;playback.resume();assert.equal(attempts,2);
|
||||
});
|
||||
@@ -29,8 +29,10 @@ test("top navigation has no Center and Park owns contour health first", () => {
|
||||
assert.equal(productModel.workspaceById("contour-health")?.root, "fleet");
|
||||
assert.deepEqual(
|
||||
productModel.workspacesForRoot("polygon").map(({ id }) => id),
|
||||
["lab-archive", "simulations", "observatory"],
|
||||
["lab-archive", "simulations", "observatory", "local-device", "spatial-scene"],
|
||||
);
|
||||
assert.equal(productModel.workspaceById("spatial-scene")?.root, "polygon");
|
||||
assert.equal(productModel.workspacesForRoot("observation").some(({ id }) => id === "spatial-scene"), false);
|
||||
assert.equal(productModel.workspaceById("lab-archive")?.kind, "lab-archive");
|
||||
assert.deepEqual(
|
||||
{
|
||||
|
||||
@@ -280,7 +280,7 @@ test("recorded player skips only a proven buffered corrupt timestamp interval",
|
||||
|
||||
test("loading and error overlays fully conceal recorded camera pixels", async () => {
|
||||
const css = await readFile(
|
||||
new URL("../src/styles/observation.css", import.meta.url),
|
||||
new URL("../../../packages/spatial-ui/src/observation.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(
|
||||
|
||||
@@ -382,7 +382,7 @@ test("the spatial header reports raw replay as an active source", async () => {
|
||||
});
|
||||
|
||||
test("the complete vendor canvas host is hidden during partial and failed admission", async () => {
|
||||
const css = await readFile(new URL("../src/styles/spatial.css", import.meta.url), "utf8");
|
||||
const css = await readFile(new URL("../../../packages/spatial-ui/src/spatial.css", import.meta.url), "utf8");
|
||||
assert.match(
|
||||
css,
|
||||
/\.rerun-viewport:is\(\[data-status="loading"\], \[data-status="error"\]\)\s+\.rerun-viewport__canvas\s*\{[^}]*visibility:\s*hidden;[^}]*pointer-events:\s*none;/s,
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {before,after,test} from 'node:test';
|
||||
import {readFileSync,readdirSync} from 'node:fs';
|
||||
import {createServer} from 'vite';
|
||||
let server,api,resolveContribution,wirelessContributions,revealEnrollment;
|
||||
before(async()=>{
|
||||
server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});
|
||||
api=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/enrollment.ts');
|
||||
({revealEnrollment}=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/revealEnrollment.ts'));
|
||||
({sensorContribution:resolveContribution}=await server.ssrLoadModule('../../packages/sensor-ui/src/extensions.ts'));
|
||||
({wirelessContributions}=await server.ssrLoadModule('../../packages/sensor-ui/src/extensions.ts'));
|
||||
});
|
||||
after(async()=>{await server?.close();});
|
||||
const initial={node_id:'node-one',available:true,fresh:true,runtime_id:'runtime-one',snapshot_revision:1,runtime_started_at:'2026-09-06T00:00:00Z',selected_device_id:'synthetic-ble'};
|
||||
function fixture(){
|
||||
let request,reads=0,posts=0,clock=Date.now();
|
||||
const accepted=[];
|
||||
const value=(status='running',revision=2)=>({...initial,snapshot_revision:revision,connected:status==='succeeded',connection_attempt:{schema_version:'missioncore.xgrids-k1-connection-attempt/v1',attempt_id:request.operation_id,connection_mode:'bridge',status,phase:'network_applied',control_state:status==='succeeded'?'ready':'unknown',safe_next_action:status==='succeeded'?'start-acquisition':'wait-for-current-attempt'}});
|
||||
const transport={
|
||||
submit:async input=>{posts++;request=input;return {operation_id:request.operation_id,state:'complete',result:value()};},
|
||||
operation:async id=>({operation_id:id,state:'complete',result:value()}),
|
||||
state:async()=>{reads++;return value('succeeded',3);},
|
||||
};
|
||||
return {transport,value,accepted,parameters:{device_id:'synthetic-ble',ssid:'synthetic-network',password:crypto.randomUUID()},
|
||||
observer:{onState:s=>accepted.push(s),now:()=>clock,pause:async()=>{clock+=100000;}},
|
||||
counts:()=>({reads,posts}),request:()=>request};
|
||||
}
|
||||
|
||||
test('network ACK waits for the exact owned control bootstrap with one POST',async()=>{
|
||||
const f=fixture();const result=await api.enroll(f.transport,initial,'connect',f.parameters,f.observer);
|
||||
assert.equal(result.connected,true);assert.deepEqual(f.counts(),{reads:1,posts:1});
|
||||
assert.equal(f.accepted[0].connection_attempt.status,'running');assert.equal(f.accepted.at(-1).connection_attempt.status,'succeeded');
|
||||
assert.equal('password' in f.parameters,false);
|
||||
});
|
||||
test('lost POST response reads the same operation ID and never submits again',async()=>{
|
||||
const f=fixture();const submit=f.transport.submit;
|
||||
f.transport.submit=async input=>{await submit(input);throw new Error('response lost');};
|
||||
let ids=[];const operation=f.transport.operation;
|
||||
f.transport.operation=async id=>{ids.push(id);return operation(id);};
|
||||
const result=await api.enroll(f.transport,initial,'connect',f.parameters,f.observer);
|
||||
assert.equal(result.connected,true);assert.equal(f.counts().posts,1);assert.deepEqual(ids,[f.request().operation_id]);
|
||||
assert.equal('password' in f.parameters,false);
|
||||
});
|
||||
test('late snapshot cannot restore readiness or overwrite newer attempt evidence',()=>{
|
||||
const current={...initial,snapshot_revision:8,connected:false};
|
||||
assert.equal(api.mergeEnrollmentState(current,{...initial,snapshot_revision:7,connected:true}),current);
|
||||
const newer={...current,runtime_id:'runtime-two',runtime_started_at:'2026-09-06T00:01:00Z'};
|
||||
assert.equal(api.mergeEnrollmentState(newer,{...initial,snapshot_revision:900,connected:true}),newer);
|
||||
assert.equal(api.mergeEnrollmentState(current,newer).runtime_id,'runtime-two');
|
||||
assert.equal(api.mergeEnrollmentState(current,{available:false}).fresh,false);
|
||||
assert.equal(api.mergeEnrollmentState(current,{...initial,node_id:'another-board'}),current);
|
||||
});
|
||||
test('a response for another operation is never accepted',async()=>{
|
||||
const f=fixture();const submit=f.transport.submit;
|
||||
f.transport.submit=async input=>({...await submit(input),operation_id:'another-operation'});
|
||||
await assert.rejects(api.enroll(f.transport,initial,'connect',f.parameters,f.observer),/другое действие/);
|
||||
assert.equal(f.counts().reads,0);assert.equal(f.counts().posts,1);
|
||||
});
|
||||
test('foreign historical success cannot settle this attempt',async()=>{
|
||||
const f=fixture();const submit=f.transport.submit;
|
||||
f.transport.submit=async input=>({...await submit(input),result:{...initial,connected:true,connection_attempt:{...f.value('succeeded').connection_attempt,attempt_id:'foreign'}}});
|
||||
f.transport.state=async()=>({...initial,connected:true,connection_attempt:{...f.value('succeeded').connection_attempt,attempt_id:'foreign'}});
|
||||
await assert.rejects(api.enroll(f.transport,initial,'connect',f.parameters,f.observer),/пока не подтверждён/);
|
||||
assert.equal(f.counts().posts,1);
|
||||
});
|
||||
test('runtime restart interrupts observation without another command',async()=>{
|
||||
const f=fixture();f.transport.state=async()=>({...initial,runtime_id:'runtime-two',runtime_started_at:'2026-09-06T00:01:00Z'});
|
||||
await assert.rejects(api.enroll(f.transport,initial,'connect',f.parameters,f.observer),/Сеанс K1 изменился/);
|
||||
assert.equal(f.counts().posts,1);assert.equal(f.accepted.at(-1).runtime_id,'runtime-two');
|
||||
});
|
||||
test('closing the observer stops reads without cancelling/replaying a device command',async()=>{
|
||||
const f=fixture();const controller=new AbortController();
|
||||
f.observer.signal=controller.signal;f.observer.pause=async()=>controller.abort();
|
||||
await assert.rejects(api.enroll(f.transport,initial,'connect',f.parameters,f.observer),/Наблюдение закрыто/);
|
||||
assert.deepEqual(f.counts(),{reads:0,posts:1});
|
||||
});
|
||||
test('station refusal is the same message in onboard and LAB presentations',()=>{
|
||||
const state={...initial,connection_attempt:{schema_version:'missioncore.xgrids-k1-connection-attempt/v1',attempt_id:'test',status:'failed',phase:'network_outcome_unknown',public_error_code:'k1-wifi-network-not-found'}};
|
||||
assert.match(api.enrollmentNotice(state),/Проверьте название сети и пароль/);
|
||||
assert.doesNotMatch(api.enrollmentNotice(state),/Bluetooth.*ошиб/);
|
||||
});
|
||||
test('Bluetooth failure before dispatch asks for discovery without blaming Wi-Fi',()=>{
|
||||
const state={...initial,connection_attempt:{schema_version:'missioncore.xgrids-k1-connection-attempt/v1',attempt_id:'test',status:'failed',stage:'connect-failed',phase:'network_not_applied',public_error_code:'BleakError',side_effect_status:'none'}};
|
||||
assert.equal(api.enrollmentBluetoothFailure(state),true);
|
||||
assert.match(api.enrollmentNotice(state),/Bluetooth.*не были отправлены/s);
|
||||
assert.doesNotMatch(api.enrollmentNotice(state),/Проверьте.*пароль/);
|
||||
const unknown={...state,connection_attempt:{...state.connection_attempt,phase:'network_outcome_unknown',side_effect_status:'unknown'}};
|
||||
assert.equal(api.enrollmentBluetoothFailure(unknown),false);
|
||||
assert.doesNotMatch(api.enrollmentNotice(unknown),/не были отправлены/);
|
||||
});
|
||||
test('applied and restored network states admit read-only verification without credentials',async()=>{
|
||||
const f=fixture();
|
||||
const applied={...initial,allowed_actions:['observe-configured-device-network'],connection_attempt:{schema_version:'missioncore.xgrids-k1-connection-attempt/v1',attempt_id:'previous',status:'failed',stage:'control-bootstrap-failed',phase:'network_applied',side_effect_status:'applied',public_error_code:'TypeError'}};
|
||||
assert.equal(api.enrollmentAllowed(applied,'verify'),true);
|
||||
assert.match(api.enrollmentNotice(applied),/Проверить состояние K1/);
|
||||
assert.equal(api.enrollmentAllowed({...applied,connection_attempt:null},'verify'),true);
|
||||
await api.enroll(f.transport,applied,'verify',{device_id:'synthetic-ble'},f.observer);
|
||||
assert.equal(f.request().action,'verify');
|
||||
assert.deepEqual(f.request().parameters,{device_id:'synthetic-ble'});
|
||||
assert.equal(f.counts().posts,1);
|
||||
});
|
||||
test('new workflow content scrolls only the containing viewport without stealing focus',()=>{
|
||||
const original={document:globalThis.document,window:globalThis.window,getComputedStyle:globalThis.getComputedStyle};
|
||||
const calls=[];let reduced=false;
|
||||
const body={};
|
||||
const scroller={parentElement:body,scrollHeight:1600,clientHeight:500,scrollTop:100,
|
||||
getBoundingClientRect:()=>({top:100,bottom:600}),scrollTo:value=>calls.push(value)};
|
||||
globalThis.document={body,documentElement:{}};
|
||||
globalThis.window={matchMedia:()=>({matches:reduced})};
|
||||
globalThis.getComputedStyle=element=>({overflowY:element===scroller?'auto':'visible'});
|
||||
const target=(top,height)=>({parentElement:scroller,getBoundingClientRect:()=>({top,bottom:top+height,height}),focus:()=>assert.fail('focus must remain with the operator')});
|
||||
try{
|
||||
revealEnrollment(target(120,250));assert.equal(calls.length,0);
|
||||
revealEnrollment(target(620,100));assert.deepEqual(calls.pop(),{top:220,behavior:'smooth'});
|
||||
reduced=true;
|
||||
revealEnrollment(target(620,800));assert.deepEqual(calls.pop(),{top:620,behavior:'instant'});
|
||||
revealEnrollment({...target(700,100),parentElement:body});assert.equal(calls.length,0);
|
||||
}finally{Object.assign(globalThis,original);}
|
||||
});
|
||||
test('sensor host can resolve zero or unrelated integrations and rejects ambiguity',()=>{
|
||||
const alpha={kind:'alpha',Detail:()=>null},beta={kind:'beta',Detail:()=>null};
|
||||
assert.equal(resolveContribution([],{kind:'alpha'}),undefined);
|
||||
assert.equal(resolveContribution([alpha,beta],{kind:'beta'}),beta);
|
||||
assert.equal(resolveContribution([alpha,{...alpha}],{kind:'alpha'}),undefined);
|
||||
for(const name of readdirSync(new URL('../../../packages/sensor-ui/src/',import.meta.url))){
|
||||
if(!/\.tsx?$/.test(name))continue;
|
||||
assert.doesNotMatch(readFileSync(new URL('../../../packages/sensor-ui/src/'+name,import.meta.url),'utf8'),/xgrids|lixel|\bk1\b|K1Detail/,'vendor dependency in '+name);
|
||||
}
|
||||
});
|
||||
test('wireless device choices come from installed contributions without a single-vendor slot',()=>{
|
||||
const wired={kind:'wired',Detail:()=>null};
|
||||
const alpha={kind:'alpha',wirelessEnrollment:{label:'Device alpha',View:()=>null}};
|
||||
const beta={kind:'beta',wirelessEnrollment:{label:'Device beta',View:()=>null}};
|
||||
assert.deepEqual(wirelessContributions([]),[]);
|
||||
assert.deepEqual(wirelessContributions([wired,alpha,beta]),[alpha,beta]);
|
||||
assert.deepEqual(wirelessContributions([alpha,{...alpha},beta]),[beta]);
|
||||
});
|
||||
test('onboard actions use current server policy and never infer authority from readiness',()=>{
|
||||
const state={...initial,connected:true,allowed_actions:['verify-control-read-only']};
|
||||
assert.equal(api.enrollmentAllowed(state,'connect'),false);
|
||||
assert.equal(api.enrollmentAllowed(state,'scan'),false);
|
||||
assert.equal(api.enrollmentAllowed(state,'verify'),true);
|
||||
assert.equal(api.enrollmentAllowed({...state,fresh:false},'verify'),false);
|
||||
});
|
||||
test('delayed operation result cannot clear an observed board outage',()=>{
|
||||
const current={...initial,fresh:false,available:false};
|
||||
const result=api.mergeEnrollmentState(current,{...initial,fresh:undefined,snapshot_revision:2});
|
||||
assert.equal(result.fresh,false);
|
||||
assert.equal(api.mergeEnrollmentState(result,{...initial,fresh:true,snapshot_revision:3}).fresh,true);
|
||||
});
|
||||
|
||||
test('failed or unknown Bluetooth search is never presented as an empty successful scan',async()=>{
|
||||
for(const state of ['failed','rejected','unknown']){
|
||||
let posts=0;
|
||||
const transport={submit:async command=>{posts++;return {
|
||||
operation_id:command.operation_id,state:state==='unknown'?'unknown':'complete',
|
||||
error:'unconfirmed device command',
|
||||
result:{...initial,candidates:[],command_result:{operation_id:command.operation_id,action:'scan',status:state}},
|
||||
};}};
|
||||
await assert.rejects(api.enroll(transport,initial,'scan'),/Не удалось выполнить поиск Bluetooth на БК/);
|
||||
assert.equal(posts,1);
|
||||
}
|
||||
assert.equal(api.enrollmentNotice({...initial,command_result:{action:'scan',status:'failed'}}),'');
|
||||
});
|
||||
|
||||
test('a confirmed scan with zero candidates remains a valid empty result',async()=>{
|
||||
const transport={submit:async command=>({operation_id:command.operation_id,state:'complete',
|
||||
result:{...initial,candidates:[],command_result:{operation_id:command.operation_id,action:'scan',status:'succeeded'}},
|
||||
})};
|
||||
assert.deepEqual((await api.enroll(transport,initial,'scan')).candidates,[]);
|
||||
});
|
||||
|
||||
test('finishing enrollment requires a verified current device session and survives no stale fence',()=>{
|
||||
const connected={...initial,connected:true,discovery_generation:3,mode_revision:2,
|
||||
device_session:{device_session_id:'session-one'},command_result:{status:'succeeded'}};
|
||||
assert.equal(api.enrollmentProof(initial,'synthetic-ble'),null);
|
||||
const proof=api.enrollmentProof(connected,'synthetic-ble');
|
||||
assert.ok(proof);
|
||||
assert.equal(api.enrollmentProofCurrent(proof,connected,'synthetic-ble'),true);
|
||||
for(const patch of [{fresh:false},{available:false},{connected:false},{runtime_id:'other-runtime'},
|
||||
{discovery_generation:4},{mode_revision:3},{selected_device_id:'other-device'},
|
||||
{device_session:{device_session_id:'other-session'}},{command_result:{status:'rejected'}}]){
|
||||
assert.equal(api.enrollmentProofCurrent(proof,{...connected,...patch},'synthetic-ble'),false);
|
||||
}
|
||||
assert.equal(api.enrollmentProofCurrent(null,connected,'synthetic-ble'),false);
|
||||
assert.equal(api.enrollmentProofCurrent(proof,connected,'other-device'),false);
|
||||
});
|
||||
|
||||
test('lost BLE candidate tells the operator to rescan without blaming Ethernet or Wi-Fi credentials',()=>{
|
||||
const notice=api.enrollmentNotice({...initial,connection_attempt:{
|
||||
schema_version:'missioncore.xgrids-k1-connection-attempt/v1',attempt_id:'test',status:'failed',
|
||||
public_error_code:'network-provision-candidate-not-fresh',
|
||||
}});
|
||||
assert.match(notice,/Найдите K1 ещё раз/);
|
||||
assert.match(notice,/Настройки Wi-Fi не были отправлены/);
|
||||
assert.doesNotMatch(notice,/пароль|Ethernet/);
|
||||
});
|
||||
|
||||
|
||||
test('pending enrollment is presented by its action button without a second notice',()=>{
|
||||
for(const phase of ['connecting','network_applied']){
|
||||
assert.equal(api.enrollmentNotice({...initial,connection_attempt:{
|
||||
schema_version:'missioncore.xgrids-k1-connection-attempt/v1',attempt_id:'test',status:'running',phase,
|
||||
}}),'');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test('discovery errors preserve their cause and never imply a Wi-Fi refusal',()=>{
|
||||
for(const [code,text,requiresScan] of [
|
||||
['ble-discovery-busy',/поиск уже выполняется/i,false],
|
||||
['ble-adapter-unavailable',/Bluetooth недоступен/,false],
|
||||
['ble-discovery-failed',/Служба Bluetooth/,false],
|
||||
['ble-selected-device-unavailable',/Выбранный K1 не найден/,true],
|
||||
]){
|
||||
const state={...initial,connection_attempt:{schema_version:'missioncore.xgrids-k1-connection-attempt/v1',
|
||||
attempt_id:'test',status:'failed',stage:'resolution-failed',phase:'network_not_applied',
|
||||
public_error_code:code,side_effect_status:'none'}};
|
||||
assert.match(api.enrollmentNotice(state),text);
|
||||
assert.match(api.enrollmentNotice(state),/Настройки Wi-Fi не были отправлены/);
|
||||
assert.doesNotMatch(api.enrollmentNotice(state),/Проверьте.*пароль/);
|
||||
assert.equal(api.enrollmentBluetoothFailure(state),requiresScan);
|
||||
state.connection_attempt.side_effect_status='unknown';
|
||||
state.connection_attempt.phase='network_outcome_unknown';
|
||||
assert.doesNotMatch(api.enrollmentNotice(state),/не были отправлены/);
|
||||
}
|
||||
});
|
||||
|
||||
test('legacy resolution failure invalidates the form before a second stale submission',()=>{
|
||||
const state={...initial,connection_attempt:{schema_version:'missioncore.xgrids-k1-connection-attempt/v1',
|
||||
attempt_id:'test',status:'failed',stage:'resolution-failed',public_error_code:'BleakDBusError',side_effect_status:'none'}};
|
||||
assert.equal(api.enrollmentBluetoothFailure(state),true);
|
||||
assert.match(api.enrollmentNotice(state),/Найдите K1 ещё раз/);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import ts from 'typescript';
|
||||
|
||||
const source=readFileSync(new URL('../../../packages/sensor-ui/src/preparation.ts',import.meta.url),'utf8');
|
||||
const code=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.ESNext}}).outputText;
|
||||
const {devicePreparation,preparationProgress}=await import('data:text/javascript;base64,'+Buffer.from(code).toString('base64'));
|
||||
const device={id:'instax4_a'};
|
||||
const operation={operation_id:'op_a',device_id:device.id,action_id:'prepare',state:'running',requested_at:'2026-09-08T12:00:00Z'};
|
||||
const preparation={operation_id:'op_a',device_id:device.id,model_id:'insta360.x4',state:'running',phase:'verify',steps:[{id:'profile',label:'Драйвер',state:'complete'},{id:'verify',label:'Выбранная камера',state:'running'}]};
|
||||
|
||||
test('progress belongs to the exact operation and physical camera in either UI',()=>{
|
||||
const inventory={items:[device],operations:[operation],preparations:[{...preparation,device_id:'instax4_b'},preparation]};
|
||||
assert.deepEqual(devicePreparation(inventory,device),preparation);
|
||||
assert.equal(devicePreparation({...inventory,preparations:[{...preparation,operation_id:'op_old'}]},device),undefined);
|
||||
assert.equal(devicePreparation({...inventory,preparations:[preparation,preparation]},device),undefined);
|
||||
});
|
||||
test('a shared installation success still leaves instance verification outstanding',()=>{
|
||||
assert.deepEqual(preparationProgress(preparation,'Подготовка'),{label:'Подготовка',value:.5,valueText:'Выбранная камера'});
|
||||
assert.equal(preparationProgress(undefined,'Команда').value,undefined);
|
||||
});
|
||||
test('legacy RealSense progress cannot appear on an Insta360 row',()=>{
|
||||
const legacy={items:[device],operations:[operation],preparation:{state:'complete',started_at:Date.parse(operation.requested_at)/1000,steps:[{id:'service',label:'Служба',state:'complete'}]}};
|
||||
assert.equal(devicePreparation(legacy,device),undefined);
|
||||
const rs={id:'rsd455_a'};
|
||||
const value=devicePreparation({...legacy,operations:[{...operation,device_id:rs.id}]},rs);
|
||||
assert.equal(value.state,'running');
|
||||
assert.equal(value.steps.at(-1).state,'running');
|
||||
});
|
||||
test('new Nodes do not fall back to stale global preparation reports',()=>{
|
||||
const rs={id:'rsd455_a'};
|
||||
assert.equal(devicePreparation({items:[rs],operations:[{...operation,device_id:rs.id}],preparations:[],preparation:{started_at:Date.now()/1000,state:'complete',steps:[]}},rs),undefined);
|
||||
});
|
||||
@@ -7,6 +7,7 @@ let server;
|
||||
let LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE;
|
||||
let liveAcquisitionRerunProfile;
|
||||
let recordedSessionRerunProfile;
|
||||
let laboratoryResultRerunProfile;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
@@ -18,6 +19,7 @@ before(async () => {
|
||||
LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE,
|
||||
liveAcquisitionRerunProfile,
|
||||
recordedSessionRerunProfile,
|
||||
laboratoryResultRerunProfile,
|
||||
} = await server.ssrLoadModule("/src/core/observation/viewerProfile.ts"));
|
||||
});
|
||||
|
||||
@@ -86,3 +88,14 @@ test("the old LAB transport is fenced as explicit legacy comparison only", () =>
|
||||
});
|
||||
assert.equal(Object.isFrozen(LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE), true);
|
||||
});
|
||||
|
||||
test("LAB has its own immutable result identity and discriminator", () => {
|
||||
const input = {resultId:"E38-result-1",sourceUrl:"/shared.rrd",kind:"recorded-session",clock:"stream_time"};
|
||||
const lab = laboratoryResultRerunProfile(input);
|
||||
assert.equal(lab.kind,"laboratory-result");
|
||||
assert.equal(lab.clock,"session_time");
|
||||
assert.equal(lab.resultId,"E38-result-1");
|
||||
assert.equal(recordedSessionRerunProfile({...input}).kind,"recorded-session");
|
||||
assert.equal(liveAcquisitionRerunProfile({...input}).kind,"live-acquisition");
|
||||
assert.throws(()=>laboratoryResultRerunProfile({...input,resultId:" "}));
|
||||
});
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
@@ -14,11 +18,27 @@
|
||||
"moduleResolution": "Bundler",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@mission-core/plugin-sdk": ["src/core/device-plugins/frontendSdk.ts"],
|
||||
"@xgrids-k1/frontend/*": ["../../plugins/xgrids-k1/frontend/src/*"],
|
||||
"react": ["node_modules/@types/react/index.d.ts"],
|
||||
"react/jsx-runtime": ["node_modules/@types/react/jsx-runtime.d.ts"],
|
||||
"@nodedc/ui-react": ["node_modules/@nodedc/ui-react/dist/index.d.ts"]
|
||||
"@mission-core/plugin-sdk": [
|
||||
"src/core/device-plugins/frontendSdk.ts"
|
||||
],
|
||||
"@xgrids-k1/frontend/*": [
|
||||
"../../plugins/xgrids-k1/frontend/src/*"
|
||||
],
|
||||
"react": [
|
||||
"node_modules/@types/react/index.d.ts"
|
||||
],
|
||||
"react/jsx-runtime": [
|
||||
"node_modules/@types/react/jsx-runtime.d.ts"
|
||||
],
|
||||
"@nodedc/ui-react": [
|
||||
"node_modules/@nodedc/ui-react/dist/index.d.ts"
|
||||
],
|
||||
"@mission-core/sensor-sdk": [
|
||||
"../../packages/sensor-ui/src/pluginSdk.ts"
|
||||
],
|
||||
"react-dom": [
|
||||
"node_modules/@types/react-dom/index.d.ts"
|
||||
]
|
||||
},
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
@@ -28,5 +48,8 @@
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src", "../../plugins/xgrids-k1/frontend/src"]
|
||||
"include": [
|
||||
"src",
|
||||
"../../plugins/xgrids-k1/frontend/src"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ export default defineConfig(({ mode }) => {
|
||||
plugins: [react(), wasm(), cesiumRuntimeAssets()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@mission-core/sensor-sdk": fileURLToPath(new URL("../../packages/sensor-ui/src/pluginSdk.ts", import.meta.url)),
|
||||
"@mission-core/plugin-sdk": fileURLToPath(
|
||||
new URL("./src/core/device-plugins/frontendSdk.ts", import.meta.url),
|
||||
),
|
||||
|
||||
@@ -69,17 +69,26 @@ 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.Monitor = node.NewMonitor()
|
||||
app.Presentation = node.NewPresentationStore(*dir)
|
||||
pairing, err := node.OpenPairing(store, *dir, version, app.Inventory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
app.Pairing = pairing
|
||||
pairing.Monitor = app.Monitor
|
||||
nodeID, _ := store.Public()
|
||||
app.Sensors, err = node.OpenSensors(*dir, nodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pairing.Sensors = app.Sensors
|
||||
app.DeviceEnrollment, err = node.OpenDeviceEnrollment(*dir, nodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pairing.DeviceEnrollment = app.DeviceEnrollment
|
||||
app.Sensors.NetworkDevices = app.DeviceEnrollment
|
||||
app.Access = &node.AccessStore{Path: filepath.Join(*dir, "ssh-keys.json"), Users: func() []string { return node.LocalAdmins("/") }}
|
||||
if err := os.MkdirAll(filepath.Dir(*socket), 0700); err != nil {
|
||||
return err
|
||||
@@ -115,6 +124,7 @@ func run() error {
|
||||
go func() { errs <- private.Serve(unix) }()
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
go app.Monitor.Run(ctx)
|
||||
go app.Sensors.WatchUSB(ctx)
|
||||
go pairing.Run(ctx)
|
||||
log.Print("Mission Core Node " + version + " listening on loopback")
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type EnrollmentCommand struct {
|
||||
ID string `json:"operation_id"`
|
||||
NodeID string `json:"node_id"`
|
||||
RuntimeID string `json:"runtime_id"`
|
||||
Action string `json:"action"`
|
||||
Deadline string `json:"deadline_at"`
|
||||
Parameters map[string]any `json:"parameters"`
|
||||
}
|
||||
|
||||
// This is the complete journal schema. No command parameters or credentials.
|
||||
type EnrollmentOperation struct {
|
||||
ID string `json:"operation_id"`
|
||||
NodeID string `json:"node_id"`
|
||||
RuntimeID string `json:"runtime_id"`
|
||||
Action string `json:"action"`
|
||||
State string `json:"state"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Result map[string]any `json:"result,omitempty"`
|
||||
Remote bool `json:"remote,omitempty"`
|
||||
Updated int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
type DeviceEnrollment struct {
|
||||
mu sync.Mutex
|
||||
root string
|
||||
nodeID string
|
||||
client *http.Client
|
||||
operations map[string]*EnrollmentOperation
|
||||
}
|
||||
|
||||
func OpenDeviceEnrollment(root, nodeID string) (*DeviceEnrollment, error) {
|
||||
dir := filepath.Join(root, "device-enrollment")
|
||||
if e := os.MkdirAll(dir, 0700); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
d := &DeviceEnrollment{root: dir, nodeID: nodeID, operations: map[string]*EnrollmentOperation{}}
|
||||
d.client = &http.Client{Timeout: 185 * time.Second, Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, "unix", "/run/mission-core-k1/driver.sock")
|
||||
}}}
|
||||
files, _ := filepath.Glob(filepath.Join(dir, "op_*.json"))
|
||||
for _, path := range files {
|
||||
data, e := os.ReadFile(path)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
var op EnrollmentOperation
|
||||
if json.Unmarshal(data, &op) != nil || !operationID.MatchString(op.ID) {
|
||||
return nil, errors.New("invalid enrollment journal")
|
||||
}
|
||||
if op.State == "running" {
|
||||
op.State = "unknown"
|
||||
op.Error = "БК перезапущен. Обновите состояние K1 перед новой командой."
|
||||
}
|
||||
d.operations[op.ID] = &op
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *DeviceEnrollment) call(ctx context.Context, path string, command any) (map[string]any, error) {
|
||||
var body io.Reader
|
||||
method := "GET"
|
||||
if command != nil {
|
||||
data, e := json.Marshal(command)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
body = bytes.NewReader(data)
|
||||
method = "POST"
|
||||
}
|
||||
request, e := http.NewRequestWithContext(ctx, method, "http://k1"+path, body)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("X-Node-Id", d.nodeID)
|
||||
response, e := d.client.Do(request)
|
||||
if e != nil {
|
||||
return nil, errors.New("Служба K1 на БК недоступна.")
|
||||
}
|
||||
defer response.Body.Close()
|
||||
var value map[string]any
|
||||
if json.NewDecoder(io.LimitReader(response.Body, 65536)).Decode(&value) != nil || response.StatusCode != 200 {
|
||||
return nil, errors.New("Действие K1 не подтверждено. Обновите состояние устройства.")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (d *DeviceEnrollment) Status() map[string]any {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
value, e := d.call(ctx, "/status", nil)
|
||||
if e != nil {
|
||||
value = map[string]any{"available": false}
|
||||
}
|
||||
value["node_id"] = d.nodeID
|
||||
value["name"], _ = os.Hostname()
|
||||
value["fresh"] = true
|
||||
return value
|
||||
}
|
||||
|
||||
func (c EnrollmentCommand) valid(nodeID string) bool {
|
||||
deadline, e := time.Parse(time.RFC3339Nano, c.Deadline)
|
||||
if e != nil || !deadline.After(time.Now()) || time.Until(deadline) > 180*time.Second || c.NodeID != nodeID || !operationID.MatchString(c.ID) || c.RuntimeID == "" || len(c.RuntimeID) > 128 {
|
||||
return false
|
||||
}
|
||||
keys := map[string]bool{}
|
||||
switch c.Action {
|
||||
case "scan", "networks":
|
||||
case "connect", "verify":
|
||||
keys = map[string]bool{"device_id": true, "discovery_generation": true, "mode_revision": true}
|
||||
id, ok := c.Parameters["device_id"].(string)
|
||||
if !ok || len(id) < 1 || len(id) > 128 {
|
||||
return false
|
||||
}
|
||||
for _, key := range []string{"discovery_generation", "mode_revision"} {
|
||||
v, ok := c.Parameters[key].(float64)
|
||||
if !ok || v < 0 || v != float64(int64(v)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if c.Action == "connect" {
|
||||
keys["ssid"] = true
|
||||
keys["password"] = true
|
||||
for key, limit := range map[string]int{"ssid": 32, "password": 64} {
|
||||
v, ok := c.Parameters[key].(string)
|
||||
if !ok || len(v) < 1 || len(v) > limit {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
if len(keys) != len(c.Parameters) {
|
||||
return false
|
||||
}
|
||||
for key := range c.Parameters {
|
||||
if !keys[key] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func copyEnrollment(value *EnrollmentOperation) *EnrollmentOperation {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
data, _ := json.Marshal(value)
|
||||
var out EnrollmentOperation
|
||||
_ = json.Unmarshal(data, &out)
|
||||
return &out
|
||||
}
|
||||
|
||||
func (d *DeviceEnrollment) Submit(c EnrollmentCommand, remote bool) (*EnrollmentOperation, error) {
|
||||
if !c.valid(d.nodeID) {
|
||||
return nil, errors.New("Проверьте БК, выбранное устройство и параметры сети.")
|
||||
}
|
||||
d.mu.Lock()
|
||||
if old := d.operations[c.ID]; old != nil {
|
||||
out := copyEnrollment(old)
|
||||
d.mu.Unlock()
|
||||
return out, nil
|
||||
}
|
||||
if len(d.operations) >= 2000 {
|
||||
for id, value := range d.operations {
|
||||
if value.State != "running" && time.Now().Unix()-value.Updated > 86400 {
|
||||
if os.Remove(filepath.Join(d.root, id+".json")) == nil {
|
||||
delete(d.operations, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(d.operations) >= 2000 {
|
||||
d.mu.Unlock()
|
||||
return nil, errors.New("Журнал подключений заполнен. Повторите позже.")
|
||||
}
|
||||
}
|
||||
for _, v := range d.operations {
|
||||
if v.State == "running" {
|
||||
d.mu.Unlock()
|
||||
return nil, errors.New("Дождитесь текущего действия K1.")
|
||||
}
|
||||
}
|
||||
d.mu.Unlock()
|
||||
if d.Status()["runtime_id"] != c.RuntimeID {
|
||||
return nil, errors.New("Сеанс K1 изменился. Обновите устройства.")
|
||||
}
|
||||
d.mu.Lock()
|
||||
// Status can yield; repeat admission under the lock before the durable write.
|
||||
if old := d.operations[c.ID]; old != nil {
|
||||
out := copyEnrollment(old)
|
||||
d.mu.Unlock()
|
||||
return out, nil
|
||||
}
|
||||
for _, v := range d.operations {
|
||||
if v.State == "running" {
|
||||
d.mu.Unlock()
|
||||
return nil, errors.New("Дождитесь текущего действия K1.")
|
||||
}
|
||||
}
|
||||
op := &EnrollmentOperation{ID: c.ID, NodeID: c.NodeID, RuntimeID: c.RuntimeID, Action: c.Action, State: "running", Remote: remote, Updated: time.Now().Unix()}
|
||||
if e := savePrivateJSON(filepath.Join(d.root, c.ID+".json"), op); e != nil {
|
||||
d.mu.Unlock()
|
||||
return nil, e
|
||||
}
|
||||
d.operations[c.ID] = op
|
||||
out := copyEnrollment(op)
|
||||
d.mu.Unlock()
|
||||
go d.execute(c)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (d *DeviceEnrollment) execute(c EnrollmentCommand) {
|
||||
deadline, _ := time.Parse(time.RFC3339Nano, c.Deadline)
|
||||
ctx, cancel := context.WithDeadline(context.Background(), deadline)
|
||||
defer cancel()
|
||||
result, e := d.call(ctx, "/operation", c)
|
||||
// Dropping references is best-effort lifetime reduction, not a claim that
|
||||
// Go can zero every immutable JSON/string copy. No secret is journalled.
|
||||
delete(c.Parameters, "password")
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
op := d.operations[c.ID]
|
||||
op.Updated = time.Now().Unix()
|
||||
op.State = "complete"
|
||||
op.Result = result
|
||||
if e != nil {
|
||||
op.State = "unknown"
|
||||
op.Error = "Действие K1 не подтверждено. Обновите состояние; повторная команда автоматически не отправляется."
|
||||
}
|
||||
if e := savePrivateJSON(filepath.Join(d.root, c.ID+".json"), op); e != nil {
|
||||
op.State = "unknown"
|
||||
op.Error = "Результат не сохранён. Обновите состояние K1."
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DeviceEnrollment) Get(id string) *EnrollmentOperation {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
return copyEnrollment(d.operations[id])
|
||||
}
|
||||
func (d *DeviceEnrollment) RemoteResults() []*EnrollmentOperation {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
out := []*EnrollmentOperation{}
|
||||
for _, op := range d.operations {
|
||||
if op.Remote {
|
||||
out = append(out, copyEnrollment(op))
|
||||
if len(out) == 8 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func (d *DeviceEnrollment) Acknowledge(ids []string) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
for _, id := range ids {
|
||||
if op := d.operations[id]; op != nil && op.State != "running" {
|
||||
op.Remote = false
|
||||
_ = savePrivateJSON(filepath.Join(d.root, id+".json"), op)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DeviceEnrollment) Routes(mux *http.ServeMux, server *Server) {
|
||||
mux.HandleFunc("GET /api/devices/enrollment", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !server.authorized(w, r) {
|
||||
return
|
||||
}
|
||||
v := d.Status()
|
||||
_, name := server.Store.Public()
|
||||
v["name"] = name
|
||||
reply(w, 200, v)
|
||||
})
|
||||
mux.HandleFunc("POST /api/devices/enrollment/operations", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !server.authorized(w, r) {
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 16384)
|
||||
var c EnrollmentCommand
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if r.Header.Get("Content-Type") != "application/json" || decoder.Decode(&c) != nil {
|
||||
reply(w, 400, map[string]string{"error": "Некорректный запрос подключения"})
|
||||
return
|
||||
}
|
||||
op, e := d.Submit(c, false)
|
||||
if e != nil {
|
||||
reply(w, 409, map[string]string{"error": e.Error()})
|
||||
return
|
||||
}
|
||||
reply(w, 202, op)
|
||||
})
|
||||
mux.HandleFunc("GET /api/devices/enrollment/operations/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !server.authorized(w, r) {
|
||||
return
|
||||
}
|
||||
op := d.Get(r.PathValue("id"))
|
||||
if op == nil {
|
||||
reply(w, 200, map[string]string{"state": "unknown", "error": "Результат недоступен. Обновите состояние K1."})
|
||||
return
|
||||
}
|
||||
reply(w, 200, op)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type enrollmentHTTP func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f enrollmentHTTP) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
||||
|
||||
func enrollmentRequest() EnrollmentCommand {
|
||||
return EnrollmentCommand{ID: "op_" + strings.Repeat("a", 32), NodeID: "node-test", RuntimeID: "runtime-test", Action: "connect", Deadline: time.Now().Add(time.Minute).UTC().Format(time.RFC3339Nano), Parameters: map[string]any{"device_id": "test-ble", "mode_revision": float64(0), "discovery_generation": float64(1), "ssid": "test-network", "password": token()}}
|
||||
}
|
||||
|
||||
func TestEnrollmentJournalsNoCredentialAndDispatchesOnce(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
d, e := OpenDeviceEnrollment(root, "node-test")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
var posts atomic.Int32
|
||||
d.client = &http.Client{Transport: enrollmentHTTP(func(r *http.Request) (*http.Response, error) {
|
||||
body := `{"available":true,"runtime_id":"runtime-test"}`
|
||||
if r.Method == "POST" {
|
||||
posts.Add(1)
|
||||
body = `{"available":true,"connected":true}`
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
|
||||
})}
|
||||
c := enrollmentRequest()
|
||||
secret := c.Parameters["password"].(string)
|
||||
if _, e = d.Submit(c, true); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for d.Get(c.ID).State == "running" && time.Now().Before(deadline) {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
if d.Get(c.ID).State != "complete" {
|
||||
t.Fatal("operation did not complete")
|
||||
}
|
||||
// A repeated delivery owns no new physical intent, even with a replaced secret.
|
||||
c.Parameters["password"] = token()
|
||||
if _, e = d.Submit(c, true); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if posts.Load() != 1 {
|
||||
t.Fatal("duplicate dispatch")
|
||||
}
|
||||
journal, e := os.ReadFile(filepath.Join(root, "device-enrollment", c.ID+".json"))
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
results, _ := json.Marshal(d.RemoteResults())
|
||||
if bytes.Contains(journal, []byte(secret)) || bytes.Contains(results, []byte(secret)) || bytes.Contains(journal, []byte("parameters")) {
|
||||
t.Fatal("credential/parameters entered journal or results")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrollmentRestartDoesNotReplayRunningIntent(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
d, e := OpenDeviceEnrollment(root, "node-test")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
c := enrollmentRequest()
|
||||
op := EnrollmentOperation{ID: c.ID, NodeID: c.NodeID, RuntimeID: c.RuntimeID, Action: c.Action, State: "running", Remote: true}
|
||||
if e := savePrivateJSON(filepath.Join(d.root, c.ID+".json"), op); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
restarted, e := OpenDeviceEnrollment(root, "node-test")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if restarted.Get(c.ID).State != "unknown" {
|
||||
t.Fatal("restart did not fence unknown result")
|
||||
}
|
||||
restarted.client = &http.Client{Transport: enrollmentHTTP(func(*http.Request) (*http.Response, error) { t.Fatal("restart contacted hardware"); return nil, nil })}
|
||||
result, e := restarted.Submit(c, true)
|
||||
if e != nil || result.State != "unknown" {
|
||||
t.Fatal("repeated intent did not retain unknown outcome")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrollmentRejectsAnotherNodeExpiredAndHostMutation(t *testing.T) {
|
||||
c := enrollmentRequest()
|
||||
if !c.valid("node-test") {
|
||||
t.Fatal("valid intent rejected")
|
||||
}
|
||||
if c.valid("another-node") {
|
||||
t.Fatal("wrong node admitted")
|
||||
}
|
||||
c.Parameters["allow_host_wifi_switch"] = true
|
||||
if c.valid("node-test") {
|
||||
t.Fatal("host association admitted")
|
||||
}
|
||||
delete(c.Parameters, "allow_host_wifi_switch")
|
||||
c.Deadline = time.Now().Add(-time.Second).Format(time.RFC3339Nano)
|
||||
if c.valid("node-test") {
|
||||
t.Fatal("expired operation admitted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Local WebKit requires completed bounded HTTP responses. The same plugin
|
||||
// delivery owns RRD/fMP4 framing, backpressure, resume and acquisition isolation.
|
||||
func (s *Server) localPreviewRoutes(mux *http.ServeMux) {
|
||||
capacity := make(chan struct{}, 2)
|
||||
for _, route := range []string{"/api/devices/preview", "/api/devices/preview/read"} {
|
||||
mux.HandleFunc("POST "+route, func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authorized(w, r) {
|
||||
return
|
||||
}
|
||||
var value json.RawMessage
|
||||
if !decode(w, r, &value) {
|
||||
return
|
||||
}
|
||||
if s.DeviceEnrollment == nil {
|
||||
http.Error(w, "Preview unavailable", 503)
|
||||
return
|
||||
}
|
||||
path := "/local-preview/read"
|
||||
if r.URL.Path == "/api/devices/preview" {
|
||||
var command SensorCommand
|
||||
if json.Unmarshal(value, &command) != nil || command.Action != "offer" || !sensorID.MatchString(command.Session.DeviceID) || !strings.HasPrefix(command.Session.DeviceID, "k1_") {
|
||||
http.Error(w, "Invalid preview", 400)
|
||||
return
|
||||
}
|
||||
path = "/local-preview"
|
||||
}
|
||||
select {
|
||||
case capacity <- struct{}{}:
|
||||
defer func() { <-capacity }()
|
||||
default:
|
||||
http.Error(w, "Close another viewer", 429)
|
||||
return
|
||||
}
|
||||
request, err := http.NewRequestWithContext(r.Context(), "POST", "http://k1"+path, bytes.NewReader(value))
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid preview", 400)
|
||||
return
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("X-Node-Id", s.DeviceEnrollment.nodeID)
|
||||
client := &http.Client{Transport: s.DeviceEnrollment.client.Transport, Timeout: 5 * time.Second}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
http.Error(w, "Preview unavailable", 503)
|
||||
return
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != 200 {
|
||||
http.Error(w, "Preview unavailable", response.StatusCode)
|
||||
return
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(response.Body, 196609))
|
||||
if err != nil || len(data) > 196608 {
|
||||
http.Error(w, "Preview unavailable", 503)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/vnd.missioncore.preview")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(data)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The collector owns sampling/storage. This bounded cache keeps monitoring I/O
|
||||
// out of the paired control heartbeat and out of acquisition lifecycles.
|
||||
type Monitor struct {
|
||||
mu sync.Mutex
|
||||
client *http.Client
|
||||
source string
|
||||
after int64
|
||||
sent int64
|
||||
value map[string]any
|
||||
}
|
||||
|
||||
func NewMonitor() *Monitor {
|
||||
return &Monitor{client: &http.Client{Timeout: 3 * time.Second, Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, "unix", "/run/mission-core-monitor/monitor.sock")
|
||||
}}}}
|
||||
}
|
||||
func (m *Monitor) read(ctx context.Context, path string, body []byte) (map[string]any, error) {
|
||||
method := "GET"
|
||||
if body != nil {
|
||||
method = "POST"
|
||||
}
|
||||
r, e := http.NewRequestWithContext(ctx, method, "http://monitor"+path, bytes.NewReader(body))
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
response, e := m.client.Do(r)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != 200 {
|
||||
return nil, errors.New("monitor unavailable")
|
||||
}
|
||||
data, e := io.ReadAll(io.LimitReader(response.Body, 262145))
|
||||
if e != nil || len(data) > 262144 {
|
||||
return nil, errors.New("monitor response budget")
|
||||
}
|
||||
var value map[string]any
|
||||
e = json.Unmarshal(data, &value)
|
||||
return value, e
|
||||
}
|
||||
func (m *Monitor) Run(ctx context.Context) {
|
||||
defer m.client.CloseIdleConnections()
|
||||
timer := time.NewTicker(2 * time.Second)
|
||||
defer timer.Stop()
|
||||
for {
|
||||
status, e := m.read(ctx, "/status", nil)
|
||||
if e == nil {
|
||||
source, _ := status["source_id"].(string)
|
||||
m.mu.Lock()
|
||||
if source != m.source {
|
||||
m.source = source
|
||||
m.after = 0
|
||||
m.sent = 0
|
||||
}
|
||||
after := m.after
|
||||
m.mu.Unlock()
|
||||
batch, be := m.read(ctx, "/batch?after="+strconv.FormatInt(after, 10), nil)
|
||||
if be == nil && batch["source_id"] == source {
|
||||
status["batch"] = batch
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.value = status
|
||||
m.mu.Unlock()
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
func (m *Monitor) Snapshot() map[string]any {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.value == nil {
|
||||
return map[string]any{"storage": "unavailable"}
|
||||
}
|
||||
if batch, ok := m.value["batch"].(map[string]any); ok {
|
||||
if rows, ok := batch["samples"].([]any); ok {
|
||||
for _, raw := range rows {
|
||||
if row, ok := raw.(map[string]any); ok {
|
||||
if seq, ok := row["seq"].(float64); ok && int64(seq) > m.sent {
|
||||
m.sent = int64(seq)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return m.value
|
||||
}
|
||||
func (m *Monitor) Acknowledge(raw json.RawMessage) {
|
||||
var ack struct {
|
||||
Source string `json:"source_id"`
|
||||
After int64 `json:"after"`
|
||||
}
|
||||
if json.Unmarshal(raw, &ack) != nil {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
// A receiver may already have a later cursor from before the Node restarted.
|
||||
// Its authenticated ACK is bounded by the collector's durable latest record.
|
||||
latest := m.sent
|
||||
if row, ok := m.value["latest"].(map[string]any); ok {
|
||||
if seq, ok := row["seq"].(float64); ok {
|
||||
latest = int64(seq)
|
||||
}
|
||||
}
|
||||
if ack.Source == m.source && ack.After >= m.after && ack.After <= latest {
|
||||
m.after = ack.After
|
||||
}
|
||||
}
|
||||
func (s *Server) monitorRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/monitor", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authorized(w, r) {
|
||||
return
|
||||
}
|
||||
if s.Monitor == nil {
|
||||
reply(w, 503, map[string]string{"error": "Мониторинг недоступен"})
|
||||
return
|
||||
}
|
||||
reply(w, 200, s.Monitor.Snapshot())
|
||||
})
|
||||
mux.HandleFunc("POST /api/monitor/events", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authorized(w, r) {
|
||||
return
|
||||
}
|
||||
var value json.RawMessage
|
||||
if !decode(w, r, &value) {
|
||||
return
|
||||
}
|
||||
if len(value) > 2048 || s.Monitor == nil {
|
||||
http.Error(w, "Monitoring unavailable", 503)
|
||||
return
|
||||
}
|
||||
if _, e := s.Monitor.read(r.Context(), "/events", value); e != nil {
|
||||
http.Error(w, "Monitoring unavailable", 503)
|
||||
return
|
||||
}
|
||||
reply(w, 200, map[string]bool{"ok": true})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMonitorAcknowledgementsRequireSameArchiveAndDurableBounds(t *testing.T) {
|
||||
m := NewMonitor()
|
||||
m.source = "archive-one"
|
||||
m.value = map[string]any{"latest": map[string]any{"seq": float64(100)}}
|
||||
for _, raw := range []string{`{"source_id":"another","after":50}`, `{"source_id":"archive-one","after":101}`, `{"source_id":"archive-one","after":-1}`} {
|
||||
m.Acknowledge(json.RawMessage(raw))
|
||||
if m.after != 0 {
|
||||
t.Fatal("accepted invalid cursor")
|
||||
}
|
||||
}
|
||||
m.Acknowledge(json.RawMessage(`{"source_id":"archive-one","after":80}`))
|
||||
if m.after != 80 {
|
||||
t.Fatal("did not restore committed receiver cursor after restart")
|
||||
}
|
||||
m.Acknowledge(json.RawMessage(`{"source_id":"archive-one","after":10}`))
|
||||
if m.after != 80 {
|
||||
t.Fatal("regressed cursor")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitorAndPreviewCannotBypassNodeSession(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
for _, path := range []string{"/api/devices/preview", "/api/devices/preview/read", "/api/monitor/events"} {
|
||||
if got := call(s, "POST", path, `{}`, nil).Code; got != 401 {
|
||||
t.Fatalf("%s: %d", path, got)
|
||||
}
|
||||
}
|
||||
if got := call(s, "GET", "/api/monitor", "", nil).Code; got != 401 {
|
||||
t.Fatal(got)
|
||||
}
|
||||
cookie := login(t, s)
|
||||
if got := call(s, "POST", "/api/devices/preview", `{}`, cookie).Code; got != 503 {
|
||||
t.Fatal(got)
|
||||
}
|
||||
s.Monitor = NewMonitor()
|
||||
if got := call(s, "GET", "/api/monitor", "", cookie).Code; got != 200 {
|
||||
t.Fatal(got)
|
||||
}
|
||||
// An unavailable collector remains independent from the authenticated UI.
|
||||
if got := call(s, "GET", "/api/status", "", cookie).Code; got != 200 {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -21,15 +22,16 @@ type Invitation struct {
|
||||
SecretHash string `json:"secret_hash,omitempty"`
|
||||
}
|
||||
type CoreBinding struct {
|
||||
BindingID string `json:"binding_id"`
|
||||
CoreID string `json:"core_id"`
|
||||
CoreName string `json:"core_name"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
CAPEM string `json:"ca_pem"`
|
||||
ClientPEM string `json:"client_pem"`
|
||||
Receipt string `json:"receipt,omitempty"`
|
||||
OfferHash string `json:"offer_hash,omitempty"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
EndpointRevision uint64 `json:"endpoint_revision,omitempty"`
|
||||
BindingID string `json:"binding_id"`
|
||||
CoreID string `json:"core_id"`
|
||||
CoreName string `json:"core_name"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
CAPEM string `json:"ca_pem"`
|
||||
ClientPEM string `json:"client_pem"`
|
||||
Receipt string `json:"receipt,omitempty"`
|
||||
OfferHash string `json:"offer_hash,omitempty"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
}
|
||||
type PairState struct {
|
||||
Schema string `json:"schema"`
|
||||
@@ -39,20 +41,22 @@ type PairState struct {
|
||||
Revocations []CoreBinding `json:"revocations,omitempty"`
|
||||
}
|
||||
type Pairing struct {
|
||||
Sensors *Sensors
|
||||
mu sync.Mutex
|
||||
path string
|
||||
store *Store
|
||||
state PairState
|
||||
now func() time.Time
|
||||
inventory func() Inventory
|
||||
version string
|
||||
lastSeen int64
|
||||
connection string
|
||||
listenError string
|
||||
failureWindow int64
|
||||
failures int
|
||||
clients map[string]*http.Client
|
||||
Monitor *Monitor
|
||||
Sensors *Sensors
|
||||
DeviceEnrollment *DeviceEnrollment
|
||||
mu sync.Mutex
|
||||
path string
|
||||
store *Store
|
||||
state PairState
|
||||
now func() time.Time
|
||||
inventory func() Inventory
|
||||
version string
|
||||
lastSeen int64
|
||||
connection string
|
||||
listenError string
|
||||
failureWindow int64
|
||||
failures int
|
||||
clients map[string]*http.Client
|
||||
}
|
||||
|
||||
func OpenPairing(store *Store, dir, version string, inventory func() Inventory) (*Pairing, error) {
|
||||
@@ -169,6 +173,12 @@ func (p *Pairing) addresses() []string {
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.SliceStable(result, func(i, j int) bool {
|
||||
if tailnetAddress(result[i]) != tailnetAddress(result[j]) {
|
||||
return tailnetAddress(result[i])
|
||||
}
|
||||
return result[i] < result[j]
|
||||
})
|
||||
return result
|
||||
}
|
||||
func (p *Pairing) invite(address string) (map[string]any, error) {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
const recoverySchema = "missioncore.node-channel-recovery/v1"
|
||||
|
||||
// Address preference is transport policy, never proof of peer identity.
|
||||
func tailnetAddress(address string) bool {
|
||||
ip := net.ParseIP(address).To4()
|
||||
return ip != nil && ip[0] == 100 && ip[1] >= 64 && ip[1] <= 127
|
||||
}
|
||||
|
||||
func recoveryTLS(b CoreBinding, key ed25519.PrivateKey, address string) (*tls.Config, error) {
|
||||
config, err := bindingTLS(b, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cert, err := bootstrapCertificate(key, address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tls.Config{
|
||||
MinVersion: tls.VersionTLS13, Certificates: []tls.Certificate{cert},
|
||||
ClientAuth: tls.RequireAndVerifyClientCert, ClientCAs: config.RootCAs,
|
||||
VerifyConnection: func(state tls.ConnectionState) error {
|
||||
if !recoveryPeer(state, b.CoreID) {
|
||||
return errors.New("recovery requires the bound Core identity")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func recoveryPeer(state tls.ConnectionState, coreID string) bool {
|
||||
if len(state.VerifiedChains) == 0 || len(state.PeerCertificates) == 0 {
|
||||
return false
|
||||
}
|
||||
key, ok := state.PeerCertificates[0].PublicKey.(ed25519.PublicKey)
|
||||
return ok && keyID("core_", key) == coreID
|
||||
}
|
||||
|
||||
func (p *Pairing) recoveryHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil || !tailnetAddress(host) || r.TLS == nil {
|
||||
http.Error(w, "Private authenticated channel required", 403)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Schema string `json:"schema"`
|
||||
BindingID string `json:"binding_id"`
|
||||
ExpectedEndpoint string `json:"expected_endpoint,omitempty"`
|
||||
ExpectedRevision uint64 `json:"expected_revision,omitempty"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
}
|
||||
if !pairDecode(w, r, &body) {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
b := p.state.Binding
|
||||
// Recheck durable authority on every request, including existing TLS sessions.
|
||||
if p.state.Phase != "paired" || b == nil || !recoveryPeer(*r.TLS, b.CoreID) || body.BindingID != b.BindingID {
|
||||
http.Error(w, "Binding unavailable", 403)
|
||||
return
|
||||
}
|
||||
if body.Schema != recoverySchema {
|
||||
http.Error(w, "Incompatible recovery protocol", 400)
|
||||
return
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/v1/channel/inspect":
|
||||
case "/v1/channel/migrate":
|
||||
u, err := url.Parse(body.Endpoint)
|
||||
if err != nil || !privateEndpoint(body.Endpoint, CorePort) || !tailnetAddress(u.Hostname()) || u.Hostname() != host {
|
||||
http.Error(w, "Endpoint must address the authenticated Core transport", 400)
|
||||
return
|
||||
}
|
||||
if body.ExpectedEndpoint != b.Endpoint || body.ExpectedRevision != b.EndpointRevision || b.EndpointRevision >= 1000000000 {
|
||||
http.Error(w, "Endpoint changed; inspect again", 409)
|
||||
return
|
||||
}
|
||||
if body.Endpoint != b.Endpoint {
|
||||
next := p.state
|
||||
copy := *b
|
||||
copy.Endpoint = body.Endpoint
|
||||
copy.EndpointRevision++
|
||||
next.Binding = ©
|
||||
if p.save(next) != nil {
|
||||
http.Error(w, "State unavailable", 503)
|
||||
return
|
||||
}
|
||||
p.connection = "offline"
|
||||
p.lastSeen = 0
|
||||
b = p.state.Binding
|
||||
}
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
id, _ := p.store.Public()
|
||||
reply(w, 200, map[string]any{"schema": recoverySchema, "node_id": id, "core_id": b.CoreID, "binding_id": b.BindingID, "endpoint": b.Endpoint, "endpoint_revision": b.EndpointRevision})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"log"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func recoveryFixture(t *testing.T) (*Pairing, tls.Certificate) {
|
||||
t.Helper()
|
||||
p, _ := testPairing(t)
|
||||
pub, key, _ := ed25519.GenerateKey(rand.Reader)
|
||||
ca := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "synthetic Core"}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature}
|
||||
der, err := x509.CreateCertificate(rand.Reader, ca, ca, pub, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ca, _ = x509.ParseCertificate(der)
|
||||
leaf := func(serial int64, public any) []byte {
|
||||
spec := &x509.Certificate{SerialNumber: big.NewInt(serial), NotBefore: ca.NotBefore, NotAfter: ca.NotAfter, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, KeyUsage: x509.KeyUsageDigitalSignature}
|
||||
value, e := x509.CreateCertificate(rand.Reader, spec, ca, public, key)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
return value
|
||||
}
|
||||
b := CoreBinding{BindingID: token(), CoreID: keyID("core_", pub), CoreName: "Test", Endpoint: "https://192.168.10.5:8782", CAPEM: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})), ClientPEM: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leaf(2, p.store.pairingKey().Public())}))}
|
||||
if err := p.save(PairState{Schema: PairSchema, Phase: "paired", Binding: &b}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p, tls.Certificate{Certificate: [][]byte{leaf(3, pub)}, PrivateKey: key}
|
||||
}
|
||||
|
||||
func recoveryCall(p *Pairing, client tls.Certificate, path string, body map[string]any) *httptest.ResponseRecorder {
|
||||
raw, _ := json.Marshal(body)
|
||||
r := httptest.NewRequest("POST", path, strings.NewReader(string(raw)))
|
||||
r.RemoteAddr = "100.64.10.5:42000"
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
cert, _ := x509.ParseCertificate(client.Certificate[0])
|
||||
r.TLS = &tls.ConnectionState{VerifiedChains: [][]*x509.Certificate{{cert}}, PeerCertificates: []*x509.Certificate{cert}}
|
||||
w := httptest.NewRecorder()
|
||||
p.recoveryHandler().ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestRecoveryMigrationPreservesTrustAndSurvivesRestart(t *testing.T) {
|
||||
p, client := recoveryFixture(t)
|
||||
old := *p.state.Binding
|
||||
body := map[string]any{"schema": recoverySchema, "binding_id": old.BindingID, "expected_endpoint": old.Endpoint, "expected_revision": 0, "endpoint": "https://100.64.10.5:8782"}
|
||||
if out := recoveryCall(p, client, "/v1/channel/migrate", body); out.Code != 200 {
|
||||
t.Fatal(out.Code, out.Body.String())
|
||||
}
|
||||
got := *p.state.Binding
|
||||
if got.BindingID != old.BindingID || got.CoreID != old.CoreID || got.CAPEM != old.CAPEM || got.ClientPEM != old.ClientPEM || got.EndpointRevision != 1 {
|
||||
t.Fatal("migration replaced authority")
|
||||
}
|
||||
if out := recoveryCall(p, client, "/v1/channel/migrate", body); out.Code != 409 {
|
||||
t.Fatal("stale request accepted")
|
||||
}
|
||||
reopened, err := OpenPairing(p.store, filepath.Dir(p.path), "test", p.inventory)
|
||||
if err != nil || reopened.state.Binding.Endpoint != got.Endpoint || reopened.state.Binding.EndpointRevision != 1 {
|
||||
t.Fatal("migration not durable", err)
|
||||
}
|
||||
inspect := map[string]any{"schema": recoverySchema, "binding_id": old.BindingID}
|
||||
out := recoveryCall(reopened, client, "/v1/channel/inspect", inspect)
|
||||
if out.Code != 200 || !strings.Contains(out.Body.String(), got.Endpoint) || strings.Contains(out.Body.String(), "PEM") {
|
||||
t.Fatal("lost ack not recoverable")
|
||||
}
|
||||
if err := reopened.cancel(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if recoveryCall(reopened, client, "/v1/channel/inspect", inspect).Code != 403 {
|
||||
t.Fatal("revocation bypass")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoveryRejectsForeignAuthorityAndWrongDestination(t *testing.T) {
|
||||
p, client := recoveryFixture(t)
|
||||
b := *p.state.Binding
|
||||
body := map[string]any{"schema": recoverySchema, "binding_id": b.BindingID, "expected_endpoint": b.Endpoint, "endpoint": "https://100.64.10.5:8782"}
|
||||
_, foreign := recoveryFixture(t)
|
||||
if recoveryCall(p, foreign, "/v1/channel/migrate", body).Code != 403 {
|
||||
t.Fatal("foreign Core accepted")
|
||||
}
|
||||
for _, destination := range []string{"https://100.64.10.6:8782", "https://8.8.8.8:8782", "https://192.168.10.5:8782", "https://100.64.10.5:443", "https://100.64.10.5:8782/path"} {
|
||||
body["endpoint"] = destination
|
||||
if recoveryCall(p, client, "/v1/channel/migrate", body).Code != 400 {
|
||||
t.Fatal("wrong destination accepted", destination)
|
||||
}
|
||||
}
|
||||
if *p.state.Binding != b {
|
||||
t.Fatal("rejection mutated binding")
|
||||
}
|
||||
// A valid Node credential signed by this same CA is not Core authority.
|
||||
block, _ := pem.Decode([]byte(b.ClientPEM))
|
||||
otherNode := tls.Certificate{Certificate: [][]byte{block.Bytes}, PrivateKey: p.store.pairingKey()}
|
||||
if recoveryCall(p, otherNode, "/v1/channel/inspect", body).Code != 403 {
|
||||
t.Fatal("Node impersonated Core")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoveryTLSRequiresBoundCoreCertificate(t *testing.T) {
|
||||
p, core := recoveryFixture(t)
|
||||
config, err := recoveryTLS(*p.state.Binding, p.store.pairingKey(), "127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) }))
|
||||
server.Config.ErrorLog = log.New(io.Discard, "", 0)
|
||||
server.TLS = config
|
||||
server.StartTLS()
|
||||
defer server.Close()
|
||||
_, foreign := recoveryFixture(t)
|
||||
block, _ := pem.Decode([]byte(p.state.Binding.ClientPEM))
|
||||
node := tls.Certificate{Certificate: [][]byte{block.Bytes}, PrivateKey: p.store.pairingKey()}
|
||||
for _, item := range []struct {
|
||||
name string
|
||||
certificates []tls.Certificate
|
||||
accepted bool
|
||||
}{
|
||||
{"Core", []tls.Certificate{core}, true}, {"missing", nil, false}, {"foreign", []tls.Certificate{foreign}, false}, {"Node", []tls.Certificate{node}, false},
|
||||
} {
|
||||
t.Run(item.name, func(t *testing.T) {
|
||||
transport := &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS13, InsecureSkipVerify: true, Certificates: item.certificates}}
|
||||
defer transport.CloseIdleConnections()
|
||||
client := &http.Client{Transport: transport, Timeout: 2 * time.Second}
|
||||
response, e := client.Get(server.URL)
|
||||
if e == nil {
|
||||
response.Body.Close()
|
||||
}
|
||||
if (e == nil) != item.accepted {
|
||||
t.Fatal("TLS admission", e)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTailnetIsFirstInvitationAddress(t *testing.T) {
|
||||
p, _ := testPairing(t)
|
||||
p.inventory = func() Inventory {
|
||||
return Inventory{Networks: []Network{
|
||||
{Up: true, Addresses: []string{"192.168.10.4/24", "100.64.10.4/32"}},
|
||||
{Up: false, Addresses: []string{"100.64.1.1/32"}},
|
||||
}}
|
||||
}
|
||||
addresses := p.addresses()
|
||||
if len(addresses) != 2 || addresses[0] != "100.64.10.4" {
|
||||
t.Fatal(addresses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOldHeartbeatCannotRevokeMigratedBinding(t *testing.T) {
|
||||
p, core := recoveryFixture(t)
|
||||
old := *p.state.Binding
|
||||
entered, release, done := make(chan struct{}), make(chan struct{}), make(chan struct{})
|
||||
p.clients[old.BindingID+old.Endpoint+digest(old.ClientPEM)] = &http.Client{
|
||||
Transport: sensorRoundTrip(func(r *http.Request) (*http.Response, error) {
|
||||
close(entered)
|
||||
<-release
|
||||
return &http.Response{StatusCode: 410, Body: io.NopCloser(strings.NewReader(`{}`)), Header: make(http.Header)}, nil
|
||||
}),
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go func() { defer close(done); p.channel(ctx) }()
|
||||
select {
|
||||
case <-entered:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("heartbeat did not start")
|
||||
}
|
||||
body := map[string]any{"schema": recoverySchema, "binding_id": old.BindingID, "expected_endpoint": old.Endpoint, "endpoint": "https://100.64.10.5:8782"}
|
||||
response := recoveryCall(p, core, "/v1/channel/migrate", body)
|
||||
cancel()
|
||||
close(release)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("heartbeat did not stop")
|
||||
}
|
||||
if response.Code != 200 || p.state.Phase != "paired" || p.state.Binding.EndpointRevision != 1 || p.connection != "offline" {
|
||||
t.Fatal("old heartbeat changed new binding")
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,7 @@ func (p *Pairing) remoteHandler() http.Handler {
|
||||
return
|
||||
}
|
||||
b := *body.Binding
|
||||
if len(b.BindingID) != 43 || len(b.CoreName) < 1 || len(b.CoreName) > 128 || len(b.CoreID) != 69 || len(b.CAPEM) > 8192 || len(b.ClientPEM) > 8192 || b.Receipt != "" || b.OfferHash != "" || b.ExpiresAt != 0 {
|
||||
if len(b.BindingID) != 43 || len(b.CoreName) < 1 || len(b.CoreName) > 128 || len(b.CoreID) != 69 || len(b.CAPEM) > 8192 || len(b.ClientPEM) > 8192 || b.Receipt != "" || b.OfferHash != "" || b.ExpiresAt != 0 || b.EndpointRevision != 0 {
|
||||
http.Error(w, "Invalid binding", 400)
|
||||
return
|
||||
}
|
||||
@@ -154,6 +154,8 @@ func (p *Pairing) Run(ctx context.Context) {
|
||||
go p.channel(ctx)
|
||||
var server *http.Server
|
||||
endpoint := ""
|
||||
serverIdentity := ""
|
||||
var refreshAt time.Time
|
||||
closeServer := func() {
|
||||
if server != nil {
|
||||
timeout, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
@@ -162,6 +164,7 @@ func (p *Pairing) Run(ctx context.Context) {
|
||||
server = nil
|
||||
}
|
||||
endpoint = ""
|
||||
serverIdentity = ""
|
||||
}
|
||||
defer closeServer()
|
||||
ticker := time.NewTicker(time.Second)
|
||||
@@ -170,29 +173,50 @@ func (p *Pairing) Run(ctx context.Context) {
|
||||
p.mu.Lock()
|
||||
_ = p.expire()
|
||||
desired := ""
|
||||
var recovery *CoreBinding
|
||||
identity := "bootstrap"
|
||||
if (p.state.Phase == "inviting" || p.state.Phase == "pending") && p.state.Invitation != nil {
|
||||
desired = p.state.Invitation.Endpoint
|
||||
}
|
||||
if p.state.Phase == "paired" && p.state.Binding != nil {
|
||||
for _, address := range p.addresses() {
|
||||
if tailnetAddress(address) {
|
||||
desired = "https://" + address + ":" + PairPort
|
||||
copy := *p.state.Binding
|
||||
recovery = ©
|
||||
identity = copy.BindingID + copy.CoreID
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
if desired != endpoint {
|
||||
if desired != endpoint || (desired != "" && (identity != serverIdentity || time.Now().After(refreshAt))) {
|
||||
closeServer()
|
||||
if desired != "" {
|
||||
u, _ := url.Parse(desired)
|
||||
cert, e := bootstrapCertificate(p.store.pairingKey(), u.Hostname())
|
||||
config := &tls.Config{MinVersion: tls.VersionTLS13, Certificates: []tls.Certificate{cert}}
|
||||
handler := p.remoteHandler()
|
||||
if recovery != nil {
|
||||
config, e = recoveryTLS(*recovery, p.store.pairingKey(), u.Hostname())
|
||||
handler = p.recoveryHandler()
|
||||
}
|
||||
var listener net.Listener
|
||||
if e == nil {
|
||||
listener, e = net.Listen("tcp4", u.Host)
|
||||
}
|
||||
p.mu.Lock()
|
||||
if e != nil {
|
||||
p.listenError = "Не удалось открыть частное подключение. Проверьте адрес и создайте приглашение повторно."
|
||||
p.listenError = "Не удалось открыть частное подключение. Проверьте доступность выбранной сети."
|
||||
} else {
|
||||
p.listenError = ""
|
||||
}
|
||||
p.mu.Unlock()
|
||||
if e == nil {
|
||||
server = &http.Server{Handler: p.remoteHandler(), ReadHeaderTimeout: 3 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 5 * time.Second, MaxHeaderBytes: 8192, TLSConfig: &tls.Config{MinVersion: tls.VersionTLS13, Certificates: []tls.Certificate{cert}}}
|
||||
server = &http.Server{Handler: handler, ReadHeaderTimeout: 3 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 5 * time.Second, MaxHeaderBytes: 8192, TLSConfig: config}
|
||||
endpoint = desired
|
||||
serverIdentity = identity
|
||||
refreshAt = time.Now().Add(12 * time.Hour)
|
||||
go func(s *http.Server, l net.Listener) { _ = s.Serve(tls.NewListener(l, s.TLSConfig)) }(server, &pairingListener{Listener: listener, slots: make(chan struct{}, 16), done: make(chan struct{})})
|
||||
}
|
||||
}
|
||||
@@ -209,7 +233,7 @@ func (p *Pairing) send(ctx context.Context, b CoreBinding, path string, payload
|
||||
if e != nil {
|
||||
return nil, 0, e
|
||||
}
|
||||
cacheKey := b.BindingID + digest(b.ClientPEM)
|
||||
cacheKey := b.BindingID + b.Endpoint + digest(b.ClientPEM)
|
||||
client := p.clients[cacheKey]
|
||||
if client == nil {
|
||||
transport := &http.Transport{TLSClientConfig: config, Proxy: nil, MaxConnsPerHost: 1, MaxIdleConnsPerHost: 1, IdleConnTimeout: 15 * time.Second, TLSHandshakeTimeout: 4 * time.Second, DialContext: (&net.Dialer{Timeout: 4 * time.Second}).DialContext}
|
||||
@@ -262,10 +286,10 @@ func (p *Pairing) channel(ctx context.Context) {
|
||||
p.mu.Unlock()
|
||||
wanted := make(map[string]bool)
|
||||
if binding != nil {
|
||||
wanted[binding.BindingID+digest(binding.ClientPEM)] = true
|
||||
wanted[binding.BindingID+binding.Endpoint+digest(binding.ClientPEM)] = true
|
||||
}
|
||||
for _, b := range revocations {
|
||||
wanted[b.BindingID+digest(b.ClientPEM)] = true
|
||||
wanted[b.BindingID+b.Endpoint+digest(b.ClientPEM)] = true
|
||||
}
|
||||
for key, c := range p.clients {
|
||||
if !wanted[key] {
|
||||
@@ -276,18 +300,42 @@ func (p *Pairing) channel(ctx context.Context) {
|
||||
if binding != nil {
|
||||
id, name := p.store.Public()
|
||||
payload := map[string]any{"schema": PairSchema, "binding_id": binding.BindingID, "node_id": id, "name": name, "version": p.version, "execution_binding": map[string]string{"node_id": id, "agent_instance_id": instance, "platform": "linux"}, "host": p.inventory(), "devices": []any{}}
|
||||
payload["core_endpoint"] = binding.Endpoint
|
||||
payload["endpoint_revision"] = binding.EndpointRevision
|
||||
if p.Sensors != nil {
|
||||
inv := p.Sensors.Inventory()
|
||||
payload["devices"] = inv["items"]
|
||||
payload["sensor_state"] = inv
|
||||
payload["sensor_results"] = p.Sensors.RemoteResults()
|
||||
}
|
||||
if p.DeviceEnrollment != nil {
|
||||
payload["device_enrollment"] = p.DeviceEnrollment.Status()
|
||||
payload["enrollment_results"] = p.DeviceEnrollment.RemoteResults()
|
||||
}
|
||||
if p.Monitor != nil {
|
||||
payload["monitor"] = p.Monitor.Snapshot()
|
||||
}
|
||||
result, status, e := p.send(ctx, *binding, "/v1/node/heartbeat", payload)
|
||||
p.mu.Lock()
|
||||
if p.state.Phase == "paired" && p.state.Binding != nil && p.state.Binding.BindingID == binding.BindingID {
|
||||
if p.state.Phase == "paired" && p.state.Binding != nil && p.state.Binding.BindingID == binding.BindingID && p.state.Binding.Endpoint == binding.Endpoint && p.state.Binding.EndpointRevision == binding.EndpointRevision {
|
||||
if e == nil && status == 200 {
|
||||
if p.Monitor != nil {
|
||||
p.Monitor.Acknowledge(result["monitor_ack"])
|
||||
}
|
||||
p.connection = "online"
|
||||
p.lastSeen = p.now().Unix()
|
||||
if p.DeviceEnrollment != nil {
|
||||
var ack []string
|
||||
if json.Unmarshal(result["enrollment_acknowledgements"], &ack) == nil {
|
||||
p.DeviceEnrollment.Acknowledge(ack)
|
||||
}
|
||||
var commands []EnrollmentCommand
|
||||
if json.Unmarshal(result["enrollment_commands"], &commands) == nil {
|
||||
for _, c := range commands {
|
||||
_, _ = p.DeviceEnrollment.Submit(c, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
if p.Sensors != nil {
|
||||
var ack []string
|
||||
if json.Unmarshal(result["sensor_acknowledgements"], &ack) == nil {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ func TestUSBEventsAreHintsAndDoNotMultiplyInterfaces(t *testing.T) {
|
||||
func TestSensorEventStreamRequiresLocalSession(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
s.Sensors, _ = OpenSensors(t.TempDir(), "node_test")
|
||||
isolateSensorHost(t, s.Sensors)
|
||||
if response := call(s, "GET", "/api/devices/events", "", nil); response.Code != 401 {
|
||||
t.Fatal("unauthenticated device stream", response.Code)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Model bindings are shipped code, never executables or paths supplied by a
|
||||
// browser. A model owns its driver/profile; a physical device owns its session.
|
||||
type sensorModel struct {
|
||||
ID, Name, Prefix, Kind, Plugin, Version string
|
||||
Vendor, Product, USBName string
|
||||
Socket, PrepareUnit, Report string
|
||||
Actions map[string]bool
|
||||
ActionTimeouts map[string]time.Duration
|
||||
}
|
||||
|
||||
func actions(names ...string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, name := range names {
|
||||
out[name] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var sensorModels = []sensorModel{
|
||||
{ID: "realsense.d455", Name: "RealSense D455", Prefix: "rsd455", Plugin: "missioncore.realsense", Version: "0.6.6",
|
||||
Vendor: "8086", Product: "0b5c", Socket: "/run/mission-core-sensors/driver.sock",
|
||||
PrepareUnit: "mission-core-node-realsense-prepare.service", Report: "/var/lib/mission-core-node-drivers/preparation.json",
|
||||
Actions: actions("prepare", "details", "rename", "verify", "start", "replay", "stop", "option", "offer", "close-peer")},
|
||||
{ID: "xgrids.k1", Name: "XGRIDS K1", Prefix: "k1", Kind: "k1",
|
||||
Actions: actions("details", "rename", "verify", "start", "stop", "option", "offer", "close-peer")},
|
||||
{ID: "insta360.x4", Name: "Insta360 X4", Prefix: "instax4", Kind: "insta360.x4", Plugin: "missioncore.insta360", Version: "0.1.3",
|
||||
// 2e1a:0002 is shared with other Insta360 models. Require the exact
|
||||
// OS product descriptor as well; SDK identity is verified after prepare.
|
||||
Vendor: "2e1a", Product: "0002", USBName: "Insta360 X4", Socket: "/run/mission-core-insta360/driver.sock",
|
||||
PrepareUnit: "mission-core-node-insta360-x4-profile.service", Report: "/var/lib/mission-core-node-profiles/insta360-x4/preparation.json",
|
||||
ActionTimeouts: map[string]time.Duration{"power.wake": 55 * time.Second},
|
||||
Actions: actions("prepare", "details", "rename", "verify", "preview.start", "preview.stop", "record.start", "record.stop", "photo.capture", "settings.read", "settings.apply", "files.list", "offer", "close-peer", "recovery.configure", "power.wake")},
|
||||
}
|
||||
|
||||
func modelForDevice(id string) *sensorModel {
|
||||
if !sensorID.MatchString(id) {
|
||||
return nil
|
||||
}
|
||||
for i := range sensorModels {
|
||||
if strings.HasPrefix(id, sensorModels[i].Prefix+"_") {
|
||||
return &sensorModels[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func currentCameraSnapshot(item map[string]any, model *sensorModel) bool {
|
||||
snapshot, _ := item["snapshot"].(map[string]any)
|
||||
context, _ := snapshot["context"].(map[string]any)
|
||||
device, _ := context["device"].(map[string]any)
|
||||
installed, _ := device["model"].(map[string]any)
|
||||
_, revision := snapshot["revision"].(float64) // Decoded driver JSON.
|
||||
observed, _ := snapshot["observed_at"].(string)
|
||||
_, err := time.Parse(time.RFC3339Nano, observed)
|
||||
return revision && err == nil && installed["plugin_version"] == model.Version && installed["model_id"] == model.ID
|
||||
}
|
||||
|
||||
func modelDeviceID(model *sensorModel, serial string) string {
|
||||
h := sha256.Sum256([]byte(serial))
|
||||
return model.Prefix + "_" + hex.EncodeToString(h[:])[:32]
|
||||
}
|
||||
|
||||
func sensorClient(socket string) *http.Client {
|
||||
return &http.Client{Timeout: 25 * time.Second, Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, "unix", socket)
|
||||
}}}
|
||||
}
|
||||
|
||||
type usbSensor struct {
|
||||
model *sensorModel
|
||||
id, speed, binding string
|
||||
stable bool
|
||||
}
|
||||
|
||||
// Read-only OS discovery works before any vendor runtime has been installed.
|
||||
// An absent or duplicated serial is not enough authority to initialize a unit.
|
||||
func discoverSensors(root string) []usbSensor {
|
||||
paths, _ := filepath.Glob(filepath.Join(root, "*"))
|
||||
items := []usbSensor{}
|
||||
counts := map[string]int{}
|
||||
for _, path := range paths {
|
||||
read := func(name string) string {
|
||||
data, _ := os.ReadFile(filepath.Join(path, name))
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
for i := range sensorModels {
|
||||
model := &sensorModels[i]
|
||||
if model.Vendor == "" || read("idVendor") != model.Vendor || read("idProduct") != model.Product || (model.USBName != "" && read("product") != model.USBName) {
|
||||
continue
|
||||
}
|
||||
serial := read("serial")
|
||||
id := modelDeviceID(model, serial)
|
||||
counts[id]++
|
||||
items = append(items, usbSensor{model: model, id: id, speed: read("speed") + " Мбит/с", binding: filepath.Base(path) + ":" + read("devnum"), stable: serial != ""})
|
||||
}
|
||||
}
|
||||
unique := []usbSensor{}
|
||||
for _, item := range items {
|
||||
if !item.stable || counts[item.id] != 1 {
|
||||
item.stable = false
|
||||
item.id = modelDeviceID(item.model, "provisional:"+item.binding)
|
||||
}
|
||||
unique = append(unique, item)
|
||||
}
|
||||
return unique
|
||||
}
|
||||
|
||||
type discoverySession struct {
|
||||
binding, session string
|
||||
stable bool
|
||||
}
|
||||
|
||||
func (s *Sensors) reconcileDiscovery(devices []usbSensor) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
live := map[string]discoverySession{}
|
||||
for _, device := range devices {
|
||||
previous := s.discoverySessions[device.id]
|
||||
if previous.binding != device.binding || previous.session == "" {
|
||||
previous = discoverySession{device.binding, "discovery_" + digest(token())[:24] + "_" + device.id, device.stable}
|
||||
}
|
||||
live[device.id] = previous
|
||||
}
|
||||
s.discoverySessions = live
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func fakeUSB(t *testing.T, root, port, serial, product, number string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(root, port)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for key, value := range map[string]string{"idVendor": "2e1a", "idProduct": "0002", "product": product, "serial": serial, "devnum": number, "speed": "5000"} {
|
||||
if err := os.WriteFile(filepath.Join(dir, key), []byte(value), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testSensorReply(value any) *http.Response {
|
||||
data, _ := json.Marshal(value)
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(string(data))), Header: http.Header{}}
|
||||
}
|
||||
|
||||
func isolatedSensors(t *testing.T) *Sensors {
|
||||
t.Helper()
|
||||
s, err := OpenSensors(t.TempDir(), "node_test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.usbRoot = t.TempDir()
|
||||
for _, client := range s.clients {
|
||||
client.Transport = sensorRoundTrip(func(*http.Request) (*http.Response, error) {
|
||||
return testSensorReply(map[string]any{"items": []any{}}), nil
|
||||
})
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestManualWakeDeadlineReachesDriverWithoutChangingInventoryTimeout(t *testing.T) {
|
||||
s := isolatedSensors(t)
|
||||
model := &sensorModels[2]
|
||||
var remaining time.Duration
|
||||
s.clients[model.ID].Transport = sensorRoundTrip(func(r *http.Request) (*http.Response, error) {
|
||||
deadline, ok := r.Context().Deadline()
|
||||
if !ok {
|
||||
t.Fatal("driver request is unbounded")
|
||||
}
|
||||
remaining = time.Until(deadline)
|
||||
return testSensorReply(map[string]any{"state": "complete"}), nil
|
||||
})
|
||||
for _, action := range []string{"power.wake", "details"} {
|
||||
if _, err := s.modelDriver(context.Background(), model, "/operation", SensorCommand{Action: action}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := 25 * time.Second
|
||||
if action == "power.wake" {
|
||||
want = 55 * time.Second
|
||||
}
|
||||
if remaining > want || remaining < want-time.Second {
|
||||
t.Fatalf("%s was cut off at %s", action, remaining)
|
||||
}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
if _, err := s.modelDriver(ctx, model, "/operation", SensorCommand{Action: "power.wake"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if remaining > time.Second || s.clients[model.ID].Timeout != 25*time.Second {
|
||||
t.Fatal("action extended the caller deadline or changed the shared client")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelDiscoveryIdentityAndHotplug(t *testing.T) {
|
||||
s := isolatedSensors(t)
|
||||
fakeUSB(t, s.usbRoot, "3-2", "synthetic-a", "Insta360 X4", "2")
|
||||
fakeUSB(t, s.usbRoot, "3-3", "synthetic-b", "Insta360 X4", "3")
|
||||
fakeUSB(t, s.usbRoot, "3-4", "synthetic-other", "Insta360 OneR", "4")
|
||||
items := s.Inventory()["items"].([]any)
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("wrong models admitted: %d", len(items))
|
||||
}
|
||||
first := items[0].(map[string]any)
|
||||
id := first["id"].(string)
|
||||
if id == items[1].(map[string]any)["id"] || first["prepared"] != false || first["configured"] != false {
|
||||
t.Fatal("instances collapsed or discovery claimed readiness")
|
||||
}
|
||||
session := sensorSessionID(first)
|
||||
if sensorSessionID(s.Inventory()["items"].([]any)[0].(map[string]any)) != session {
|
||||
t.Fatal("refresh changed session")
|
||||
}
|
||||
if err := os.RemoveAll(filepath.Join(s.usbRoot, "3-2")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.Inventory()
|
||||
fakeUSB(t, s.usbRoot, "3-2", "synthetic-a", "Insta360 X4", "7")
|
||||
again := s.Inventory()["items"].([]any)[0].(map[string]any)
|
||||
if again["id"] != id || sensorSessionID(again) == session {
|
||||
t.Fatal("replug did not retain identity and renew session")
|
||||
}
|
||||
// D455 IDs use the original unnamespaced serial hash, preserving archives.
|
||||
if got := modelDeviceID(&sensorModels[0], "abc"); got != "rsd455_ba7816bf8f01cfea414140de5dae2223" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if sensorModels[1].Kind != "k1" {
|
||||
t.Fatal("K1 contribution binding changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDuplicateSerialCannotAcquireCameraAuthority(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
fakeUSB(t, root, "3-2", "duplicate", "Insta360 X4", "2")
|
||||
fakeUSB(t, root, "3-3", "duplicate", "Insta360 X4", "3")
|
||||
items := discoverSensors(root)
|
||||
if len(items) != 2 || items[0].id == items[1].id || items[0].stable || items[1].stable {
|
||||
t.Fatal("ambiguous USB devices disappeared or acquired stable authority")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyCameraProfileKeepsRemoteInitializationAvailable(t *testing.T) {
|
||||
s := isolatedSensors(t)
|
||||
fakeUSB(t, s.usbRoot, "3-2", "synthetic-legacy", "Insta360 X4", "2")
|
||||
model := &sensorModels[2]
|
||||
id := modelDeviceID(model, "synthetic-legacy")
|
||||
legacy := s.discovery(id, "5000", true)
|
||||
legacy["prepared"] = true
|
||||
snapshot := legacy["snapshot"].(map[string]any)
|
||||
delete(snapshot, "observed_at")
|
||||
delete(snapshot, "revision")
|
||||
s.clients[model.ID].Transport = sensorRoundTrip(func(*http.Request) (*http.Response, error) {
|
||||
return testSensorReply(map[string]any{"items": []any{legacy}}), nil
|
||||
})
|
||||
for _, state := range []string{"idle", "live", "unknown"} {
|
||||
snapshot["acquisition"] = state
|
||||
items := s.Inventory()["items"].([]any)
|
||||
if len(items) != 1 {
|
||||
t.Fatal("legacy runtime hid or duplicated the USB camera")
|
||||
}
|
||||
item := items[0].(map[string]any)
|
||||
fresh := item["snapshot"].(map[string]any)
|
||||
if item["prepared"] != false || fresh["observed_at"] == nil || fresh["revision"] == nil || sensorSessionID(item) == "" {
|
||||
t.Fatal("legacy snapshot escaped the discovery fallback")
|
||||
}
|
||||
if (state != "idle") != (item["preparation_safe"] == false) {
|
||||
t.Fatal("legacy acquisition safety was lost")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func awaitSensor(t *testing.T, predicate func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(4 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if predicate() {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("sensor operation timed out")
|
||||
}
|
||||
|
||||
func TestNewCameraUsesReadyProfileWithoutInterruptingAnotherInstance(t *testing.T) {
|
||||
s := isolatedSensors(t)
|
||||
fakeUSB(t, s.usbRoot, "3-2", "synthetic-active", "Insta360 X4", "2")
|
||||
fakeUSB(t, s.usbRoot, "3-3", "synthetic-new", "Insta360 X4", "3")
|
||||
active := modelDeviceID(&sensorModels[2], "synthetic-active")
|
||||
newDevice := modelDeviceID(&sensorModels[2], "synthetic-new")
|
||||
var runs atomic.Int32
|
||||
var stopped atomic.Bool
|
||||
release := make(chan struct{})
|
||||
var releaseOnce sync.Once
|
||||
defer releaseOnce.Do(func() { close(release) })
|
||||
s.runPreparation = func(context.Context, string) error {
|
||||
runs.Add(1)
|
||||
return errors.New("must not redeploy a ready shared profile")
|
||||
}
|
||||
s.clients["insta360.x4"].Transport = sensorRoundTrip(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/inventory" {
|
||||
items := []any{}
|
||||
for _, id := range []string{active, newDevice} {
|
||||
item := s.discovery(id, "5000", true)
|
||||
item["prepared"] = true
|
||||
item["preparation_safe"] = id != active
|
||||
snapshot := item["snapshot"].(map[string]any)
|
||||
snapshot["context"].(map[string]any)["session_id"] = "sdk_" + id
|
||||
if id == active {
|
||||
snapshot["acquisition"] = "streaming"
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return testSensorReply(map[string]any{"items": items}), nil
|
||||
}
|
||||
var command SensorCommand
|
||||
_ = json.NewDecoder(r.Body).Decode(&command)
|
||||
if command.Action == "verify" && command.Session.DeviceID == newDevice {
|
||||
<-release
|
||||
} else if command.Action == "preview.stop" && command.Session.DeviceID == active {
|
||||
stopped.Store(true)
|
||||
} else {
|
||||
return nil, errors.New("cross-camera command")
|
||||
}
|
||||
return testSensorReply(map[string]any{"state": "complete", "result": map[string]bool{"ok": true}}), nil
|
||||
})
|
||||
s.Inventory()
|
||||
command := sensorTestCommand()
|
||||
command.Action = "prepare"
|
||||
command.Session = SensorSession{DeviceID: newDevice, SessionID: "sdk_" + newDevice}
|
||||
if _, err := s.Submit(command, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
awaitSensor(t, func() bool { op := s.Get(command.ID); return op.Preparation != nil && op.Preparation.Phase == "verify" })
|
||||
stop := sensorTestCommand()
|
||||
stop.ID = "op_" + strings.Repeat("b", 32)
|
||||
stop.Idempotency = stop.ID
|
||||
stop.Action = "preview.stop"
|
||||
stop.Session = SensorSession{DeviceID: active, SessionID: "sdk_" + active}
|
||||
if _, err := s.Submit(stop, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
awaitSensor(t, func() bool { return stopped.Load() })
|
||||
if runs.Load() != 0 {
|
||||
t.Fatal("new-camera preparation redeployed the shared profile")
|
||||
}
|
||||
releaseOnce.Do(func() { close(release) })
|
||||
awaitSensor(t, func() bool { return s.Get(command.ID).State == "complete" })
|
||||
}
|
||||
|
||||
func TestLocalAndRemotePrepareShareProfileButVerifyEachInstance(t *testing.T) {
|
||||
s := isolatedSensors(t)
|
||||
fakeUSB(t, s.usbRoot, "3-2", "synthetic-a", "Insta360 X4", "2")
|
||||
fakeUSB(t, s.usbRoot, "3-3", "synthetic-b", "Insta360 X4", "3")
|
||||
initial := s.Inventory()["items"].([]any)
|
||||
var installed atomic.Bool
|
||||
var runs atomic.Int32
|
||||
var mu sync.Mutex
|
||||
verified := []string{}
|
||||
release := make(chan struct{})
|
||||
s.runPreparation = func(ctx context.Context, unit string) error {
|
||||
if unit != "mission-core-node-insta360-x4-profile.service" {
|
||||
return errors.New("wrong unit")
|
||||
}
|
||||
runs.Add(1)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-release:
|
||||
}
|
||||
installed.Store(true)
|
||||
return nil
|
||||
}
|
||||
s.clients["insta360.x4"].Transport = sensorRoundTrip(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/inventory" {
|
||||
items := []any{}
|
||||
if installed.Load() {
|
||||
for _, raw := range initial {
|
||||
old := raw.(map[string]any)
|
||||
id := old["id"].(string)
|
||||
item := s.discovery(id, "5000", true)
|
||||
item["prepared"] = true
|
||||
item["snapshot"].(map[string]any)["context"].(map[string]any)["session_id"] = "sdk_" + id
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
return testSensorReply(map[string]any{"items": items}), nil
|
||||
}
|
||||
var c SensorCommand
|
||||
_ = json.NewDecoder(r.Body).Decode(&c)
|
||||
if c.Action != "verify" || c.Session.SessionID != "sdk_"+c.Session.DeviceID {
|
||||
return nil, errors.New("wrong instance session")
|
||||
}
|
||||
mu.Lock()
|
||||
verified = append(verified, c.Session.DeviceID)
|
||||
mu.Unlock()
|
||||
if c.Session.DeviceID == initial[1].(map[string]any)["id"] {
|
||||
return testSensorReply(map[string]any{"state": "error", "error": "no frames"}), nil
|
||||
}
|
||||
return testSensorReply(map[string]any{"state": "complete", "result": map[string]any{"device_id": c.Session.DeviceID}}), nil
|
||||
})
|
||||
// An active camera of another model must not block X4 installation.
|
||||
s.client.Transport = sensorRoundTrip(func(*http.Request) (*http.Response, error) {
|
||||
item := s.discovery(sensorTestCommand().Session.DeviceID, "5000", true)
|
||||
item["snapshot"].(map[string]any)["acquisition"] = "streaming"
|
||||
return testSensorReply(map[string]any{"items": []any{item}}), nil
|
||||
})
|
||||
commands := []SensorCommand{}
|
||||
for i, raw := range initial {
|
||||
item := raw.(map[string]any)
|
||||
c := sensorTestCommand()
|
||||
c.Action = "prepare"
|
||||
c.ID = "op_" + strings.Repeat(string(rune('a'+i)), 32)
|
||||
c.Idempotency = c.ID
|
||||
c.Session = SensorSession{DeviceID: item["id"].(string), SessionID: sensorSessionID(item)}
|
||||
commands = append(commands, c)
|
||||
if _, err := s.Submit(c, i == 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
awaitSensor(t, func() bool {
|
||||
return s.Get(commands[0].ID).Preparation != nil && s.Get(commands[1].ID).Preparation != nil
|
||||
})
|
||||
close(release)
|
||||
awaitSensor(t, func() bool {
|
||||
return s.Get(commands[0].ID).State != "running" && s.Get(commands[1].ID).State != "running"
|
||||
})
|
||||
if runs.Load() != 1 {
|
||||
t.Fatalf("deployed %d times", runs.Load())
|
||||
}
|
||||
if s.Get(commands[0].ID).State != "complete" || s.Get(commands[1].ID).State != "error" {
|
||||
t.Fatal("instance outcomes mixed")
|
||||
}
|
||||
s.mu.Lock()
|
||||
a, b := s.initialized[commands[0].Session.DeviceID], s.initialized[commands[1].Session.DeviceID]
|
||||
s.mu.Unlock()
|
||||
if !a || b {
|
||||
t.Fatal("success initialized a different camera")
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(verified) != 2 || verified[0] == verified[1] {
|
||||
t.Fatal("verification routed to same instance")
|
||||
}
|
||||
if len(s.RemoteResults()) != 1 {
|
||||
t.Fatal("remote result was lost")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStalePrepareAndCrossModelActionsCannotMutate(t *testing.T) {
|
||||
s := isolatedSensors(t)
|
||||
fakeUSB(t, s.usbRoot, "3-2", "synthetic-a", "Insta360 X4", "2")
|
||||
item := s.Inventory()["items"].([]any)[0].(map[string]any)
|
||||
var calls atomic.Int32
|
||||
s.runPreparation = func(context.Context, string) error { calls.Add(1); return nil }
|
||||
c := sensorTestCommand()
|
||||
c.Action = "prepare"
|
||||
c.Session.DeviceID = item["id"].(string)
|
||||
if _, err := s.Submit(c, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
awaitSensor(t, func() bool { return s.Get(c.ID).State != "running" })
|
||||
if calls.Load() != 0 || s.Get(c.ID).State != "error" {
|
||||
t.Fatal("stale session initialized a camera")
|
||||
}
|
||||
c = sensorTestCommand()
|
||||
c.Action = "record.start"
|
||||
if _, err := s.Submit(c, false); err == nil {
|
||||
t.Fatal("X4 recording command admitted for D455")
|
||||
}
|
||||
c.Session.DeviceID = item["id"].(string)
|
||||
c.Action = "start"
|
||||
if _, err := s.Submit(c, false); err == nil {
|
||||
t.Fatal("legacy ambiguous start admitted for X4")
|
||||
}
|
||||
c.Action = "prepare"
|
||||
c.Parameters = map[string]any{"unit": "unrelated.service"}
|
||||
if _, err := s.Submit(c, false); err == nil {
|
||||
t.Fatal("caller chose privileged profile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordingWithoutPreviewBlocksModelPreparation(t *testing.T) {
|
||||
s := isolatedSensors(t)
|
||||
fakeUSB(t, s.usbRoot, "3-2", "synthetic-a", "Insta360 X4", "2")
|
||||
item := s.Inventory()["items"].([]any)[0].(map[string]any)
|
||||
// The camera's SD recording is independent from acquisition/preview.
|
||||
item["prepared"] = true
|
||||
item["preparation_safe"] = false
|
||||
s.clients["insta360.x4"].Transport = sensorRoundTrip(func(*http.Request) (*http.Response, error) {
|
||||
return testSensorReply(map[string]any{"items": []any{item}}), nil
|
||||
})
|
||||
var calls atomic.Int32
|
||||
s.runPreparation = func(context.Context, string) error { calls.Add(1); return nil }
|
||||
c := sensorTestCommand()
|
||||
c.Action = "prepare"
|
||||
c.Session = SensorSession{DeviceID: item["id"].(string), SessionID: sensorSessionID(item)}
|
||||
if _, err := s.Submit(c, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
awaitSensor(t, func() bool { return s.Get(c.ID).State != "running" })
|
||||
if s.Get(c.ID).State != "error" || calls.Load() != 0 {
|
||||
t.Fatal("preparation changed a model with active camera recording")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
)
|
||||
|
||||
var errPreparationUncertain = errors.New("Результат подготовки неизвестен. Обновите состояние устройства.")
|
||||
|
||||
type preparationStep struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
State string `json:"state"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type sensorPreparation struct {
|
||||
OperationID string `json:"operation_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
ModelID string `json:"model_id"`
|
||||
StartedAt float64 `json:"started_at"`
|
||||
ProfileStartedAt float64 `json:"profile_started_at"`
|
||||
State string `json:"state"`
|
||||
Phase string `json:"phase"`
|
||||
Steps []preparationStep `json:"steps"`
|
||||
}
|
||||
|
||||
type profilePreparation struct {
|
||||
done chan struct{}
|
||||
started float64
|
||||
err error
|
||||
users int
|
||||
finished bool
|
||||
}
|
||||
|
||||
func runModelPreparation(ctx context.Context, unit string) error {
|
||||
cmd := exec.CommandContext(ctx, "/usr/bin/systemctl", "start", unit)
|
||||
cmd.Env = []string{"PATH=/usr/bin:/bin", "LANG=C", "DBUS_SYSTEM_BUS_ADDRESS=unix:path=/run/dbus/system_bus_socket"}
|
||||
if cmd.Run() != nil {
|
||||
if ctx.Err() != nil {
|
||||
return errPreparationUncertain
|
||||
}
|
||||
return errors.New("Подготовка драйвера не завершена. Проверьте этапы и повторите действие.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sensorSessionID(item map[string]any) string {
|
||||
snapshot, _ := item["snapshot"].(map[string]any)
|
||||
context, _ := snapshot["context"].(map[string]any)
|
||||
session, _ := context["session_id"].(string)
|
||||
return session
|
||||
}
|
||||
|
||||
func (s *Sensors) preparationPhase(c SensorCommand, model *sensorModel, job *profilePreparation, phase, state string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
op := s.operations[c.ID]
|
||||
if op == nil {
|
||||
return errors.New("Операция подготовки не найдена.")
|
||||
}
|
||||
started, _ := time.Parse(time.RFC3339Nano, c.Requested)
|
||||
deploy, verify := "running", "pending"
|
||||
if phase == "verify" {
|
||||
deploy, verify = "complete", "running"
|
||||
}
|
||||
if state != "running" {
|
||||
if phase == "profile" {
|
||||
deploy, verify = state, "blocked"
|
||||
} else {
|
||||
verify = state
|
||||
}
|
||||
}
|
||||
op.Preparation = &sensorPreparation{OperationID: c.ID, DeviceID: c.Session.DeviceID, ModelID: model.ID,
|
||||
StartedAt: float64(started.UnixMilli()) / 1000, ProfileStartedAt: job.started, State: state, Phase: phase,
|
||||
Steps: []preparationStep{{ID: "profile", Label: "Подготовка драйвера", State: deploy}, {ID: "verify", Label: "Проверка выбранной камеры", State: verify}}}
|
||||
op.Updated = time.Now().Unix()
|
||||
err := s.write(c.ID+".json", op)
|
||||
s.events.notify()
|
||||
return err
|
||||
}
|
||||
|
||||
// A concurrent prepare for another instance of this model joins the same
|
||||
// deployment. Each command subsequently verifies only its own physical unit.
|
||||
func (s *Sensors) profileJob(model *sensorModel) *profilePreparation {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if job := s.preparing[model.ID]; job != nil {
|
||||
job.users++
|
||||
return job
|
||||
}
|
||||
job := &profilePreparation{done: make(chan struct{}), started: float64(time.Now().UnixMilli()) / 1000, users: 1}
|
||||
s.preparing[model.ID] = job
|
||||
go func() {
|
||||
// Only this profile's devices can be affected by its service activation.
|
||||
for _, raw := range s.Inventory()["items"].([]any) {
|
||||
item := raw.(map[string]any)
|
||||
id, _ := item["id"].(string)
|
||||
if modelForDevice(id) != model {
|
||||
continue
|
||||
}
|
||||
snapshot, _ := item["snapshot"].(map[string]any)
|
||||
if state := snapshot["acquisition"]; (state != "idle" && state != "failed") || item["preparation_safe"] == false {
|
||||
job.err = errors.New("Остановите захват устройств этой модели перед подготовкой драйвера.")
|
||||
break
|
||||
}
|
||||
}
|
||||
if job.err == nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 310*time.Second)
|
||||
job.err = s.runPreparation(ctx, model.PrepareUnit)
|
||||
cancel()
|
||||
}
|
||||
s.mu.Lock()
|
||||
job.finished = true
|
||||
if job.users == 0 {
|
||||
delete(s.preparing, model.ID)
|
||||
}
|
||||
close(job.done)
|
||||
s.mu.Unlock()
|
||||
s.events.notify()
|
||||
}()
|
||||
return job
|
||||
}
|
||||
|
||||
func reusableCameraProfile(item map[string]any, model *sensorModel) bool {
|
||||
if item["configured"] == true || item["prepared"] != true || item["preparation_safe"] == false {
|
||||
return false
|
||||
}
|
||||
snapshot, _ := item["snapshot"].(map[string]any)
|
||||
if snapshot["acquisition"] != "idle" {
|
||||
return false
|
||||
}
|
||||
context, _ := snapshot["context"].(map[string]any)
|
||||
device, _ := context["device"].(map[string]any)
|
||||
installed, _ := device["model"].(map[string]any)
|
||||
return installed["plugin_version"] == model.Version && installed["model_id"] == model.ID
|
||||
}
|
||||
|
||||
func (s *Sensors) prepare(c SensorCommand, selected map[string]any) (result any, err error) {
|
||||
model := modelForDevice(c.Session.DeviceID)
|
||||
if model == nil || model.PrepareUnit == "" {
|
||||
return nil, errors.New("Подготовка этой модели не поддерживается.")
|
||||
}
|
||||
deadline, _ := time.Parse(time.RFC3339Nano, c.Deadline)
|
||||
ctx, cancel := context.WithDeadline(context.Background(), deadline)
|
||||
defer cancel()
|
||||
if ctx.Err() != nil {
|
||||
return nil, errors.New("Срок команды истёк. Устройство не изменено.")
|
||||
}
|
||||
s.mu.Lock()
|
||||
initialBinding := s.discoverySessions[c.Session.DeviceID].binding
|
||||
s.mu.Unlock()
|
||||
// A newly attached camera can use the already running admitted profile.
|
||||
// Verification belongs to this camera and need not stop other instances.
|
||||
reuse := reusableCameraProfile(selected, model)
|
||||
s.mu.Lock()
|
||||
if active := s.preparing[model.ID]; active != nil && !active.finished {
|
||||
reuse = false
|
||||
}
|
||||
s.mu.Unlock()
|
||||
var job *profilePreparation
|
||||
if reuse {
|
||||
job = &profilePreparation{done: make(chan struct{}), started: float64(time.Now().UnixMilli()) / 1000, users: 1, finished: true}
|
||||
close(job.done)
|
||||
} else {
|
||||
job = s.profileJob(model)
|
||||
}
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
job.users--
|
||||
if job.users == 0 && job.finished && s.preparing[model.ID] == job {
|
||||
delete(s.preparing, model.ID)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
phase := "profile"
|
||||
defer func() {
|
||||
state := "complete"
|
||||
if err != nil {
|
||||
state = "error"
|
||||
}
|
||||
if errors.Is(err, errPreparationUncertain) {
|
||||
state = "unknown"
|
||||
}
|
||||
if e := s.preparationPhase(c, model, job, phase, state); e != nil {
|
||||
err = errPreparationUncertain
|
||||
}
|
||||
}()
|
||||
if s.preparationPhase(c, model, job, phase, "running") != nil {
|
||||
return nil, errPreparationUncertain
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, errPreparationUncertain
|
||||
case <-job.done:
|
||||
if job.err != nil {
|
||||
return nil, job.err
|
||||
}
|
||||
}
|
||||
phase = "verify"
|
||||
if s.preparationPhase(c, model, job, phase, "running") != nil {
|
||||
return nil, errPreparationUncertain
|
||||
}
|
||||
// X4 bounds vendor Open at 40 seconds; allow that startup window without
|
||||
// treating an active unit as a verified device. The command deadline still
|
||||
// bounds the observer independently of the root preparation transaction.
|
||||
for attempt := 0; attempt < 50; attempt++ {
|
||||
if ctx.Err() != nil {
|
||||
return nil, errPreparationUncertain
|
||||
}
|
||||
inventory := s.Inventory()
|
||||
s.mu.Lock()
|
||||
binding := s.discoverySessions[c.Session.DeviceID].binding
|
||||
s.mu.Unlock()
|
||||
if initialBinding != "" && binding != initialBinding {
|
||||
return nil, errors.New("Камера переподключена во время подготовки. Повторите проверку устройства.")
|
||||
}
|
||||
for _, raw := range inventory["items"].([]any) {
|
||||
item := raw.(map[string]any)
|
||||
if item["id"] != c.Session.DeviceID || item["online"] != true || item["prepared"] != true {
|
||||
continue
|
||||
}
|
||||
verify := c
|
||||
verify.Action = "verify"
|
||||
verify.Session.SessionID = sensorSessionID(item)
|
||||
if verify.Session.SessionID == "" {
|
||||
return nil, errors.New("Драйвер не подтвердил сеанс камеры.")
|
||||
}
|
||||
response, e := s.modelDriver(ctx, model, "/operation", verify)
|
||||
if e != nil || response["state"] == "unknown" {
|
||||
return nil, errPreparationUncertain
|
||||
}
|
||||
if response["state"] != "complete" {
|
||||
message, _ := response["error"].(string)
|
||||
if message == "" {
|
||||
message = "Не удалось проверить изображение выбранной камеры."
|
||||
}
|
||||
return nil, errors.New(message)
|
||||
}
|
||||
return response["result"], nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, errPreparationUncertain
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
}
|
||||
return nil, errors.New("Драйвер установлен, но камера не открылась. Проверьте USB-подключение.")
|
||||
}
|
||||
|
||||
// Project only the matching model run. A previous success or another model's
|
||||
// report cannot become the progress of the selected device's verification.
|
||||
func preparationView(value *sensorPreparation) *sensorPreparation {
|
||||
copy := *value
|
||||
model := modelForDevice(value.DeviceID)
|
||||
if model == nil || len(value.Steps) == 0 {
|
||||
return ©
|
||||
}
|
||||
data, err := os.ReadFile(model.Report)
|
||||
if err != nil || len(data) > 32768 {
|
||||
return ©
|
||||
}
|
||||
var report struct {
|
||||
ModelID string `json:"model_id"`
|
||||
StartedAt float64 `json:"started_at"`
|
||||
Steps []preparationStep `json:"steps"`
|
||||
}
|
||||
if json.Unmarshal(data, &report) != nil || report.ModelID != value.ModelID || report.StartedAt < value.ProfileStartedAt || len(report.Steps) == 0 || len(report.Steps) > 32 {
|
||||
return ©
|
||||
}
|
||||
copy.Steps = append(report.Steps, value.Steps[len(value.Steps)-1])
|
||||
return ©
|
||||
}
|
||||
@@ -3,15 +3,11 @@ package node
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -37,27 +33,34 @@ type SensorCommand struct {
|
||||
Parameters map[string]any `json:"parameters"`
|
||||
}
|
||||
type SensorOperation struct {
|
||||
Command SensorCommand `json:"command"`
|
||||
State string `json:"state"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Result any `json:"result,omitempty"`
|
||||
Remote bool `json:"remote,omitempty"`
|
||||
Updated int64 `json:"updated_at"`
|
||||
Command SensorCommand `json:"command"`
|
||||
State string `json:"state"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Result any `json:"result,omitempty"`
|
||||
Remote bool `json:"remote,omitempty"`
|
||||
Updated int64 `json:"updated_at"`
|
||||
Preparation *sensorPreparation `json:"preparation,omitempty"`
|
||||
}
|
||||
type Sensors struct {
|
||||
events sensorEvents
|
||||
mu sync.Mutex
|
||||
prepareMu sync.Mutex
|
||||
root string
|
||||
nodeID string
|
||||
instance string
|
||||
client *http.Client
|
||||
operations map[string]*SensorOperation
|
||||
names map[string]string
|
||||
initialized map[string]bool
|
||||
events sensorEvents
|
||||
mu sync.Mutex
|
||||
inventoryMu sync.Mutex
|
||||
root string
|
||||
nodeID string
|
||||
instance string
|
||||
client *http.Client
|
||||
clients map[string]*http.Client
|
||||
usbRoot string
|
||||
discoverySessions map[string]discoverySession
|
||||
preparing map[string]*profilePreparation
|
||||
runPreparation func(context.Context, string) error
|
||||
NetworkDevices *DeviceEnrollment
|
||||
operations map[string]*SensorOperation
|
||||
names map[string]string
|
||||
initialized map[string]bool
|
||||
}
|
||||
|
||||
var sensorID = regexp.MustCompile(`^rsd455_[0-9a-f]{32}$`)
|
||||
var sensorID = regexp.MustCompile(`^[a-z][a-z0-9]{1,31}_[0-9a-f]{32}$`)
|
||||
var operationID = regexp.MustCompile(`^op_[0-9a-f]{32}$`)
|
||||
|
||||
func OpenSensors(root, nodeID string) (*Sensors, error) {
|
||||
@@ -65,10 +68,13 @@ func OpenSensors(root, nodeID string) (*Sensors, error) {
|
||||
if e := os.MkdirAll(dir, 0700); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
s := &Sensors{root: dir, nodeID: nodeID, instance: "discovery_" + digest(token())[:24], operations: map[string]*SensorOperation{}, names: map[string]string{}, initialized: map[string]bool{}}
|
||||
s.client = &http.Client{Timeout: 25 * time.Second, Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, "unix", "/run/mission-core-sensors/driver.sock")
|
||||
}}}
|
||||
s := &Sensors{root: dir, nodeID: nodeID, instance: "discovery_" + digest(token())[:24], operations: map[string]*SensorOperation{}, names: map[string]string{}, initialized: map[string]bool{}, clients: map[string]*http.Client{}, usbRoot: "/sys/bus/usb/devices", discoverySessions: map[string]discoverySession{}, preparing: map[string]*profilePreparation{}, runPreparation: runModelPreparation}
|
||||
for _, model := range sensorModels {
|
||||
if model.Socket != "" {
|
||||
s.clients[model.ID] = sensorClient(model.Socket)
|
||||
}
|
||||
}
|
||||
s.client = s.clients["realsense.d455"]
|
||||
files, _ := filepath.Glob(filepath.Join(dir, "op_*.json"))
|
||||
for _, p := range files {
|
||||
data, e := os.ReadFile(p)
|
||||
@@ -82,11 +88,17 @@ func OpenSensors(root, nodeID string) (*Sensors, error) {
|
||||
if v.State == "running" {
|
||||
v.State = "unknown"
|
||||
v.Error = "Результат операции неизвестен после перезапуска. Проверьте состояние устройства."
|
||||
if v.Preparation != nil {
|
||||
v.Preparation.State = "unknown"
|
||||
}
|
||||
}
|
||||
s.operations[v.Command.ID] = &v
|
||||
}
|
||||
data, _ := os.ReadFile(filepath.Join(dir, "names.json"))
|
||||
_ = json.Unmarshal(data, &s.names)
|
||||
if s.names == nil {
|
||||
s.names = map[string]string{}
|
||||
}
|
||||
data, _ = os.ReadFile(filepath.Join(dir, "initialized.json"))
|
||||
_ = json.Unmarshal(data, &s.initialized)
|
||||
if s.initialized == nil {
|
||||
@@ -132,6 +144,34 @@ func (s *Sensors) write(name string, value any) error {
|
||||
return d.Sync()
|
||||
}
|
||||
func (s *Sensors) driver(path string, body any) (map[string]any, error) {
|
||||
return s.modelDriver(context.Background(), &sensorModels[0], path, body)
|
||||
}
|
||||
func (s *Sensors) modelDriver(ctx context.Context, model *sensorModel, path string, body any) (map[string]any, error) {
|
||||
if model.Prefix == "k1" {
|
||||
if s.NetworkDevices == nil {
|
||||
return nil, errors.New("Служба устройства недоступна.")
|
||||
}
|
||||
if path == "/operation" {
|
||||
path = "/sensor-operation"
|
||||
}
|
||||
return s.NetworkDevices.call(ctx, path, body)
|
||||
}
|
||||
client := s.clients[model.ID]
|
||||
if model.ID == "realsense.d455" {
|
||||
client = s.client
|
||||
}
|
||||
if client == nil {
|
||||
return nil, errors.New("Интеграция устройства не установлена.")
|
||||
}
|
||||
if command, ok := body.(SensorCommand); ok && path == "/operation" {
|
||||
if timeout := model.ActionTimeouts[command.Action]; timeout > 0 {
|
||||
// A model may need longer to confirm an action. Preserve the shared
|
||||
// inventory client and the request's earlier context deadline.
|
||||
bounded := *client
|
||||
bounded.Timeout = timeout
|
||||
client = &bounded
|
||||
}
|
||||
}
|
||||
method := "GET"
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
@@ -142,7 +182,7 @@ func (s *Sensors) driver(path string, body any) (map[string]any, error) {
|
||||
}
|
||||
reader = bytes.NewReader(data)
|
||||
}
|
||||
req, e := http.NewRequest(method, "http://driver"+path, reader)
|
||||
req, e := http.NewRequestWithContext(ctx, method, "http://driver"+path, reader)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
@@ -150,13 +190,14 @@ func (s *Sensors) driver(path string, body any) (map[string]any, error) {
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
response, e := s.client.Do(req)
|
||||
response, e := client.Do(req)
|
||||
if e != nil {
|
||||
return nil, errors.New("Служба камеры недоступна. Подготовьте устройство.")
|
||||
}
|
||||
defer response.Body.Close()
|
||||
var result map[string]any
|
||||
if json.NewDecoder(io.LimitReader(response.Body, 1024*1024)).Decode(&result) != nil {
|
||||
data, readErr := io.ReadAll(io.LimitReader(response.Body, 2*1024*1024+1))
|
||||
if readErr != nil || len(data) > 2*1024*1024 || json.Unmarshal(data, &result) != nil {
|
||||
return nil, errors.New("Не удалось прочитать результат драйвера.")
|
||||
}
|
||||
if response.StatusCode != 200 {
|
||||
@@ -166,52 +207,69 @@ func (s *Sensors) driver(path string, body any) (map[string]any, error) {
|
||||
return result, nil
|
||||
}
|
||||
func (s *Sensors) Inventory() map[string]any {
|
||||
// Concurrent local/remote refreshes must observe the same hotplug generation.
|
||||
s.inventoryMu.Lock()
|
||||
defer s.inventoryMu.Unlock()
|
||||
usb := discoverSensors(s.usbRoot)
|
||||
s.reconcileDiscovery(usb)
|
||||
items := []any{}
|
||||
seen := map[string]bool{}
|
||||
if result, e := s.driver("/inventory", nil); e == nil {
|
||||
if found, ok := result["items"].([]any); ok {
|
||||
for _, v := range found {
|
||||
item, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
id, _ := item["id"].(string)
|
||||
seen[id] = true
|
||||
s.mu.Lock()
|
||||
item["configured"] = s.initialized[id]
|
||||
if n := s.names[id]; n != "" {
|
||||
item["name"] = n
|
||||
}
|
||||
s.mu.Unlock()
|
||||
items = append(items, item)
|
||||
unsafePreparation := map[string]bool{}
|
||||
for idx := range sensorModels {
|
||||
model := &sensorModels[idx]
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
result, err := s.modelDriver(ctx, model, "/inventory", nil)
|
||||
cancel()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
found, _ := result["items"].([]any)
|
||||
for _, raw := range found {
|
||||
item, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
id, _ := item["id"].(string)
|
||||
if modelForDevice(id) != model || seen[id] {
|
||||
continue
|
||||
}
|
||||
if model.ID == "insta360.x4" && !currentCameraSnapshot(item, model) {
|
||||
// Older installed profiles must not break the paired inventory.
|
||||
// USB discovery still exposes their initialization action, while
|
||||
// preserving a legacy runtime's refusal to interrupt acquisition.
|
||||
snapshot, _ := item["snapshot"].(map[string]any)
|
||||
unsafePreparation[id] = item["preparation_safe"] == false || snapshot["acquisition"] != "idle"
|
||||
continue
|
||||
}
|
||||
s.mu.Lock()
|
||||
if model.PrepareUnit != "" {
|
||||
item["configured"] = s.initialized[id]
|
||||
}
|
||||
if name := s.names[id]; name != "" {
|
||||
item["name"] = name
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if model.Kind != "" {
|
||||
item["kind"] = model.Kind
|
||||
}
|
||||
seen[id] = true
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
paths, _ := filepath.Glob("/sys/bus/usb/devices/*")
|
||||
for _, path := range paths {
|
||||
read := func(n string) string {
|
||||
b, _ := os.ReadFile(filepath.Join(path, n))
|
||||
return strings.TrimSpace(string(b))
|
||||
for _, device := range usb {
|
||||
if !seen[device.id] {
|
||||
seen[device.id] = true
|
||||
item := s.discovery(device.id, device.speed, true)
|
||||
if unsafePreparation[device.id] {
|
||||
item["preparation_safe"] = false
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if read("idVendor") != "8086" || read("idProduct") != "0b5c" {
|
||||
continue
|
||||
}
|
||||
serial := read("serial")
|
||||
if serial == "" {
|
||||
continue
|
||||
}
|
||||
h := sha256.Sum256([]byte(serial))
|
||||
id := "rsd455_" + hex.EncodeToString(h[:])[:32]
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
items = append(items, s.discovery(id, read("speed")+" Мбит/с", true))
|
||||
}
|
||||
s.mu.Lock()
|
||||
configured := []string{}
|
||||
for id, ready := range s.initialized {
|
||||
if ready && sensorID.MatchString(id) && !seen[id] {
|
||||
if model := modelForDevice(id); ready && model != nil && model.PrepareUnit != "" && !seen[id] {
|
||||
configured = append(configured, id)
|
||||
}
|
||||
}
|
||||
@@ -219,28 +277,41 @@ func (s *Sensors) Inventory() map[string]any {
|
||||
for _, id := range configured {
|
||||
items = append(items, s.discovery(id, "—", false))
|
||||
}
|
||||
|
||||
var preparation any
|
||||
if data, e := os.ReadFile("/var/lib/mission-core-node-drivers/preparation.json"); e == nil && len(data) < 32768 {
|
||||
if data, err := os.ReadFile(sensorModels[0].Report); err == nil && len(data) < 32768 {
|
||||
_ = json.Unmarshal(data, &preparation)
|
||||
}
|
||||
s.mu.Lock()
|
||||
operations := []any{}
|
||||
preparations := []*sensorPreparation{}
|
||||
for _, v := range s.operations {
|
||||
if time.Now().Unix()-v.Updated < 600 {
|
||||
if time.Now().Unix()-v.Updated < 600 || v.State == "running" {
|
||||
operations = append(operations, map[string]any{"operation_id": v.Command.ID, "device_id": v.Command.Session.DeviceID, "action_id": v.Command.Action, "requested_at": v.Command.Requested, "state": v.State, "error": v.Error})
|
||||
if v.Preparation != nil {
|
||||
copy := *v.Preparation
|
||||
preparations = append(preparations, ©)
|
||||
}
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return map[string]any{"schema": "missioncore.node.devices/v1", "items": items, "preparation": preparation, "operations": operations}
|
||||
for i, value := range preparations {
|
||||
preparations[i] = preparationView(value)
|
||||
}
|
||||
return map[string]any{"schema": "missioncore.node.devices/v1", "items": items, "preparation": preparation, "preparations": preparations, "operations": operations}
|
||||
}
|
||||
func (s *Sensors) discovery(id, speed string, online bool) map[string]any {
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
model := modelForDevice(id)
|
||||
s.mu.Lock()
|
||||
name, configured := s.names[id], s.initialized[id]
|
||||
identity, identityPresent := s.discoverySessions[id]
|
||||
session := s.discoverySessions[id].session
|
||||
if session == "" {
|
||||
session = s.instance + "_" + id
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if name == "" {
|
||||
name = "RealSense D455"
|
||||
name = model.Name
|
||||
}
|
||||
connectivity, enrollment := "offline", "empty"
|
||||
if online {
|
||||
@@ -249,21 +320,30 @@ func (s *Sensors) discovery(id, speed string, online bool) map[string]any {
|
||||
if configured {
|
||||
enrollment = "enrolled"
|
||||
}
|
||||
return map[string]any{"id": id, "name": name, "model": "RealSense D455", "configured": configured, "prepared": false, "verified": false, "online": online, "usb": speed, "layers": []any{}, "snapshot": map[string]any{
|
||||
"context": map[string]any{"session_id": s.instance + "_" + id, "device": map[string]any{"device_id": id, "model": map[string]string{"plugin_id": "missioncore.realsense", "plugin_version": "0.6.6", "model_id": "realsense.d455"}, "stability": "stable", "basis": "hardware-identifier"}, "execution": map[string]string{"node_id": s.nodeID, "agent_instance_id": s.instance, "platform": "linux"}, "opened_at": now},
|
||||
stability, basis := "stable", "hardware-identifier"
|
||||
initializable := !identityPresent || identity.stable
|
||||
if !initializable {
|
||||
stability, basis = "provisional", "transport-local"
|
||||
}
|
||||
return map[string]any{"id": id, "name": name, "model": model.Name, "kind": model.Kind, "initializable": initializable, "configured": configured, "prepared": false, "verified": false, "online": online, "usb": speed, "layers": []any{}, "snapshot": map[string]any{
|
||||
"context": map[string]any{"session_id": session, "device": map[string]any{"device_id": id, "model": map[string]string{"plugin_id": model.Plugin, "plugin_version": model.Version, "model_id": model.ID}, "stability": stability, "basis": basis}, "execution": map[string]string{"node_id": s.nodeID, "agent_instance_id": s.instance, "platform": "linux"}, "opened_at": now},
|
||||
"revision": 0, "enrollment": enrollment, "connectivity": connectivity, "acquisition": "idle", "observed_at": now}}
|
||||
}
|
||||
|
||||
func sensorViewAction(action string) bool {
|
||||
return action == "details" || action == "offer" || action == "close-peer"
|
||||
return action == "details" || action == "offer" || action == "close-peer" || action == "settings.read" || action == "files.list"
|
||||
}
|
||||
|
||||
func (s *Sensors) Submit(c SensorCommand, remote bool) (*SensorOperation, error) {
|
||||
if c.APIVersion != SensorSchema || c.Kind != "OperationRequest" || !operationID.MatchString(c.ID) || c.Idempotency != c.ID || !sensorID.MatchString(c.Session.DeviceID) || len(c.Session.SessionID) > 192 {
|
||||
model := modelForDevice(c.Session.DeviceID)
|
||||
if c.APIVersion != SensorSchema || c.Kind != "OperationRequest" || !operationID.MatchString(c.ID) || c.Idempotency != c.ID || model == nil || c.Session.SessionID == "" || len(c.Session.SessionID) > 192 {
|
||||
return nil, errors.New("Некорректная команда устройства.")
|
||||
}
|
||||
if !map[string]bool{"prepare": true, "details": true, "rename": true, "verify": true, "start": true, "replay": true, "stop": true, "option": true, "offer": true, "close-peer": true}[c.Action] {
|
||||
return nil, errors.New("Операция не поддерживается.")
|
||||
if !model.Actions[c.Action] {
|
||||
return nil, errors.New("Операция не поддерживается этой моделью.")
|
||||
}
|
||||
if c.Action == "prepare" && len(c.Parameters) != 0 {
|
||||
return nil, errors.New("Подготовка использует встроенный профиль устройства.")
|
||||
}
|
||||
deadline, e := time.Parse(time.RFC3339Nano, c.Deadline)
|
||||
requested, e2 := time.Parse(time.RFC3339Nano, c.Requested)
|
||||
@@ -285,7 +365,7 @@ func (s *Sensors) Submit(c SensorCommand, remote bool) (*SensorOperation, error)
|
||||
return nil, errors.New("Срок команды истёк. Устройство не изменено.")
|
||||
}
|
||||
for _, v := range s.operations {
|
||||
if v.State == "running" && v.Command.Action == "prepare" {
|
||||
if v.State == "running" && v.Command.Action == "prepare" && (v.Preparation == nil || v.Preparation.Phase == "profile") && c.Action != "prepare" && !sensorViewAction(c.Action) && modelForDevice(v.Command.Session.DeviceID) == model {
|
||||
return nil, errors.New("Подготовка модели ещё выполняется.")
|
||||
}
|
||||
if v.State == "running" && v.Command.Session.DeviceID == c.Session.DeviceID && !sensorViewAction(c.Action) && !sensorViewAction(v.Command.Action) {
|
||||
@@ -327,10 +407,20 @@ func (s *Sensors) execute(c SensorCommand) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if item == nil {
|
||||
deadline, _ := time.Parse(time.RFC3339Nano, c.Deadline)
|
||||
if !deadline.After(time.Now()) {
|
||||
err = errors.New("Срок команды истёк. Устройство не изменено.")
|
||||
} else if item == nil {
|
||||
err = errors.New("Камера не обнаружена. Проверьте подключение.")
|
||||
} else if c.Action == "prepare" && item["online"] != true {
|
||||
err = errors.New("Камера отключена. Проверьте подключение.")
|
||||
} else if c.Action == "prepare" && item["initializable"] == false {
|
||||
err = errors.New("Не удалось однозначно определить камеру. Проверьте её идентификатор и подключение.")
|
||||
} else if (c.Action == "prepare" || c.Action == "rename") && sensorSessionID(item) != c.Session.SessionID {
|
||||
err = errors.New("Сеанс устройства изменился. Обновите сведения.")
|
||||
} else if c.Action == "prepare" {
|
||||
result, err = s.prepare(c)
|
||||
result, err = s.prepare(c, item)
|
||||
uncertain = errors.Is(err, errPreparationUncertain)
|
||||
} else if c.Action == "rename" {
|
||||
name, ok := c.Parameters["name"].(string)
|
||||
if !ok || strings.TrimSpace(name) == "" || len([]rune(name)) > 80 || strings.ContainsAny(name, "\n\r\t") {
|
||||
@@ -344,7 +434,10 @@ func (s *Sensors) execute(c SensorCommand) {
|
||||
}
|
||||
} else {
|
||||
var v map[string]any
|
||||
v, err = s.driver("/operation", c)
|
||||
deadline, _ := time.Parse(time.RFC3339Nano, c.Deadline)
|
||||
ctx, cancel := context.WithDeadline(context.Background(), deadline)
|
||||
v, err = s.modelDriver(ctx, modelForDevice(c.Session.DeviceID), "/operation", c)
|
||||
cancel()
|
||||
uncertain = err != nil || v["state"] == "unknown"
|
||||
if err == nil {
|
||||
if v["state"] == "complete" {
|
||||
@@ -381,48 +474,6 @@ func (s *Sensors) execute(c SensorCommand) {
|
||||
v.Error = "Не удалось сохранить результат операции. Обновите состояние устройства."
|
||||
}
|
||||
}
|
||||
func (s *Sensors) prepare(c SensorCommand) (any, error) {
|
||||
s.prepareMu.Lock()
|
||||
defer s.prepareMu.Unlock()
|
||||
for _, raw := range s.Inventory()["items"].([]any) {
|
||||
item := raw.(map[string]any)
|
||||
snap := item["snapshot"].(map[string]any)
|
||||
if state := snap["acquisition"]; state != "idle" && state != "failed" {
|
||||
return nil, errors.New("Остановите захват камер перед подготовкой модели.")
|
||||
}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 310*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "/usr/bin/systemctl", "start", "mission-core-node-realsense-prepare.service")
|
||||
cmd.Env = []string{"PATH=/usr/bin:/bin", "LANG=C", "DBUS_SYSTEM_BUS_ADDRESS=unix:path=/run/dbus/system_bus_socket"}
|
||||
if cmd.Run() != nil {
|
||||
return nil, errors.New("Подготовка драйвера не завершена. Проверьте этапы и повторите действие.")
|
||||
}
|
||||
for i := 0; i < 12; i++ {
|
||||
inv := s.Inventory()
|
||||
for _, v := range inv["items"].([]any) {
|
||||
item := v.(map[string]any)
|
||||
if item["id"] == c.Session.DeviceID && item["prepared"] == true {
|
||||
snap := item["snapshot"].(map[string]any)
|
||||
sc := snap["context"].(map[string]any)
|
||||
verify := c
|
||||
verify.Action = "verify"
|
||||
verify.Session.SessionID = sc["session_id"].(string)
|
||||
result, e := s.driver("/operation", verify)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if result["state"] != "complete" {
|
||||
message, _ := result["error"].(string)
|
||||
return nil, errors.New(message)
|
||||
}
|
||||
return result["result"], nil
|
||||
}
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
return nil, errors.New("Драйвер установлен, но камера не открылась. Проверьте USB-подключение.")
|
||||
}
|
||||
func (s *Sensors) Get(id string) *SensorOperation {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@@ -9,6 +9,17 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Synthetic tests own both USB discovery and every model transport.
|
||||
func isolateSensorHost(t *testing.T, s *Sensors) {
|
||||
t.Helper()
|
||||
s.usbRoot = t.TempDir()
|
||||
for _, client := range s.clients {
|
||||
client.Transport = sensorRoundTrip(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"items":[]}`)), Header: http.Header{}}, nil
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func sensorTestCommand() SensorCommand {
|
||||
now := time.Now()
|
||||
id := "op_01234567890123456789012345678901"
|
||||
@@ -16,6 +27,7 @@ func sensorTestCommand() SensorCommand {
|
||||
}
|
||||
func TestSensorRejectsAuthorityAndExpiredRequests(t *testing.T) {
|
||||
s, e := OpenSensors(t.TempDir(), "node_test")
|
||||
isolateSensorHost(t, s)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
@@ -39,12 +51,16 @@ func TestSensorRejectsAuthorityAndExpiredRequests(t *testing.T) {
|
||||
func TestSensorUncertainCrashDoesNotReplay(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
s, _ := OpenSensors(root, "node_test")
|
||||
isolateSensorHost(t, s)
|
||||
isolateSensorHost(t, s)
|
||||
c := sensorTestCommand()
|
||||
old := &SensorOperation{Command: c, State: "running", Updated: time.Now().Unix()}
|
||||
if e := s.write(c.ID+".json", old); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
s, e := OpenSensors(root, "node_test")
|
||||
isolateSensorHost(t, s)
|
||||
isolateSensorHost(t, s)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
@@ -65,6 +81,7 @@ func TestSensorPreservesUncertainDriverOutcome(t *testing.T) {
|
||||
for _, response := range []string{`{"state":"unknown","error":"uncertain"}`, "transport-failure"} {
|
||||
t.Run(response, func(t *testing.T) {
|
||||
s, _ := OpenSensors(t.TempDir(), "node_test")
|
||||
isolateSensorHost(t, s)
|
||||
c := sensorTestCommand()
|
||||
s.operations[c.ID] = &SensorOperation{Command: c, State: "running"}
|
||||
s.client = &http.Client{Transport: sensorRoundTrip(func(r *http.Request) (*http.Response, error) {
|
||||
@@ -88,12 +105,16 @@ func TestSensorPreservesUncertainDriverOutcome(t *testing.T) {
|
||||
func TestSensorConfiguredIdentitySurvivesRestartAndDisconnect(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
s, _ := OpenSensors(root, "node_test")
|
||||
isolateSensorHost(t, s)
|
||||
isolateSensorHost(t, s)
|
||||
c := sensorTestCommand()
|
||||
s.initialized[c.Session.DeviceID] = true
|
||||
if e := s.write("initialized.json", s.initialized); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
s, e := OpenSensors(root, "node_test")
|
||||
isolateSensorHost(t, s)
|
||||
isolateSensorHost(t, s)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
|
||||
@@ -13,20 +13,23 @@ import (
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
Store *Store
|
||||
Pairing *Pairing
|
||||
Sensors *Sensors
|
||||
Assets fs.FS
|
||||
Origin string
|
||||
Version string
|
||||
Inventory func() Inventory
|
||||
Access *AccessStore
|
||||
Tailscale func() TailscaleStatus
|
||||
Environment func() EnvironmentStatus
|
||||
mu sync.Mutex
|
||||
logins map[string]time.Time
|
||||
sessions map[string]time.Time
|
||||
Now func() time.Time
|
||||
Monitor *Monitor
|
||||
Store *Store
|
||||
Pairing *Pairing
|
||||
Sensors *Sensors
|
||||
DeviceEnrollment *DeviceEnrollment
|
||||
Assets fs.FS
|
||||
Origin string
|
||||
Version string
|
||||
Inventory func() Inventory
|
||||
Access *AccessStore
|
||||
Tailscale func() TailscaleStatus
|
||||
Environment func() EnvironmentStatus
|
||||
Presentation *PresentationStore
|
||||
mu sync.Mutex
|
||||
logins map[string]time.Time
|
||||
sessions map[string]time.Time
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func token() string {
|
||||
@@ -79,9 +82,17 @@ func reply(w http.ResponseWriter, status int, v any) {
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
s.localPreviewRoutes(mux)
|
||||
s.monitorRoutes(mux)
|
||||
if s.Presentation != nil {
|
||||
s.presentationRoutes(mux)
|
||||
}
|
||||
if s.Sensors != nil {
|
||||
s.Sensors.Routes(mux, s)
|
||||
}
|
||||
if s.DeviceEnrollment != nil {
|
||||
s.DeviceEnrollment.Routes(mux, s)
|
||||
}
|
||||
if s.Pairing != nil {
|
||||
s.Pairing.localRoutes(mux, s)
|
||||
}
|
||||
@@ -161,7 +172,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
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
#!/usr/bin/python3
|
||||
"""Independent one-second collector, local archive and private Node read adapter."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import socketserver
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from http.server import BaseHTTPRequestHandler
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
from linux_metrics import LinuxMetrics
|
||||
from storage import SCHEMA, Archive
|
||||
|
||||
SOCKET = Path("/run/mission-core-monitor/monitor.sock")
|
||||
|
||||
|
||||
class Collector:
|
||||
def __init__(self):
|
||||
self.stop = threading.Event()
|
||||
self.lock = threading.Lock()
|
||||
self.latest = None
|
||||
self.definitions = {}
|
||||
self.events = deque(maxlen=64)
|
||||
self.storage = "starting"
|
||||
self.database_bytes = None
|
||||
self.archive = None
|
||||
self.boot = Path("/proc/sys/kernel/random/boot_id").read_text().strip()
|
||||
|
||||
def event(self, value):
|
||||
if (
|
||||
not isinstance(value, dict)
|
||||
or set(value) - {"code", "kind", "locations"}
|
||||
or value.get("code") not in {"ui-error", "ui-rejection", "ui-render-error"}
|
||||
or not isinstance(value.get("kind"), str)
|
||||
or not re.fullmatch("[A-Za-z]{1,48}", value["kind"])
|
||||
or not isinstance(value.get("locations", []), list)
|
||||
or len(value.get("locations", [])) > 8
|
||||
or any(
|
||||
not isinstance(v, str)
|
||||
or not re.fullmatch(r"[A-Za-z0-9_-]{1,96}\.js:[0-9]{1,9}:[0-9]{1,9}", v)
|
||||
for v in value.get("locations", [])
|
||||
)
|
||||
):
|
||||
raise ValueError("invalid-event")
|
||||
with self.lock:
|
||||
if len(self.events) < 64:
|
||||
self.events.append({**value, "at": time.time()})
|
||||
|
||||
def run(self):
|
||||
metrics = LinuxMetrics()
|
||||
maintenance = 0
|
||||
while not self.stop.is_set():
|
||||
started = time.monotonic()
|
||||
try:
|
||||
values = metrics.sample()
|
||||
with self.lock:
|
||||
events = list(self.events)
|
||||
sample = dict(
|
||||
at=time.time(), boot_id=self.boot, uptime=started, values=values, events=events
|
||||
)
|
||||
if self.archive is None:
|
||||
self.archive = Archive()
|
||||
# Maintenance also runs while writes are paused by a disk budget.
|
||||
if started - maintenance > 60:
|
||||
self.database_bytes = self.archive.maintain()
|
||||
maintenance = started
|
||||
record = self.archive.append(sample, metrics.definitions)
|
||||
with self.lock:
|
||||
for _ in events:
|
||||
self.events.popleft()
|
||||
self.latest = record
|
||||
self.definitions = metrics.definitions
|
||||
self.storage = "ready"
|
||||
except Exception as error:
|
||||
logging.warning("monitor sample failed class=%s", type(error).__name__)
|
||||
with self.lock:
|
||||
self.storage = (
|
||||
str(error)
|
||||
if str(error) in {"disk-reserve", "archive-budget"}
|
||||
else "unavailable"
|
||||
)
|
||||
self.stop.wait(max(0.05, 1 - (time.monotonic() - started)))
|
||||
|
||||
def status(self):
|
||||
with self.lock:
|
||||
return dict(
|
||||
schema=SCHEMA,
|
||||
source_id=self.archive.source if self.archive else None,
|
||||
latest=self.latest,
|
||||
definitions=self.definitions,
|
||||
storage=self.storage,
|
||||
database_bytes=self.database_bytes,
|
||||
sample_interval_seconds=1,
|
||||
retention_days=7,
|
||||
database_budget_bytes=2 * 1024**3,
|
||||
free_reserve_bytes=2 * 1024**3,
|
||||
)
|
||||
|
||||
|
||||
class Server(socketserver.ThreadingMixIn, socketserver.UnixStreamServer):
|
||||
daemon_threads = True
|
||||
block_on_close = False
|
||||
|
||||
def __init__(self, collector):
|
||||
self.collector = collector
|
||||
self.capacity = threading.BoundedSemaphore(6)
|
||||
super().__init__(str(SOCKET), Handler)
|
||||
|
||||
def process_request(self, request, address):
|
||||
if not self.capacity.acquire(False):
|
||||
request.close()
|
||||
return
|
||||
request.settimeout(5)
|
||||
super().process_request(request, address)
|
||||
|
||||
def process_request_thread(self, request, address):
|
||||
try:
|
||||
super().process_request_thread(request, address)
|
||||
finally:
|
||||
self.capacity.release()
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *_):
|
||||
pass
|
||||
|
||||
def reply(self, value, status=200):
|
||||
payload = json.dumps(value, allow_nan=False, separators=(",", ":")).encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def do_GET(self):
|
||||
path = urlsplit(self.path)
|
||||
try:
|
||||
if path.path == "/status":
|
||||
self.reply(self.server.collector.status())
|
||||
elif path.path == "/batch":
|
||||
after = int(parse_qs(path.query).get("after", ["0"])[0])
|
||||
if not 0 <= after <= 2**53 - 1:
|
||||
raise ValueError()
|
||||
archive = self.server.collector.archive
|
||||
if archive is None:
|
||||
self.reply({"error": "Archive unavailable"}, 503)
|
||||
else:
|
||||
self.reply(archive.batch(after))
|
||||
else:
|
||||
self.reply({"error": "Unknown request"}, 404)
|
||||
except Exception:
|
||||
self.reply({"error": "Archive unavailable"}, 503)
|
||||
|
||||
def do_POST(self):
|
||||
try:
|
||||
size = int(self.headers.get("Content-Length", "0"))
|
||||
if self.path != "/events" or not 0 < size <= 2048:
|
||||
raise ValueError()
|
||||
self.server.collector.event(json.loads(self.rfile.read(size)))
|
||||
self.reply({"ok": True})
|
||||
except (ValueError, TypeError):
|
||||
self.reply({"error": "Invalid event"}, 400)
|
||||
|
||||
|
||||
def main():
|
||||
logging.basicConfig(level=logging.WARNING)
|
||||
os.umask(0o007)
|
||||
collector = Collector()
|
||||
from journal_events import watch
|
||||
|
||||
threading.Thread(
|
||||
target=watch, args=(collector,), name="node-system-events", daemon=True
|
||||
).start()
|
||||
SOCKET.unlink(missing_ok=True)
|
||||
server = Server(collector)
|
||||
worker = threading.Thread(target=collector.run, name="node-system-sampler", daemon=True)
|
||||
worker.start()
|
||||
|
||||
def stop(*_):
|
||||
collector.stop.set()
|
||||
threading.Thread(target=server.shutdown, daemon=True).start()
|
||||
|
||||
signal.signal(signal.SIGTERM, stop)
|
||||
signal.signal(signal.SIGINT, stop)
|
||||
try:
|
||||
server.serve_forever(poll_interval=0.5)
|
||||
finally:
|
||||
collector.stop.set()
|
||||
server.server_close()
|
||||
worker.join(5)
|
||||
SOCKET.unlink(missing_ok=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Classify bounded journal records; never persist free-form messages or payloads."""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
|
||||
RULES = (
|
||||
("system-oom", r"out of memory|oom-kill|killed process"),
|
||||
("gpu-reset", r"GPU HANG|GPU reset|Resetting.*(gpu|chip)|i915.*(hang|reset|error)"),
|
||||
("disk-error", r"I/O error|EXT4-fs error|nvme.*(timeout|reset)|ata.*failed command"),
|
||||
("usb-disconnected", r"USB disconnect"),
|
||||
("usb-error", r"usb.*(error -|unable to enumerate|reset.*device)"),
|
||||
("service-failed", r"Failed with result|Main process exited|Failed to start"),
|
||||
("ui-process-exit", r"node-ui-process-terminated"),
|
||||
("ui-load-failed", r"node-ui-load-failed"),
|
||||
)
|
||||
|
||||
|
||||
def classify(record):
|
||||
message = record.get("MESSAGE")
|
||||
if not isinstance(message, str):
|
||||
return None
|
||||
code = next((code for code, pattern in RULES if re.search(pattern, message, re.I)), None)
|
||||
if code is None:
|
||||
return None
|
||||
try:
|
||||
at = int(record["__REALTIME_TIMESTAMP"]) / 1_000_000
|
||||
except (KeyError, ValueError, TypeError):
|
||||
return None
|
||||
return dict(
|
||||
code=code,
|
||||
kind="Kernel" if record.get("_TRANSPORT") == "kernel" else "System",
|
||||
at=at,
|
||||
locations=[],
|
||||
)
|
||||
|
||||
|
||||
def watch(collector):
|
||||
args = [
|
||||
"/usr/bin/journalctl",
|
||||
"--follow",
|
||||
"--lines=0",
|
||||
"--output=json",
|
||||
"--no-pager",
|
||||
"_TRANSPORT=kernel",
|
||||
"+",
|
||||
"SYSLOG_IDENTIFIER=mission-core-node-ui",
|
||||
"+",
|
||||
"_SYSTEMD_UNIT=mission-core-node.service",
|
||||
"+",
|
||||
"_SYSTEMD_UNIT=mission-core-k1.service",
|
||||
"+",
|
||||
"_SYSTEMD_UNIT=mission-core-node-monitor.service",
|
||||
"+",
|
||||
"_SYSTEMD_UNIT=postgresql@16-ndcmonitor.service",
|
||||
]
|
||||
for unit in (
|
||||
"mission-core-node.service",
|
||||
"mission-core-k1.service",
|
||||
"mission-core-node-monitor.service",
|
||||
"postgresql@16-ndcmonitor.service",
|
||||
):
|
||||
args.extend(["+", "UNIT=" + unit])
|
||||
while not collector.stop.is_set():
|
||||
process = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
||||
|
||||
def retire(process=process):
|
||||
while process.poll() is None:
|
||||
if collector.stop.wait(1):
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
return
|
||||
|
||||
guard = threading.Thread(target=retire, daemon=True)
|
||||
guard.start()
|
||||
try:
|
||||
while not collector.stop.is_set():
|
||||
line = process.stdout.readline(65537)
|
||||
if not line:
|
||||
break
|
||||
if len(line) > 65536:
|
||||
while line and not line.endswith(b"\n"):
|
||||
line = process.stdout.readline(65537)
|
||||
continue
|
||||
try:
|
||||
event = classify(json.loads(line))
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if event:
|
||||
with collector.lock:
|
||||
if len(collector.events) < 64:
|
||||
collector.events.append(event)
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
process.stdout.close()
|
||||
collector.stop.wait(10)
|
||||
@@ -0,0 +1,353 @@
|
||||
"""Read-only Linux counters. Missing or reset counters never manufacture zero."""
|
||||
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class LinuxMetrics:
|
||||
def __init__(self, root=Path("/"), clock=time.monotonic):
|
||||
self.root, self.clock = Path(root), clock
|
||||
self.previous = {}
|
||||
self.previous_time = None
|
||||
self.definitions = {}
|
||||
|
||||
def text(self, path):
|
||||
try:
|
||||
return (self.root / str(path).lstrip("/")).read_text()[:262144].strip()
|
||||
except (OSError, UnicodeError):
|
||||
return None
|
||||
|
||||
def number(self, path):
|
||||
try:
|
||||
return float(self.text(path))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
def paths(self, pattern):
|
||||
return sorted(self.root.glob(pattern))[:128]
|
||||
|
||||
def sample(self):
|
||||
now = self.clock()
|
||||
elapsed = None if self.previous_time is None else now - self.previous_time
|
||||
self.previous_time = now
|
||||
values, definitions, counters = {}, {}, {}
|
||||
|
||||
def metric(
|
||||
key, label, group, resource, unit, value, reason="Счётчик недоступен в этой системе"
|
||||
):
|
||||
values[key] = None if value is None else round(value, 3)
|
||||
definitions[key] = dict(
|
||||
label=label,
|
||||
group=group,
|
||||
resource=resource,
|
||||
unit=unit,
|
||||
reason=reason if value is None else None,
|
||||
)
|
||||
|
||||
def delta(key, value):
|
||||
if value is None:
|
||||
return None
|
||||
counters[key] = value
|
||||
previous = self.previous.get(key)
|
||||
return (
|
||||
None
|
||||
if previous is None or value < previous or not elapsed or elapsed > 10
|
||||
else (value - previous) / elapsed
|
||||
)
|
||||
|
||||
stat = self.text("proc/stat") or ""
|
||||
cpus = 0
|
||||
for line in stat.splitlines():
|
||||
parts = line.split()
|
||||
if not re.fullmatch(r"cpu\d*", parts[0]):
|
||||
continue
|
||||
if parts[0] != "cpu":
|
||||
cpus += 1
|
||||
data = [int(v) for v in parts[1:9]]
|
||||
total = delta(parts[0] + ".total", sum(data))
|
||||
idle = delta(parts[0] + ".idle", data[3] + data[4])
|
||||
wait = delta(parts[0] + ".iowait", data[4])
|
||||
for name, label, value in [
|
||||
(
|
||||
"usage",
|
||||
"Загрузка",
|
||||
None if not total or idle is None else 100 * (1 - idle / total),
|
||||
),
|
||||
(
|
||||
"iowait",
|
||||
"Ожидание диска",
|
||||
None if not total or wait is None else 100 * wait / total,
|
||||
),
|
||||
]:
|
||||
metric(
|
||||
parts[0] + "." + name,
|
||||
label,
|
||||
"CPU",
|
||||
parts[0],
|
||||
"%",
|
||||
value,
|
||||
"Ожидаем два последовательных измерения",
|
||||
)
|
||||
if not cpus:
|
||||
metric("cpu.usage", "Загрузка", "CPU", "CPU", "%", None)
|
||||
memory = {}
|
||||
for line in (self.text("proc/meminfo") or "").splitlines():
|
||||
parts = line.replace(":", "").split()
|
||||
if len(parts) > 1:
|
||||
memory[parts[0]] = int(parts[1]) * 1024
|
||||
for key, label in [
|
||||
("MemTotal", "Всего"),
|
||||
("MemAvailable", "Доступно"),
|
||||
("SwapTotal", "Swap всего"),
|
||||
("SwapFree", "Swap свободно"),
|
||||
("Dirty", "Ожидает записи"),
|
||||
("Writeback", "Записывается"),
|
||||
]:
|
||||
metric("memory." + key, label, "RAM", "Память", "bytes", memory.get(key))
|
||||
total, available = memory.get("MemTotal"), memory.get("MemAvailable")
|
||||
metric(
|
||||
"memory.usage",
|
||||
"Использовано",
|
||||
"RAM",
|
||||
"Память",
|
||||
"%",
|
||||
None if not total or available is None else 100 * (1 - available / total),
|
||||
)
|
||||
for index, load in enumerate((self.text("proc/loadavg") or "").split()[:3]):
|
||||
metric(
|
||||
"load." + str(index),
|
||||
["За 1 минуту", "За 5 минут", "За 15 минут"][index],
|
||||
"CPU",
|
||||
"Load average",
|
||||
"",
|
||||
float(load),
|
||||
)
|
||||
for resource in ["cpu", "memory", "io"]:
|
||||
for line in (self.text("proc/pressure/" + resource) or "").splitlines():
|
||||
parts = line.split()
|
||||
fields = dict(v.split("=") for v in parts[1:])
|
||||
metric(
|
||||
"pressure." + resource + "." + parts[0],
|
||||
"Задержка за 10 секунд",
|
||||
"Ожидание ресурсов",
|
||||
resource + " · " + parts[0],
|
||||
"%",
|
||||
float(fields["avg10"]),
|
||||
)
|
||||
for interface in self.paths("sys/class/net/*"):
|
||||
name = interface.name
|
||||
for field, label in [
|
||||
("rx_bytes", "Приём"),
|
||||
("tx_bytes", "Передача"),
|
||||
("rx_errors", "Ошибки приёма"),
|
||||
("tx_errors", "Ошибки передачи"),
|
||||
("rx_dropped", "Потери приёма"),
|
||||
("tx_dropped", "Потери передачи"),
|
||||
]:
|
||||
raw = self.number(interface.relative_to(self.root) / "statistics" / field)
|
||||
metric(
|
||||
"net." + name + "." + field,
|
||||
label,
|
||||
"Сеть",
|
||||
name,
|
||||
"bytes/s" if field.endswith("bytes") else "1/s",
|
||||
delta("net." + name + "." + field, raw),
|
||||
)
|
||||
speed = self.number(interface.relative_to(self.root) / "speed")
|
||||
metric(
|
||||
"net." + name + ".speed",
|
||||
"Скорость соединения",
|
||||
"Сеть",
|
||||
name,
|
||||
"Mbit/s",
|
||||
speed if speed is not None and speed > 0 else None,
|
||||
)
|
||||
diskstats = (self.text("proc/diskstats") or "").splitlines()
|
||||
for line in diskstats:
|
||||
p = line.split()
|
||||
if (
|
||||
len(p) < 14
|
||||
or p[2].startswith(("loop", "ram"))
|
||||
or not (self.root / "sys/block" / p[2]).exists()
|
||||
):
|
||||
continue
|
||||
name = p[2]
|
||||
for index, label, unit, multiplier in [
|
||||
(5, "Чтение", "bytes/s", 512),
|
||||
(9, "Запись", "bytes/s", 512),
|
||||
(3, "Операции чтения", "1/s", 1),
|
||||
(7, "Операции записи", "1/s", 1),
|
||||
(12, "Занятость", "%", 0.1),
|
||||
]:
|
||||
key = "disk." + name + "." + str(index)
|
||||
rate = delta(key, int(p[index]))
|
||||
metric(key, label, "Диски", name, unit, None if rate is None else rate * multiplier)
|
||||
if self.root == Path("/"):
|
||||
mounts = set()
|
||||
for line in (self.text("proc/mounts") or "").splitlines():
|
||||
p = line.split()
|
||||
if len(p) < 3 or not p[0].startswith("/dev/") or p[1] in mounts:
|
||||
continue
|
||||
mount = p[1].replace("\\040", " ")
|
||||
mounts.add(mount)
|
||||
try:
|
||||
st = os.statvfs(mount)
|
||||
key = "fs." + str(len(mounts))
|
||||
metric(
|
||||
key + ".free",
|
||||
"Свободно",
|
||||
"Хранилище",
|
||||
mount,
|
||||
"bytes",
|
||||
st.f_bavail * st.f_frsize,
|
||||
)
|
||||
metric(
|
||||
key + ".usage",
|
||||
"Использовано",
|
||||
"Хранилище",
|
||||
mount,
|
||||
"%",
|
||||
100 * (1 - st.f_bfree / st.f_blocks) if st.f_blocks else None,
|
||||
)
|
||||
metric(
|
||||
key + ".inodes",
|
||||
"Использовано inode",
|
||||
"Хранилище",
|
||||
mount,
|
||||
"%",
|
||||
100 * (1 - st.f_ffree / st.f_files) if st.f_files else None,
|
||||
)
|
||||
except OSError:
|
||||
metric(
|
||||
"fs." + str(len(mounts)) + ".free",
|
||||
"Свободно",
|
||||
"Хранилище",
|
||||
mount,
|
||||
"bytes",
|
||||
None,
|
||||
)
|
||||
for path in self.paths("sys/class/hwmon/hwmon*/temp*_input"):
|
||||
relative = path.relative_to(self.root)
|
||||
name = self.text(path.parent.relative_to(self.root) / "name") or path.parent.name
|
||||
raw = self.number(relative)
|
||||
metric(
|
||||
"temperature." + path.parent.name + "." + path.stem,
|
||||
"Температура",
|
||||
"Температура",
|
||||
name + " · " + path.stem,
|
||||
"°C",
|
||||
None if raw is None else raw / 1000,
|
||||
)
|
||||
for card in self.paths("sys/class/drm/card[0-9]"):
|
||||
base = card.relative_to(self.root)
|
||||
metric(
|
||||
"gpu." + card.name + ".usage",
|
||||
"Загрузка",
|
||||
"GPU",
|
||||
card.name,
|
||||
"%",
|
||||
self.number(base / "device/gpu_busy_percent"),
|
||||
"Драйвер не публикует общий счётчик загрузки GPU",
|
||||
)
|
||||
metric(
|
||||
"gpu." + card.name + ".frequency",
|
||||
"Частота",
|
||||
"GPU",
|
||||
card.name,
|
||||
"MHz",
|
||||
self.number(base / "gt_cur_freq_mhz"),
|
||||
)
|
||||
for field, label in [
|
||||
("mem_info_vram_used", "Видеопамять занята"),
|
||||
("mem_info_vram_total", "Видеопамять всего"),
|
||||
]:
|
||||
metric(
|
||||
"gpu." + card.name + "." + field,
|
||||
label,
|
||||
"GPU",
|
||||
card.name,
|
||||
"bytes",
|
||||
self.number(base / "device" / field),
|
||||
"Выделенная видеопамять или её счётчик недоступны",
|
||||
)
|
||||
for usb in self.paths("sys/bus/usb/devices/*"):
|
||||
if not (usb / "idVendor").exists():
|
||||
continue
|
||||
base = usb.relative_to(self.root)
|
||||
name = usb.name
|
||||
metric(
|
||||
"usb." + name + ".speed",
|
||||
"Скорость соединения",
|
||||
"USB",
|
||||
name,
|
||||
"Mbit/s",
|
||||
self.number(base / "speed"),
|
||||
)
|
||||
metric(
|
||||
"usb." + name + ".traffic",
|
||||
"Трафик",
|
||||
"USB",
|
||||
name,
|
||||
"bytes/s",
|
||||
None,
|
||||
"USB-драйвер не публикует счётчик трафика порта",
|
||||
)
|
||||
groups = self.paths("sys/fs/cgroup/system.slice/mission-core*.service")
|
||||
groups += self.paths(
|
||||
"sys/fs/cgroup/system.slice/system-postgresql.slice/postgresql@16-ndcmonitor.service"
|
||||
)
|
||||
groups += self.paths(
|
||||
"sys/fs/cgroup/user.slice/user-*.slice/user@*.service/app.slice/app-gnome-org.nodedc.MissionCoreNode-*.scope"
|
||||
)
|
||||
for path in groups:
|
||||
base = path.relative_to(self.root)
|
||||
name = path.name
|
||||
key = "service." + re.sub("[^A-Za-z0-9_.-]", "_", name)
|
||||
metric(
|
||||
key + ".memory",
|
||||
"Память",
|
||||
"Приложения",
|
||||
name,
|
||||
"bytes",
|
||||
self.number(base / "memory.current"),
|
||||
)
|
||||
metric(
|
||||
key + ".peak",
|
||||
"Пик памяти",
|
||||
"Приложения",
|
||||
name,
|
||||
"bytes",
|
||||
self.number(base / "memory.peak"),
|
||||
)
|
||||
fields = dict(
|
||||
line.split() for line in (self.text(base / "cpu.stat") or "").splitlines()
|
||||
)
|
||||
rate = delta(
|
||||
key + ".cpu", float(fields["usage_usec"]) if "usage_usec" in fields else None
|
||||
)
|
||||
metric(
|
||||
key + ".cpu",
|
||||
"Загрузка CPU",
|
||||
"Приложения",
|
||||
name,
|
||||
"%",
|
||||
None if rate is None else rate / 10000 / max(cpus, 1),
|
||||
)
|
||||
fields = dict(
|
||||
line.split() for line in (self.text(base / "memory.events") or "").splitlines()
|
||||
)
|
||||
metric(
|
||||
key + ".oom",
|
||||
"Убийств из-за нехватки памяти",
|
||||
"Приложения",
|
||||
name,
|
||||
"",
|
||||
float(fields["oom_kill"]) if "oom_kill" in fields else None,
|
||||
)
|
||||
self.previous = counters
|
||||
# A bounded hardware inventory protects the control-plane payload budget.
|
||||
keys = list(values)[:384]
|
||||
self.definitions = {key: definitions[key] for key in keys}
|
||||
return {key: values[key] for key in keys}
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE EXTENSION IF NOT EXISTS timescaledb;
|
||||
CREATE TABLE IF NOT EXISTS monitor_identity (singleton boolean PRIMARY KEY DEFAULT true CHECK(singleton), source_id text NOT NULL);
|
||||
INSERT INTO monitor_identity(singleton,source_id) VALUES(true,gen_random_uuid()::text) ON CONFLICT DO NOTHING;
|
||||
CREATE TABLE IF NOT EXISTS monitor_samples (
|
||||
seq bigint GENERATED ALWAYS AS IDENTITY,
|
||||
observed_at timestamptz NOT NULL,
|
||||
payload jsonb NOT NULL,
|
||||
PRIMARY KEY(observed_at,seq)
|
||||
);
|
||||
SELECT create_hypertable('monitor_samples',by_range('observed_at',INTERVAL '1 hour'),if_not_exists=>true);
|
||||
CREATE INDEX IF NOT EXISTS monitor_samples_cursor ON monitor_samples(seq);
|
||||
CREATE TABLE IF NOT EXISTS monitor_definitions(singleton boolean PRIMARY KEY DEFAULT true CHECK(singleton),payload jsonb NOT NULL);
|
||||
REVOKE ALL ON DATABASE mission_core_monitor FROM PUBLIC;
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Bounded local Timescale archive; transport acknowledgements never delete data."""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from contextlib import contextmanager
|
||||
from datetime import UTC, datetime
|
||||
|
||||
SCHEMA = "missioncore.node-system-monitor/v1"
|
||||
MAX_BATCH_BYTES = 196608
|
||||
DATABASE_BUDGET = 2 * 1024**3
|
||||
|
||||
|
||||
class Archive:
|
||||
def __init__(self):
|
||||
from psycopg2.pool import ThreadedConnectionPool
|
||||
|
||||
self.pool = ThreadedConnectionPool(
|
||||
1,
|
||||
4,
|
||||
host="/run/mission-core-monitor-db",
|
||||
port=5433,
|
||||
dbname="mission_core_monitor",
|
||||
user="mission-core-monitor",
|
||||
connect_timeout=3,
|
||||
options="-c statement_timeout=3000",
|
||||
)
|
||||
try:
|
||||
with self.connection() as db, db.cursor() as cursor:
|
||||
cursor.execute("SELECT source_id FROM monitor_identity")
|
||||
self.source = cursor.fetchone()[0]
|
||||
except Exception:
|
||||
self.pool.closeall()
|
||||
raise
|
||||
self.definitions = None
|
||||
self.database_bytes = None
|
||||
|
||||
@contextmanager
|
||||
def connection(self):
|
||||
db = self.pool.getconn()
|
||||
try:
|
||||
yield db
|
||||
db.commit()
|
||||
except BaseException:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
self.pool.putconn(db)
|
||||
|
||||
def append(self, sample, definitions):
|
||||
# This reserve belongs to telemetry, never to acquisition storage.
|
||||
if shutil.disk_usage("/var/lib/mission-core-monitor").free < 2 * 1024**3:
|
||||
raise RuntimeError("disk-reserve")
|
||||
if self.database_bytes is None or self.database_bytes >= DATABASE_BUDGET:
|
||||
raise RuntimeError("archive-budget")
|
||||
payload = json.dumps(sample, separators=(",", ":"), allow_nan=False)
|
||||
if len(payload.encode()) > 32768:
|
||||
raise ValueError("sample-budget")
|
||||
with self.connection() as db, db.cursor() as cursor:
|
||||
if definitions != self.definitions:
|
||||
cursor.execute(
|
||||
"INSERT INTO monitor_definitions VALUES(true,%s) ON CONFLICT(singleton) "
|
||||
"DO UPDATE SET payload=excluded.payload",
|
||||
(json.dumps(definitions),),
|
||||
)
|
||||
cursor.execute(
|
||||
"INSERT INTO monitor_samples(observed_at,payload) VALUES(%s,%s) RETURNING seq",
|
||||
(datetime.fromtimestamp(sample["at"], UTC), payload),
|
||||
)
|
||||
seq = cursor.fetchone()[0]
|
||||
self.definitions = definitions
|
||||
return {**sample, "seq": seq}
|
||||
|
||||
def batch(self, after):
|
||||
with self.connection() as db, db.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT seq,payload FROM monitor_samples WHERE seq>%s ORDER BY seq LIMIT 32",
|
||||
(after,),
|
||||
)
|
||||
rows = []
|
||||
size = 0
|
||||
for seq, payload in cursor.fetchall():
|
||||
record = {**payload, "seq": seq}
|
||||
size += len(json.dumps(record).encode())
|
||||
if size > MAX_BATCH_BYTES:
|
||||
break
|
||||
rows.append(record)
|
||||
cursor.execute("SELECT min(seq),max(seq),min(observed_at) FROM monitor_samples")
|
||||
first, last, oldest = cursor.fetchone()
|
||||
return dict(
|
||||
schema=SCHEMA,
|
||||
source_id=self.source,
|
||||
samples=rows,
|
||||
first_seq=first,
|
||||
last_seq=last,
|
||||
retained_since=oldest.timestamp() if oldest else None,
|
||||
)
|
||||
|
||||
def maintain(self):
|
||||
with self.connection() as db, db.cursor() as cursor:
|
||||
cursor.execute("SELECT drop_chunks('monitor_samples',INTERVAL '7 days')")
|
||||
cursor.execute("SELECT pg_database_size(current_database())")
|
||||
size = cursor.fetchone()[0]
|
||||
if size >= DATABASE_BUDGET:
|
||||
# Drop one oldest completed chunk per pass, retaining the live hour.
|
||||
cursor.execute(
|
||||
"SELECT min(range_end) FROM timescaledb_information.chunks "
|
||||
"WHERE hypertable_name='monitor_samples' AND range_end<NOW()"
|
||||
)
|
||||
boundary = cursor.fetchone()[0]
|
||||
if boundary:
|
||||
cursor.execute(
|
||||
"SELECT drop_chunks('monitor_samples',older_than=>%s::timestamptz)",
|
||||
(boundary,),
|
||||
)
|
||||
cursor.execute("SELECT pg_database_size(current_database())")
|
||||
size = cursor.fetchone()[0]
|
||||
self.database_bytes = size
|
||||
return size
|
||||
@@ -1,6 +1,7 @@
|
||||
// An authenticated Node action may start only this fixed model job.
|
||||
polkit.addRule(function(action, subject) {
|
||||
if (subject.user === "mission-core-node" && action.id === "org.freedesktop.systemd1.manage-units" && action.lookup("unit") === "mission-core-node-realsense-prepare.service" && action.lookup("verb") === "start") {
|
||||
var unit = action.lookup("unit");
|
||||
if (subject.user === "mission-core-node" && action.id === "org.freedesktop.systemd1.manage-units" && (unit === "mission-core-node-realsense-prepare.service" || unit === "mission-core-node-insta360-x4-profile.service") && action.lookup("verb") === "start") {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -11,13 +11,15 @@ import sys
|
||||
from build_deb import build, VERSION, BRAND_SHA256
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DG_COMMIT = "999864e5b0a81555823cfa1ea6e8cf8a417c37f1"
|
||||
DG_COMMIT = "8c53f73ee521561a1cc7944a7f7cf113702f40b5"
|
||||
|
||||
|
||||
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()}
|
||||
@@ -32,6 +34,9 @@ def provenance():
|
||||
"base_commit": subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip(),
|
||||
"design_guideline_commit": DG_COMMIT,
|
||||
"design_guideline_files": guideline_sources(),
|
||||
"shared_sensor_ui_files": {str(p.relative_to(ROOT.parents[1])): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted((ROOT.parents[1] / "packages/sensor-ui/src").rglob("*")) if p.is_file()},
|
||||
"shared_spatial_ui_files": {str(p.relative_to(ROOT.parents[1])): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted((ROOT.parents[1] / "packages/spatial-ui/src").rglob("*")) if p.is_file()},
|
||||
"k1_frontend_files": {str(p.relative_to(ROOT.parents[1])): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted((ROOT.parents[1] / "plugins/xgrids-k1/frontend/src").rglob("*")) if p.is_file()},
|
||||
"toolchain": json.loads((ROOT / "toolchain.json").read_text()), "files": files}
|
||||
|
||||
|
||||
|
||||
@@ -4,16 +4,18 @@
|
||||
No install operation, sudo, container, package-manager mutation or network I/O.
|
||||
"""
|
||||
import argparse
|
||||
import gzip
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tarfile
|
||||
import sys
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
VERSION = "0.6.11"
|
||||
BINARY_VERSION = "0.8.21"
|
||||
VERSION = "0.8.21-3"
|
||||
sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging"))
|
||||
from debian import package
|
||||
|
||||
BRAND_SHA256 = "8bfee8ca9f98e0db48d98aae3af4b32493b8593e18b064a0239d513d824182af"
|
||||
|
||||
|
||||
@@ -31,29 +33,6 @@ def desktop_icon(brand):
|
||||
+ brand + b'</svg>\n')
|
||||
|
||||
|
||||
def tarball(files):
|
||||
stream = io.BytesIO()
|
||||
with tarfile.open(fileobj=stream, mode="w", format=tarfile.GNU_FORMAT) as archive:
|
||||
directories = {str(parent) for name, _, _ in files for parent in Path(name).parents if str(parent) != "."}
|
||||
for name in sorted(directories):
|
||||
item = tarfile.TarInfo(name + "/")
|
||||
item.type, item.mode = tarfile.DIRTYPE, 0o755
|
||||
item.uname = item.gname = "root"
|
||||
archive.addfile(item)
|
||||
for name, data, mode in sorted(files):
|
||||
item = tarfile.TarInfo(name)
|
||||
item.size, item.mode, item.uid, item.gid = len(data), mode, 0, 0
|
||||
item.uname = item.gname = "root"
|
||||
archive.addfile(item, io.BytesIO(data))
|
||||
return gzip.compress(stream.getvalue(), mtime=0)
|
||||
|
||||
|
||||
def ar_member(name, data):
|
||||
header = f"{name + '/':<16}{0:<12}{0:<6}{0:<6}{'100644':<8}{len(data):<10}`\n".encode()
|
||||
assert len(header) == 60
|
||||
return header + data + (b"\n" if len(data) % 2 else b"")
|
||||
|
||||
|
||||
def build(binary, destination):
|
||||
payload = binary.read_bytes()
|
||||
if payload[:4] != b"\x7fELF" or payload[4:6] != b"\x02\x01" or payload[18:20] != b"\x3e\x00":
|
||||
@@ -65,7 +44,7 @@ Architecture: amd64
|
||||
Maintainer: NODE.DC local build <noreply@example.invalid>
|
||||
Section: admin
|
||||
Priority: optional
|
||||
Depends: adduser, systemd, python3, python3-gi, gir1.2-gtk-3.0, gir1.2-webkit2-4.1, pkexec, polkitd, ca-certificates, hicolor-icon-theme
|
||||
Depends: adduser, systemd (>= 255), python3 (>= 3.12), python3 (<< 3.13), python3-gi, gir1.2-gtk-3.0, gir1.2-webkit2-4.1, pkexec, polkitd, ca-certificates, hicolor-icon-theme, network-manager, iproute2, python3-psycopg2, postgresql-16 (>= 16.15), timescaledb-2-oss-postgresql-16 (= 2.29.2~ubuntu24.04-1615), udev, libc6 (>= 2.35), libstdc++6 (>= 12), libgcc-s1, zlib1g
|
||||
Description: Mission Core onboard computer configuration
|
||||
Local graphical setup, host inventory, SSH access and persistent node identity.
|
||||
""".encode()
|
||||
@@ -89,14 +68,34 @@ Description: Mission Core onboard computer configuration
|
||||
("environment_helper.py", "usr/lib/mission-core-node/environment_helper.py", 0o644),
|
||||
("mission-core-node-environment.service", "usr/lib/systemd/system/mission-core-node-environment.service", 0o644),
|
||||
("60-environment.conf", "usr/share/mission-core-node/60-environment.conf", 0o644),
|
||||
("setup-monitor", "usr/lib/mission-core-node/setup-monitor", 0o755),
|
||||
("mission-core-node-monitor.service", "usr/lib/systemd/system/mission-core-node-monitor.service", 0o644),
|
||||
]:
|
||||
files.append((path, (p / source).read_bytes(), mode))
|
||||
for path in sorted((ROOT / 'monitor').iterdir()):
|
||||
if path.suffix in {'.py', '.sql'}:
|
||||
files.append(('usr/lib/mission-core-node/monitor/' + path.name, path.read_bytes(), 0o644))
|
||||
files.append(("usr/share/mission-core-node/environment-profile.json", (ROOT / "internal/node/environment-profile.json").read_bytes(), 0o644))
|
||||
for name in ("realsense_prepare.py", "realsense_iio_access.py"):
|
||||
files.append(("usr/lib/mission-core-node/" + name, (p / name).read_bytes(), 0o644))
|
||||
for name in ("mission-core-realsense.service", "mission-core-node-realsense-prepare.service"):
|
||||
files.append(("usr/lib/systemd/system/" + name, (p / name).read_bytes(), 0o644))
|
||||
files.append(("usr/share/polkit-1/rules.d/50-mission-core-device-prepare.rules", (p / "50-mission-core-device-prepare.rules").read_bytes(), 0o644))
|
||||
# Node owns the first-use bootstrap; the optional model package owns its
|
||||
# SDK, worker processes, USB grants and runtime preparation. No overlapping
|
||||
# dpkg file ownership and no compiler/download prerequisite at first use.
|
||||
profile_raw = (p / "insta360-profile.json").read_bytes()
|
||||
profile = json.loads(profile_raw)
|
||||
package_name = "mission-core-insta360-x4_" + profile["version"] + "_amd64.deb"
|
||||
x4 = (ROOT / "build/model-packages" / package_name).read_bytes()
|
||||
if len(x4) != profile["bytes"] or hashlib.sha256(x4).hexdigest() != profile["sha256"]:
|
||||
raise ValueError("Bundled X4 package differs from the admitted release")
|
||||
files.extend([
|
||||
("usr/lib/mission-core-node/insta360_profile.py", (p / "insta360_profile.py").read_bytes(), 0o644),
|
||||
("usr/lib/systemd/system/mission-core-node-insta360-x4-profile.service", (p / "mission-core-node-insta360-x4-profile.service").read_bytes(), 0o644),
|
||||
("usr/share/mission-core-node/profiles/insta360-x4/profile.json", profile_raw, 0o644),
|
||||
("usr/share/mission-core-node/profiles/insta360-x4/" + package_name, x4, 0o644),
|
||||
])
|
||||
files.append(("usr/share/mission-core-node/realsense/70-mission-core-realsense.rules", (p / "70-mission-core-realsense.rules").read_bytes(), 0o644))
|
||||
bundle = json.loads((p / "realsense-bundle.json").read_text())
|
||||
files.append(("usr/share/mission-core-node/realsense/bundle.json", (p / "realsense-bundle.json").read_bytes(), 0o644))
|
||||
@@ -112,7 +111,7 @@ Description: Mission Core onboard computer configuration
|
||||
files.append(("usr/lib/mission-core-node/sdk/missioncore_plugin_sdk/" + str(path.relative_to(sdk)), path.read_bytes(), 0o644))
|
||||
if (ROOT / "build/provenance.json").exists():
|
||||
files.append(("usr/share/doc/mission-core-node/provenance.json", (ROOT / "build/provenance.json").read_bytes(), 0o644))
|
||||
archive = b"!<arch>\n" + ar_member("debian-binary", b"2.0\n") + ar_member("control.tar.gz", tarball(controls)) + ar_member("data.tar.gz", tarball(files))
|
||||
archive = package(controls, files)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_bytes(archive)
|
||||
digest = hashlib.sha256(archive).hexdigest()
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Snapshot the reviewed Node/Core sources and Linux build inputs for the Mini."""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import stat
|
||||
import subprocess
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
NODE = Path(__file__).resolve().parents[1]
|
||||
REPO = NODE.parents[1]
|
||||
DG = REPO.parent / "NODEDC_DESIGN_GUIDELINE"
|
||||
DG_COMMIT = "8c53f73ee521561a1cc7944a7f7cf113702f40b5"
|
||||
|
||||
|
||||
def files(root):
|
||||
values = (
|
||||
subprocess.check_output(
|
||||
["git", "ls-files", "-c", "-o", "--exclude-standard", "-z"], cwd=root
|
||||
)
|
||||
.decode()
|
||||
.split("\0")
|
||||
)
|
||||
for name in sorted(set(values)):
|
||||
path = root / name
|
||||
if not name or name.startswith(".codex/") or not path.is_file():
|
||||
continue
|
||||
if path.is_symlink() or not path.resolve().is_relative_to(root):
|
||||
raise ValueError("Unexpected source link")
|
||||
yield path
|
||||
|
||||
|
||||
def build(qualified, node_only=False):
|
||||
commit = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=DG, text=True).strip()
|
||||
if commit != DG_COMMIT:
|
||||
raise ValueError("Design Guideline revision differs from the admitted source")
|
||||
paths = list(files(REPO))
|
||||
# Build DG from its own pinned workspace lock. Its generated dist and
|
||||
# transitive dependencies are not copied from a developer installation.
|
||||
paths += [
|
||||
path
|
||||
for path in files(DG)
|
||||
if path.relative_to(DG).parts[0]
|
||||
in ("packages", "registry", "docs", "scripts", "server", "apps")
|
||||
]
|
||||
paths += [
|
||||
DG / name
|
||||
for name in (
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"tsconfig.base.json",
|
||||
"apps/catalog/package.json",
|
||||
)
|
||||
]
|
||||
lock = json.loads((NODE / "packaging/linux-toolchains.json").read_text())
|
||||
paths += [NODE / "build/linux-toolchains" / item["name"] for item in lock["files"]]
|
||||
for item in lock["files"]:
|
||||
data = (NODE / "build/linux-toolchains" / item["name"]).read_bytes()
|
||||
if hashlib.sha256(data).hexdigest() != item["sha256"]:
|
||||
raise ValueError("Build toolchain changed")
|
||||
wheel_lock = json.loads((NODE / "packaging/realsense-bundle.json").read_text())
|
||||
paths += [NODE / "build/realsense-wheels" / item["name"] for item in wheel_lock["wheels"]]
|
||||
profile = json.loads((NODE / "packaging/insta360-profile.json").read_text())
|
||||
package_name = "mission-core-insta360-x4_" + profile["version"] + "_amd64.deb"
|
||||
if qualified.name != package_name:
|
||||
raise ValueError("Unexpected model package name")
|
||||
package = qualified.read_bytes()
|
||||
if len(package) != profile["bytes"] or hashlib.sha256(package).hexdigest() != profile["sha256"]:
|
||||
raise ValueError("Qualified model package changed")
|
||||
entries = {str(path.relative_to(REPO.parent)): path for path in sorted(set(paths))}
|
||||
# The source artifact owns this build staging copy; no manual copy on the
|
||||
# board, runtime path override or operator compiler dependency is needed.
|
||||
virtual = str(REPO.name + "/apps/node-agent/build/model-packages/" + package_name)
|
||||
entries[virtual] = qualified
|
||||
metadata = {}
|
||||
for name, path in entries.items():
|
||||
data = path.read_bytes()
|
||||
if len(data) > 100 * 1024 * 1024:
|
||||
raise ValueError("Source file exceeds the artifact bound")
|
||||
metadata[name] = {
|
||||
"bytes": len(data),
|
||||
"sha256": hashlib.sha256(data).hexdigest(),
|
||||
"mode": 0o755 if path.stat().st_mode & stat.S_IXUSR else 0o644,
|
||||
}
|
||||
entrypoint = (NODE / "packaging/linux_build_entry.py").read_bytes()
|
||||
manifest = {
|
||||
"schema": "missioncore.node.linux-build-source/v1",
|
||||
"profile": "node-only" if node_only else "node-core",
|
||||
"repository": REPO.name,
|
||||
"base_commit": subprocess.check_output(
|
||||
["git", "rev-parse", "HEAD"], cwd=REPO, text=True
|
||||
).strip(),
|
||||
"design_guideline_commit": commit,
|
||||
"toolchains": lock,
|
||||
"entrypoint_sha256": hashlib.sha256(entrypoint).hexdigest(),
|
||||
"files": metadata,
|
||||
}
|
||||
raw = json.dumps(manifest, sort_keys=True, indent=2).encode() + b"\n"
|
||||
identifier = hashlib.sha256(raw).hexdigest()[:24]
|
||||
output = NODE / "build" / ("mission-core-node-linux-build-" + identifier + ".pyz")
|
||||
with output.open("xb") as stream, zipfile.ZipFile(stream, "w") as archive:
|
||||
for name, data in (("__main__.py", entrypoint), ("source.json", raw)):
|
||||
archive.writestr(name, data)
|
||||
for name, path in entries.items():
|
||||
archive.write(
|
||||
path,
|
||||
"source/" + name,
|
||||
compress_type=zipfile.ZIP_DEFLATED
|
||||
if path.stat().st_size < 4 * 1024 * 1024
|
||||
else zipfile.ZIP_STORED,
|
||||
)
|
||||
output.chmod(0o600)
|
||||
return {
|
||||
"artifact": str(output),
|
||||
"source_id": identifier,
|
||||
"files": len(entries),
|
||||
"bytes": output.stat().st_size,
|
||||
"sha256": hashlib.sha256(output.read_bytes()).hexdigest(),
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--model-package", type=Path, required=True)
|
||||
parser.add_argument("--node-only", action="store_true")
|
||||
args = parser.parse_args()
|
||||
print(json.dumps(build(args.model_package.resolve(), args.node_only)))
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Package the Ubuntu-qualified Node installer with the shared release launcher."""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
REPO = ROOT.parents[1]
|
||||
|
||||
|
||||
def build(qualified):
|
||||
evidence = json.loads((qualified / "qualification.json").read_text())
|
||||
if evidence["state"] != "complete":
|
||||
raise ValueError("A completed Ubuntu qualification is required")
|
||||
candidates = [name for name in evidence["artifacts"] if name.startswith("mission-core-node_")]
|
||||
if len(candidates) != 1:
|
||||
raise ValueError("One qualified Node package is required")
|
||||
package = candidates[0]
|
||||
match = re.fullmatch(
|
||||
r"mission-core-node_([0-9]+\.[0-9]+\.[0-9]+(?:-[1-9][0-9]*)?)_amd64\.deb", package
|
||||
)
|
||||
if not match:
|
||||
raise ValueError("Invalid package name")
|
||||
payload = (qualified / package).read_bytes()
|
||||
expected = evidence["artifacts"][package]
|
||||
if (
|
||||
len(payload) != expected["bytes"]
|
||||
or hashlib.sha256(payload).hexdigest() != expected["sha256"]
|
||||
):
|
||||
raise ValueError("Qualified Node package changed")
|
||||
files = {
|
||||
package: payload,
|
||||
"install": (ROOT / "packaging/install-owner-release").read_bytes(),
|
||||
"install_release.py": (ROOT / "packaging/install_owner_release.py").read_bytes(),
|
||||
}
|
||||
entry = (REPO / "plugins/insta360-x4/packaging/owner_release_entry.py").read_bytes()
|
||||
manifest = {
|
||||
"schema": "missioncore.node.owner-release/v1",
|
||||
"version": match[1],
|
||||
"qualification_profile": "node-package-and-local-ui",
|
||||
"qualification_sha256": hashlib.sha256(
|
||||
(qualified / "qualification.json").read_bytes()
|
||||
).hexdigest(),
|
||||
"clean_image_qualified": False,
|
||||
"entrypoint_sha256": hashlib.sha256(entry).hexdigest(),
|
||||
"files": {
|
||||
name: {"bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()}
|
||||
for name, data in files.items()
|
||||
},
|
||||
}
|
||||
raw = json.dumps(manifest, indent=2, sort_keys=True).encode() + b"\n"
|
||||
identifier = hashlib.sha256(raw).hexdigest()[:24]
|
||||
output = ROOT / "build" / ("mission-core-node-install-" + identifier + ".pyz")
|
||||
with output.open("xb") as stream, zipfile.ZipFile(stream, "w") as archive:
|
||||
for name, data in {"__main__.py": entry, "release.json": raw, **files}.items():
|
||||
archive.writestr(name, data)
|
||||
output.chmod(0o600)
|
||||
return {
|
||||
"artifact": str(output),
|
||||
"release_id": identifier,
|
||||
"bytes": output.stat().st_size,
|
||||
"sha256": hashlib.sha256(output.read_bytes()).hexdigest(),
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--qualified", type=Path, required=True)
|
||||
print(json.dumps(build(parser.parse_args().qualified)))
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Versioned Python/Go X509 interoperability check; synthetic public certificates only."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
import zipfile
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
GO_SOURCE = r"""package main
|
||||
import (
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"os"
|
||||
)
|
||||
func cert(name string) *x509.Certificate {
|
||||
raw, err := os.ReadFile(name); if err != nil { panic(err) }
|
||||
block, _ := pem.Decode(raw); if block == nil { panic("invalid fixture") }
|
||||
value, err := x509.ParseCertificate(block.Bytes); if err != nil { panic(err) }; return value
|
||||
}
|
||||
func main() {
|
||||
root := cert("root.pem"); bad := cert("legacy.pem"); good := cert("fixed.pem")
|
||||
roots := x509.NewCertPool(); roots.AddCert(root)
|
||||
options := x509.VerifyOptions{
|
||||
Roots: roots, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
|
||||
}
|
||||
_, legacyErr := bad.Verify(options)
|
||||
if _, ok := legacyErr.(x509.UnknownAuthorityError); !ok { panic("legacy failure not reproduced") }
|
||||
if _, err := good.Verify(options); err != nil { panic(err) }
|
||||
if string(good.RawSubjectPublicKeyInfo) != string(root.RawSubjectPublicKeyInfo) {
|
||||
panic("Core key changed")
|
||||
}
|
||||
if string(good.RawSubject) == string(root.RawSubject) { panic("ambiguous leaf subject") }
|
||||
json.NewEncoder(os.Stdout).Encode(map[string]any{"legacy_rejected":true,"fixed_verified":true,"core_key_preserved":true})
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def digest(data):
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def build():
|
||||
from cryptography import x509
|
||||
from cryptography.x509.oid import ExtendedKeyUsageOID
|
||||
|
||||
from k1link.fleet.recovery import client_context
|
||||
from k1link.fleet.trust import CoreTrust, pem
|
||||
|
||||
node = Path(__file__).resolve().parents[1]
|
||||
lock = json.loads((node / "packaging/linux-toolchains.json").read_text())
|
||||
go = next(item for item in lock["files"] if item["directory"] == "go")
|
||||
toolchain = (node / "build/linux-toolchains" / go["name"]).read_bytes()
|
||||
if digest(toolchain) != go["sha256"]:
|
||||
raise ValueError("Pinned toolchain changed")
|
||||
with tempfile.TemporaryDirectory(prefix="missioncore-synthetic-ca-") as temporary:
|
||||
trust = CoreTrust(Path(temporary))
|
||||
client_context(trust)
|
||||
fixed = x509.load_pem_x509_certificates(
|
||||
(Path(temporary) / "recovery-client.pem").read_bytes()
|
||||
)[0]
|
||||
legacy = (
|
||||
trust.builder(trust.ca.subject, trust.key.public_key(), 1)
|
||||
.issuer_name(trust.ca.subject)
|
||||
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
|
||||
.add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.CLIENT_AUTH]), critical=False)
|
||||
.sign(trust.key, None)
|
||||
)
|
||||
files = {
|
||||
"root.pem": pem(trust.ca).encode(),
|
||||
"fixed.pem": pem(fixed).encode(),
|
||||
"legacy.pem": pem(legacy).encode(),
|
||||
"check.go": GO_SOURCE.encode(),
|
||||
"go.tar.gz": toolchain,
|
||||
}
|
||||
entry = Path(__file__).read_bytes()
|
||||
manifest = {
|
||||
"schema": "missioncore.channel-x509-check/v1",
|
||||
"toolchain": go,
|
||||
"entry_sha256": digest(entry),
|
||||
"files": {
|
||||
name: {"bytes": len(data), "sha256": digest(data)} for name, data in files.items()
|
||||
},
|
||||
}
|
||||
raw = json.dumps(manifest, sort_keys=True).encode()
|
||||
identifier = digest(raw)[:24]
|
||||
path = node / "build" / ("mission-core-channel-check-" + identifier + ".pyz")
|
||||
with path.open("xb") as stream, zipfile.ZipFile(stream, "w") as archive:
|
||||
for name, data in {"__main__.py": entry, "check.json": raw, **files}.items():
|
||||
archive.writestr(name, data)
|
||||
path.chmod(0o600)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"artifact": str(path),
|
||||
"id": identifier,
|
||||
"bytes": path.stat().st_size,
|
||||
"sha256": digest(path.read_bytes()),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def run():
|
||||
if os.geteuid() == 0 or (platform.system(), platform.machine()) != ("Linux", "x86_64"):
|
||||
raise ValueError("Use the unprivileged Ubuntu account")
|
||||
if sys.argv[1:] not in ([], ["--job"], ["--clean"]):
|
||||
raise ValueError("Only the fixed certificate check is allowed")
|
||||
os.umask(0o077)
|
||||
artifact = Path(sys.argv[0]).resolve()
|
||||
with zipfile.ZipFile(artifact) as archive:
|
||||
raw = archive.read("check.json")
|
||||
manifest = json.loads(raw)
|
||||
names = {"root.pem", "legacy.pem", "fixed.pem", "check.go", "go.tar.gz"}
|
||||
if (
|
||||
manifest["schema"] != "missioncore.channel-x509-check/v1"
|
||||
or set(manifest["files"]) != names
|
||||
or set(archive.namelist()) != names | {"__main__.py", "check.json"}
|
||||
or len(archive.namelist()) != 7
|
||||
or digest(archive.read("__main__.py")) != manifest["entry_sha256"]
|
||||
):
|
||||
raise ValueError("Unexpected check artifact")
|
||||
identifier = digest(raw)[:24]
|
||||
root = Path("/var/tmp/mission-core-channel-checks")
|
||||
folder = root / identifier
|
||||
for path in (root, folder):
|
||||
path.mkdir(mode=0o700, exist_ok=True)
|
||||
info = path.lstat()
|
||||
if path.is_symlink() or info.st_uid != os.geteuid() or info.st_mode & 0o077:
|
||||
raise ValueError("Check directory is not private and owned")
|
||||
if sys.argv[1:] == ["--clean"]:
|
||||
if not (folder / "report.json").exists():
|
||||
raise ValueError("Preserve a completed attempt before cleanup")
|
||||
shutil.rmtree(folder)
|
||||
print(json.dumps({"cleaned": identifier}))
|
||||
return
|
||||
if not sys.argv[1:]:
|
||||
if any(folder.iterdir()):
|
||||
raise ValueError("Preserve the prior attempt before retry")
|
||||
for name, expected in manifest["files"].items():
|
||||
data = archive.read(name)
|
||||
if len(data) != expected["bytes"] or digest(data) != expected["sha256"]:
|
||||
raise ValueError("Check input changed")
|
||||
(folder / name).write_bytes(data)
|
||||
(folder / "report.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"state": "staging",
|
||||
"id": identifier,
|
||||
"started_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
with tarfile.open(folder / "go.tar.gz") as tar:
|
||||
members = tar.getmembers()
|
||||
if len(members) > 40000 or sum(m.size for m in members) > 1024**3:
|
||||
raise ValueError("Toolchain extraction exceeds bound")
|
||||
if any(
|
||||
Path(m.name).is_absolute()
|
||||
or Path(m.name).parts[0] != "go"
|
||||
or ".." in Path(m.name).parts
|
||||
for m in members
|
||||
):
|
||||
raise ValueError("Unexpected toolchain path")
|
||||
tar.extractall(folder, members=members, filter="data")
|
||||
subprocess.run(
|
||||
[
|
||||
"/usr/bin/systemd-run",
|
||||
"--user",
|
||||
"--quiet",
|
||||
"--collect",
|
||||
"--wait",
|
||||
"--pipe",
|
||||
"--unit=mission-core-channel-check-" + identifier,
|
||||
"--property=MemoryMax=1G",
|
||||
"--property=CPUQuota=150%",
|
||||
"--property=TasksMax=128",
|
||||
"--property=RuntimeMaxSec=180",
|
||||
"--property=KillMode=control-group",
|
||||
"--property=Nice=10",
|
||||
"/usr/bin/python3",
|
||||
str(artifact),
|
||||
"--job",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
print((folder / "report.json").read_text())
|
||||
return
|
||||
group = next(
|
||||
line.split(":", 2)[2]
|
||||
for line in Path("/proc/self/cgroup").read_text().splitlines()
|
||||
if line.startswith("0::")
|
||||
)
|
||||
control = Path("/sys/fs/cgroup") / group.lstrip("/")
|
||||
quota, period = map(int, (control / "cpu.max").read_text().split())
|
||||
if (
|
||||
int((control / "memory.max").read_text()) > 1024**3
|
||||
or quota / period > 1.5
|
||||
or int((control / "pids.max").read_text()) > 128
|
||||
):
|
||||
raise ValueError("Check limits not enforced")
|
||||
started = time.monotonic()
|
||||
report = {
|
||||
"schema": manifest["schema"],
|
||||
"id": identifier,
|
||||
"started_at": datetime.now(UTC).isoformat(),
|
||||
"monotonic": started,
|
||||
"artifact_sha256": digest(artifact.read_bytes()),
|
||||
}
|
||||
env = {
|
||||
"PATH": str(folder / "go/bin") + ":/usr/bin:/bin",
|
||||
"GOENV": "off",
|
||||
"GOCACHE": str(folder / "cache"),
|
||||
"GOPATH": str(folder / "gopath"),
|
||||
"GOTOOLCHAIN": "local",
|
||||
"GOPROXY": "off",
|
||||
"GOMAXPROCS": "2",
|
||||
"GOMEMLIMIT": "512MiB",
|
||||
"CGO_ENABLED": "0",
|
||||
"GOWORK": "off",
|
||||
}
|
||||
# No SDK, USB, installed files, network fetches, or real host credentials.
|
||||
result = subprocess.run(
|
||||
[str(folder / "go/bin/go"), "run", "-p", "1", "check.go"],
|
||||
cwd=folder,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
timeout=150,
|
||||
)
|
||||
(folder / "stdout").write_bytes(result.stdout)
|
||||
(folder / "stderr").write_bytes(result.stderr)
|
||||
report.update(
|
||||
state="complete" if result.returncode == 0 else "error",
|
||||
exit_code=result.returncode,
|
||||
duration_seconds=time.monotonic() - started,
|
||||
finished_at=datetime.now(UTC).isoformat(),
|
||||
stdout_sha256=digest(result.stdout),
|
||||
stderr_sha256=digest(result.stderr),
|
||||
)
|
||||
if result.returncode == 0:
|
||||
report["checks"] = json.loads(result.stdout)
|
||||
(folder / "report.json").write_text(json.dumps(report, indent=2) + "\n")
|
||||
if result.returncode:
|
||||
raise RuntimeError("Certificate interop check failed; inspect private report")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if sys.argv[1:] == ["--build"]:
|
||||
build()
|
||||
else:
|
||||
run()
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Run only synthetic X4 bootstrap checks in disposable unprivileged Ubuntu staging."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import zipfile
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
FILES = {
|
||||
"apps/node-agent/packaging/insta360_profile.py",
|
||||
"plugins/insta360-x4/tests/check_profile.py",
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
if os.geteuid() == 0 or platform.freedesktop_os_release().get("VERSION_ID") != "24.04":
|
||||
raise ValueError("Run as an ordinary Ubuntu 24.04 user")
|
||||
started, monotonic = datetime.now(UTC).isoformat(), time.monotonic()
|
||||
with zipfile.ZipFile(sys.argv[0]) as archive:
|
||||
manifest = json.loads(archive.read("manifest.json"))
|
||||
if set(archive.namelist()) != FILES | {"__main__.py", "manifest.json"} or set(
|
||||
manifest
|
||||
) != FILES | {"__main__.py"}:
|
||||
raise ValueError("Unexpected check artifact")
|
||||
payload = {name: archive.read(name) for name in manifest}
|
||||
if any(hashlib.sha256(raw).hexdigest() != manifest[name] for name, raw in payload.items()):
|
||||
raise ValueError("Check artifact changed")
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="mission-core-x4-profile-check-", dir="/var/tmp"
|
||||
) as folder:
|
||||
root = Path(folder)
|
||||
for name in FILES:
|
||||
target = root / name
|
||||
target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
target.write_bytes(payload[name])
|
||||
(root / "plugins/insta360-x4/build").mkdir(mode=0o700)
|
||||
result = subprocess.run(
|
||||
["/usr/bin/python3", "-I", str(root / "plugins/insta360-x4/tests/check_profile.py")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"schema": "missioncore.node.x4-profile-check/v1",
|
||||
"started_at": started,
|
||||
"monotonic_started": monotonic,
|
||||
"duration_seconds": time.monotonic() - monotonic,
|
||||
"state": "complete" if result.returncode == 0 else "error",
|
||||
"files": manifest,
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
}
|
||||
)
|
||||
)
|
||||
return result.returncode
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user