Author SHA1 Message Date
DCCONSTRUCTIONS be58d589e2 feat(fleet): add board observation center with live sources and maps
Add adaptive per-vehicle layouts, full-panel dragging, source controls and automatic preview recovery for RealSense, Insta360 X4 and XGRIDS K1 observation views. Integrate cached Cesium map layers through the private NAS gateway link, retain bounded sensor command receipts, and document validation and operational handoff.
2026-09-10 22:12:05 +03:00
DCCONSTRUCTIONS 5f75afb720 docs(node): record current handoff status and X4 acceptance boundaries 2026-09-10 16:39:29 +03:00
DCCONSTRUCTIONS 99f29218d7 feat(x4): add per-camera manual wake and hide power action when connected 2026-09-10 16:27:48 +03:00
DCCONSTRUCTIONS db14ee5435 Record repeatable X4 reconnect and prepare recovery cancellation test 2026-09-10 14:36:07 +03:00
DCCONSTRUCTIONS 81dd44906d Record first X4 USB reconnect and automatic preview acceptance 2026-09-10 14:28:34 +03:00
DCCONSTRUCTIONS d45b329de8 Add packaged per-camera X4 wake and preview recovery 2026-09-10 14:20:52 +03:00
DCCONSTRUCTIONS f164606828 Add bounded X4 Bluetooth wake and record Android recovery proof 2026-09-10 12:48:09 +03:00
DCCONSTRUCTIONS 556e376a62 Record X4 wake by short Power with USB retained and BLE discovery 2026-09-10 12:08:01 +03:00
DCCONSTRUCTIONS 1a2de8df65 Package bounded X4 BLE diagnostics and USB mode reader 2026-09-10 11:59:00 +03:00
DCCONSTRUCTIONS b536f0049a Document X4 Android reconnect protocol research and recovery options 2026-09-10 11:25:07 +03:00
DCCONSTRUCTIONS 3b85299fd9 Document X4 USB power limits from root hub diagnostics 2026-09-10 11:04:04 +03:00
DCCONSTRUCTIONS 505844adc3 Record failed X4 USB reconnect with preview stopped 2026-09-10 10:44:27 +03:00
DCCONSTRUCTIONS 89b0eeca3f Record automatic X4 USB mode and two-camera viewing confirmation 2026-09-10 10:27:40 +03:00
DCCONSTRUCTIONS 1488a46794 Document X4 USB mode recovery and verified video after restart 2026-09-10 10:09:29 +03:00
DCCONSTRUCTIONS e4339f4e88 Record B11 hardware acceptance and X4 USB reconnect failure 2026-09-10 09:47:34 +03:00
DCCONSTRUCTIONS 7e257275ab Record X4 checkpoint and Ops implementation report
Link the committed Core/Design Guideline pair to MISSIONCOR-77 and preserve
MISSIONCOR-76 baseline through an appended report. Record successful MCP
readback, qualified source comparison and remaining hardware acceptance.
2026-09-10 09:25:12 +03:00
DCCONSTRUCTIONS a3c15e11e9 Add packaged Insta360 X4 integration and recover paired Node channels
Discover independent camera instances and prepare their versioned runtime
from Node or remote Core. Add isolated SDK workers, camera controls, raw
dual-fisheye WebRTC preview, and shared action/region loading states.

Recover existing Node bindings over known Tailscale addresses after a Core
LAN address change. Preserve identities and trust, pin both peers, migrate
endpoints with revision checks, and require real heartbeats for online status.
Fix the Python client certificate profile for Go X509 verification.

Pin Design Guideline 8c53f73 and retain installer/build/acceptance history.
Node 0.8.19 is installed; X4 0.1.3-3 is bundled but hardware activation is pending.

