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
DCCONSTRUCTIONS 020a878915 docs(node): reconcile release status and build provenance for 0.6.11 2026-09-06 01:39:02 +03:00
DCCONSTRUCTIONS 404285419f fix(sensors): align device lamp with identity and refresh to end 2026-09-06 01:18:47 +03:00
DCCONSTRUCTIONS ba0c948ca1 fix(node): show camera connectivity and remove company header marks 2026-09-06 01:05:19 +03:00
DCCONSTRUCTIONS 429b4681fe fix(node): use compact guideline copy and plain setup checkmarks 2026-09-06 00:44:23 +03:00
DCCONSTRUCTIONS 22d69107ba feat(node): push USB changes and preserve sensor setup across sessions 2026-09-06 00:24:48 +03:00
DCCONSTRUCTIONS a8647c4d87 fix(node): verify D455 access and share sensor progress and recording UI 2026-09-05 23:25:11 +03:00
DCCONSTRUCTIONS b1aaa40508 Add D455 sensor host and shared Node/Core preparation surface 2026-09-05 22:27:07 +03:00
DCCONSTRUCTIONS 3616acc648 fix(node): clear consumed invitations after confirmed pairing 2026-09-05 21:37:56 +03:00
DCCONSTRUCTIONS e82d012907 feat(node): pair onboard computers with the Core fleet through UI 2026-09-05 21:16:13 +03:00
DCCONSTRUCTIONS fc545f8440 docs(node): proceed to pairing with clean-install acceptance deferred 2026-09-05 20:31:43 +03:00
DCCONSTRUCTIONS a98ef339de fix(node): show completed setup checks with a timestamp 2026-09-05 20:11:17 +03:00
DCCONSTRUCTIONS 9062126913 fix(node): make environment progress readable from the desktop 2026-09-05 19:54:08 +03:00
DCCONSTRUCTIONS 59e14c5bc0 feat(node): configure system environment through the desktop workflow 2026-09-05 19:46:52 +03:00
DCCONSTRUCTIONS 15bb793e5e fix(node): restore Linux network inventory and expose read failures 2026-09-05 18:47:12 +03:00
DCCONSTRUCTIONS 79911eb316 feat(node): consolidate board system views and plan fleet pairing 2026-09-05 18:35:57 +03:00
DCCONSTRUCTIONS d696842f5d feat(node): package Ubuntu desktop setup and trusted access 2026-09-05 17:27:59 +03:00
DCCONSTRUCTIONS 57f2537af3 Document AI Inference architecture and freeze current baseline 2026-09-05 10:05:01 +03:00
DCCONSTRUCTIONS e56eb42507 docs: inventory Rerun customizations and upstream dependencies 2026-09-05 09:21:28 +03:00
507 changed files with 61424 additions and 4981 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
+10 -211
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,16 +7,11 @@ import {
ApplicationPanel,
ApplicationShell,
Button,
Checker,
ColorField,
ControlRow,
HeaderNavigation,
HeaderProfile,
HeaderWorkspace,
Icon,
Inspector,
RangeControl,
Select,
StatusBadge,
TextField,
UserProfileMenu,
@@ -67,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";
@@ -78,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;
@@ -629,7 +608,10 @@ export default function App() {
return () => window.clearTimeout(timer);
}, [layoutSaveNotice]);
const [fleetCreateRequest, setFleetCreateRequest] = useState(0);
const onAddVehicle = useCallback(() => setFleetCreateRequest(value => value + 1), []);
const contentActions = useApplicationPanelActions({
onAddVehicle,
definition: activeDefinition,
refreshRuntime: runtime.refresh,
resetConnectionScenario: runtime.resetConnectionScenario,
@@ -645,8 +627,6 @@ export default function App() {
brandHref="/"
brandLabel="NODEDC MISSION CORE"
center={
<>
<HeaderWorkspace kind="mark" label="Mission Core" imageUrl="/nodedc-mark.svg" />
<HeaderNavigation
label="Архитектурные блоки пункта управления"
value={activeRoot ?? undefined}
@@ -656,7 +636,6 @@ export default function App() {
}))}
onChange={selectRoot}
/>
</>
}
right={
<HeaderProfile>
@@ -802,7 +781,7 @@ export default function App() {
settleRecordedReplaySwitch(outcome)}
onDeleteBegin={releaseRecordedReplayForDelete}
/>
) : activeDefinition.kind === "datasets" ? (
) : activeDefinition.kind === "vehicles" ? null : activeDefinition.kind === "datasets" ? (
<StatusBadge tone="neutral">Offline evaluation</StatusBadge>
) : activeDefinition.kind === "lab-archive" ? (
laboratoryAnnotation.control
@@ -828,6 +807,7 @@ export default function App() {
) : (
<WorkspaceRenderer
definition={activeDefinition}
fleetCreateRequest={fleetCreateRequest}
state={activeRuntimeState}
backendStatus={runtime.backendStatus}
sourceUrl={effectiveSourceUrl}
@@ -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,
@@ -53,7 +53,7 @@ export const LABORATORY_REPORT_TEMPLATE_VERSION =
const EXECUTION_LABELS: Record<LaboratoryExecutionClass, string> = {
deterministic: "Детерминированный",
"ai-inference": "AI inference",
"ai-inference": "AI Inference",
hybrid: "Гибридный",
};
@@ -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>
);
@@ -5,6 +5,7 @@ import type { WorkspaceDefinition } from "../productModel";
interface ApplicationPanelActionsOptions {
definition: WorkspaceDefinition | null;
onAddVehicle?: () => void;
refreshRuntime: () => void;
resetConnectionScenario?: () => Promise<boolean>;
connectionScenarioResetting: boolean;
@@ -43,6 +44,7 @@ export function deviceRuntimeUtilityAction({
export function useApplicationPanelActions({
definition,
onAddVehicle,
refreshRuntime,
resetConnectionScenario,
connectionScenarioResetting,
@@ -52,6 +54,7 @@ export function useApplicationPanelActions({
}: ApplicationPanelActionsOptions): ApplicationPanelUtilityAction[] {
return useMemo(() => {
const actions: ApplicationPanelUtilityAction[] = [];
if (definition?.kind === "vehicles" && onAddVehicle) actions.push({ label: "Добавить аппарат", icon: "plus", onClick: onAddVehicle });
if (definition?.kind === "device") {
actions.push(deviceRuntimeUtilityAction({
refreshRuntime,
@@ -71,6 +74,7 @@ export function useApplicationPanelActions({
return actions;
}, [
definition,
onAddVehicle,
refreshRuntime,
resetConnectionScenario,
connectionScenarioResetting,
@@ -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)}`),
};
}
@@ -0,0 +1,61 @@
import { useCallback, useEffect, useState } from "react";
export interface BoardHost {
hostname: string; os: string; architecture: string; cpus: number;
memory_kib: number | null; collected_at: string;
networks: { name: string; up: boolean; addresses: string[] }[];
usb: { port: string; product: string }[];
}
export interface Vehicle {
id: string; node_id: string; name: string; platform: string;
enrollment: "pending" | "paired" | "revoked" | "failed";
connectivity: "online" | "offline"; last_seen: number | null;
host: BoardHost | null; notice: string; revision: number;
}
export interface FleetPreview {
preview_id: string; node_id: string; name: string; host: BoardHost;
endpoint: string; expires_at: number;
}
export async function fleetRequest<T>(path = "", method = "GET", body?: unknown): Promise<T> {
const response = await fetch(`/api/v1/fleet${path}`, {
method, credentials: "same-origin", cache: "no-store",
headers: body === undefined ? {} : { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(12000),
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(typeof data.detail === "string" ? data.detail : "Не удалось выполнить действие с аппаратом.");
}
return response.json();
}
export function useFleet() {
const [items, setItems] = useState<Vehicle[] | null>(null);
const [error, setError] = useState("");
const refresh = useCallback(async () => {
const value = await fleetRequest<{ items: Vehicle[] }>();
setItems(value.items); setError(""); return value.items;
}, []);
useEffect(() => {
let active = true;
let fallback: ReturnType<typeof setInterval> | undefined;
const read = async () => {
try { const value = await fleetRequest<{ items: Vehicle[] }>(); if (active) { setItems(value.items); setError(""); } }
catch { if (active) setError("Реестр недоступен. Показаны последние полученные сведения; связь сейчас не подтверждена."); }
};
void read();
const events = new EventSource("/api/v1/fleet/events");
events.onmessage = event => {
if (!active) return;
try { const value = JSON.parse(event.data); setItems(value.items); setError(""); if (fallback) { clearInterval(fallback); fallback = undefined; } }
catch { unavailable(); }
};
const unavailable = () => {
if (!active) return;
setError("Связь обновляется. Показаны последние полученные сведения.");
if (!fallback) fallback = setInterval(() => void read(), 5000);
};
events.onerror = unavailable;
return () => { active = false; events.close(); if (fallback) clearInterval(fallback); };
}, []);
return { items, error, refresh };
}
@@ -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" };
}
@@ -177,7 +177,7 @@ export async function renameAICompositionRunProjection(
displayName: string,
): Promise<string> {
if (!/^ai-composition-[a-f0-9]{64}$/.test(runId)) {
throw new Error("Некорректная идентичность результата AI inference.");
throw new Error("Некорректная идентичность результата AI Inference.");
}
const normalized = displayName.trim();
if (!normalized || normalized.length > 160) {
@@ -193,19 +193,19 @@ export async function renameAICompositionRunProjection(
});
const body = await bodyOf(response);
if (!response.ok) throw apiError(body, response.status);
const row = record(body, "результат AI inference");
const row = record(body, "результат AI Inference");
exactKeys(row, ["display_name", "run_id", "schema_version"]);
if (
row.schema_version !== "missioncore.observatory-ai-composition-run-projection/v1"
|| row.run_id !== runId
|| row.display_name !== normalized
) throw new Error("Сервер не подтвердил переименование результата AI inference.");
) throw new Error("Сервер не подтвердил переименование результата AI Inference.");
return normalized;
}
export async function deleteAICompositionRunProjection(runId: string): Promise<void> {
if (!/^ai-composition-[a-f0-9]{64}$/.test(runId)) {
throw new Error("Некорректная идентичность результата AI inference.");
throw new Error("Некорректная идентичность результата AI Inference.");
}
const response = await fetch(`/api/v1/observatory/ai-composition-runs/${encodeURIComponent(runId)}`, {
method: "DELETE",
@@ -96,7 +96,7 @@ export async function deleteObservatoryLabProjection(
headers: { Accept: "application/json" },
signal,
},
"Не удалось удалить лабораторный результат из Обсерватории.",
"Не удалось удалить лабораторный результат из AI Inference.",
);
const body = await responseBody(response);
if (!response.ok || response.status !== 204) {
@@ -132,7 +132,7 @@ function admittedProjectionId(binding: ObservatoryRecordedRunBinding): string {
|| !SAFE_SESSION_ID.test(sessionId)
) {
throw new ObservatoryCatalogMutationError(
"Выбранный результат не допущен к изменению каталога Обсерватории.",
"Выбранный результат не допущен к изменению каталога AI Inference.",
);
}
return sessionId;
@@ -39,7 +39,7 @@ type ObservatoryCatalogMutationDraft =
function errorMessage(error: unknown): string {
return error instanceof Error && error.message.trim()
? error.message
: "Каталог Обсерватории недоступен.";
: "Каталог AI Inference недоступен.";
}
function isAbortError(error: unknown): boolean {
+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>
+17 -27
View File
@@ -16,6 +16,7 @@ export type WorkspaceKind =
| "map"
| "timeline"
| "missions"
| "vehicles"
| "catalog"
| "contour-health"
| "compute-modules"
@@ -93,7 +94,7 @@ export const roots: RootDefinition[] = [
label: "Парк",
title: "Аппараты и устройства",
eyebrow: "ПАРК / УСТРОЙСТВА",
description: "Реестр аппаратов, локальное подключение, сенсоры и конфигурации борта.",
description: "Реестр аппаратов, бортовые компьютеры, сенсоры и конфигурации борта.",
statement: "Одинаково подключать одиночный стенд, наземную платформу и будущий рой.",
accent: "АППАРАТЫ И ПОЛЕЗНАЯ НАГРУЗКА",
},
@@ -170,9 +171,9 @@ export const workspaces: WorkspaceDefinition[] = [
{
id: "observatory",
root: "polygon",
label: "Обсерватория",
title: "AI inference",
eyebrow: "ТЕСТОВЫЙ КОНТУР / ОБСЕРВАТОРИЯ",
label: "AI Inference",
title: "AI Inference",
eyebrow: "ТЕСТОВЫЙ КОНТУР / AI INFERENCE",
description: "Сессии и квалификация без доступа к управлению.",
icon: "eye",
kind: "observatory",
@@ -193,31 +194,20 @@ export const workspaces: WorkspaceDefinition[] = [
id: "vehicles",
root: "fleet",
label: "Аппараты",
title: "Реестр аппаратов",
title: "Аппараты",
eyebrow: "ПАРК / РЕЕСТР",
description: "Нейтральный реестр наземных, воздушных и стационарных платформ.",
description: "Аппараты и их бортовые компьютеры в частном контуре.",
icon: "apps",
kind: "catalog",
groups: [
{
title: "Идентичность аппарата",
description: "Никакой привязки продуктовой модели к конкретному производителю.",
capabilities: [
ready("Локальный стенд", "Первый аппарат представлен текущим устройством и его адаптером."),
contract("Паспорт борта", "Тип, серийный профиль, вычислитель, питание и транспорт."),
contract("Состояние доступности", "Онлайн, занят, обслуживание, потеря связи."),
later("Группы и рои", "Логические группы, роли и совместное назначение миссий."),
],
},
],
kind: "vehicles",
groups: [],
},
{
id: "local-device",
root: "fleet",
label: "Подключение",
title: "Подключение",
eyebrow: "ПАРК / ТЕКУЩИЙ АДАПТЕР",
description: "Выбор модели, сценарий установленного плагина и запуск доступного потока.",
root: "polygon",
label: "Тестовые устройства",
title: "Тестовые устройства",
eyebrow: "LAB / ТЕСТОВЫЕ УСТРОЙСТВА",
description: "Подключение устройств к компьютеру оператора для испытаний и записи.",
icon: "network",
kind: "device",
groups: [],
@@ -268,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,6 @@
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";
import {
@@ -31,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";
@@ -89,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,
@@ -410,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"
@@ -497,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}
@@ -529,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"
@@ -573,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>
@@ -609,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>
@@ -667,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)}
@@ -697,8 +629,8 @@ function SpatialWorkspace({
className="scene-timeline"
/>
) : null}
{visibleMediaSources.map((source, index) => (
</>}
media={<> {visibleMediaSources.map((source, index) => (
<FloatingObservationWindow
key={source.id}
source={source}
@@ -749,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>
@@ -761,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({
@@ -1167,6 +1094,8 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
return <TimelineWorkspace {...props} />;
case "missions":
return <MissionWorkspace {...props} />;
case "vehicles":
return <VehiclesWorkspace createRequest={props.fleetCreateRequest} />;
case "catalog":
return <CatalogWorkspace {...props} />;
case "contour-health":
@@ -35,6 +35,7 @@ export interface LaboratoryViewAction {
}
export interface WorkspaceRendererProps {
fleetCreateRequest?: number;
definition: WorkspaceDefinition;
state: MissionRuntimeState | null;
backendStatus: BackendStatus;
@@ -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>;
}
@@ -0,0 +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 {createFleetSensorTransport} from '../../core/fleet/sensorTransport';
export function VehicleSensors({vehicleID,enabled,onDetailChange}:{vehicleID:string;enabled:boolean;onDetailChange:(open:boolean)=>void}){
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}/>;
}
@@ -0,0 +1,77 @@
import {BoardObservationCenter} from './observation/BoardObservationCenter';
import {BoardMonitorView} from './BoardMonitorView';
import { useEffect, useRef, useState } from "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";
const platforms = [{ value: "ugv", label: "Наземный (UGV)" }, { value: "uav", label: "Воздушный (UAV)" }, { value: "stationary", label: "Стационарный" }, { value: "other", label: "Другой" }];
const platformLabel = (value: string) => platforms.find(item => item.value === value)?.label ?? value;
function statusLabel(item: Vehicle) { return item.enrollment === "pending" ? "Подтверждаем привязку" : item.enrollment === "revoked" ? "Доверие отозвано" : item.enrollment === "failed" ? "Привязка не завершена" : item.connectivity === "online" ? "В сети" : "Нет связи"; }
export function VehiclesWorkspace({ createRequest = 0 }: { createRequest?: number }) {
const fleet = useFleet();
const [adding, setAdding] = useState(false);
const [code, setCode] = useState("");
const [name, setName] = useState("");
const [platform, setPlatform] = useState("ugv");
const [preview, setPreview] = useState<FleetPreview | null>(null);
const [pending, setPending] = useState(false);
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]);
function close() { if (pending) return; setAdding(false); setPreview(null); setCode(""); setName(""); setError(""); }
async function inspect(event: React.FormEvent) {
event.preventDefault(); if (pending) return;
setPending(true); setError("");
try { const value = await fleetRequest<FleetPreview>("/preview", "POST", { code: code.trim() }); setPreview(value); setName(current => current || value.name); setCode(""); }
catch (error) { setError(error instanceof Error ? error.message : "Не удалось проверить приглашение."); }
finally { setPending(false); }
}
async function add() {
if (!preview || pending) return;
setPending(true); setError("");
try {
const item = await fleetRequest<Vehicle>("", "POST", { preview_id: preview.preview_id, name: name.trim(), platform });
await fleet.refresh(); setSelected(item.id); setAdding(false); setPreview(null); setCode(""); setName("");
} catch (error) { setError(error instanceof Error ? error.message : "Не удалось добавить аппарат."); void fleet.refresh().catch(() => undefined); }
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>}
{detail ? <>
<div><Button onClick={() => {setSelected(null);setSensorOpen(false);}}>К списку аппаратов</Button></div>
{!sensorOpen && <SettingsCard title={detail.name} description={`${platformLabel(detail.platform)} · с бортовым компьютером`} actions={<StatusBadge tone={!fleet.error && detail.enrollment === "paired" && detail.connectivity === "online" ? "success" : "neutral"}>{fleet.error ? "Нет свежих данных" : statusLabel(detail)}</StatusBadge>}>
{detail.notice && <p role="status">{detail.notice}</p>}
<dl className="fleet-facts"><div><dt>Бортовой компьютер</dt><dd>{detail.node_id}</dd></div>
<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>
<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 ? <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} />
<TextField label="Название аппарата" value={name} maxLength={80} onChange={event => setName(event.target.value)} disabled={pending} autoComplete="off" />
{preview ? <SettingsCard title={preview.name} description="Идентичность БК проверена по приглашению"><dl className="fleet-facts"><div><dt>Идентификатор БК</dt><dd>{preview.node_id}</dd></div><div><dt>Система</dt><dd>{preview.host.os} · {preview.host.architecture}</dd></div><div><dt>Адрес БК</dt><dd>{preview.endpoint}</dd></div></dl><Button disabled={pending} onClick={() => setPreview(null)}>Другое приглашение</Button></SettingsCard> : <TextAreaField label="Код приглашения из Node" value={code} rows={5} maxLength={4096} spellCheck={false} autoComplete="off" disabled={pending} onChange={event => setCode(event.target.value)} />}
{error && <p role="alert">{error}</p>}
</form>
</Window>
<ConfirmationModal open={revoking !== null} title="Отозвать привязку БК?" description={`Аппарат «${revoking?.name ?? ""}» останется в реестре, а его БК потеряет доступ к Core. БК получит отзыв при следующем соединении.`} confirmLabel="Отозвать" cancelLabel="Отмена" danger onClose={() => setRevoking(null)} onConfirm={async () => { if (!revoking) return; try { await fleetRequest(`/${encodeURIComponent(revoking.id)}`, "DELETE"); await fleet.refresh(); setRevoking(null); } catch (error) { setError(error instanceof Error ? error.message : "Не удалось отозвать привязку."); throw error; } }} />
</div>;
}
@@ -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); }
@@ -0,0 +1,13 @@
.fleet-workspace, .fleet-form { display: flex; flex-direction: column; gap: var(--nodedc-space-4); }
.fleet-workspace { padding: var(--nodedc-space-4); }
.fleet-facts { display: grid; gap: var(--nodedc-space-3); }
.fleet-facts > div { display: grid; grid-template-columns: minmax(120px, 1fr) minmax(0, 2fr); gap: var(--nodedc-space-3); }
.fleet-facts dd { margin: 0; overflow-wrap: anywhere; }
.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,
@@ -165,7 +165,7 @@ function compositionProducedEvidence(
function mutationErrorMessage(error: unknown): string {
return error instanceof Error && error.message.trim()
? error.message
: "Не удалось изменить лабораторный результат в Обсерватории.";
: "Не удалось изменить лабораторный результат в AI Inference.";
}
function evidenceConfigurationLabel(
@@ -276,12 +276,12 @@ function aiJobStageDetail(
return "Не удалось запустить модель. Конфигурацию можно отправить повторно";
}
if (job.publication.state === "failed") {
return "Результат рассчитан, но не сохранён в Обсерватории";
return "Результат рассчитан, но не сохранён в AI Inference";
}
if (job.state === "succeeded") {
if (job.publication.state === "pending") return "Завершаем сохранение результата";
if (job.publication.state === "not-required") return "Публикация результата не требуется";
return "Результат сохранён в Обсерватории";
return "Результат сохранён в AI Inference";
}
if (!progress) {
return "Готовим конфигурацию и место в очереди";
@@ -862,7 +862,7 @@ export function ObservatoryWorkspace({
{run.state === "ready" ? (
<>
<IconButton
label={`Удалить ${run.displayName ?? run.presentation.configurationLabel} из Обсерватории`}
label={`Удалить ${run.displayName ?? run.presentation.configurationLabel} из AI Inference`}
onClick={() => openCompositionDelete(run)}
>
<Icon name="trash" size={16} />
@@ -952,7 +952,7 @@ export function ObservatoryWorkspace({
{evidence.recordedRun ? (
<>
<IconButton
label={`Удалить ${evidence.lab.labId} из Обсерватории`}
label={`Удалить ${evidence.lab.labId} из AI Inference`}
onClick={() => openDelete(evidence)}
>
<Icon name="trash" size={16} />
@@ -1094,7 +1094,7 @@ export function ObservatoryWorkspace({
<Window
open={renameTarget !== null || compositionRenameTarget !== null}
title="Переименовать лабораторный результат"
subtitle="Меняется только отображаемое название в Обсерватории"
subtitle="Меняется только отображаемое название в AI Inference"
size="sm"
closeOnBackdrop={mutationPending !== "rename"}
closeOnEscape={mutationPending !== "rename"}
@@ -1161,12 +1161,12 @@ export function ObservatoryWorkspace({
<ConfirmationModal
open={deleteTarget !== null || compositionDeleteTarget !== null}
title="Удалить результат из Обсерватории?"
title="Удалить результат из AI Inference?"
description={deleteTarget || compositionDeleteTarget ? (
<div className="observatory-delete-confirmation">
<p>
Будут удалены только каталожная проекция <strong>{deleteTarget?.label ?? compositionDeleteTarget?.displayName ?? compositionDeleteTarget?.presentation.configurationLabel}</strong>
{" "}и её отображаемое название в Обсерватории.
{" "}и её отображаемое название в AI Inference.
</p>
<p>
Исходная сессия, запечатанный лабораторный результат и файлы доказательств
@@ -1177,7 +1177,7 @@ export function ObservatoryWorkspace({
) : null}
</div>
) : null}
confirmLabel="Удалить из Обсерватории"
confirmLabel="Удалить из AI Inference"
pendingLabel="Удаляем…"
danger
onClose={() => {
@@ -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(
@@ -114,10 +114,10 @@ test("Observatory mutations reject response drift and preserve API detail", asyn
);
await assert.rejects(
deleteObservatoryLabProjection(binding, {
fetcher: async () => Response.json({ detail: "Проекция не принадлежит Обсерватории." }, { status: 409 }),
fetcher: async () => Response.json({ detail: "Проекция не принадлежит AI Inference." }, { status: 409 }),
}),
(error) => error instanceof ObservatoryCatalogMutationError
&& error.status === 409
&& error.message === "Проекция не принадлежит Обсерватории.",
&& error.message === "Проекция не принадлежит AI Inference.",
);
});
@@ -27,16 +27,16 @@ 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"),
{
id: "observatory",
root: "polygon",
label: "Обсерватория",
title: "AI inference",
eyebrow: "ТЕСТОВЫЙ КОНТУР / ОБСЕРВАТОРИЯ",
label: "AI Inference",
title: "AI Inference",
eyebrow: "ТЕСТОВЫЙ КОНТУР / AI INFERENCE",
description: "Сессии и квалификация без доступа к управлению.",
icon: "eye",
kind: "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(
@@ -200,7 +200,7 @@ test("Observatory rename and delete use admitted projection mutations and canoni
assert.match(workspace, /<Window[\s\S]*title="Переименовать лабораторный результат"/);
assert.match(workspace, /<TextField[\s\S]*label="Название"[\s\S]*maxLength=\{160\}/);
assert.match(workspace, /<WindowFooterActions>[\s\S]*Сохранить/);
assert.match(workspace, /<ConfirmationModal[\s\S]*title="Удалить результат из Обсерватории\?"/);
assert.match(workspace, /<ConfirmationModal[\s\S]*title="Удалить результат из AI Inference\?"/);
assert.match(workspace, /Исходная сессия, запечатанный лабораторный результат и файлы доказательств/);
assert.match(workspace, /renameAICompositionRunProjection/);
assert.match(workspace, /deleteAICompositionRunProjection/);
@@ -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);
});
@@ -0,0 +1,24 @@
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/sensorStatus.ts',import.meta.url),'utf8');
const code=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.ESNext}}).outputText;
const {sensorStatus}=await import('data:text/javascript;base64,'+Buffer.from(code).toString('base64'));
const connected={online:true,configured:true,prepared:true,verified:false,snapshot:{acquisition:'idle',enrollment:'enrolled'}};
test('a configured, reconnected camera is connected before another frame verification',()=>{
assert.deepEqual(sensorStatus(connected,true),{label:'Подключено',tone:'success'});
});
test('stale board data cannot assert camera connectivity',()=>{
assert.equal(sensorStatus(connected,false).tone,'neutral');
assert.equal(sensorStatus({...connected,online:false},true).label,'Не подключено');
});
test('a capture failure or unavailable driver is not shown as healthy',()=>{
assert.equal(sensorStatus({...connected,snapshot:{acquisition:'failed'}},true).tone,'danger');
assert.equal(sensorStatus({...connected,prepared:false},true).tone,'warning');
});
test('an unconfigured camera still requires preparation',()=>{
assert.deepEqual(sensorStatus({...connected,configured:false},true),{label:'Требуется подготовка',tone:'neutral'});
});
@@ -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),
),
+4
View File
@@ -0,0 +1,4 @@
/build/
/ui/node_modules/
/ui/dist/
/web/dist/
+222
View File
@@ -0,0 +1,222 @@
# Mission Core Node — onboard configuration and sensors
## Current candidate: 0.6.11
The installed candidate on the qualification Mini is 0.6.11. It contains the
native GTK/WebKit application, seven-step environment configuration, host and
USB/network inventory, trusted SSH key management, Tailscale access, explicit
Node/Core pairing, and the first RealSense D455 sensor workflow. Supported host:
Ubuntu 24.04 LTS Desktop amd64. This is an early candidate, not a completed or
fully hardware-qualified Node v1.
The shared Node/Core sensor UI supports preparation, naming, profiles/options,
live RGB/depth/IR/points/motion, and capture/recording commands owned by the
board. After initial preparation, the device row keeps one connectivity lamp,
settings and viewer actions; driver redeployment moves into device settings.
USB events update inventory while persistent identity/name/configuration remain
available across sessions. A green connection lamp does not assert that frames
are currently being acquired or that every sensor option has been qualified.
Actual GUI acceptance and its limits are recorded in
[the sensor architecture report](../../docs/node/05_SENSOR_HOST_AND_SHARED_CONTROL.md).
Node GUI raw playback has passed. Core replay/API event acceptance still needs
the separately approved canonical server restart. Resumable raw transfer/import,
continuous unplug/replug without service restart, different USB ports, native
WebKit media, recovery/soak and clean-OS qualification remain open. The owner
powered the Mini off after the session; no later hardware checks are implied.
Installed package: `mission-core-node_0.6.11_amd64.deb`, 94,021,846 bytes;
SHA-256 `2d593616d64ed9fecb99e5758763ae52610731aaf1467fb23c7d75b19ffef0fa`.
Its source/provenance base is `404285419f864da7306eef6ee07a2077ad30e674`.
Subsequent documentation commits do not imply another package installation.
## Bootstrap history: 0.20.4
0.4.0 makes «Настройка окружения» the first system view. A fixed privileged
helper starts a durable, versioned systemd workflow for packages, the board
service, network/USB inventory, SSH and Tailscale. Operator-controlled SSH keys
and Tailscale login are available in the same section. The package bootstraps
the GUI/service; operational configuration is performed by its UI button.
Operator copy is OS-neutral; actual OS/version appears only in «Обзор БК».
Support remains Ubuntu 24.04 LTS Desktop amd64.
0.3.2 also repairs Linux interface inventory: the unprivileged service admits
AF_NETLINK for OS metadata reads while retaining an empty capability set.
Inventory and UI distinguish a failed network/address read from an empty list.
0.3.1 consolidates host inventory, USB, Tailscale and SSH under «Система».
«Обзор БК» contains host facts, the board name and an observed connectivity
summary. The separate configuration page and redundant Node health badge are
removed. «Устройства» is reserved for driver-backed devices and remains disabled
until a real device workflow exists. The admitted Node/Core pairing and fleet
surface plan is in `docs/node/03_SYSTEM_AND_VEHICLE_PAIRING_SURFACE.md`.
0.3.0 adopts the canonical Mission Core shell, navigation, system views and a
GUI list of trusted SSH devices. ResourceRow is shared from Design Guideline;
see `docs/node/02_NODE_DESKTOP_SURFACE.md` for composition and acceptance.
The Debian package still contains the GTK/WebKit desktop launcher and bundled
React UI. Real Mini installation and read-only UI checks passed; physical
reboot, bare-Ubuntu installation and full GUI upgrade/removal remain open.
**Qualification in progress (2026-09-05):** the owner installed 0.2.0 through
App Center after closing Synaptic, which had blocked package installation.
PackageKit completed successfully and the packaged service is active.
The owner subsequently confirmed the corrected icon and Tailscale “online” with
the board address under 0.2.2. 0.2.2 corrects the desktop icon's
canvas and configures the pinned provider's HTTPS control transport. The earlier
0.2.1 icon-only candidate was superseded before installation.
Manual SSH bootstrap is authorized for engineering access only.
0.2.3 also routes expired-session errors from Tailscale polling to the existing
application login surface. Browser acceptance confirmed that restarting the
temporary test service now shows login instead of a misleading unavailable
provider. This does not preserve authentication across a service restart.
Source of truth: MISSIONCOR-76 and its UI-FIRST / BRIDGE-ONLY / system
configuration comments. Product surface and physical acceptance procedure:
`docs/node/01_BOOTSTRAP_SURFACE_AND_ACCEPTANCE.md` at the repository root.
This is an independently built application in the Mission Core monorepo.
Version 0.2.0 contains local host/USB/network inventory, persistent Ed25519
identity, GUI naming, OS-authenticated local launch, redacted report export,
OpenSSH installation/autostart, and GUI enrollment/revocation of Ed25519 public
keys for local Ubuntu administrators. The agent and desktop window run
unprivileged; fixed polkit helpers admit only local login and the shipped system/network actions. SSH configuration is
owned by the versioned environment workflow, with conflict detection and cleanup on removal.
In 0.2.0, Core pairing/mTLS, sensor plugins, capture, media and recovery were
subsequent vertical increments. See the current candidate section above for
their present implementation and acceptance boundaries.
## Operator workflow
Open the `.deb` in Ubuntu's graphical package installer, install it, then launch
Mission Core Node from the applications menu and approve the normal OS dialog.
The application opens in its own GTK window with embedded WebKit rendering and
native system dialogs for authorization and report saving. No external browser
is opened. Closing the window leaves the independent board service running.
The system installer resolves dependencies from Ubuntu repositories; internet
access is needed when those dependencies are absent. There are no shared
credentials in the package. SSH public keys are enrolled explicitly in the UI.
No shell, Go, Python environment setup, npm, or source checkout is required from
the operator. The included Python launcher uses the system Python dependency.
The desktop icon contains the unchanged canonical NODE.DC mark from the admitted
Design Guideline revision inside a transparent square SVG canvas. This gives
desktop loaders square intrinsic dimensions without stretching the mark. After
upgrading the package, close and reopen the
application window so its native helpers and embedded UI have matching features.
**Observed installer limitation (2026-09-05):** App Center revision 1270 on the
qualification board showed “installed” instead of offering the 0.1.1 → 0.2.0
local-file upgrade. Do not claim that update path is accepted. The owner's
subsequent GUI removal succeeded; reinstall attempts then failed before dpkg
because Synaptic remained open and held `/var/lib/dpkg/lock-frontend`.
Exit Synaptic through File → Quit before retrying the `.deb` in App Center.
Do not delete package-manager locks or terminate a running transaction. The
0.2.0 SHA-256 still matches, and APT simulation resolves its dependencies;
neither check alone establishes actual installation. The following GUI retry
completed successfully and the installed package is 0.2.0. A complete product installer
must still qualify GUI upgrade, removal and actionable lock/error handling.
## Private network setup
The optional Tailscale panel has real install, login, waiting-for-approval,
stopped, starting, unavailable and connected states. Installation and connection
use two fixed root-owned polkit helpers from the desktop window. The web API
can only read a reduced local status; it cannot run commands or change network
settings. The generic Node identity and capture lifecycle do not depend on
Tailscale. Pairing to Mission Core is a separate explicit UI operation; see
[the pairing protocol](../../docs/node/04_NODE_CORE_PAIRING_PROTOCOL.md).
On a new machine, the helper downloads the official amd64 `.deb` pinned in
`packaging/tailscale-release.json`, verifies SHA-256 before invoking APT, installs
without removing other packages, and enables `tailscaled`. It does not add an
APT repository or upgrade an existing Tailscale installation. An existing
stopped authenticated configuration is resumed with a bare `tailscale up`;
fresh login explicitly disables accepting remote DNS and subnet routes. No
exit node, advertised subnet, Tailscale SSH, forced reauthentication or reset
is configured. Incompatible existing preferences fail instead of being reset.
For a new provider install, and when explicitly reconnecting a disconnected
provider, a root-owned systemd drop-in selects `TS_FORCE_NOISE_443=true`.
The board's port-80 control connection stalled after registration with queued
unacknowledged data; the upstream `debug ts2021` handshake succeeded over 443.
The helper checks the daemon's effective flag and restarts it only when needed.
An already Running/NeedsMachineAuth provider is left untouched. Conflicting
custom drop-ins are preserved and reported. Keys, DNS and route preferences
are not changed. This drop-in remains with the independent provider on Node
removal. The transport setting is specific to pinned Tailscale, not Node identity.
The provider's validated `https://login.tailscale.com/a/...` URL opens in the
user's normal browser only after the explicit login action. Node never collects
the account password or exports the login URL to JS, its status API or reports.
The status probe requests no peers and returns only installation/state, local
Tailscale addresses and the provider's online flag. Closing Node or uninstalling
it does not disconnect or remove the independently installed Tailscale service.
Source contracts: [Tailscale stable packages](https://pkgs.tailscale.com/stable/),
[pinned up implementation](https://github.com/tailscale/tailscale/blob/v1.102.3/cmd/tailscale/cli/up.go),
[pinned status implementation](https://github.com/tailscale/tailscale/blob/v1.102.3/cmd/tailscale/cli/status.go).
HTTPS underlay: [pinned control dialer](https://github.com/tailscale/tailscale/blob/v1.102.3/control/controlhttp/client.go).
JSON contracts are version-sensitive; review the adapter when updating the pin.
Only Ubuntu 24.04 LTS Desktop amd64 is admitted by this first package. No blind
upgrade of OS, firmware, network profiles, router settings or camera SDK occurs.
Existing Ubuntu SSH authentication is retained. Keys enrolled in Node are
limited to private source addresses. Removing Node removes its SSH integration,
but leaves the SSH server and persistent Node state available for reinstall.
## Engineering build (not the operator installation procedure)
Install UI dependencies with `npm ci --ignore-scripts` in `ui/`. The build
requires the sibling Design Guideline repository used by the monorepo, at
`999864e5b0a81555823cfa1ea6e8cf8a417c37f1` (the exact `DG_COMMIT` in
`packaging/build.py`, not necessarily the latest documentation commit). In that checkout, run
`npm ci --ignore-scripts` and `npm run build:packages` before building Node.
Its dependencies are bundled into the binary; the board never references that
sibling path. The pinned commit includes ResourceRow and the shared shell fixes;
source and generated export hashes are also retained in package provenance.
Use the Go release pinned in `toolchain.json`; download it from the official
Go distribution and verify its SHA-256. No global Go install is needed.
```sh
python3 packaging/build.py --go /path/to/verified/go/bin/go
```
This runs the production UI build, replaces generated embedded assets, builds
a static Linux amd64 Go binary, records source/build provenance, and packages
the `.deb` without executing any installer scripts. `build/` is ignored.
Validation is sequential: `go test -race ./...`, the Control Station application
architecture boundary test, Node UI typecheck/unit tests/build, then desktop GUI QA.
Package script syntax/archive checks and a macOS browser run cannot establish
Ubuntu systemd/polkit/SSH or clean-install acceptance. Those require the GUI
procedure on the actual board. The temporary native QA build must be stopped
after inspection; the canonical Mission Core on port 8000 stays running.
For this board, the owner's engineering checkout is under
`Загрузки/NDC/MISSION_CORE` in the operator's home directory. Keep complete Git history separately
from generated artifacts; do not copy another worktree's `.git` pointer. The
launcher accepts `--development-socket` for an unprivileged development service
on the board. This does not grant OS privileges and is not installer acceptance.
K1 uses wireless Bridge in the common LAN only. D455 is attached by USB; check
its actual negotiated speed and SDK operation separately from enumeration.
## Local authority
The node service binds only `127.0.0.1:8780`. This is not the remote Node/Core
control plane. Its private Unix socket is `0600` in a `0700` directory. The
root-owned launcher helper has a fixed executable and socket; no user command,
path, URL or environment is executed with elevated privileges. One-use login
tokens expire after one minute, authenticated cookies after eight hours, and
all sessions expire on service restart. Identity corruption fails closed.
The D455 worker reads USB/SDK serials internally to match one physical device;
the shared sensor API uses a derived stable device ID. Treat identifiers, host
inventory and captured data as private operational evidence. Credentials,
private keys, real recordings, exported host reports and runtime state stay
outside normal Git. See the architecture reports for the exact API/report
boundaries rather than treating source-test fixtures as live evidence.
+41
View File
@@ -0,0 +1,41 @@
// Engineering-only UI fixture: uses the production API and isolated storage.
// No fixture is linked into cmd/node-agent or installed by the Debian package.
package main
import (
"bytes"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
"time"
"nodedc.local/mission-core/node-agent/internal/node"
"nodedc.local/mission-core/node-agent/web"
)
func main() {
dir := "/private/tmp/mc-node-ui-040-qa"
store, err := node.OpenStore(dir); if err != nil { log.Fatal(err) }
assets, _ := fs.Sub(web.Assets, "dist")
memory, available := uint64(8388608), uint64(5242880)
app := &node.Server{Store: store, Assets: assets, Origin: "http://127.0.0.1:8780", Version: "0.4.0-qa", Inventory: func() node.Inventory {
inventory := node.Inventory{CollectedAt: time.Now().UTC().Format(time.RFC3339), Hostname:"qa-board", OS:"Ubuntu 24.04.4 LTS", Architecture:"amd64", CPUs:8, MemoryKiB:&memory, AvailableKiB:&available,
NetworksReadable:true, Networks: []node.Network{{Name:"ethernet-qa", Up:true, AddressesReadable:true, Addresses:[]string{"192.0.2.10/24"}}},
USB:[]node.USB{{Port:"2-1", Vendor:"8086", ProductID:"0b5c", Product:"Intel RealSense D455 · QA", Speed:"5000"}}, USBReadable:true, Warnings:[]string{}}
if _, err := os.Stat(filepath.Join(dir,"network-unavailable")); err == nil { inventory.NetworksReadable=false; inventory.Networks=[]node.Network{}; inventory.Warnings=[]string{"Не удалось прочитать сетевые интерфейсы"} }
return inventory
}, Access: &node.AccessStore{Path:filepath.Join(dir,"ssh-keys.json"), Users:func()[]string{return []string{"operator"}}}, Tailscale:func()node.TailscaleStatus {
if _, err := os.Stat(filepath.Join(dir,"offline")); err == nil { return node.TailscaleStatus{Installed:true, State:"unavailable", Addresses:[]string{}} }
return node.TailscaleStatus{Installed:true, State:"Running", Online:true, Addresses:[]string{"100.64.0.10"}}
}}
if err := os.WriteFile(filepath.Join(dir,"login-url"),[]byte(app.IssueLogin()),0600); err != nil {log.Fatal(err)}
handler := app.Handler()
http.HandleFunc("/",func(w http.ResponseWriter,r *http.Request){
if r.URL.Path == "/qa-bridge.js" { w.Header().Set("Content-Type","application/javascript"); _,_ = w.Write([]byte(`window.missionCoreDesktop={networkSetup:true};`)); return }
if r.URL.Path == "/" { b,_:=fs.ReadFile(assets,"index.html"); b=bytes.Replace(b,[]byte("</head>"),[]byte(`<script src="/qa-bridge.js"></script></head>`),1); w.Header().Set("Content-Type","text/html"); _,_=w.Write(b); return }
handler.ServeHTTP(w,r)
})
log.Print("isolated Node UI QA: 127.0.0.1:8780")
log.Fatal(http.ListenAndServe("127.0.0.1:8780",nil))
}
+167
View File
@@ -0,0 +1,167 @@
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"io/fs"
"log"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"nodedc.local/mission-core/node-agent/internal/node"
"nodedc.local/mission-core/node-agent/web"
)
var version = "0.2.0"
const defaultSocket = "/run/mission-core-node/admin.sock"
func main() {
if err := run(); err != nil {
log.Print(err)
os.Exit(1)
}
}
func run() error {
if len(os.Args) > 1 && os.Args[1] == "authorize" {
return authorize(defaultSocket)
}
if len(os.Args) == 3 && os.Args[1] == "ssh-keys" {
value, err := node.AuthorizedKeys("/var/lib/mission-core-node/ssh-keys.json", os.Args[2])
if err == nil {
fmt.Print(value)
}
return err
}
flags := flag.NewFlagSet("node-agent", flag.ContinueOnError)
dir := flags.String("state", "/var/lib/mission-core-node", "private state directory")
socket := flags.String("socket", defaultSocket, "private launcher socket")
listen := flags.String("listen", "127.0.0.1:8780", "loopback UI address")
if err := flags.Parse(os.Args[1:]); err != nil {
return err
}
host, _, err := net.SplitHostPort(*listen)
if err != nil || host != "127.0.0.1" {
return errors.New("local UI must bind 127.0.0.1")
}
// Bind before touching state/socket; a second instance cannot replace identity or launcher authority.
tcp, err := net.Listen("tcp", *listen)
if err != nil {
return err
}
defer tcp.Close()
store, err := node.OpenStore(*dir)
if err != nil {
return err
}
assets, err := fs.Sub(web.Assets, "dist")
if err != nil {
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
}
if info, e := os.Lstat(*socket); e == nil {
if info.Mode()&os.ModeSocket == 0 {
return errors.New("launcher path is not a socket")
}
if err := os.Remove(*socket); err != nil {
return err
}
} else if !os.IsNotExist(e) {
return e
}
unix, err := net.Listen("unix", *socket)
if err != nil {
return err
}
defer unix.Close()
defer os.Remove(*socket)
if err := os.Chmod(*socket, 0600); err != nil {
return err
}
admin := http.NewServeMux()
admin.HandleFunc("POST /login", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"url": app.IssueLogin()})
})
public := &http.Server{Handler: app.Handler(), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 30 * time.Second, MaxHeaderBytes: 8192}
private := &http.Server{Handler: admin, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 10 * time.Second, MaxHeaderBytes: 8192}
errs := make(chan error, 2)
go func() { errs <- public.Serve(tcp) }()
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")
select {
case err = <-errs:
case <-ctx.Done():
}
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
public.Shutdown(shutdown)
private.Shutdown(shutdown)
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
}
func authorize(socket string) error {
// Called by a fixed, root-owned polkit helper. No user-supplied URL, command,
// path, or environment is interpreted by the privileged operation.
client := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", socket)
}}}
res, err := client.Post("http://local/login", "application/json", nil)
if err != nil {
return errors.New("Node is unavailable")
}
defer res.Body.Close()
if res.StatusCode != 200 {
return errors.New("Node rejected local authorization")
}
var value struct {
URL string `json:"url"`
}
if err := json.NewDecoder(io.LimitReader(res.Body, 1024)).Decode(&value); err != nil {
return err
}
fmt.Print(value.URL)
return nil
}
+3
View File
@@ -0,0 +1,3 @@
module nodedc.local/mission-core/node-agent
go 1.26.0
+265
View File
@@ -0,0 +1,265 @@
package node
import (
"bufio"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"net"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"unicode"
"unicode/utf8"
)
type AccessKey struct {
ID string `json:"id"`
User string `json:"user"`
Label string `json:"label"`
PublicKey string `json:"public_key"`
}
type AccessStore struct {
mu sync.Mutex
Path string
Users func() []string
}
var usernamePattern = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}$`)
// Only existing local administrative accounts are eligible. Never root,
// a supplied home directory, an arbitrary NSS principal, or a generated user.
func LocalAdmins(root string) []string {
group, _ := os.ReadFile(filepath.Join(root, "etc/group"))
admins := map[string]bool{}
for _, line := range strings.Split(string(group), "\n") {
p := strings.Split(line, ":")
if len(p) == 4 && p[0] == "sudo" {
for _, u := range strings.Split(p[3], ",") {
admins[u] = true
}
}
}
passwd, _ := os.ReadFile(filepath.Join(root, "etc/passwd"))
result := []string{}
for _, line := range strings.Split(string(passwd), "\n") {
p := strings.Split(line, ":")
if len(p) != 7 {
continue
}
uid, e := strconv.Atoi(p[2])
if e == nil && uid >= 1000 && uid < 65534 && admins[p[0]] && usernamePattern.MatchString(p[0]) && !strings.HasSuffix(p[6], "nologin") && !strings.HasSuffix(p[6], "false") {
result = append(result, p[0])
}
}
return result
}
func canonicalKey(key string) (string, string, error) {
parts := strings.Fields(strings.TrimSpace(key))
bad := errors.New("Нужен публичный ключ Ed25519, начинающийся с ssh-ed25519; приватный ключ вводить нельзя")
if len(parts) < 2 || parts[0] != "ssh-ed25519" || strings.ContainsAny(key, "\r\n") {
return "", "", bad
}
b, e := base64.StdEncoding.DecodeString(parts[1])
if e != nil || len(b) != 51 {
return "", "", bad
}
if binary.BigEndian.Uint32(b[:4]) != 11 || string(b[4:15]) != "ssh-ed25519" || binary.BigEndian.Uint32(b[15:19]) != 32 {
return "", "", bad
}
h := sha256.Sum256(b)
return "ssh-ed25519 " + base64.StdEncoding.EncodeToString(b), "SHA256:" + base64.RawStdEncoding.EncodeToString(h[:]), nil
}
func ReadAccess(path string) ([]AccessKey, error) {
b, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return []AccessKey{}, nil
}
if err != nil {
return nil, err
}
var keys []AccessKey
if err = json.Unmarshal(b, &keys); err != nil {
return nil, err
}
if len(keys) > 64 {
return nil, errors.New("too many access keys")
}
for _, k := range keys {
key, id, err := canonicalKey(k.PublicKey)
if err != nil || key != k.PublicKey || id != k.ID || !usernamePattern.MatchString(k.User) {
return nil, errors.New("invalid access store")
}
}
return keys, nil
}
func (a *AccessStore) List() ([]AccessKey, error) {
a.mu.Lock()
defer a.mu.Unlock()
return ReadAccess(a.Path)
}
func (a *AccessStore) allowed(user string) bool {
for _, u := range a.Users() {
if user == u {
return true
}
}
return false
}
func (a *AccessStore) change(fn func([]AccessKey) ([]AccessKey, error)) error {
a.mu.Lock()
defer a.mu.Unlock()
keys, err := ReadAccess(a.Path)
if err != nil {
return errors.New("Хранилище SSH недоступно")
}
keys, err = fn(keys)
if err != nil {
return err
}
b, err := json.Marshal(keys)
if err != nil {
return err
}
f, err := os.CreateTemp(filepath.Dir(a.Path), ".ssh-keys-*")
if err != nil {
return err
}
defer os.Remove(f.Name())
if _, err = f.Write(b); err != nil {
f.Close()
return err
}
if err = f.Sync(); err != nil {
f.Close()
return err
}
if err = f.Close(); err != nil {
return err
}
return os.Rename(f.Name(), a.Path)
}
func (a *AccessStore) Add(user, label, key string) error {
if !a.allowed(user) {
return errors.New("Выберите существующую учётную запись администратора системы")
}
label = strings.TrimSpace(label)
if label == "" || utf8.RuneCountInString(label) > 64 || strings.ContainsFunc(label, unicode.IsControl) {
return errors.New("Название ключа должно содержать от 1 до 64 символов")
}
key, id, err := canonicalKey(key)
if err != nil {
return err
}
return a.change(func(keys []AccessKey) ([]AccessKey, error) {
for _, k := range keys {
if k.ID == id && k.User == user {
return keys, nil
}
}
if len(keys) >= 64 {
return nil, errors.New("Достигнут предел 64 ключа")
}
return append(keys, AccessKey{ID: id, User: user, Label: label, PublicKey: key}), nil
})
}
func (a *AccessStore) Remove(user, id string) error {
return a.change(func(keys []AccessKey) ([]AccessKey, error) {
next := []AccessKey{}
for _, k := range keys {
if k.User != user || k.ID != id {
next = append(next, k)
}
}
return next, nil
})
}
func SSHReady() bool {
c, err := net.DialTimeout("tcp", "127.0.0.1:22", 400*time.Millisecond)
if err != nil {
return false
}
defer c.Close()
c.SetReadDeadline(time.Now().Add(400 * time.Millisecond))
s := bufio.NewScanner(c)
return s.Scan() && strings.HasPrefix(s.Text(), "SSH-2.0-")
}
func (s *Server) accessRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/access", func(w http.ResponseWriter, r *http.Request) {
if !s.authorized(w, r) {
return
}
keys, err := s.Access.List()
if err != nil {
reply(w, 503, map[string]string{"error": "Хранилище SSH недоступно"})
return
}
reply(w, 200, map[string]any{"users": s.Access.Users(), "keys": keys, "ssh_ready": SSHReady()})
})
mux.HandleFunc("POST /api/access", func(w http.ResponseWriter, r *http.Request) {
if !s.authorized(w, r) {
return
}
var b struct {
User string `json:"user"`
Label string `json:"label"`
Key string `json:"key"`
}
if !decode(w, r, &b) {
return
}
if err := s.Access.Add(b.User, b.Label, b.Key); err != nil {
reply(w, 400, map[string]string{"error": err.Error()})
return
}
reply(w, 200, map[string]bool{"ok": true})
})
mux.HandleFunc("DELETE /api/access", func(w http.ResponseWriter, r *http.Request) {
if !s.authorized(w, r) {
return
}
var b struct {
User string `json:"user"`
ID string `json:"id"`
}
if !decode(w, r, &b) {
return
}
if err := s.Access.Remove(b.User, b.ID); err != nil {
reply(w, 503, map[string]string{"error": "Не удалось удалить ключ"})
return
}
reply(w, 200, map[string]bool{"ok": true})
})
}
func AuthorizedKeys(path, user string) (string, error) {
a := &AccessStore{Path: path, Users: func() []string { return LocalAdmins("/") }}
if !a.allowed(user) {
return "", nil
}
keys, err := a.List()
if err != nil {
return "", err
}
var out strings.Builder
for _, k := range keys {
if k.User == user {
out.WriteString(`from="10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,100.64.0.0/10,127.0.0.0/8,::1,fc00::/7,fe80::/10" ` + k.PublicKey + "\n")
}
}
return out.String(), nil
}
@@ -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,13 @@
{
"schema": "missioncore.node.environment/v1",
"revision": "ubuntu-24.04-amd64/2",
"steps": [
{"id":"platform","label":"Проверка системы","description":"Операционная система и архитектура БК","requires":[]},
{"id":"packages","label":"Установка системных пакетов","description":"OpenSSH Server и зависимости окружения","requires":["platform"]},
{"id":"node-service","label":"Настройка службы БК","description":"Автозапуск и доступ к системной инвентаризации","requires":["platform"]},
{"id":"network-inventory","label":"Получение сетевых настроек","description":"Интерфейсы и назначенные адреса","requires":["node-service"]},
{"id":"usb-inventory","label":"Получение USB-устройств","description":"Оборудование, обнаруженное операционной системой","requires":["node-service"]},
{"id":"ssh-service","label":"Настройка SSH","description":"Запуск сервера и подключение реестра доверенных ключей","requires":["packages","node-service"]},
{"id":"tailscale-install","label":"Установка Tailscale","description":"Проверенный пакет и системная служба частной сети","requires":["packages"]}
]
}
@@ -0,0 +1,100 @@
package node
import (
"context"
_ "embed"
"encoding/json"
"io"
"os"
"os/exec"
"time"
)
//go:embed environment-profile.json
var environmentProfile []byte
type EnvironmentRun struct {
Schema string `json:"schema"`
ProfileRevision string `json:"profile_revision"`
RunID string `json:"run_id"`
State string `json:"state"`
StartedAt float64 `json:"started_at"`
UpdatedAt float64 `json:"updated_at"`
Steps []EnvironmentStep `json:"steps"`
}
type EnvironmentStep struct {
ID string `json:"id"`
State string `json:"state"`
Detail string `json:"detail"`
}
type EnvironmentStatus struct {
Profile json.RawMessage `json:"profile"`
Run *EnvironmentRun `json:"run"`
Available bool `json:"available"`
}
func ReadEnvironment() EnvironmentStatus {
result := readEnvironmentFile("/var/lib/mission-core-node-environment/last-run.json")
if result.Run != nil && result.Run.State == "running" {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
state, err := exec.CommandContext(ctx, "/usr/bin/systemctl", "show", "--property=ActiveState", "--value", "mission-core-node-environment.service").Output()
if err != nil || (string(state) != "activating\n" && string(state) != "active\n") {
// A stopped job/reboot cannot leave yesterday's spinner running.
result.Run.State = "interrupted"
}
}
return result
}
func readEnvironmentFile(path string) EnvironmentStatus {
result := EnvironmentStatus{Profile: json.RawMessage(environmentProfile), Available: true}
f, err := os.Open(path)
if os.IsNotExist(err) {
return result
}
if err != nil {
result.Available = false
return result
}
defer f.Close()
info, err := f.Stat()
if err != nil || !info.Mode().IsRegular() || info.Size() > 32768 {
result.Available = false
return result
}
var run EnvironmentRun
decoder := json.NewDecoder(io.LimitReader(f, 32769))
if decoder.Decode(&run) != nil || decoder.Decode(new(any)) != io.EOF || !validEnvironmentRun(run) {
result.Available = false
return result
}
result.Run = &run
return result
}
// Invalid or inconsistent progress cannot turn a failed setup into green UI.
func validEnvironmentRun(run EnvironmentRun) bool {
if run.Schema != "missioncore.node.environment/v1" || run.RunID == "" || len(run.RunID) > 64 || len(run.Steps) == 0 || len(run.Steps) > 32 || run.ProfileRevision == "" {
return false
}
if run.State != "running" && run.State != "complete" && run.State != "error" {
return false
}
seen := map[string]bool{}
for _, step := range run.Steps {
if step.ID == "" || len(step.ID) > 64 || seen[step.ID] || len(step.Detail) > 4096 {
return false
}
seen[step.ID] = true
switch step.State {
case "pending", "running", "complete", "error", "blocked":
default:
return false
}
if run.State == "complete" && step.State != "complete" {
return false
}
}
return true
}
@@ -0,0 +1,51 @@
package node
import (
"os"
"path/filepath"
"testing"
)
func TestEnvironmentStatusIsAuthorizedReadOnly(t *testing.T) {
s := newTestServer(t)
s.Environment = func() EnvironmentStatus { return EnvironmentStatus{Available: true, Profile: environmentProfile} }
if got := call(s, "GET", "/api/environment", "", nil); got.Code != 401 {
t.Fatal(got.Code)
}
cookie := login(t, s)
if got := call(s, "GET", "/api/environment", "", cookie); got.Code != 200 {
t.Fatal(got.Code, got.Body.String())
}
if got := call(s, "POST", "/api/environment", "{}", cookie); got.Code == 200 {
t.Fatal("HTTP can mutate system")
}
}
func TestMissingCorruptAndPartialEnvironmentReports(t *testing.T) {
path := filepath.Join(t.TempDir(), "last-run.json")
result := readEnvironmentFile(path)
if !result.Available || result.Run != nil {
t.Fatal("new install not admitted")
}
samples := []string{
`{broken`,
`{"schema":"missioncore.node.environment/v1","profile_revision":"test/1","run_id":"test","state":"complete","steps":[{"id":"network-inventory","state":"error"}]}`,
`{"schema":"missioncore.node.environment/v1","profile_revision":"test/1","run_id":"test","state":"complete","steps":[{"id":"x","state":"complete"},{"id":"x","state":"complete"}]}`,
`{"schema":"missioncore.node.environment/v1","profile_revision":"test/1","run_id":"test","state":"complete","steps":[{"id":"x","state":"complete"}]} {}`,
}
for _, sample := range samples {
if err := os.WriteFile(path, []byte(sample), 0600); err != nil {
t.Fatal(err)
}
if got := readEnvironmentFile(path); got.Available || got.Run != nil {
t.Fatal("invalid report admitted", sample)
}
}
if err := os.WriteFile(path, []byte(`{"schema":"missioncore.node.environment/v1","profile_revision":"test/1","run_id":"test","state":"error","steps":[{"id":"packages","state":"error"},{"id":"ssh-service","state":"blocked"}]}`), 0600); err != nil {
t.Fatal(err)
}
result = readEnvironmentFile(path)
if !result.Available || result.Run == nil || result.Run.State != "error" || result.Run.Steps[1].State != "blocked" {
t.Fatal("failure lost")
}
}
+118
View File
@@ -0,0 +1,118 @@
package node
import (
"net"
"os"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"time"
)
type Network struct {
Name string `json:"name"`
Up bool `json:"up"`
Addresses []string `json:"addresses"`
AddressesReadable bool `json:"addresses_readable"`
}
type USB struct {
Port string `json:"port"`
Vendor string `json:"vendor"`
ProductID string `json:"product_id"`
Product string `json:"product"`
Speed string `json:"speed_mbps"`
}
type Inventory struct {
CollectedAt string `json:"collected_at"`
Hostname string `json:"hostname"`
OS string `json:"os"`
Architecture string `json:"architecture"`
CPUs int `json:"cpus"`
MemoryKiB *uint64 `json:"memory_kib"`
AvailableKiB *uint64 `json:"available_kib"`
Networks []Network `json:"networks"`
NetworksReadable bool `json:"networks_readable"`
USB []USB `json:"usb"`
USBReadable bool `json:"usb_readable"`
Warnings []string `json:"warnings"`
}
// Host reads only local kernel/OS metadata. It never probes network devices,
// opens camera streams, reads device serials, or changes a network interface.
func Host(root string) Inventory {
read := func(p string) string {
b, _ := os.ReadFile(filepath.Join(root, p))
return strings.TrimSpace(string(b))
}
host, _ := os.Hostname()
v := Inventory{CollectedAt: time.Now().UTC().Format(time.RFC3339), Hostname: host, OS: runtime.GOOS, Architecture: runtime.GOARCH, CPUs: runtime.NumCPU(), Networks: []Network{}, USB: []USB{}, Warnings: []string{}}
for _, line := range strings.Split(read("etc/os-release"), "\n") {
if x, ok := strings.CutPrefix(line, "PRETTY_NAME="); ok {
v.OS = strings.Trim(x, "\"")
}
}
for _, line := range strings.Split(read("proc/meminfo"), "\n") {
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
n, e := strconv.ParseUint(fields[1], 10, 64)
if e != nil {
continue
}
if fields[0] == "MemTotal:" {
v.MemoryKiB = &n
}
if fields[0] == "MemAvailable:" {
v.AvailableKiB = &n
}
}
if v.MemoryKiB == nil {
v.Warnings = append(v.Warnings, "Сведения о памяти недоступны")
}
var warnings []string
v.Networks, v.NetworksReadable, warnings = readNetworks(net.Interfaces, func(it net.Interface) ([]net.Addr, error) { return it.Addrs() })
v.Warnings = append(v.Warnings, warnings...)
entries, err := os.ReadDir(filepath.Join(root, "sys/bus/usb/devices"))
v.USBReadable = err == nil
if err != nil {
v.Warnings = append(v.Warnings, "Сведения об USB недоступны")
}
for _, e := range entries {
prefix := filepath.Join("sys/bus/usb/devices", e.Name())
vendor := read(filepath.Join(prefix, "idVendor"))
if vendor == "" {
continue
}
v.USB = append(v.USB, USB{Port: e.Name(), Vendor: vendor, ProductID: read(filepath.Join(prefix, "idProduct")), Product: read(filepath.Join(prefix, "product")), Speed: read(filepath.Join(prefix, "speed"))})
}
return v
}
func readNetworks(list func() ([]net.Interface, error), addrs func(net.Interface) ([]net.Addr, error)) ([]Network, bool, []string) {
result, warnings := []Network{}, []string{}
interfaces, err := list()
if err != nil {
return result, false, []string{"Не удалось прочитать сетевые интерфейсы"}
}
for _, it := range interfaces {
if it.Flags&net.FlagLoopback != 0 {
continue
}
addresses, err := addrs(it)
n := Network{Name: it.Name, Up: it.Flags&net.FlagUp != 0, Addresses: []string{}, AddressesReadable: err == nil}
if err != nil {
warnings = append(warnings, "Адреса интерфейса "+it.Name+" недоступны")
} else {
for _, a := range addresses {
n.Addresses = append(n.Addresses, a.String())
}
}
sort.Strings(n.Addresses)
result = append(result, n)
}
sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name })
return result, true, warnings
}
@@ -0,0 +1,31 @@
package node
import (
"errors"
"net"
"testing"
)
func TestNetworkInventoryDistinguishesFailureFromEmpty(t *testing.T) {
denied := errors.New("read denied")
for _, failure := range []error{nil, denied} {
rows, readable, warnings := readNetworks(func() ([]net.Interface, error) { return nil, failure }, func(net.Interface) ([]net.Addr, error) { t.Fatal("no interface to read"); return nil, nil })
if len(rows) != 0 || readable != (failure == nil) || (len(warnings) > 0) != (failure != nil) {
t.Fatal(rows, readable, warnings)
}
}
}
func TestNetworkAddressFailureKeepsInterfaceWithoutInventingAddresses(t *testing.T) {
rows, readable, warnings := readNetworks(func() ([]net.Interface, error) {
return []net.Interface{{Name: "loop", Flags: net.FlagLoopback}, {Name: "ethernet", Flags: net.FlagUp}}, nil
}, func(it net.Interface) ([]net.Addr, error) {
if it.Name != "ethernet" {
t.Fatal("read loopback")
}
return []net.Addr{&net.IPAddr{IP: net.ParseIP("192.0.2.1")}}, errors.New("partial result")
})
if !readable || len(rows) != 1 || rows[0].Name != "ethernet" || !rows[0].Up || rows[0].AddressesReadable || len(rows[0].Addresses) != 0 || len(warnings) != 1 {
t.Fatal(rows, readable, warnings)
}
}
@@ -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)
}
}
+255
View File
@@ -0,0 +1,255 @@
package node
import (
"bytes"
"encoding/base64"
"encoding/binary"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"testing/fstest"
"time"
)
func newTestServer(t *testing.T) *Server {
t.Helper()
state, e := OpenStore(t.TempDir())
if e != nil {
t.Fatal(e)
}
return &Server{Store: state, Origin: "http://127.0.0.1:8780", Assets: fstest.MapFS{"index.html": {Data: []byte("test-only asset")}}, Inventory: func() Inventory {
return Inventory{Hostname: "private-host", Networks: []Network{{Name: "eth0", Addresses: []string{"192.168.10.4/24"}}}}
}}
}
func call(s *Server, method, path, body string, cookie *http.Cookie) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, s.Origin+path, strings.NewReader(body))
r.Header.Set("Origin", s.Origin)
r.Header.Set("Content-Type", "application/json")
if cookie != nil {
r.AddCookie(cookie)
}
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, r)
return w
}
func login(t *testing.T, s *Server) *http.Cookie {
t.Helper()
v := strings.TrimPrefix(s.IssueLogin(), s.Origin+"/#login=")
w := call(s, "POST", "/api/session", `{"token":"`+v+`"}`, nil)
if w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
return w.Result().Cookies()[0]
}
func TestIdentitySurvivesRenameAndReopen(t *testing.T) {
dir := t.TempDir()
s, e := OpenStore(dir)
if e != nil {
t.Fatal(e)
}
id, _ := s.Public()
if e = s.Rename("Борт 1"); e != nil {
t.Fatal(e)
}
s, e = OpenStore(dir)
if e != nil {
t.Fatal(e)
}
next, name := s.Public()
if next != id || name != "Борт 1" {
t.Fatal(next, name)
}
info, _ := os.Stat(filepath.Join(dir, "identity.json"))
if info.Mode().Perm() != 0600 {
t.Fatal(info.Mode())
}
if e = s.Rename("bad\nname"); e == nil {
t.Fatal("accepted control character")
}
}
func TestCorruptIdentityNeverReplaced(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "identity.json")
bad := []byte(`{"version":1,"private_key":"bad"}`)
os.WriteFile(p, bad, 0600)
if _, e := OpenStore(dir); e == nil {
t.Fatal("corrupt state accepted")
}
got, _ := os.ReadFile(p)
if !bytes.Equal(got, bad) {
t.Fatal("identity replaced")
}
}
func TestLoginOneUseConcurrentAndExpires(t *testing.T) {
s := newTestServer(t)
now := time.Now()
s.Now = func() time.Time { return now }
v := strings.TrimPrefix(s.IssueLogin(), s.Origin+"/#login=")
var wg sync.WaitGroup
codes := make(chan int, 8)
for i := 0; i < 8; i++ {
wg.Add(1)
go func() { defer wg.Done(); codes <- call(s, "POST", "/api/session", `{"token":"`+v+`"}`, nil).Code }()
}
wg.Wait()
close(codes)
success := 0
for c := range codes {
if c == 200 {
success++
} else if c != 401 {
t.Fatal(c)
}
}
if success != 1 {
t.Fatal(success)
}
v = strings.TrimPrefix(s.IssueLogin(), s.Origin+"/#login=")
now = now.Add(time.Minute)
if call(s, "POST", "/api/session", `{"token":"`+v+`"}`, nil).Code != 401 {
t.Fatal("expired launch accepted")
}
c := login(t, s)
if !c.HttpOnly || c.SameSite != http.SameSiteStrictMode {
t.Fatal(c)
}
now = now.Add(8 * time.Hour)
if call(s, "GET", "/api/status", "", c).Code != 401 {
t.Fatal("expired session accepted")
}
}
func TestUnauthenticatedAndCrossSiteRequestsDenied(t *testing.T) {
s := newTestServer(t)
c := login(t, s)
for _, path := range []string{"/api/status", "/api/report"} {
if call(s, "GET", path, "", nil).Code != 401 {
t.Fatal(path)
}
}
for _, kind := range []string{"origin", "host", "metadata", "missing-origin"} {
r := httptest.NewRequest("PUT", s.Origin+"/api/name", strings.NewReader(`{"name":"attacker"}`))
r.AddCookie(c)
r.Header.Set("Origin", s.Origin)
r.Header.Set("Content-Type", "application/json")
switch kind {
case "origin":
r.Header.Set("Origin", "https://evil.example")
case "host":
r.Host = "evil.example"
case "metadata":
r.Header.Set("Sec-Fetch-Site", "cross-site")
case "missing-origin":
r.Header.Del("Origin")
}
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, r)
if w.Code != 403 {
t.Fatal(kind, w.Code)
}
}
_, name := s.Store.Public()
if name == "attacker" {
t.Fatal("cross-site state changed")
}
}
func TestReportDoesNotLeakPrivateState(t *testing.T) {
s := newTestServer(t)
c := login(t, s)
w := call(s, "GET", "/api/report", "", c)
if w.Code != 200 {
t.Fatal(w.Code)
}
id, _ := s.Store.Public()
for _, secret := range []string{"private-host", "192.168.10.4", id, "private_key", c.Value} {
if strings.Contains(w.Body.String(), secret) {
t.Fatal("report leaked", secret)
}
}
if !strings.Contains(w.Header().Get("Content-Disposition"), "attachment") {
t.Fatal("not downloadable")
}
}
func TestLogoutAndStrictJSON(t *testing.T) {
s := newTestServer(t)
c := login(t, s)
for _, body := range []string{`{"name":"x"} {}`, `{"name":"x","other":1}`} {
if call(s, "PUT", "/api/name", body, c).Code != 400 {
t.Fatal("accepted invalid document")
}
}
if call(s, "POST", "/api/logout", `{}`, c).Code != 200 {
t.Fatal("logout failed")
}
if call(s, "GET", "/api/status", "", c).Code != 401 {
t.Fatal("session survived logout")
}
}
func syntheticKey() string {
b := make([]byte, 51)
binary.BigEndian.PutUint32(b[:4], 11)
copy(b[4:15], "ssh-ed25519")
binary.BigEndian.PutUint32(b[15:19], 32)
for i := 19; i < len(b); i++ {
b[i] = byte(i)
}
return "ssh-ed25519 " + base64.StdEncoding.EncodeToString(b)
}
func TestSSHKeyEnrollmentRejectsCommandsAndRoot(t *testing.T) {
a := &AccessStore{Path: filepath.Join(t.TempDir(), "ssh-keys.json"), Users: func() []string { return []string{"operator"} }}
key := syntheticKey()
for _, bad := range []string{"command=\"sh\" " + key, key + "\n" + key, "-----BEGIN PRIVATE KEY-----", "ssh-ed25519 YQ=="} {
if e := a.Add("operator", "laptop", bad); e == nil {
t.Fatal("unsafe key accepted")
}
}
if e := a.Add("root", "laptop", key); e == nil {
t.Fatal("root accepted")
}
if e := a.Add("operator", "laptop", key+" private-comment"); e != nil {
t.Fatal(e)
}
if e := a.Add("operator", "laptop", key); e != nil {
t.Fatal(e)
}
keys, e := a.List()
if e != nil || len(keys) != 1 || keys[0].PublicKey != key {
t.Fatal(keys, e)
}
if e := a.Remove("operator", keys[0].ID); e != nil {
t.Fatal(e)
}
keys, _ = a.List()
if len(keys) != 0 {
t.Fatal("revocation failed")
}
}
func TestLinuxInventoryUsesActualMetadataWithoutSerial(t *testing.T) {
dir := t.TempDir()
for p, v := range map[string]string{"etc/os-release": "PRETTY_NAME=\"Synthetic Linux\"", "proc/meminfo": "MemTotal: 8388608 kB\nMemAvailable: 4000000 kB", "sys/bus/usb/devices/1-2/idVendor": "8086", "sys/bus/usb/devices/1-2/idProduct": "0b5c", "sys/bus/usb/devices/1-2/product": "Synthetic camera", "sys/bus/usb/devices/1-2/speed": "5000", "sys/bus/usb/devices/1-2/serial": "do-not-read"} {
target := filepath.Join(dir, p)
os.MkdirAll(filepath.Dir(target), 0700)
os.WriteFile(target, []byte(v), 0600)
}
v := Host(dir)
if v.OS != "Synthetic Linux" || *v.MemoryKiB != 8388608 || !v.USBReadable || len(v.USB) != 1 || v.USB[0].Speed != "5000" {
t.Fatal(v)
}
b, _ := json.Marshal(v)
if bytes.Contains(b, []byte("do-not-read")) {
t.Fatal("serial leaked")
}
}
+243
View File
@@ -0,0 +1,243 @@
package node
import (
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"os"
"path/filepath"
"sort"
"sync"
"time"
)
type Invitation struct {
ID string `json:"id"`
Endpoint string `json:"endpoint"`
ExpiresAt int64 `json:"expires_at"`
SecretHash string `json:"secret_hash,omitempty"`
}
type CoreBinding struct {
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"`
Phase string `json:"phase"`
Invitation *Invitation `json:"invitation,omitempty"`
Binding *CoreBinding `json:"binding,omitempty"`
Revocations []CoreBinding `json:"revocations,omitempty"`
}
type Pairing struct {
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) {
p := &Pairing{store: store, path: filepath.Join(dir, "core-binding.json"), now: time.Now, inventory: inventory, version: version, connection: "offline", clients: make(map[string]*http.Client), state: PairState{Schema: PairSchema, Phase: "unpaired"}}
data, e := os.ReadFile(p.path)
if os.IsNotExist(e) {
return p, nil
}
if e != nil {
return nil, e
}
info, e := os.Lstat(p.path)
if e != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 || len(data) > 65536 {
return nil, errors.New("invalid Core binding permissions or size")
}
if json.Unmarshal(data, &p.state) != nil || p.state.Schema != PairSchema {
return nil, errors.New("invalid Core binding; recovery required")
}
switch p.state.Phase {
case "unpaired", "inviting", "pending", "paired", "revoked":
default:
return nil, errors.New("unknown Core binding state")
}
if (p.state.Phase == "pending" || p.state.Phase == "paired") && p.state.Binding == nil {
return nil, errors.New("incomplete Core binding")
}
if (p.state.Phase == "inviting" || p.state.Phase == "pending") && p.state.Invitation == nil {
return nil, errors.New("incomplete invitation")
}
if (p.state.Phase == "paired" || p.state.Phase == "revoked") && p.state.Invitation != nil {
next := p.state
next.Invitation = nil
if e := p.save(next); e != nil {
return nil, e
}
}
return p, nil
}
func savePrivateJSON(path string, value any) error {
data, e := json.Marshal(value)
if e != nil {
return e
}
f, e := os.CreateTemp(filepath.Dir(path), ".binding-*")
if e != nil {
return e
}
defer os.Remove(f.Name())
if _, e = f.Write(data); e != nil {
f.Close()
return e
}
if e = f.Sync(); e != nil {
f.Close()
return e
}
if e = f.Close(); e != nil {
return e
}
if e = os.Rename(f.Name(), path); e != nil {
return e
}
dir, e := os.Open(filepath.Dir(path))
if e != nil {
return e
}
defer dir.Close()
return dir.Sync()
}
func (p *Pairing) save(next PairState) error {
if e := savePrivateJSON(p.path, next); e != nil {
return e
}
p.state = next
return nil
}
func digest(value string) string { h := sha256.Sum256([]byte(value)); return hex.EncodeToString(h[:]) }
func (p *Pairing) expire() error {
if (p.state.Phase == "inviting" && p.state.Invitation.ExpiresAt <= p.now().Unix()) || (p.state.Phase == "pending" && p.state.Binding.ExpiresAt <= p.now().Unix()) {
return p.save(PairState{Schema: PairSchema, Phase: "unpaired", Revocations: p.state.Revocations})
}
return nil
}
func (p *Pairing) status() map[string]any {
p.mu.Lock()
defer p.mu.Unlock()
_ = p.expire()
id, _ := p.store.Public()
out := map[string]any{"phase": p.state.Phase, "node_id": id, "connection": p.connection, "last_seen": p.lastSeen, "notice": p.listenError, "addresses": p.addresses(), "pending_revocations": len(p.state.Revocations)}
if i := p.state.Invitation; i != nil {
out["invitation"] = map[string]any{"id": i.ID, "endpoint": i.Endpoint, "expires_at": i.ExpiresAt}
}
if b := p.state.Binding; b != nil {
out["binding"] = map[string]any{"binding_id": b.BindingID, "core_id": b.CoreID, "core_name": b.CoreName, "endpoint": b.Endpoint}
}
return out
}
func (p *Pairing) addresses() []string {
result := []string{}
for _, network := range p.inventory().Networks {
if !network.Up {
continue
}
for _, alias := range network.Addresses {
address := alias
for i, c := range address {
if c == '/' {
address = address[:i]
break
}
}
if PrivateAddress(address) {
result = append(result, address)
}
}
}
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) {
p.mu.Lock()
defer p.mu.Unlock()
if e := p.expire(); e != nil {
return nil, e
}
if p.state.Phase == "paired" || p.state.Phase == "pending" {
return nil, errors.New("Сначала отмените текущую привязку")
}
found := false
for _, a := range p.addresses() {
if a == address {
found = true
}
}
if !found {
return nil, errors.New("Выберите доступный частный адрес этого БК")
}
secret := token()
i := &Invitation{ID: token(), Endpoint: "https://" + address + ":" + PairPort, ExpiresAt: p.now().Add(10 * time.Minute).Unix(), SecretHash: digest(secret)}
if e := p.save(PairState{Schema: PairSchema, Phase: "inviting", Invitation: i, Revocations: p.state.Revocations}); e != nil {
return nil, e
}
p.connection = "offline"
p.lastSeen = 0
id, _ := p.store.Public()
code, _ := json.Marshal(map[string]any{"schema": PairSchema, "node_id": id, "id": i.ID, "endpoint": i.Endpoint, "expires_at": i.ExpiresAt, "secret": secret})
return map[string]any{"code": "MCN1." + base64.RawURLEncoding.EncodeToString(code), "expires_at": i.ExpiresAt}, nil
}
func (p *Pairing) cancel() error {
p.mu.Lock()
defer p.mu.Unlock()
revocations := append([]CoreBinding(nil), p.state.Revocations...)
if p.state.Binding != nil {
if len(revocations) >= 8 {
return errors.New("Дождитесь доставки предыдущих отзывов доверия")
}
revocations = append(revocations, *p.state.Binding)
}
p.connection = "offline"
p.listenError = ""
p.lastSeen = 0
return p.save(PairState{Schema: PairSchema, Phase: "unpaired", Revocations: revocations})
}
func (p *Pairing) checkInvitation(id, secret string) bool {
now := p.now().Unix()
if now-p.failureWindow >= 60 {
p.failureWindow = now
p.failures = 0
}
if p.failures >= 32 {
return false
}
i := p.state.Invitation
if i == nil || i.ID != id || i.ExpiresAt <= now || subtle.ConstantTimeCompare([]byte(i.SecretHash), []byte(digest(secret))) != 1 {
p.failures++
return false
}
return true
}
@@ -0,0 +1,96 @@
package node
import (
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/hex"
"encoding/pem"
"errors"
"math/big"
"net"
"net/url"
"time"
)
const PairSchema = "missioncore.node-pairing/v1"
const PairPort = "8781"
const CorePort = "8782"
func PrivateAddress(value string) bool {
ip := net.ParseIP(value)
if ip == nil || ip.To4() == nil {
return false
}
return ip.IsPrivate() || (ip.To4()[0] == 100 && ip.To4()[1] >= 64 && ip.To4()[1] <= 127)
}
func privateEndpoint(value, port string) bool {
u, e := url.Parse(value)
return e == nil && u.Scheme == "https" && u.User == nil && u.Path == "" && u.RawQuery == "" && u.Fragment == "" && u.Port() == port && PrivateAddress(u.Hostname())
}
func keyID(prefix string, key ed25519.PublicKey) string {
sum := sha256.Sum256(key)
return prefix + hex.EncodeToString(sum[:])
}
func (s *Store) pairingKey() ed25519.PrivateKey {
s.mu.Lock()
defer s.mu.Unlock()
return append(ed25519.PrivateKey(nil), s.state.PrivateKey...)
}
func bootstrapCertificate(key ed25519.PrivateKey, address string) (tls.Certificate, error) {
serial, e := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if e != nil {
return tls.Certificate{}, e
}
spec := &x509.Certificate{SerialNumber: serial, Subject: pkix.Name{CommonName: "Mission Core Node"}, NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(24 * time.Hour), IPAddresses: []net.IP{net.ParseIP(address)}, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, BasicConstraintsValid: true}
der, e := x509.CreateCertificate(rand.Reader, spec, spec, key.Public(), key)
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}, e
}
func bindingTLS(b CoreBinding, key ed25519.PrivateKey) (*tls.Config, error) {
if !privateEndpoint(b.Endpoint, CorePort) {
return nil, errors.New("Core address is not private")
}
block, _ := pem.Decode([]byte(b.CAPEM))
if block == nil {
return nil, errors.New("missing Core certificate")
}
ca, e := x509.ParseCertificate(block.Bytes)
if e != nil {
return nil, e
}
pub, ok := ca.PublicKey.(ed25519.PublicKey)
if !ok || keyID("core_", pub) != b.CoreID || !ca.IsCA || ca.CheckSignatureFrom(ca) != nil {
return nil, errors.New("Core identity mismatch")
}
block, _ = pem.Decode([]byte(b.ClientPEM))
if block == nil {
return nil, errors.New("missing client certificate")
}
cert, e := x509.ParseCertificate(block.Bytes)
if e != nil {
return nil, e
}
roots := x509.NewCertPool()
roots.AddCert(ca)
if _, e = cert.Verify(x509.VerifyOptions{Roots: roots, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}}); e != nil {
return nil, e
}
nodePub, ok := cert.PublicKey.(ed25519.PublicKey)
if !ok || !nodePub.Equal(key.Public()) {
return nil, errors.New("client identity mismatch")
}
return &tls.Config{MinVersion: tls.VersionTLS13, RootCAs: roots, Certificates: []tls.Certificate{{Certificate: [][]byte{cert.Raw}, PrivateKey: key}}}, nil
}
// Once the issued credential expires, the old Core cannot accept this binding.
func clientExpired(b CoreBinding) bool {
block, _ := pem.Decode([]byte(b.ClientPEM))
if block == nil {
return false
}
cert, e := x509.ParseCertificate(block.Bytes)
return e == nil && time.Now().After(cert.NotAfter)
}
@@ -0,0 +1,40 @@
package node
import (
"net"
"sync"
)
// Bound unauthenticated bootstrap sockets before TLS allocates a goroutine.
type pairingListener struct {
net.Listener
slots chan struct{}
done chan struct{}
once sync.Once
}
func (l *pairingListener) Accept() (net.Conn, error) {
select {
case l.slots <- struct{}{}:
case <-l.done:
return nil, net.ErrClosed
}
c, e := l.Listener.Accept()
if e != nil {
<-l.slots
return nil, e
}
return &pairingConn{Conn: c, release: func() { <-l.slots }}, nil
}
func (l *pairingListener) Close() error {
l.once.Do(func() { close(l.done) })
return l.Listener.Close()
}
type pairingConn struct {
net.Conn
release func()
once sync.Once
}
func (c *pairingConn) Close() error { e := c.Conn.Close(); c.once.Do(c.release); return e }
@@ -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")
}
}

Some files were not shown because too many files have changed in this diff Show More