Validation: qualified DG/Node builds and Go race tests; 31 fleet tests;
Python-to-Go certificate interoperability and live tailnet recovery with five
fresh heartbeats; prior 38 X4 tests and bounded remote WebRTC acceptance.
Clean-OS, replug/power autonomy, local X4 video and long-run stability remain open.
2026-09-10 09:21:24 +03:00
DCCONSTRUCTIONS 54a85fdf50 Record installed R19 recovery and onboard K1 acceptance 2026-09-08 11:01:59 +03:00
DCCONSTRUCTIONS 14f8375713 Repair monitor cluster upgrade without blocking K1 package configuration 2026-09-08 10:44:27 +03:00
DCCONSTRUCTIONS 6492ebcdee Record R18 evidence and retain signed telemetry values in plots 2026-09-08 01:53:30 +03:00
DCCONSTRUCTIONS e57511f975 Keep K1 runtime payload manifest in canonical order 2026-09-08 01:41:27 +03:00
DCCONSTRUCTIONS f0802d2713 Fix onboard WebKit preview and archive board telemetry locally 2026-09-08 01:37:52 +03:00
DCCONSTRUCTIONS d8afb61229 Record successful Node R17 installation on owner Ubuntu board 2026-09-07 23:28:46 +03:00
DCCONSTRUCTIONS 421a8c515a Record R17 package validation and pending onboard transfer 2026-09-07 23:03:11 +03:00
DCCONSTRUCTIONS a23c5b2005 Reuse canonical home and settings on Node and admit local K1 viewer 2026-09-07 22:55:13 +03:00
DCCONSTRUCTIONS 1832943558 Record verified Ops publication of onboard K1 laboratory checkpoint 2026-09-07 21:50:38 +03:00
DCCONSTRUCTIONS cc90709919 Record owner-confirmed onboard K1 Bridge laboratory checkpoint 2026-09-07 21:43:19 +03:00
DCCONSTRUCTIONS ee5d760c30 Verify R16 onboard installation and service health 2026-09-07 21:27:40 +03:00
DCCONSTRUCTIONS 0e9faa6824 Record inspected R16 artifacts and pending onboard acceptance 2026-09-07 21:22:41 +03:00
DCCONSTRUCTIONS c6693d6f44 Retain K1 connection between scans and admit the next named acquisition 2026-09-07 21:13:17 +03:00
DCCONSTRUCTIONS ed9a77ba73 Record verified R15 installation after owner restart 2026-09-07 20:16:42 +03:00
DCCONSTRUCTIONS 7dd2c1c836 Record verified R15 packages and pending onboard acceptance 2026-09-07 20:13:37 +03:00
DCCONSTRUCTIONS 762d77ef95 Unify K1 discovery ownership and verification across enrollment paths 2026-09-07 20:04:46 +03:00
DCCONSTRUCTIONS 9c4d70b0e9 Record R14 public artifacts and staged installation scope 2026-09-07 19:04:52 +03:00
DCCONSTRUCTIONS eed53dbc0b Fix onboard K1 enrollment continuity and share named acquisition preparation 2026-09-07 18:53:38 +03:00
DCCONSTRUCTIONS 4f333fc11c Record verified R13 installation on onboard computer 2026-09-07 17:59:36 +03:00
DCCONSTRUCTIONS 35a517f921 Record R13 artifact provenance and pending installation acceptance 2026-09-07 17:54:13 +03:00
DCCONSTRUCTIONS 9bba44f7c4 Preserve onboard preview recordings across backpressure and share the complete spatial scene 2026-09-07 17:47:10 +03:00
DCCONSTRUCTIONS 9b6534287a Record R12 update artifacts and pending clean-cache acceptance 2026-09-07 16:53:32 +03:00
DCCONSTRUCTIONS 9e03404f23 Admit onboard camera preview only after its acquisition producer is ready 2026-09-07 16:44:38 +03:00
DCCONSTRUCTIONS fa8ac760a1 Share the K1 live scene template and preserve idle media channels 2026-09-07 16:38:39 +03:00
DCCONSTRUCTIONS 309bdf759e Confirm installed K1 R11 runtime and authority availability 2026-09-07 15:57:53 +03:00
DCCONSTRUCTIONS ff00851216 Record staged K1 R11 artifact and acceptance gate 2026-09-07 15:54:38 +03:00
DCCONSTRUCTIONS ad24518b71 Refresh selected BlueZ path before K1 GATT operations 2026-09-07 15:50:06 +03:00
DCCONSTRUCTIONS 7ce09eabcd Record R10 release hashes and remaining acceptance gates 2026-09-07 15:32:56 +03:00
DCCONSTRUCTIONS 92b60de945 Preserve RRD preview frames and clarify onboard K1 status 2026-09-07 15:27:13 +03:00
DCCONSTRUCTIONS 67398fef10 Record staged R9 packages and pending installed acceptance 2026-09-07 14:55:28 +03:00
DCCONSTRUCTIONS a93f1f1b8b Accept systemd credential delivery and clarify manual K1 control 2026-09-07 14:44:46 +03:00
DCCONSTRUCTIONS 102e93796b Record staged K1 causal diagnostics release R8 2026-09-07 14:16:18 +03:00
DCCONSTRUCTIONS 4f715e41d2 Preserve native K1 error causes and distinguish Wi-Fi actions 2026-09-07 14:11:34 +03:00
DCCONSTRUCTIONS 8f17a7e282 Confirm installed Linux route contract fix R7 2026-09-07 13:55:50 +03:00
DCCONSTRUCTIONS 7187fd86bd Record prepared Linux route contract fix release R7 2026-09-07 13:53:03 +03:00
DCCONSTRUCTIONS e6521e855a Fix Linux route probe contract and expose K1 verification after Wi-Fi apply 2026-09-07 13:46:01 +03:00
DCCONSTRUCTIONS 06d216ac4a Record installed wireless steps and diagnostic release R6 2026-09-07 13:35:25 +03:00
DCCONSTRUCTIONS d7986cb62f Group onboard wireless steps and trace journalled Bluetooth failures 2026-09-07 13:27:21 +03:00
DCCONSTRUCTIONS a0732386f1 docs(node): record R5 package and pending Ubuntu authentication 2026-09-07 13:08:01 +03:00
DCCONSTRUCTIONS 5c4546a2a6 fix(node): clarify wireless enrollment and restore active K1 after upgrade 2026-09-07 13:02:28 +03:00
DCCONSTRUCTIONS ef1c1f50de docs(k1): record successful onboard Bluetooth discovery 2026-09-07 12:46:14 +03:00
DCCONSTRUCTIONS 2f80507bc0 docs(k1): record R4 onboard installation and pending hardware acceptance 2026-09-07 12:44:22 +03:00
DCCONSTRUCTIONS d2dbfc9c85 fix(k1): translate onboard operation identities at plugin boundary 2026-09-07 12:34:48 +03:00
DCCONSTRUCTIONS 3dffe39c89 Record installation of the onboard discovery fix 2026-09-07 12:20:27 +03:00
DCCONSTRUCTIONS 77acb377e2 Fix onboard BLE admission and stage wireless device enrollment 2026-09-07 12:11:48 +03:00
DCCONSTRUCTIONS 2fdf1d3cc4 Record private K1 installer release and Mini installation acceptance 2026-09-07 11:56:32 +03:00
DCCONSTRUCTIONS d8cc5367c4 Package onboard K1 separately with a private portable installer 2026-09-07 11:43:24 +03:00
DCCONSTRUCTIONS 111fe3dcfb docs(k1): record Node 0.7.1 installation and acceptance boundaries 2026-09-07 10:57:56 +03:00
DCCONSTRUCTIONS 63ea2bed67 feat(k1): stabilize LAB bridge and isolate onboard device integration 2026-09-07 10:31:33 +03:00
408 changed files with 44613 additions and 5204 deletions
+34
View File
@@ -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 owners 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
+5 -206
View File
@@ -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" };
}
+2 -2
View File
@@ -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>
+9 -9
View File
@@ -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
View File
@@ -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";
-1
View File
@@ -22,7 +22,6 @@
@import "./styles/responsive.css";
@import "./styles/observation.css";
@import "./styles/observatory.css";
@import "./styles/environment-settings.css";
@import "./styles/system-telemetry.css";
@import "./styles/artifact-health.css";
@import "./styles/map.css";
@@ -1,141 +0,0 @@
.environment-settings {
display: grid;
gap: 1rem;
}
.environment-settings__copy {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
}
.environment-settings__editor {
display: grid;
gap: 1rem;
}
.environment-settings__surface {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(18rem, 24rem);
align-items: center;
gap: 1rem;
}
.environment-settings__surface > span {
color: var(--nodedc-text-secondary);
font-size: 0.74rem;
font-weight: 650;
}
.environment-settings__surface .nodedc-select-anchor,
.environment-settings__surface .nodedc-select {
width: 100%;
}
.environment-settings__quick-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
}
.environment-settings__quick-actions > div {
display: grid;
gap: 0.4rem;
}
.environment-settings__quick-actions span {
color: var(--nodedc-text-secondary);
font-size: 0.68rem;
font-weight: 650;
}
.environment-settings__quick-actions .nodedc-select-anchor,
.environment-settings__quick-actions .nodedc-select {
width: 100%;
}
.environment-media-playlist {
display: grid;
min-width: 0;
gap: 0.85rem;
}
.environment-media-playlist__head {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.environment-media-playlist__head > div {
display: grid;
min-width: 0;
gap: 0.22rem;
}
.environment-media-playlist__head span {
color: var(--nodedc-text-secondary);
font-size: var(--nodedc-font-size-sm);
font-weight: var(--nodedc-font-weight-medium);
}
.environment-media-playlist__head p,
.environment-media-playlist__empty,
.environment-media-playlist__timing > span,
.environment-media-playlist__error {
margin: 0;
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
line-height: 1.4;
}
.environment-media-playlist__error {
color: rgb(var(--nodedc-danger-rgb));
}
.environment-media-playlist__items {
display: grid;
min-width: 0;
gap: 0.7rem;
}
.environment-media-playlist__item {
display: grid;
min-width: 0;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: 0.4rem;
}
.environment-media-playlist__item-actions {
display: flex;
align-items: center;
gap: 0.1rem;
padding-top: 1.65rem;
}
.environment-media-playlist__timing {
display: grid;
gap: 0.42rem;
}
@media (max-width: 760px) {
.environment-settings__copy,
.environment-settings__quick-actions {
grid-template-columns: 1fr;
}
.environment-settings__surface {
grid-template-columns: 1fr;
}
.environment-media-playlist__item {
grid-template-columns: minmax(0, 1fr);
}
.environment-media-playlist__item-actions {
justify-self: end;
padding-top: 0;
}
}
+1 -659
View File
@@ -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";
-113
View File
@@ -48,116 +48,3 @@
height: 100%;
overflow: hidden;
}
.landing-stage {
position: relative;
min-width: 0;
min-height: 0;
overflow: hidden;
border-radius: var(--nodedc-radius-card);
background: var(--station-stage);
}
.landing-stage__media,
.landing-stage__shade {
position: absolute;
inset: 0;
}
.landing-stage__media img,
.landing-stage__media video {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.landing-stage__shade {
z-index: 1;
background:
linear-gradient(90deg, rgb(5 6 8 / 0.82) 0%, rgb(5 6 8 / 0.54) 46%, rgb(5 6 8 / 0.24) 100%),
linear-gradient(0deg, rgb(5 6 8 / 0.58), transparent 38%);
pointer-events: none;
}
.landing-stage:not([data-has-media="true"]) .landing-stage__shade {
background:
radial-gradient(circle at 68% 42%, rgb(255 255 255 / 0.035), transparent 34%),
linear-gradient(90deg, rgb(5 6 8 / 0.2), transparent 62%);
}
.landing-stage__copy {
position: absolute;
z-index: 2;
top: 50%;
left: clamp(2rem, 5vw, 6rem);
width: min(39rem, 50%);
transform: translateY(-55%);
}
.landing-stage__copy h1 {
margin: 0.75rem 0 1rem;
color: var(--nodedc-text-primary);
font-size: clamp(3rem, 7vw, 7.6rem);
font-weight: 600;
letter-spacing: -0.072em;
line-height: 0.87;
}
.landing-stage__copy p {
max-width: 34rem;
margin: 0;
color: var(--nodedc-text-secondary);
font-size: clamp(0.8rem, 1vw, 1rem);
line-height: 1.6;
}
.landing-stage__actions {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
margin-top: 1.6rem;
}
.landing-stage__status {
position: absolute;
z-index: 2;
top: 2.4rem;
right: 2.4rem;
display: grid;
width: min(24rem, 30vw);
gap: 0.6rem;
border-radius: 1.25rem;
background: rgb(10 10 12 / 0.58);
padding: 1rem;
backdrop-filter: blur(18px);
}
.landing-stage__status > div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.landing-stage__status p {
margin: 0.15rem 0 0;
color: var(--nodedc-text-muted);
font-size: 0.66rem;
line-height: 1.45;
}
.landing-stage__footer {
position: absolute;
z-index: 2;
right: 2.4rem;
bottom: 1.8rem;
left: 2.4rem;
display: flex;
justify-content: space-between;
gap: 1rem;
color: var(--nodedc-text-muted);
font-size: 0.57rem;
font-weight: 780;
letter-spacing: 0.12em;
}
+1 -484
View File
@@ -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", "Пароль WiFi");
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", "Пароль WiFi").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="Пароль WiFi"[\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 к WiFi<\/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", "Пароль WiFi").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", "Пароль WiFi").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", "Пароль WiFi").props.type, "password");
assert.equal(elementByProp(tree, "label", "Показать пароль").props.disabled, true);
assert.equal(elementByProp(tree, "label", "Пароль WiFi").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:" "}));
});
+30 -7
View File
@@ -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"
]
}
+1
View File
@@ -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),
),
+10
View File
@@ -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)
})
}
}
+159
View File
@@ -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)
}
}
+33 -23
View File
@@ -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 = &copy
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 = &copy
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 &copy
}
data, err := os.ReadFile(model.Report)
if err != nil || len(data) > 32768 {
return &copy
}
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
}
copy.Steps = append(report.Steps, value.Steps[len(value.Steps)-1])
return &copy
}
+172 -121
View File
@@ -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, &copy)
}
}
}
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)
}
+30 -15
View File
@@ -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
+201
View File
@@ -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()
+103
View File
@@ -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)
+353
View File
@@ -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}
+13
View File
@@ -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;
+118
View File
@@ -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;
}
});
+6 -1
View File
@@ -11,13 +11,15 @@ import sys
from build_deb import build, VERSION, BRAND_SHA256
ROOT = Path(__file__).resolve().parents[1]
DG_COMMIT = "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}
+28 -29
View File
@@ -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