Author SHA1 Message Date
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
DCCONSTRUCTIONS b2a1b23131 fix(observatory): preserve camera across follow transitions 2026-09-05 08:46:35 +03:00
DCCONSTRUCTIONS cada687173 feat(observatory): ship modular AI inference labs 2026-09-04 17:59:05 +03:00
DCCONSTRUCTIONS eff60e490a docs(observatory): record saved replay evidence and open visual gates 2026-09-03 13:24:32 +03:00
DCCONSTRUCTIONS 9db29bcdc6 feat(observatory): share native result replay and expose saved TGS layers 2026-09-03 13:23:56 +03:00
DCCONSTRUCTIONS 27979854a7 feat(observatory): cache sealed camera and TGS replay on Core 2026-09-03 13:15:07 +03:00
DCCONSTRUCTIONS 3ea8d987a7 docs(observatory): record real M49 publication and restart acceptance 2026-09-03 11:50:32 +03:00
DCCONSTRUCTIONS bd947b49f4 fix(observatory): preserve source identity on unchanged reindex 2026-09-03 11:43:03 +03:00
DCCONSTRUCTIONS 54c8d82884 ops(observatory): install exact recorded-progress agent layers 2026-09-03 11:22:03 +03:00
DCCONSTRUCTIONS bee8552003 feat(observatory): expose attempt-bound recorded progress 2026-09-03 11:17:56 +03:00
DCCONSTRUCTIONS 4e0565eb4b docs(observatory): refresh four-stage recorded-first roadmap 2026-09-03 10:35:48 +03:00
DCCONSTRUCTIONS 44afadb021 docs(observatory): record selector rules and local UI acceptance 2026-09-03 10:16:12 +03:00
296 changed files with 38309 additions and 1936 deletions
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Синхронизированная запись</title>
<style>
html, body, #viewer { width: 100%; height: 100%; margin: 0; overflow: hidden; }
canvas { display: block; }
</style>
</head>
<body>
<div id="viewer"></div>
<script type="module" src="/src/components/rerun/isolatedRerunEntry.ts"></script>
</body>
</html>
+5 -5
View File
@@ -10,7 +10,6 @@ import {
ControlRow,
HeaderNavigation,
HeaderProfile,
HeaderWorkspace,
Icon,
Inspector,
RangeControl,
@@ -629,7 +628,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 +647,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 +656,6 @@ export default function App() {
}))}
onChange={selectRoot}
/>
</>
}
right={
<HeaderProfile>
@@ -802,7 +801,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 +827,7 @@ export default function App() {
) : (
<WorkspaceRenderer
definition={activeDefinition}
fleetCreateRequest={fleetCreateRequest}
state={activeRuntimeState}
backendStatus={runtime.backendStatus}
sourceUrl={effectiveSourceUrl}
@@ -1,4 +1,11 @@
import { useEffect, useRef, useState } from "react";
import { createIsolatedRerunHost } from "./rerun/isolatedRerunHost";
import { keepRecordedBlueprintSession } from "../core/observation/recordedBlueprintLifecycle";
import {
RECORDED_RERUN_ORBITAL_EYE,
RECORDED_RERUN_PLAN_EYE,
type RecordedRerunCameraEye,
} from "./rerun/recordedRerunCameraJournal";
import type { SceneSettings } from "../sceneSettings";
import {
@@ -138,6 +145,17 @@ export interface RerunViewportProps {
interface RerunBlueprintChannel {
endpointUrl: string;
cameraContract?: string | null;
appliedFollowTrajectory?: boolean | null;
pendingFollowCameraEye?: RecordedRerunCameraEye | null;
configureCameraJournal?: (
eye: RecordedRerunCameraEye,
spatialViewportStart: number,
) => void;
getCameraEye?: () => RecordedRerunCameraEye;
setCameraViewportStart?: (spatialViewportStart: number) => void;
getCurrentTimeNs?: () => number | null;
setCameraMaxOrbitalRadius?: (maxOrbitalRadius: number) => void;
channel: {
readonly ready: boolean;
send_rrd: (rrdBytes: Uint8Array) => void;
@@ -166,6 +184,13 @@ interface RecordedRerunIdentity {
const RECORDED_RRD_PATH = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/recording\.rrd$/;
const LAB_RECORDED_REPLAY_PATH = /^\/api\/v1\/laboratory\/vegetation-shadow\/lab-v1-vegetation-shadow-[a-f0-9]{64}\/canonical-replay\.rrd$/;
const PORTABLE_RECORDED_REPLAY_PATH = /^\/api\/v1\/observatory\/portable-results\/(?:m49-tgs-portable-review|lab-v1-eomt-ddrnet|ai-layer-(?:ddrnet|eomt|rf-detr|object-distance))-[a-f0-9]{64}\/replays\/[a-f0-9]{64}\/recording\.rrd$/;
const COMPOSITION_RECORDED_REPLAY_PATH = /^\/api\/v1\/observatory\/ai-composition-runs\/ai-composition-[a-f0-9]{64}\/replays\/[a-f0-9]{64}\/recording\.rrd$/;
export function isRecordedRrdSource(path: string): boolean {
return RECORDED_RRD_PATH.test(path) || LAB_RECORDED_REPLAY_PATH.test(path)
|| PORTABLE_RECORDED_REPLAY_PATH.test(path) || COMPOSITION_RECORDED_REPLAY_PATH.test(path);
}
const RECORDED_BLUEPRINT_PATH = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/blueprint\.rrd$/;
const RECORDED_PERCEPTION_PATH = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/perception\.rrd$/;
const LAB_RECORDED_PERCEPTION_PATH = /^\/api\/v1\/laboratory\/vegetation-shadow\/lab-v1-vegetation-shadow-[a-f0-9]{64}\/canonical-overlay\.rrd$/;
@@ -230,8 +255,7 @@ export function resolveRecordedViewerSourceUrl(
const expectedViewerSourceUrl = `${descriptor.sourceUrl}?generation=${descriptor.sha256}`;
const endpoint = new URL(descriptor.viewerSourceUrl, `${base.origin}/`);
if (
!(RECORDED_RRD_PATH.test(descriptor.sourceUrl)
|| LAB_RECORDED_REPLAY_PATH.test(descriptor.sourceUrl)) ||
!isRecordedRrdSource(descriptor.sourceUrl) ||
descriptor.viewerSourceUrl !== expectedViewerSourceUrl ||
endpoint.origin !== base.origin ||
endpoint.pathname !== descriptor.sourceUrl ||
@@ -262,7 +286,9 @@ export function resolveRecordedBlueprintUrl(
if (explicitSourceUrl !== undefined) {
const explicit = explicitSourceUrl.trim();
if (
!LAB_RECORDED_REPLAY_PATH.test(normalized)
!(LAB_RECORDED_REPLAY_PATH.test(normalized)
|| PORTABLE_RECORDED_REPLAY_PATH.test(normalized)
|| COMPOSITION_RECORDED_REPLAY_PATH.test(normalized))
|| !RECORDED_BLUEPRINT_PATH.test(explicit)
) return null;
const endpoint = new URL(explicit, `${base.origin}/`);
@@ -388,7 +414,13 @@ export async function fetchRecordedBlueprintRrd(
followTrajectory = false,
semanticLayer,
unifiedPerception,
unifiedCameraShare = 0.46,
planView = false,
cameraEye,
eyeRelativeToTracking = false,
currentTimeNs,
reactivateUpdates = false,
onCameraMaxOrbitalRadius,
perceptionLayers = {
enabled: false,
detections2d: false,
@@ -405,7 +437,13 @@ export async function fetchRecordedBlueprintRrd(
followTrajectory?: boolean;
semanticLayer?: "city" | "vegetation";
unifiedPerception?: boolean;
unifiedCameraShare?: number;
planView?: boolean;
cameraEye?: RecordedRerunCameraEye;
eyeRelativeToTracking?: boolean;
currentTimeNs?: number | null;
reactivateUpdates?: boolean;
onCameraMaxOrbitalRadius?: (maxOrbitalRadius: number) => void;
perceptionLayers?: RecordedPerceptionLayers;
fetcher?: typeof globalThis.fetch;
},
@@ -421,10 +459,8 @@ export async function fetchRecordedBlueprintRrd(
!RECORDED_BLUEPRINT_PATH.test(endpoint.pathname) ||
!Number.isFinite(settings.accumulationSeconds) ||
settings.accumulationSeconds < 0 ||
settings.accumulationSeconds > 3600 ||
!Number.isFinite(settings.pointSize) ||
settings.pointSize < 0.1 ||
settings.pointSize > 32 ||
!["intensity", "height", "distance", "rgb", "class"].includes(settings.colorMode) ||
!["turbo", "viridis", "plasma", "grayscale", "custom"].includes(settings.palette) ||
!/^#[0-9A-Fa-f]{6}$/.test(settings.customColor) ||
@@ -432,12 +468,28 @@ export async function fetchRecordedBlueprintRrd(
![0, 1].includes(viewResetGeneration) ||
(semanticLayer !== undefined && !["city", "vegetation"].includes(semanticLayer)) ||
typeof resolvedUnifiedPerception !== "boolean" ||
!Number.isFinite(unifiedCameraShare) ||
unifiedCameraShare < 0.1 ||
unifiedCameraShare > 0.9 ||
typeof planView !== "boolean" ||
typeof eyeRelativeToTracking !== "boolean" ||
typeof reactivateUpdates !== "boolean" ||
(eyeRelativeToTracking && (cameraEye === undefined || currentTimeNs == null)) ||
(cameraEye !== undefined && [
...cameraEye.position,
...cameraEye.lookTarget,
...cameraEye.eyeUp,
].some((value) => !Number.isFinite(value))) ||
(currentTimeNs !== undefined && currentTimeNs !== null && (
!Number.isSafeInteger(currentTimeNs) || currentTimeNs < 0
)) ||
[
perceptionLayers.enabled,
perceptionLayers.cameraImage ?? true,
perceptionLayers.detections2d,
perceptionLayers.segmentation,
perceptionLayers.cuboids3d,
perceptionLayers.costmap ?? false,
].some((value) => typeof value !== "boolean") ||
identity.applicationId !== "nodedc_mission_core_recorded" ||
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(identity.recordingId) ||
@@ -468,11 +520,27 @@ export async function fetchRecordedBlueprintRrd(
view_reset_generation: viewResetGeneration,
follow_trajectory: followTrajectory,
unified_perception: resolvedUnifiedPerception,
unified_camera_share: unifiedCameraShare,
semantic_layer: semanticLayer ?? null,
plan_view: planView,
eye_position: cameraEye?.position ?? null,
eye_look_target: cameraEye?.lookTarget ?? null,
eye_up: cameraEye?.eyeUp ?? null,
...(eyeRelativeToTracking ? { eye_relative_to_tracking: true } : {}),
...(currentTimeNs === undefined || currentTimeNs === null ? {} : {
current_time_ns: currentTimeNs,
}),
show_detections_2d: perceptionLayers.detections2d,
show_camera_image: perceptionLayers.cameraImage ?? true,
show_segmentation: perceptionLayers.segmentation,
show_cuboids_3d: perceptionLayers.cuboids3d,
...(perceptionLayers.costmap === undefined ? {} : {
show_costmap: perceptionLayers.costmap,
reactivate_updates: true,
}),
...(reactivateUpdates && perceptionLayers.costmap === undefined
? { reactivate_updates: true }
: {}),
}),
signal,
});
@@ -496,9 +564,34 @@ export async function fetchRecordedBlueprintRrd(
) {
throw new Error("Invalid recorded blueprint RRD");
}
const maxOrbitalRadius = Number(
response.headers.get("X-MissionCore-Camera-Max-Orbital-Radius"),
);
if (Number.isFinite(maxOrbitalRadius) && maxOrbitalRadius >= 0.02) {
onCameraMaxOrbitalRadius?.(maxOrbitalRadius);
}
return payload;
}
export function recordedCameraJournalContract(
{
activeView,
viewResetGeneration,
planView,
}: {
activeView: RecordedRerunView;
viewResetGeneration: 0 | 1;
planView: boolean;
followTrajectory: boolean;
},
): string {
// Following is a tracking property of the current native eye. It must never
// initialize the browser journal again: doing so replaces the operator's
// current pose with the startup preset immediately before the blueprint is
// sent. Plan/3D and explicit reset are the only preset transitions.
return [activeView, viewResetGeneration, planView].join(":");
}
export async function fetchRecordedPerceptionRrd(
endpointUrl: string,
identity: RecordedRerunIdentity,
@@ -707,6 +800,7 @@ export function RerunViewport({
const recordedPerceptionSourceUrl = recordedProfile?.perceptionSourceUrl;
const recordedSemanticLayer = recordedProfile?.semanticLayer;
const recordedUnifiedPerception = recordedProfile?.unifiedPerception ?? false;
const recordedUnifiedCameraShare = recordedProfile?.unifiedCameraShare ?? 0.46;
const recordedPlanView = recordedProfile?.planView ?? false;
const recordedPerceptionRetryGeneration =
recordedProfile?.perceptionRetryGeneration ?? 0;
@@ -752,9 +846,9 @@ export function RerunViewport({
: sourceUrl
? resolveRecordedPerceptionUrl(sourceUrl, window.location.origin)
: null;
const recordedPointColorsUrl = sourceUrl
? resolveRecordedPointColorsUrl(sourceUrl, window.location.origin)
: null;
const recordedPointColorsUrl = recordedBlueprintUrl
? recordedBlueprintUrl.replace(/\/blueprint\.rrd$/, "/point-colors.rrd")
: sourceUrl ? resolveRecordedPointColorsUrl(sourceUrl, window.location.origin) : null;
const presentationStatus = rerunPresentationStatus(
status,
presentationGate,
@@ -791,8 +885,7 @@ export function RerunViewport({
return;
}
const isRecordedSource = RECORDED_RRD_PATH.test(normalizedSource)
|| LAB_RECORDED_REPLAY_PATH.test(normalizedSource);
const isRecordedSource = isRecordedRrdSource(normalizedSource);
let resolvedSource: string;
try {
if (isRecordedSource) {
@@ -827,6 +920,9 @@ export function RerunViewport({
diagnosticLifecycle.verifyBuild();
let disposed = false;
const blueprintOwnerId = crypto.randomUUID().replaceAll("-", "");
blueprintSessionIdRef.current = blueprintOwnerId;
let releaseBlueprintSession = () => {};
let disposeViewer: (() => void) | undefined;
let recordedOpenWatchdog: {
arm: () => void;
@@ -1073,6 +1169,11 @@ export function RerunViewport({
);
}
activeViewerLifecycleRef.current = disposeActiveViewerLifecycle;
const restoreAfterPageCache = (event: PageTransitionEvent) => {
if (event.persisted) setRetryNonce((nonce) => nonce + 1);
};
window.addEventListener("pagehide", disposeActiveViewerLifecycle);
window.addEventListener("pageshow", restoreAfterPageCache);
host.replaceChildren();
appliedPointColorKeyRef.current = null;
@@ -1080,15 +1181,22 @@ export function RerunViewport({
setRecordingBufferProgress(null);
onStatusChange?.("loading");
void import("@rerun-io/web-viewer")
.then(async ({ WebViewer }) => {
const isolatedHost = isRecordedSource ? createIsolatedRerunHost(host) : null;
disposeViewer = () => isolatedHost?.dispose();
const nativeViewer = isolatedHost?.ready ?? import("@rerun-io/web-viewer")
.then(({ WebViewer }) => ({ viewer: new WebViewer(), mount: host }));
void nativeViewer
.then(async ({ viewer, mount }) => {
// React StrictMode intentionally disposes the first effect while the
// dynamic import is still pending. Never start that stale viewer (the
// vendor API treats a missing host as document.body).
if (disposed) return;
const viewer = new WebViewer();
if (disposed) {
viewer.stop();
isolatedHost?.dispose();
return;
}
disposeViewer = createReentrantViewerDisposer(() => {
releaseBlueprintSession();
clearRecordingTimers();
clearPlaybackRangeTimer();
playbackTimeUpdates.cancel();
@@ -1146,6 +1254,7 @@ export function RerunViewport({
// A partially initialized WASM handle can already be gone after a
// startup failure.
}
isolatedHost?.dispose();
host.replaceChildren();
});
if (isRecordedSource && recordedArtifact) {
@@ -1182,6 +1291,13 @@ export function RerunViewport({
event.application_id === "nodedc_mission_core_recorded" &&
/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(event.recording_id)
) {
releaseBlueprintSession = keepRecordedBlueprintSession({
endpointUrl: recordedBlueprintUrl,
origin: window.location.origin,
applicationId: event.application_id,
recordingId: event.recording_id,
ownerId: blueprintOwnerId,
});
const current = recordedIdentityRef.current;
if (
current?.applicationId !== event.application_id ||
@@ -1491,7 +1607,7 @@ export function RerunViewport({
};
await viewer.start(
rerunViewerInitialSource(resolvedSource),
host,
mount,
viewerOptions,
);
if (disposed) {
@@ -1509,7 +1625,36 @@ export function RerunViewport({
if (recordedBlueprintUrl) {
const channel = viewer.open_channel("missioncore/recorded-blueprint");
blueprintChannel = { endpointUrl: recordedBlueprintUrl, channel };
blueprintChannel = {
endpointUrl: recordedBlueprintUrl,
cameraContract: null,
appliedFollowTrajectory: null,
pendingFollowCameraEye: null,
configureCameraJournal: (eye, spatialViewportStart) => {
if ("configure_camera_journal" in viewer) {
viewer.configure_camera_journal(eye, spatialViewportStart);
}
},
getCameraEye: () => "get_camera_eye" in viewer
? viewer.get_camera_eye()
: RECORDED_RERUN_ORBITAL_EYE,
setCameraViewportStart: (spatialViewportStart) => {
if ("set_camera_viewport_start" in viewer) {
viewer.set_camera_viewport_start(spatialViewportStart);
}
},
getCurrentTimeNs: () => {
const currentIdentity = recordedIdentityRef.current;
if (!currentIdentity) return null;
return viewer.get_current_time(currentIdentity.recordingId, "session_time");
},
setCameraMaxOrbitalRadius: (maxOrbitalRadius) => {
if ("set_camera_max_orbital_radius" in viewer) {
viewer.set_camera_max_orbital_radius(maxOrbitalRadius);
}
},
channel,
};
blueprintChannelRef.current = blueprintChannel;
setBlueprintChannelRevision((revision) => revision + 1);
}
@@ -1569,6 +1714,8 @@ export function RerunViewport({
});
return () => {
window.removeEventListener("pagehide", disposeActiveViewerLifecycle);
window.removeEventListener("pageshow", restoreAfterPageCache);
if (activeViewerLifecycleRef.current === disposeActiveViewerLifecycle) {
activeViewerLifecycleRef.current = null;
}
@@ -1938,6 +2085,9 @@ export function RerunViewport({
useEffect(() => {
if (!recordedBlueprintUrl || !sceneSettings) return;
// Initialize the portable following-eye blueprint after native admission
// and its initial seek, not while the base RRD is still arriving.
if (recordedPerceptionLayers.costmap !== undefined && status !== "ready") return;
const active = blueprintChannelRef.current;
const identity = recordedIdentityRef.current;
if (
@@ -1946,29 +2096,116 @@ export function RerunViewport({
active.endpointUrl !== recordedBlueprintUrl ||
!active.channel.ready
) return;
const cameraContract = recordedCameraJournalContract(
{
activeView: recordedView,
viewResetGeneration: recordedViewResetGeneration,
planView: recordedPlanView,
followTrajectory: recordedFollowTrajectory,
},
);
const cameraContractChanged = active.cameraContract !== cameraContract;
if (cameraContractChanged) {
active.configureCameraJournal?.(
recordedPlanView ? RECORDED_RERUN_PLAN_EYE : RECORDED_RERUN_ORBITAL_EYE,
recordedUnifiedPerception ? recordedUnifiedCameraShare : 0,
);
}
const abort = new AbortController();
void fetchRecordedBlueprintRrd(recordedBlueprintUrl, sceneSettings, identity, {
origin: window.location.origin,
blueprintSessionId: blueprintSessionIdRef.current,
signal: abort.signal,
activeView: recordedView,
viewResetGeneration: recordedViewResetGeneration,
followTrajectory: recordedFollowTrajectory,
perceptionLayers: recordedPerceptionLayers,
semanticLayer: recordedSemanticLayer,
unifiedPerception: recordedUnifiedPerception,
planView: recordedPlanView,
}).then((payload) => {
if (
abort.signal.aborted ||
blueprintChannelRef.current !== active ||
recordedIdentityRef.current !== identity ||
!active.channel.ready
) {
const previousFollow = active.appliedFollowTrajectory ?? false;
const enablingFollow = recordedFollowTrajectory && !previousFollow;
const disablingFollow = !recordedFollowTrajectory && previousFollow;
const transitionEye = (enablingFollow || disablingFollow)
? active.getCameraEye?.()
: null;
if (enablingFollow && transitionEye) {
active.pendingFollowCameraEye = transitionEye;
} else if (disablingFollow) {
active.pendingFollowCameraEye = null;
}
const pendingFollowEye = recordedFollowTrajectory
? active.pendingFollowCameraEye ?? null
: null;
const requestBlueprint = async (
cameraEye?: RecordedRerunCameraEye,
eyeRelativeToTracking = false,
reactivateUpdates = false,
) => {
const currentTimeNs = active.getCurrentTimeNs?.();
if (eyeRelativeToTracking && currentTimeNs == null) {
throw new Error("Recorded tracking cursor is unavailable");
}
return fetchRecordedBlueprintRrd(recordedBlueprintUrl, sceneSettings, identity, {
origin: window.location.origin,
blueprintSessionId: blueprintSessionIdRef.current,
signal: abort.signal,
activeView: recordedView,
viewResetGeneration: recordedViewResetGeneration,
followTrajectory: recordedFollowTrajectory,
perceptionLayers: recordedPerceptionLayers,
semanticLayer: recordedSemanticLayer,
unifiedPerception: recordedUnifiedPerception,
unifiedCameraShare: recordedUnifiedCameraShare,
planView: recordedPlanView,
cameraEye,
eyeRelativeToTracking,
currentTimeNs,
reactivateUpdates,
onCameraMaxOrbitalRadius: (maxOrbitalRadius) => {
if (blueprintChannelRef.current === active) {
active.setCameraMaxOrbitalRadius?.(maxOrbitalRadius);
}
},
});
};
const canApply = () => (
!abort.signal.aborted &&
blueprintChannelRef.current === active &&
recordedIdentityRef.current === identity &&
active.channel.ready
);
const applyPayload = (payload: Uint8Array) => {
if (!canApply()) return false;
active.channel.send_rrd(payload);
active.setCameraViewportStart?.(
recordedUnifiedPerception ? recordedUnifiedCameraShare : 0,
);
return true;
};
void (async () => {
const firstEye = disablingFollow
? transitionEye ?? undefined
: !enablingFollow && (cameraContractChanged || pendingFollowEye)
? pendingFollowEye ?? active.getCameraEye?.()
: undefined;
const firstEyeIsTrackingRelative = Boolean(firstEye) && (
disablingFollow || recordedFollowTrajectory
);
const firstPayload = await requestBlueprint(
firstEye,
firstEyeIsTrackingRelative,
enablingFollow || disablingFollow || Boolean(pendingFollowEye),
);
if (!applyPayload(firstPayload)) return;
active.cameraContract = cameraContract;
active.appliedFollowTrajectory = recordedFollowTrajectory;
if (!enablingFollow || !transitionEye) {
if (pendingFollowEye && firstEye === pendingFollowEye) {
active.pendingFollowCameraEye = null;
}
return;
}
active.channel.send_rrd(payload);
}).catch(() => {
// Rerun 0.36.3 intentionally fits a newly tracked transform to its
// bounding box. Apply the operator's relative eye on the next native
// frame, after tracking is established, to retain direction and zoom.
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
if (!canApply()) return;
const stabilizedPayload = await requestBlueprint(transitionEye, true, true);
if (!applyPayload(stabilizedPayload)) return;
active.pendingFollowCameraEye = null;
})().catch(() => {
// The recording remains usable with its embedded default blueprint.
// A later settings change retries through the same small channel.
});
@@ -1981,11 +2218,15 @@ export function RerunViewport({
recordedFollowTrajectory,
recordedSemanticLayer,
recordedUnifiedPerception,
recordedUnifiedCameraShare,
recordedPlanView,
recordedPerceptionLayers.enabled,
recordedPerceptionLayers.cameraImage,
recordedPerceptionLayers.detections2d,
recordedPerceptionLayers.segmentation,
recordedPerceptionLayers.cuboids3d,
recordedPerceptionLayers.costmap,
status,
sceneSettings?.accumulationSeconds,
sceneSettings?.customColor,
sceneSettings?.colorMode,
@@ -0,0 +1,33 @@
import { CanonicalResultRerunReplay } from "./CanonicalResultRerunReplay";
import { resolveAICompositionReplay } from "../../core/laboratory/canonicalLabReplay";
import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive";
import type { AICompositionRun } from "../../core/observatory/aiComposition";
function resolveComposition(
runId: string,
launch: ObservationSessionReplayLaunch,
options: { signal: AbortSignal },
) {
return resolveAICompositionReplay(runId, launch, options);
}
export function AICompositionReplay({ run }: { run: AICompositionRun }) {
const semantics = run.moduleIds.includes("ddrnet") || run.moduleIds.includes("eomt");
const detections = run.moduleIds.includes("rf-detr")
|| run.moduleIds.includes("object-distance");
const costmap = run.moduleIds.includes("tgs");
return (
<CanonicalResultRerunReplay
key={run.runId}
resultId={run.runId}
sessionId={run.sourceSessionId}
resolveReplay={resolveComposition}
semantics={semantics}
detections={detections}
costmap={costmap}
moduleIds={run.moduleIds}
viewerLayers={run.presentation.viewerLayers}
label={run.configurationLabel}
/>
);
}
@@ -88,6 +88,9 @@ export function CanonicalRecordedLabReplay<
mediaLayerControls,
spatialLayerControls,
spatialLeadingControl,
spatialTrailingControl,
mediaModeControlsVisible = true,
paneToolbarsAlwaysVisible = false,
mediaMultiLayer = false,
mediaContent,
spatialContent,
@@ -116,10 +119,14 @@ export function CanonicalRecordedLabReplay<
mediaLayerControls?: ReactNode;
spatialLayerControls?: ReactNode;
spatialLeadingControl?: ReactNode;
spatialTrailingControl?: ReactNode;
mediaModeControlsVisible?: boolean;
/** Keeps renderer-local controls inside their viewport when only one pane is present. */
paneToolbarsAlwaysVisible?: boolean;
mediaMultiLayer?: boolean;
mediaContent?: ReactNode;
spatialContent?: ReactNode;
/** One upstream Rerun viewer owns both panes and the shared playback clock. */
/** One upstream Rerun viewer owns both panes; this controlled split owns their shared geometry. */
unifiedContent?: ReactNode;
emptyMessage: string;
deckOverlays?: ReactNode;
@@ -162,14 +169,14 @@ export function CanonicalRecordedLabReplay<
aria-label={mediaAriaLabel}
hidden={mediaMode === "none"}
>
{splitView ? (
{splitView || paneToolbarsAlwaysVisible ? (
<div
className="m4-replay-threat-visual__pane-toolbar"
data-pane-toolbar="media"
data-multi-semantic={mediaMultiLayer ? "true" : undefined}
>
{mediaLayerControls}
{mediaModeControls}
{mediaModeControlsVisible ? mediaModeControls : null}
</div>
) : null}
{mediaContent}
@@ -181,7 +188,7 @@ export function CanonicalRecordedLabReplay<
data-pane="spatial"
aria-label={spatialAriaLabel}
>
{splitView ? (
{splitView || paneToolbarsAlwaysVisible ? (
<div
className="m4-replay-threat-visual__pane-toolbar"
data-pane-toolbar="spatial"
@@ -190,6 +197,7 @@ export function CanonicalRecordedLabReplay<
<div className="m4-replay-threat-visual__spatial-toolbar-end">
{spatialLayerControls}
{spatialModeControls}
{spatialTrailingControl}
</div>
</div>
) : null}
@@ -216,7 +224,7 @@ export function CanonicalRecordedLabReplay<
expanded={expanded}
onModeChange={onMediaModeChange}
onExpandedChange={onExpandedChange}
modeControlsVisible={!splitView}
modeControlsVisible={!splitView && !paneToolbarsAlwaysVisible}
actions={actions}
overlay={overlay}
transport={transport}
@@ -241,7 +249,7 @@ export function CanonicalRecordedLabReplay<
orientation={splitOrientation}
minPrimarySize={splitView ? 24 : 0}
minSecondarySize={splitView ? 24 : 0}
resizable={splitView && !unifiedContent}
resizable={splitView}
separatorLabel="Изменить размер видео/камеры и 3D/плана"
/>
{mediaMode === "none" && spatialMode === "none" ? (
@@ -0,0 +1,524 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
ActivityIndicator,
Button,
Checker,
ControlRow,
Icon,
IconButton,
Inspector,
InspectorSelectField,
RangeControl,
ToastStack,
Window,
type ToastItem,
} from "@nodedc/ui-react";
import { ObservationTimeline } from "../ObservationTimeline";
import {
RerunViewport,
isRecordedPlaybackPresentationReady,
type RerunPlaybackController,
type RerunPlaybackState,
type RerunViewportStatus,
} from "../RerunViewport";
import {
CanonicalRecordedLabReplay,
useCanonicalRecordedLabReplayState,
} from "../laboratory/CanonicalRecordedLabReplay";
import type { CanonicalLabReplayDescriptor } from "../../core/laboratory/canonicalLabReplay";
import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive";
import { recordedSessionRerunProfile } from "../../core/observation/viewerProfile";
import { resolveObservationSessionReplay } from "../../core/observation/useObservationSessions";
import type { AIViewerLayer } from "../../core/observatory/aiComposition";
import {
fetchLabViewProfile,
saveLabViewProfile,
} from "../../core/observatory/labViewProfile";
import {
defaultSceneSettings,
type PointColorMode,
type PointPalette,
type SceneSettings,
} from "../../sceneSettings";
type MediaMode = "camera";
type SpatialMode = "3d" | "plan";
interface CanonicalReplayLaunch {
base: ObservationSessionReplayLaunch;
replay: CanonicalLabReplayDescriptor;
}
const RERUN_UNIFIED_CAMERA_SHARE_PERCENT = 46;
const LAB_SCENE_SETTINGS_STORAGE_PREFIX = "missioncore.observatory.lab-scene-settings.v1:";
const LAB_VIEW_PROFILE_ERROR_TOAST = "observatory-lab-view-profile-error";
const COLOR_OPTIONS: readonly { value: PointColorMode; label: string }[] = [
{ value: "intensity", label: "Интенсивность" },
{ value: "height", label: "Высота Z" },
{ value: "distance", label: "Дистанция" },
{ value: "rgb", label: "RGB" },
{ value: "class", label: "Класс" },
];
const PALETTE_OPTIONS: readonly { value: PointPalette; label: string }[] = [
{ value: "turbo", label: "Turbo" },
{ value: "viridis", label: "Viridis" },
{ value: "plasma", label: "Plasma" },
{ value: "grayscale", label: "Серый" },
];
const LAB_SCENE_DEFAULTS: SceneSettings = {
...defaultSceneSettings,
pointSize: 3.8,
accumulationSeconds: 5,
};
export function normalizeLabSceneSettings(settings: SceneSettings): SceneSettings {
const pointSize = Number.isFinite(settings.pointSize) ? settings.pointSize : LAB_SCENE_DEFAULTS.pointSize;
const accumulationSeconds = Number.isFinite(settings.accumulationSeconds)
? settings.accumulationSeconds : LAB_SCENE_DEFAULTS.accumulationSeconds;
return {
...settings,
pointSize: Math.round(Math.max(0.1, pointSize) * 10) / 10,
accumulationSeconds: Math.round(Math.max(0, accumulationSeconds)),
};
}
function loadLabSceneSettings(resultId: string): SceneSettings {
if (typeof window === "undefined") return LAB_SCENE_DEFAULTS;
try {
const raw = window.localStorage.getItem(`${LAB_SCENE_SETTINGS_STORAGE_PREFIX}${resultId}`);
if (raw === null) return LAB_SCENE_DEFAULTS;
const value: unknown = JSON.parse(raw);
if (typeof value !== "object" || value === null || Array.isArray(value)) return LAB_SCENE_DEFAULTS;
const row = value as Record<string, unknown>;
const pointSize = row.pointSize;
const accumulationSeconds = row.accumulationSeconds;
const colorMode = row.colorMode;
const palette = row.palette;
const showGrid = row.showGrid;
const showLabels = row.showLabels;
const showCameraFrustums = row.showCameraFrustums;
if (
typeof pointSize !== "number" || !Number.isFinite(pointSize) || pointSize < 0.1
|| typeof accumulationSeconds !== "number" || !Number.isFinite(accumulationSeconds)
|| accumulationSeconds < 0
|| !COLOR_OPTIONS.some((option) => option.value === colorMode)
|| !PALETTE_OPTIONS.some((option) => option.value === palette)
|| typeof showGrid !== "boolean"
|| typeof showLabels !== "boolean"
|| typeof showCameraFrustums !== "boolean"
) return LAB_SCENE_DEFAULTS;
return {
...LAB_SCENE_DEFAULTS,
pointSize,
accumulationSeconds,
colorMode: colorMode as PointColorMode,
palette: palette as PointPalette,
showGrid,
showLabels,
showCameraFrustums,
};
} catch {
return LAB_SCENE_DEFAULTS;
}
}
export function CanonicalResultRerunReplay({
resultId,
sessionId,
initialPlaybackStartSeconds,
resolveReplay,
semantics = false,
detections = false,
costmap = false,
moduleIds = [],
viewerLayers = [],
label = "Сохранённый результат",
}: {
resultId: string;
sessionId: string;
initialPlaybackStartSeconds?: number;
resolveReplay: (resultId: string, launch: ObservationSessionReplayLaunch, options: { signal: AbortSignal }) => Promise<CanonicalLabReplayDescriptor>;
semantics?: boolean;
detections?: boolean;
costmap?: boolean;
portable?: boolean;
moduleIds?: readonly string[];
viewerLayers?: readonly AIViewerLayer[];
label?: string;
}) {
const {
mediaMode,
spatialMode,
splitPrimarySize,
splitOrientation,
expanded,
onMediaModeChange,
onSpatialModeChange,
onSplitPrimarySizeChange,
onExpandedChange,
} = useCanonicalRecordedLabReplayState<MediaMode, SpatialMode>({
initialMediaMode: "camera",
initialSpatialMode: "3d",
});
const layerIds = useMemo(
() => new Set(viewerLayers.map((layer) => layer.layerId)),
[viewerLayers],
);
const hasProjectedLayers = viewerLayers.length > 0;
const hasDDRNet = layerIds.has("camera.ddrnet")
|| (!hasProjectedLayers && semantics && moduleIds.includes("ddrnet"));
const hasEoMT = layerIds.has("camera.eomt")
|| (!hasProjectedLayers && semantics && moduleIds.includes("eomt"));
const hasFallbackSemantics = semantics && !hasDDRNet && !hasEoMT;
const hasDetections = layerIds.has("camera.detections") || (!hasProjectedLayers && detections);
const hasTgs = layerIds.has("spatial.tgs") || (!hasProjectedLayers && costmap);
const legacyUnclassified = !hasProjectedLayers
&& moduleIds.length === 0
&& !semantics
&& !detections
&& !costmap;
const hasCameraPane = layerIds.has("camera.source")
|| (!hasProjectedLayers && (legacyUnclassified || semantics || detections));
const hasSourcePoints = layerIds.has("spatial.source-points")
|| (!hasProjectedLayers && (legacyUnclassified || costmap || moduleIds.includes("object-distance")));
const hasLocalSlam = layerIds.has("spatial.local-slam")
|| (!hasProjectedLayers && (legacyUnclassified || costmap || moduleIds.includes("object-distance")));
const hasSpatialPane = hasSourcePoints || hasLocalSlam || hasTgs;
const splitView = hasCameraPane && hasSpatialPane;
const [showCamera, setShowCamera] = useState(true);
const [showDDRNet, setShowDDRNet] = useState(hasDDRNet || hasFallbackSemantics);
const [showEoMT, setShowEoMT] = useState(hasEoMT);
const [showDetections, setShowDetections] = useState(hasDetections);
const [showSourcePoints, setShowSourcePoints] = useState(hasSourcePoints);
const [showLocalSlam, setShowLocalSlam] = useState(hasLocalSlam);
const [showTgs, setShowTgs] = useState(hasTgs);
const [followTarget, setFollowTarget] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [sceneDraft, setSceneDraft] = useState<SceneSettings>(() => loadLabSceneSettings(resultId));
const [blueprintSplitPrimarySize, setBlueprintSplitPrimarySize] = useState(
RERUN_UNIFIED_CAMERA_SHARE_PERCENT,
);
const [toasts, setToasts] = useState<ToastItem[]>([]);
const [viewResetGeneration, setViewResetGeneration] = useState<0 | 1>(hasTgs ? 1 : 0);
const [playback, setPlayback] = useState<RerunPlaybackState | null>(null);
const [playbackController, setPlaybackController] = useState<RerunPlaybackController | null>(null);
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>("idle");
const [launch, setLaunch] = useState<CanonicalReplayLaunch | null>(null);
const [launchError, setLaunchError] = useState<string | null>(null);
const sceneDraftRef = useRef(sceneDraft);
const previousSplitViewRef = useRef(splitView);
const trackedLaunchSha256Ref = useRef<string | null>(null);
useEffect(() => {
const launchSha256 = launch?.replay.sha256 ?? null;
if (trackedLaunchSha256Ref.current !== launchSha256 || (splitView && !previousSplitViewRef.current)) {
onSplitPrimarySizeChange(RERUN_UNIFIED_CAMERA_SHARE_PERCENT);
setBlueprintSplitPrimarySize(RERUN_UNIFIED_CAMERA_SHARE_PERCENT);
}
trackedLaunchSha256Ref.current = launchSha256;
previousSplitViewRef.current = splitView;
}, [launch?.replay.sha256, onSplitPrimarySizeChange, splitView]);
useEffect(() => {
if (!splitView) return;
const timer = window.setTimeout(() => setBlueprintSplitPrimarySize(splitPrimarySize), 75);
return () => window.clearTimeout(timer);
}, [splitPrimarySize, splitView]);
useEffect(() => {
try {
window.localStorage.setItem(
`${LAB_SCENE_SETTINGS_STORAGE_PREFIX}${resultId}`,
JSON.stringify({
pointSize: sceneDraft.pointSize,
accumulationSeconds: sceneDraft.accumulationSeconds,
colorMode: sceneDraft.colorMode,
palette: sceneDraft.palette,
showGrid: sceneDraft.showGrid,
showLabels: sceneDraft.showLabels,
showCameraFrustums: sceneDraft.showCameraFrustums,
}),
);
} catch {
// The visual remains usable when the browser refuses local persistence.
}
}, [resultId, sceneDraft]);
useEffect(() => {
const controller = new AbortController();
void fetchLabViewProfile(resultId, controller.signal).then((settings) => {
if (!controller.signal.aborted && settings) {
sceneDraftRef.current = settings;
setSceneDraft(settings);
}
}).catch(() => {
// The per-browser copy remains a usable fallback while the local service recovers.
});
return () => controller.abort();
}, [resultId]);
useEffect(() => {
const controller = new AbortController();
setLaunch(null);
setLaunchError(null);
setPlayback(null);
setPlaybackController(null);
setViewerStatus("idle");
void resolveObservationSessionReplay(sessionId, {
signal: controller.signal,
maximumWaitMs: 30 * 60 * 1000,
onUpdate: () => undefined,
}).then(async (value) => ({
base: value,
replay: await resolveReplay(resultId, value, { signal: controller.signal }),
})).then((value) => {
if (!controller.signal.aborted) setLaunch(value);
}).catch((caught: unknown) => {
if (!controller.signal.aborted) {
setLaunchError(caught instanceof Error ? caught.message : "Сохранённая запись недоступна.");
}
});
return () => controller.abort();
}, [resultId, sessionId, resolveReplay]);
const presentationReady = playbackController !== null
&& isRecordedPlaybackPresentationReady(viewerStatus, playback);
const presentationState = launchError || viewerStatus === "error"
? "error"
: presentationReady ? "ready" : "loading";
const sceneSettings = useMemo<SceneSettings>(() => ({
...sceneDraft,
projection: spatialMode === "plan" ? "2d" : "3d",
showPoints: spatialMode !== null && (showSourcePoints || showLocalSlam),
showTrajectory: spatialMode !== null && showLocalSlam,
accumulationSeconds: showLocalSlam ? sceneDraft.accumulationSeconds : 0,
}), [sceneDraft, showLocalSlam, showSourcePoints, spatialMode]);
const semanticVisible = showDDRNet || showEoMT;
const profile = launch ? recordedSessionRerunProfile({
sourceUrl: launch.replay.sourceUrl,
artifact: {
sourceUrl: launch.replay.sourceUrl,
viewerSourceUrl: launch.replay.viewerSourceUrl,
byteLength: launch.replay.byteLength,
sha256: launch.replay.sha256,
},
blueprintSourceUrl: launch.replay.blueprintSourceUrl,
autoplayWhenReady: false,
presentationGate: "ready",
expectedTimelineStartSeconds: launch.base.timelineStartSeconds,
expectedTimelineEndSeconds: launch.base.timelineEndSeconds,
initialPlaybackStartSeconds: initialPlaybackStartSeconds
?? ((launch.base.mediaSources[0]?.timelineStartSeconds ?? launch.base.timelineStartSeconds)
+ (hasTgs ? 0.000001 : 0)),
view: hasCameraPane && !hasSpatialPane ? "perception" : "spatial",
viewResetGeneration,
followTrajectory: followTarget,
semanticLayer: showEoMT ? "city" : "vegetation",
unifiedPerception: splitView,
unifiedCameraShare: blueprintSplitPrimarySize / 100,
planView: spatialMode === "plan",
perceptionLayers: {
enabled: true,
cameraImage: showCamera,
detections2d: hasDetections && showDetections,
segmentation: semantics && semanticVisible,
cuboids3d: false,
costmap: hasTgs && showTgs,
},
perceptionRetryGeneration: 0,
lockPerceptionCameraInteraction: false,
}) : null;
const toggle = (caption: string, active: boolean, onClick: () => void) => (
<Button key={caption} size="dense" shape="pill" variant={active ? "primary" : "secondary"}
aria-pressed={active} onClick={onClick}>
{caption}
</Button>
);
const openSlamSettings = useCallback(() => {
if (expanded) onExpandedChange(false);
setSettingsOpen(true);
}, [expanded, onExpandedChange]);
const patchSceneDraft = useCallback((patch: Partial<SceneSettings>) => {
const next = { ...sceneDraftRef.current, ...patch };
sceneDraftRef.current = next;
setSceneDraft(next);
}, []);
const closeSlamSettings = useCallback(() => {
const next = normalizeLabSceneSettings(sceneDraftRef.current);
sceneDraftRef.current = next;
setSceneDraft(next);
setSettingsOpen(false);
void saveLabViewProfile(resultId, next).then((settings) => {
sceneDraftRef.current = settings;
setSceneDraft(settings);
setToasts((current) => current.filter(({ id }) => id !== LAB_VIEW_PROFILE_ERROR_TOAST));
}).catch(() => {
setToasts((current) => [
...current.filter(({ id }) => id !== LAB_VIEW_PROFILE_ERROR_TOAST),
{
id: LAB_VIEW_PROFILE_ERROR_TOAST,
tone: "error",
title: "Настройки LAB не сохранились",
description: "Изменения остались в текущем просмотре. Откройте настройки и повторите.",
},
]);
});
}, [resultId]);
const mediaLayerControls = (
<div className="m4-replay-threat-visual__pane-layer-controls" role="group" aria-label="Слои камеры">
{toggle("КАМЕРА", showCamera, () => setShowCamera((value) => !value))}
{hasEoMT ? toggle("EOMT", showEoMT, () => setShowEoMT((value) => !value)) : null}
{hasDDRNet || hasFallbackSemantics
? toggle("DDRNET", showDDRNet, () => setShowDDRNet((value) => !value)) : null}
{hasDetections
? toggle("РАМКИ", showDetections, () => setShowDetections((value) => !value)) : null}
</div>
);
const spatialLayerControls = (
<div className="m4-replay-threat-visual__pane-layer-controls" role="group" aria-label="Слои облака точек">
{hasSourcePoints
? toggle("ИСХ. ТОЧКИ", showSourcePoints, () => setShowSourcePoints((value) => !value)) : null}
{hasLocalSlam ? (
<div className="m4-replay-threat-visual__layer-with-settings">
{toggle("ЛОК. SLAM", showLocalSlam, () => setShowLocalSlam((value) => !value))}
<IconButton label="Настройки облака SLAM" onClick={openSlamSettings}>
<Icon name="settings" size={14} />
</IconButton>
</div>
) : null}
{hasTgs ? toggle("TGS", showTgs, () => setShowTgs((value) => !value)) : null}
</div>
);
const followTargetControl = toggle(
"СЛЕДОВАТЬ",
followTarget,
() => setFollowTarget((value) => !value),
);
const resetSpatialView = (
<IconButton label="Сбросить положение видов"
onClick={() => setViewResetGeneration((value) => value === 0 ? 1 : 0)}>
<Icon name="refresh" size={16} />
</IconButton>
);
const transport = presentationReady && playback && playbackController ? (
<ObservationTimeline
className="m4-replay-threat-visual__timeline canonical-vegetation-rerun-replay__timeline"
active
sourceCount={3}
mode="recorded"
seekable
synchronization="shared-clock"
rangeNs={playback.rangeNs}
currentNs={playback.currentNs}
playing={playback.playing}
onSeek={playbackController.seek}
onPlayingChange={playbackController.setPlaying}
showJumpToEnd={false}
/>
) : undefined;
return (
<div className="canonical-vegetation-rerun-replay" data-presentation-state={presentationState}
aria-busy={presentationState === "loading"}>
<CanonicalRecordedLabReplay
label={label}
mediaMode={hasCameraPane ? mediaMode ?? "camera" : "none"}
mediaModes={[{ value: "camera", label: "КАМЕРА" }]}
spatialMode={hasSpatialPane ? spatialMode ?? "3d" : "none"}
spatialModes={[{ value: "3d", label: "3D" }, { value: "plan", label: "ПЛАН" }]}
expanded={expanded}
splitPrimarySize={splitPrimarySize}
splitOrientation={splitView ? "vertical" : splitOrientation}
mediaAriaLabel="Камера и рассчитанные слои"
spatialAriaLabel={spatialMode === "plan" ? "Вид сверху" : "Трёхмерная сцена"}
mediaLayerControls={mediaLayerControls}
spatialLayerControls={spatialLayerControls}
spatialTrailingControl={followTargetControl}
mediaModeControlsVisible={false}
paneToolbarsAlwaysVisible
mediaMultiLayer
actions={resetSpatialView}
unifiedContent={profile ? (
<div className="canonical-vegetation-rerun-replay__viewport-lock"
data-split-view={splitView ? "true" : undefined}>
<RerunViewport profile={profile} sceneSettings={sceneSettings}
onStatusChange={setViewerStatus} onPlaybackChange={setPlayback}
onPlaybackControllerChange={setPlaybackController} />
</div>
) : launchError ? (
<div className="l3-visual-audit__state" role="alert">{launchError}</div>
) : <div aria-hidden="true" />}
emptyMessage="Слои результата недоступны."
deckOverlays={presentationState === "loading" ? (
<div className="canonical-vegetation-rerun-replay__loading">
<ActivityIndicator label="Загружаем синхронизированную запись" />
</div>
) : undefined}
transport={transport}
onMediaModeChange={onMediaModeChange}
onSpatialModeChange={onSpatialModeChange}
onExpandedChange={onExpandedChange}
onSplitPrimarySizeChange={onSplitPrimarySizeChange}
/>
<Window open={settingsOpen} title="Отображение" subtitle="Параметры облака SLAM"
placement="end" size="sm" onClose={closeSlamSettings}>
<Inspector defaultOpen={["points"]} sections={[
{
id: "points",
label: "Облако точек",
description: "Размер и способ окрашивания",
content: <>
<RangeControl label="Размер точки" min={0.1} max={10} step={0.1}
exactValueBounds={{ min: 0.1 }}
value={sceneDraft.pointSize} formatValue={(value) => `${value.toFixed(1)} пкс`}
onChange={(pointSize) => patchSceneDraft({ pointSize })} />
<InspectorSelectField label="Атрибут цвета" value={sceneDraft.colorMode}
options={[...COLOR_OPTIONS]}
onChange={(colorMode) => patchSceneDraft({ colorMode })} />
<InspectorSelectField label="Палитра" value={sceneDraft.palette}
options={[...PALETTE_OPTIONS]}
onChange={(palette) => patchSceneDraft({ palette })} />
</>,
},
{
id: "history",
label: "Накопление и время",
description: "История облака и траектория",
content: <RangeControl label="Накопление" min={0} max={500} step={1}
exactValueBounds={{ min: 0 }}
value={sceneDraft.accumulationSeconds} formatValue={(value) => `${value} с`}
onChange={(accumulationSeconds) => patchSceneDraft({ accumulationSeconds })} />,
},
{
id: "environment",
label: "Окружение сцены",
description: "Сетка, подписи и камеры",
content: <>
<ControlRow label="Сетка"><Checker checked={sceneDraft.showGrid} label="Показывать"
onChange={(showGrid) => patchSceneDraft({ showGrid })} /></ControlRow>
<ControlRow label="Подписи"><Checker checked={sceneDraft.showLabels} label="Показывать"
onChange={(showLabels) => patchSceneDraft({ showLabels })} /></ControlRow>
<ControlRow label="Камеры"><Checker checked={sceneDraft.showCameraFrustums} label="Показывать"
onChange={(showCameraFrustums) => patchSceneDraft({ showCameraFrustums })} /></ControlRow>
</>,
},
]} />
</Window>
<ToastStack items={toasts}
onDismiss={(id) => setToasts((current) => current.filter((item) => item.id !== id))} />
</div>
);
}
@@ -1,383 +1,12 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type PointerEvent as ReactPointerEvent,
} from "react";
import {
ActivityIndicator,
Button,
Icon,
SegmentedControl,
} from "@nodedc/ui-react";
import { ObservationTimeline } from "../ObservationTimeline";
import {
RerunViewport,
isRecordedPlaybackPresentationReady,
type RerunPlaybackController,
type RerunPlaybackState,
type RerunViewportStatus,
} from "../RerunViewport";
import {
CanonicalRecordedLabReplay,
useCanonicalRecordedLabReplayState,
} from "../laboratory/CanonicalRecordedLabReplay";
import {
resolveCanonicalLabReplay,
type CanonicalLabReplayDescriptor,
} from "../../core/laboratory/canonicalLabReplay";
import { CanonicalResultRerunReplay } from "./CanonicalResultRerunReplay";
import { resolveCanonicalLabReplay } from "../../core/laboratory/canonicalLabReplay";
import type { VegetationFullRouteReview } from "../../core/laboratory/vegetationShadow";
import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive";
import { recordedSessionRerunProfile } from "../../core/observation/viewerProfile";
import { resolveObservationSessionReplay } from "../../core/observation/useObservationSessions";
import { defaultSceneSettings } from "../../sceneSettings";
type MediaMode = "video" | "camera";
type SpatialMode = "3d" | "plan";
type SpatialLayer = "source" | "local" | "tgs" | "semantic";
type SemanticLayer = "city" | "vegetation";
interface CanonicalReplayLaunch {
base: ObservationSessionReplayLaunch;
replay: CanonicalLabReplayDescriptor;
}
const RERUN_UNIFIED_CAMERA_SHARE_PERCENT = 46;
const RERUN_NATIVE_DIVIDER_HIT_SLOP_PX = 10;
export function CanonicalVegetationRerunReplay({
resultId,
review,
}: {
export function CanonicalVegetationRerunReplay({ resultId, review }: {
resultId: string;
review: VegetationFullRouteReview;
}) {
const {
mediaMode,
spatialMode,
splitPrimarySize,
splitOrientation,
expanded,
onMediaModeChange,
onSpatialModeChange,
onSplitPrimarySizeChange,
onExpandedChange,
} = useCanonicalRecordedLabReplayState<MediaMode, SpatialMode>({
initialMediaMode: "video",
initialSpatialMode: "3d",
});
const splitView = mediaMode !== null && spatialMode !== null;
const [semanticLayer, setSemanticLayer] = useState<SemanticLayer>("vegetation");
const [showSemantics, setShowSemantics] = useState(true);
const [spatialLayer, setSpatialLayer] = useState<SpatialLayer>("source");
const [viewResetGeneration, setViewResetGeneration] = useState<0 | 1>(0);
const [playback, setPlayback] = useState<RerunPlaybackState | null>(null);
const [playbackController, setPlaybackController] =
useState<RerunPlaybackController | null>(null);
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>("idle");
const [launch, setLaunch] = useState<CanonicalReplayLaunch | null>(null);
const [launchError, setLaunchError] = useState<string | null>(null);
const viewerFrameRef = useRef<HTMLDivElement>(null);
const nativeSplitPercentRef = useRef(RERUN_UNIFIED_CAMERA_SHARE_PERCENT);
const nativeSplitTrackingCleanupRef = useRef<(() => void) | null>(null);
const previousSplitViewRef = useRef(splitView);
const trackedLaunchSha256Ref = useRef<string | null>(null);
const stopNativeSplitTracking = useCallback(() => {
nativeSplitTrackingCleanupRef.current?.();
nativeSplitTrackingCleanupRef.current = null;
}, []);
const trackNativeSplit = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if (
!splitView
|| event.button !== 0
|| !(event.target instanceof HTMLCanvasElement)
) return;
const frame = viewerFrameRef.current;
if (!frame) return;
const bounds = frame.getBoundingClientRect();
if (bounds.width <= 0) return;
const dividerX = bounds.left
+ bounds.width * nativeSplitPercentRef.current / 100;
if (Math.abs(event.clientX - dividerX) > RERUN_NATIVE_DIVIDER_HIT_SLOP_PX) return;
stopNativeSplitTracking();
const pointerId = event.pointerId;
const update = (pointerEvent: PointerEvent) => {
if (pointerEvent.pointerId !== pointerId) return;
const currentBounds = frame.getBoundingClientRect();
if (currentBounds.width <= 0) return;
const next = Math.min(90, Math.max(
10,
(pointerEvent.clientX - currentBounds.left) / currentBounds.width * 100,
));
nativeSplitPercentRef.current = next;
frame.style.setProperty("--canonical-rerun-camera-pane", `${next}%`);
};
const stop = (pointerEvent: PointerEvent) => {
if (pointerEvent.pointerId !== pointerId) return;
stopNativeSplitTracking();
};
window.addEventListener("pointermove", update, true);
window.addEventListener("pointerup", stop, true);
window.addEventListener("pointercancel", stop, true);
nativeSplitTrackingCleanupRef.current = () => {
window.removeEventListener("pointermove", update, true);
window.removeEventListener("pointerup", stop, true);
window.removeEventListener("pointercancel", stop, true);
};
}, [splitView, stopNativeSplitTracking]);
useEffect(() => stopNativeSplitTracking, [stopNativeSplitTracking]);
useEffect(() => {
if (!splitView) stopNativeSplitTracking();
const launchSha256 = launch?.replay.sha256 ?? null;
if (
trackedLaunchSha256Ref.current !== launchSha256
|| (splitView && !previousSplitViewRef.current)
) {
nativeSplitPercentRef.current = RERUN_UNIFIED_CAMERA_SHARE_PERCENT;
}
trackedLaunchSha256Ref.current = launchSha256;
previousSplitViewRef.current = splitView;
const cameraPanePercent = mediaMode === null
? 0
: splitView
? nativeSplitPercentRef.current
: 100;
viewerFrameRef.current?.style.setProperty(
"--canonical-rerun-camera-pane",
`${cameraPanePercent}%`,
);
}, [launch?.replay.sha256, mediaMode, splitView, stopNativeSplitTracking]);
useEffect(() => {
const controller = new AbortController();
setLaunch(null);
setLaunchError(null);
setPlayback(null);
setPlaybackController(null);
setViewerStatus("idle");
void resolveObservationSessionReplay(review.sessionId, {
signal: controller.signal,
maximumWaitMs: 30 * 60 * 1000,
onUpdate: () => undefined,
}).then(async (value) => ({
base: value,
replay: await resolveCanonicalLabReplay(resultId, value, {
signal: controller.signal,
}),
})).then((value) => {
if (!controller.signal.aborted) setLaunch(value);
}).catch((caught: unknown) => {
if (!controller.signal.aborted) {
setLaunchError(
caught instanceof Error ? caught.message : "Каноническая запись RAV004 недоступна.",
);
}
});
return () => controller.abort();
}, [resultId, review.sessionId]);
const presentationReady = playbackController !== null
&& isRecordedPlaybackPresentationReady(viewerStatus, playback);
const presentationState = launchError || viewerStatus === "error"
? "error"
: presentationReady
? "ready"
: "loading";
const sceneSettings = useMemo(() => ({
...defaultSceneSettings,
accumulationSeconds: spatialLayer === "local" ? 5 : 0,
showPoints: spatialMode !== null,
showTrajectory: spatialMode !== null,
showGrid: spatialMode !== null,
pointSize: 3.8,
}), [spatialLayer, spatialMode]);
const profile = launch ? recordedSessionRerunProfile({
sourceUrl: launch.replay.sourceUrl,
artifact: {
sourceUrl: launch.replay.sourceUrl,
viewerSourceUrl: launch.replay.viewerSourceUrl,
byteLength: launch.replay.byteLength,
sha256: launch.replay.sha256,
},
blueprintSourceUrl: launch.replay.blueprintSourceUrl,
autoplayWhenReady: false,
presentationGate: "ready",
expectedTimelineStartSeconds: launch.base.timelineStartSeconds,
expectedTimelineEndSeconds: launch.base.timelineEndSeconds,
initialPlaybackStartSeconds: review.timelineStartSeconds,
view: mediaMode !== null ? "perception" : "spatial",
viewResetGeneration,
followTrajectory: true,
semanticLayer,
unifiedPerception: splitView,
planView: spatialMode === "plan",
perceptionLayers: {
enabled: mediaMode !== null,
detections2d: mediaMode === "video",
segmentation: mediaMode === "video" && showSemantics,
cuboids3d: false,
},
perceptionRetryGeneration: 0,
lockPerceptionCameraInteraction: mediaMode !== null,
}) : null;
const mediaLayerControls = (
<div
className="m4-replay-threat-visual__pane-layer-controls"
role="group"
aria-label="Слои камеры и видео"
>
<Button
size="dense"
shape="pill"
variant={showSemantics ? "primary" : "secondary"}
aria-pressed={showSemantics}
onClick={() => setShowSemantics((visible) => !visible)}
>
СЕМАНТИКА
</Button>
<SegmentedControl
value={semanticLayer}
items={[
{ value: "city", label: "ГОРОД · EoMT" },
{ value: "vegetation", label: "ПРИРОДА · DDRNet" },
]}
label="Источник семантики"
size="dense"
onChange={(value) => {
setSemanticLayer(value);
setShowSemantics(true);
}}
/>
</div>
);
const spatialLayerControls = (
<div
className="m4-replay-threat-visual__pane-layer-controls"
role="group"
aria-label="Пространственные слои RAV004"
>
<SegmentedControl
value={spatialLayer}
items={[
{ value: "source", label: "ИСХ. ТОЧКИ" },
{ value: "local", label: "ЛОК. SLAM" },
{ value: "tgs", label: "TGS", disabled: true },
{ value: "semantic", label: "СЕМАНТИКА", disabled: true },
]}
label="Пространственные слои"
size="dense"
onChange={setSpatialLayer}
/>
</div>
);
const resetSpatialView = (
<Button
size="dense"
variant="ghost"
icon={<Icon name="refresh" size={14} />}
aria-label="Сбросить положение 3D камеры"
title="Сбросить положение 3D камеры"
onClick={() => setViewResetGeneration((value) => value === 0 ? 1 : 0)}
>
</Button>
);
const transport = presentationReady && playback && playbackController ? (
<ObservationTimeline
className="m4-replay-threat-visual__timeline canonical-vegetation-rerun-replay__timeline"
active
sourceCount={3}
mode="recorded"
seekable
synchronization="shared-clock"
rangeNs={playback.rangeNs}
currentNs={playback.currentNs}
playing={playback.playing}
onSeek={playbackController.seek}
onPlayingChange={playbackController.setPlaying}
showJumpToEnd={false}
/>
) : undefined;
return (
<div
className="canonical-vegetation-rerun-replay"
data-presentation-state={presentationState}
aria-busy={presentationState === "loading"}
>
<CanonicalRecordedLabReplay
label="RAVNOVES004TREE · канонический повтор Rerun"
mediaMode={mediaMode ?? "none"}
mediaModes={[
{ value: "video", label: "ВИДЕО" },
{ value: "camera", label: "КАМЕРА" },
]}
spatialMode={spatialMode ?? "none"}
spatialModes={[
{ value: "3d", label: "3D" },
{ value: "plan", label: "ПЛАН" },
]}
expanded={expanded}
splitPrimarySize={splitPrimarySize}
splitOrientation={splitOrientation}
mediaAriaLabel={mediaMode === "camera" ? "Камера" : "Видео и семантика"}
spatialAriaLabel={spatialMode === "plan" ? "Вид сверху" : "Трёхмерная сцена"}
mediaLayerControls={mediaLayerControls}
spatialLayerControls={spatialLayerControls}
spatialLeadingControl={resetSpatialView}
mediaMultiLayer
unifiedContent={profile ? (
<div
ref={viewerFrameRef}
className="canonical-vegetation-rerun-replay__viewport-lock"
data-split-view={splitView ? "true" : undefined}
style={{
"--canonical-rerun-camera-pane": `${
mediaMode === null
? 0
: splitView
? nativeSplitPercentRef.current
: 100
}%`,
} as CSSProperties}
onPointerDownCapture={splitView ? trackNativeSplit : undefined}
>
<RerunViewport
profile={profile}
sceneSettings={sceneSettings}
onStatusChange={setViewerStatus}
onPlaybackChange={setPlayback}
onPlaybackControllerChange={setPlaybackController}
/>
</div>
) : launchError ? (
<div className="l3-visual-audit__state" role="alert">
{launchError}
</div>
) : (
<div aria-hidden="true" />
)}
emptyMessage="Выберите ВИДЕО/КАМЕРА или 3D/ПЛАН. Общие часы Rerun останутся на месте."
deckOverlays={presentationState === "loading" ? (
<div className="canonical-vegetation-rerun-replay__loading">
<ActivityIndicator label="Загружаем синхронизированную запись" />
</div>
) : undefined}
transport={transport}
onMediaModeChange={onMediaModeChange}
onSpatialModeChange={onSpatialModeChange}
onExpandedChange={onExpandedChange}
onSplitPrimarySizeChange={onSplitPrimarySizeChange}
/>
</div>
);
return <CanonicalResultRerunReplay resultId={resultId} sessionId={review.sessionId}
initialPlaybackStartSeconds={review.timelineStartSeconds} semantics
resolveReplay={resolveCanonicalLabReplay} label="RAVNOVES004TREE · канонический повтор Rerun" />;
}
@@ -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,51 @@
import { GlassSurface } from "@nodedc/ui-react";
import { CanonicalResultRerunReplay } from "./CanonicalResultRerunReplay";
import { resolveCanonicalLabReplay } from "../../core/laboratory/canonicalLabReplay";
import type { ObservatoryPortableResultReview } from "../../core/observatory/recordedRun";
import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive";
function resolvePortableTgsReplay(resultId: string, launch: ObservationSessionReplayLaunch,
options: { signal: AbortSignal }) {
return resolveCanonicalLabReplay(resultId, launch, { ...options, sourceKind: "portable-tgs" });
}
function resolvePortableSemanticReplay(resultId: string, launch: ObservationSessionReplayLaunch,
options: { signal: AbortSignal }) {
return resolveCanonicalLabReplay(resultId, launch, { ...options, sourceKind: "portable-semantic" });
}
function resolvePortableObjectReplay(resultId: string, launch: ObservationSessionReplayLaunch,
options: { signal: AbortSignal }) {
return resolveCanonicalLabReplay(resultId, launch, { ...options, sourceKind: "portable-objects" });
}
export function PortableResultReplay({
review,
label,
}: {
review: ObservatoryPortableResultReview;
label?: string;
}) {
const schema = review.resultDocument.schema_version;
const tgs = schema === "missioncore.recorded-tgs-costmap-review/v2";
const module = review.resultDocument.module;
const moduleId = module && typeof module === "object" && "module_id" in module
? module.module_id : undefined;
const semantics = schema === "missioncore.recorded-eomt-ddrnet-review/v2"
|| (schema === "missioncore.recorded-ai-layer-review/v1"
&& (moduleId === "ddrnet" || moduleId === "eomt"));
const objects = schema === "missioncore.recorded-ai-layer-review/v1"
&& (moduleId === "rf-detr" || moduleId === "object-distance");
if (!tgs && !semantics && !objects) return (
<GlassSurface className="observatory-replay-state" padding="lg">
<p role="alert">Сохранённый результат этого профиля пока не поддерживает визуальный разбор.</p>
</GlassSurface>
);
return <CanonicalResultRerunReplay key={review.resultId} resultId={review.resultId}
sessionId={review.sourceSessionId} portable costmap={tgs} semantics={semantics}
detections={objects}
moduleIds={typeof moduleId === "string" ? [moduleId] : []}
label={label}
resolveReplay={tgs ? resolvePortableTgsReplay
: objects ? resolvePortableObjectReplay : resolvePortableSemanticReplay} />;
}
@@ -0,0 +1,261 @@
import { useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
Button,
FieldFrame,
Select,
StatusBadge,
Window,
WindowFooterActions,
} from "@nodedc/ui-react";
import {
aiModuleIdForSetup,
fetchAIModuleCatalog,
fetchAICompositionRuns,
fetchAICompositionJobs,
saveAIComposition,
type AICompositionReceipt,
type AICompositionRun,
type AIGroupId,
type AIModuleCatalog,
} from "../../core/observatory/aiComposition";
import type { ObservatoryRecordedJob } from "../../core/observatory/recordedJobs";
const groups: readonly { id: AIGroupId; label: string; description: string }[] = [
{ id: "segmentation", label: "Сегментация", description: "Пиксельные маски сцены" },
{ id: "detection", label: "Объекты", description: "Рамки и классы объектов" },
{ id: "geometry", label: "Облако точек / TGS", description: "Опорная поверхность и карта проходимости" },
{ id: "range", label: "Дистанция", description: "Расстояния до объектов" },
{ id: "motion", label: "Движение", description: "Треки и динамика объектов" },
{ id: "policy", label: "Политика", description: "Теневые правила интерпретации" },
];
export function AIConfigurationWindow({
open, sourceSessionId, sourceLabel, onClose, onCalculated,
}: {
readonly open: boolean;
readonly sourceSessionId: string;
readonly sourceLabel: string;
readonly onClose: () => void;
readonly onCalculated: (receipt: AICompositionReceipt) => void;
}) {
const [catalog, setCatalog] = useState<AIModuleCatalog | null>(null);
const [existingJobs, setExistingJobs] = useState<readonly ObservatoryRecordedJob[]>([]);
const [existingRuns, setExistingRuns] = useState<readonly AICompositionRun[]>([]);
const [selected, setSelected] = useState<Partial<Record<AIGroupId, string>>>({});
const [state, setState] = useState<"idle" | "loading" | "ready" | "saving" | "error">("idle");
const [message, setMessage] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
const request = new AbortController();
setState("loading");
setMessage(null);
void Promise.all([
fetchAIModuleCatalog(request.signal),
fetchAICompositionJobs(sourceSessionId, request.signal),
fetchAICompositionRuns(sourceSessionId, request.signal),
]).then(([value, jobs, runs]) => {
if (request.signal.aborted) return;
setCatalog(value);
setExistingJobs(jobs);
setExistingRuns(runs);
setSelected({});
setState("ready");
}).catch((error: unknown) => {
if (request.signal.aborted) return;
setMessage(error instanceof Error ? error.message : "Каталог AI-модулей недоступен.");
setState("error");
});
return () => request.abort();
}, [open, sourceSessionId]);
const existingByModule = useMemo(() => {
const result = new Map<string, ObservatoryRecordedJob>();
for (const job of existingJobs) {
if (job.state === "failed" || job.publication.state === "failed") continue;
const moduleId = aiModuleIdForSetup(job.setupId);
if (moduleId && !result.has(moduleId)) result.set(moduleId, job);
}
return result;
}, [existingJobs]);
const selections = useMemo(() => groups.flatMap(({ id }) => {
const moduleId = selected[id];
const module = catalog?.groups.find((item) => item.group === id)?.modules.find(
(item) => item.moduleId === moduleId,
);
return module ? [{ group: id, module }] : [];
}), [catalog, selected]);
const selectedModuleIds = useMemo(
() => selections.map(({ module }) => module.moduleId).sort(),
[selections],
);
const matchingRun = useMemo(() => existingRuns.find((run) => (
run.state !== "failed"
&& [...run.moduleIds].sort().join("\0") === selectedModuleIds.join("\0")
)), [existingRuns, selectedModuleIds]);
const configurationAlreadyExists = selections.length > 0 && matchingRun !== undefined;
const configurationIsRunning = matchingRun?.state === "running";
function selectModule(group: AIGroupId, moduleId: string): void {
if (!catalog) return;
setSelected((current) => {
const next = { ...current, [group]: moduleId === "none" ? undefined : moduleId };
const modules = catalog.groups.flatMap((item) => item.modules);
const selectedIds = new Set(Object.values(next).filter((value): value is string => Boolean(value)));
if (moduleId !== "none") {
const module = modules.find((item) => item.moduleId === moduleId);
for (const requirement of module?.requires ?? []) {
if (requirement.startsWith("source.")) continue;
if (modules.some((item) => selectedIds.has(item.moduleId) && item.provides.includes(requirement))) {
continue;
}
const providers = modules.filter((item) => item.provides.includes(requirement));
if (providers.length === 1) {
const providerGroup = catalog.groups.find((item) => item.modules.some(
(candidate) => candidate.moduleId === providers[0]?.moduleId,
))?.group;
if (providerGroup) {
next[providerGroup] = providers[0]?.moduleId;
selectedIds.add(providers[0]?.moduleId ?? "");
}
}
}
} else {
let changed = true;
while (changed) {
changed = false;
const capabilities = new Set(modules.filter((item) => Object.values(next).includes(item.moduleId))
.flatMap((item) => item.provides));
for (const item of modules.filter((candidate) => Object.values(next).includes(candidate.moduleId))) {
if (item.requires.some((requirement) => !requirement.startsWith("source.") && !capabilities.has(requirement))) {
const dependentGroup = catalog.groups.find((candidate) => candidate.modules.some(
(module) => module.moduleId === item.moduleId,
))?.group;
if (dependentGroup) {
next[dependentGroup] = undefined;
changed = true;
}
}
}
}
}
return next;
});
setMessage(null);
if (state === "error") setState("ready");
}
async function calculate(): Promise<void> {
if (selections.length === 0 || state === "saving") return;
if (configurationAlreadyExists) {
setMessage("Эта конфигурация уже рассчитана. Выберите другую конфигурацию.");
return;
}
setState("saving");
setMessage(null);
try {
const receipt = await saveAIComposition(sourceSessionId, selections);
onCalculated(receipt);
onClose();
} catch (error) {
setMessage(error instanceof Error ? error.message : "Не удалось запустить расчёт.");
setState("error");
}
}
return <Window
open={open}
className="observatory-ai-config-window"
title="Сконфигурировать AI-слой"
subtitle={sourceLabel}
size="lg"
closeOnBackdrop={state !== "saving"}
closeOnEscape={state !== "saving"}
onClose={onClose}
footer={<WindowFooterActions>
{configurationAlreadyExists ? (
<span className="observatory-ai-config__duplicate" role="status">
{configurationIsRunning
? "Текущая конфигурация уже рассчитывается."
: "Текущая конфигурация уже рассчитана."}
</span>
) : null}
<Button
variant="primary"
className={state === "saving" ? "observatory-ai-config__calculate--saving" : undefined}
disabled={state !== "ready" || selections.length === 0 || configurationAlreadyExists}
aria-busy={state === "saving"}
onClick={() => void calculate()}
>
{state === "saving" ? "Отправляем на Worker…" : "Рассчитать"}
</Button>
</WindowFooterActions>}
>
<div className="observatory-ai-config" aria-busy={state === "loading" || state === "saving"}>
<p className="observatory-ai-config__lead">
Выберите модуль в каждом нужном слое. Зависимые блоки подключаются явно в этой конфигурации.
</p>
{state === "loading" ? <ActivityIndicator label="Загружаем AI-модули" /> : null}
{catalog ? groups.map(({ id, label, description }) => {
const modules = catalog.groups.find((item) => item.group === id)?.modules ?? [];
const selectedModule = modules.find((item) => item.moduleId === selected[id]);
const selectedExisting = selectedModule
? existingByModule.get(selectedModule.moduleId)
: undefined;
const allCalculated = modules.length > 0 && modules.every(
(module) => existingByModule.has(module.moduleId),
);
return <section className="observatory-ai-module-group" key={id}>
<header>
<div>
<strong>{label}</strong>
<span>{description}</span>
</div>
<StatusBadge tone={selectedModule ? "accent" : "neutral"}>
{selectedExisting
? selectedExisting.state === "succeeded" ? "Уже рассчитано" : "В расчёте"
: selectedModule?.label ?? (allCalculated
? "Результат доступен"
: modules.length ? "Не используется" : "Не установлен")}
</StatusBadge>
</header>
<div className="observatory-ai-module-group__body">
<FieldFrame
label={`Модуль: ${label}`}
description={modules.length
? allCalculated
? "Готовый результат можно включить в новую конфигурацию без повторного расчёта."
: id === "segmentation"
? "DDRNet и EoMT — независимые альтернативы; выберите одну модель."
: id === "range"
? "Дистанция использует рамки детектора и синхронное облако точек."
: id === "geometry"
? "TGS обрабатывает LiDAR независимо от сегментации и детектора."
: "Связи с другими блоками определяются входами выбранного модуля."
: "Для этого слоя на Worker 006 пока нет установленного модуля."}
>
<Select
label={`Выбрать модуль: ${label}`}
value={selected[id] ?? "none"}
options={[{ value: "none", label: "Не использовать" }, ...modules.map((module) => ({
value: module.moduleId,
label: module.label,
description: existingByModule.has(module.moduleId)
? "Готовый результат будет использован без повторного расчёта"
: module.dockerName,
}))]}
disabled={modules.length === 0 || state === "saving"}
onChange={(value) => selectModule(id, value)}
/>
</FieldFrame>
</div>
</section>;
}) : null}
{message ? <p className="observatory-ai-config__error" role="alert">{message}</p> : null}
</div>
</Window>;
}
@@ -0,0 +1,10 @@
import { WebViewer } from "@rerun-io/web-viewer";
import type { RerunFrameWindow } from "./recordedRerunProtocol";
import { createRecordedRerunOwner } from "./recordedRerunOwner";
// This entry is loaded only in the owned iframe. Upstream code is unmodified;
// destroying the frame also ends its timers, WASM, video and WebGPU lifetime.
const mount = document.getElementById("viewer");
if (!mount) throw new Error("Recorded viewer mount is missing");
(window as RerunFrameWindow).missionCoreRerun = createRecordedRerunOwner(() => new WebViewer(), mount);
@@ -0,0 +1,77 @@
import { createRecordedRerunFacade, type RecordedRerunViewer } from "./recordedRerunFacade";
import type { RerunFrameWindow } from "./recordedRerunProtocol";
import { relayIsolatedRerunInput } from "./isolatedRerunInput";
/** Own the complete recorded-viewer realm, not just its canvas or SDK handle. */
export function createIsolatedRerunHost(host: HTMLElement) {
let frame: HTMLIFrameElement | null = host.ownerDocument.createElement("iframe");
frame.title = "Синхронизированная запись";
frame.className = "rerun-viewport__runtime";
frame.src = "/rerun-runtime.html";
let disposed = false;
let releaseFacade: (() => void) | null = null;
let releaseInput: (() => void) | null = null;
let frameWindow: RerunFrameWindow | null = null;
let rejectReady: ((reason: Error) => void) | null = null;
let timer: ReturnType<typeof setTimeout> | undefined;
const dispose = () => {
if (disposed) return;
disposed = true;
clearTimeout(timer);
frame!.onload = null;
frame!.onerror = null;
releaseInput?.();
releaseInput = null;
try {
releaseFacade?.();
} catch {
// Realm teardown is required even if a partial native start failed.
} finally {
releaseFacade = null;
frameWindow = null;
frame!.remove();
frame = null;
rejectReady?.(new Error("Recorded viewer was disposed"));
rejectReady = null;
}
};
const ready = new Promise<{ viewer: RecordedRerunViewer; mount: HTMLElement }>((resolve, reject) => {
rejectReady = reject;
frame!.onload = () => {
if (disposed) return;
try {
frameWindow = frame!.contentWindow as RerunFrameWindow | null;
const api = frameWindow?.missionCoreRerun;
if (!api || api.version !== 2) throw new Error("Recorded viewer host is unavailable");
const owner = createRecordedRerunFacade((request, bytes) => {
const current = frameWindow?.missionCoreRerun;
if (!current) throw new Error("Recorded viewer was disposed");
return current.invoke(request, bytes);
});
releaseFacade = owner.dispose;
api.connect(owner.notify);
releaseInput = relayIsolatedRerunInput(host, frameWindow, frame);
clearTimeout(timer);
rejectReady = null;
frame!.onload = null;
frame!.onerror = null;
resolve({ viewer: owner.facade, mount: host });
} catch (error) {
reject(new Error(String(error)));
dispose();
}
};
frame!.onerror = () => {
reject(new Error("Recorded viewer host failed to load"));
dispose();
};
timer = setTimeout(() => {
reject(new Error("Recorded viewer host timed out"));
dispose();
}, 30_000);
});
host.append(frame);
return { ready, dispose };
}
@@ -0,0 +1,17 @@
/** Relay Escape from the isolated viewer realm to the owning application. */
export function relayIsolatedRerunInput(
host: HTMLElement,
frameWindow: Window | null,
_frame: HTMLIFrameElement | null,
) {
const parent = host.ownerDocument.defaultView;
const escape = (event: KeyboardEvent) => {
if (event.key !== "Escape" || event.defaultPrevented || !parent) return;
host.dispatchEvent(new parent.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
};
frameWindow?.addEventListener("keydown", escape);
return () => {
frameWindow?.removeEventListener("keydown", escape);
frameWindow = null;
};
}
@@ -0,0 +1,244 @@
export type RecordedRerunCameraEye = {
readonly position: readonly [number, number, number];
readonly lookTarget: readonly [number, number, number];
readonly eyeUp: readonly [number, number, number];
};
type Vector3 = [number, number, number];
type DragMode = "rotate" | "pan" | "roll";
const ROTATION_RADIANS_PER_POINT = 0.004;
const RERUN_WEB_LINE_SCROLL_POINTS = 8;
const RERUN_ORBITAL_SCROLL_DIVISOR = 200;
const RERUN_WEB_PINCH_SCROLL_DIVISOR = 100;
const RERUN_MIN_ORBIT_DISTANCE = 0.02;
const DOM_WHEEL_DELTA_LINE = 1;
const DOM_WHEEL_DELTA_PAGE = 2;
export const RECORDED_RERUN_ORBITAL_EYE: RecordedRerunCameraEye = {
position: [16, -16, 18],
lookTarget: [0, 0, 0],
eyeUp: [0, 0, 1],
};
export const RECORDED_RERUN_PLAN_EYE: RecordedRerunCameraEye = {
position: [0, 0, 30],
lookTarget: [0, 0, 0],
eyeUp: [0, 1, 0],
};
const vector = (value: readonly [number, number, number]): Vector3 => [...value];
const add = (a: Vector3, b: Vector3): Vector3 => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
const subtract = (a: Vector3, b: Vector3): Vector3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
const scale = (value: Vector3, factor: number): Vector3 => [value[0] * factor, value[1] * factor, value[2] * factor];
const dot = (a: Vector3, b: Vector3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
const cross = (a: Vector3, b: Vector3): Vector3 => [
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
];
const length = (value: Vector3) => Math.hypot(...value);
const normalize = (value: Vector3, fallback: Vector3): Vector3 => {
const magnitude = length(value);
return magnitude > Number.EPSILON ? scale(value, 1 / magnitude) : fallback;
};
const clamp = (value: number, minimum: number, maximum: number) => Math.max(minimum, Math.min(maximum, value));
const rotateAroundAxis = (value: Vector3, rawAxis: Vector3, angle: number): Vector3 => {
const axis = normalize(rawAxis, [0, 0, 1]);
const cosine = Math.cos(angle);
const sine = Math.sin(angle);
return add(
add(scale(value, cosine), scale(cross(axis, value), sine)),
scale(axis, dot(axis, value) * (1 - cosine)),
);
};
function cloneEye(value: RecordedRerunCameraEye): {
position: Vector3;
lookTarget: Vector3;
eyeUp: Vector3;
} {
return {
position: vector(value.position),
lookTarget: vector(value.lookTarget),
eyeUp: vector(value.eyeUp),
};
}
/**
* Mirrors Rerun 0.36.3's orbital eye math while the native viewer remains the
* renderer. Rerun writes operator navigation into its active blueprint clone;
* the WebViewer API cannot read that clone. Keeping the same three eye vectors
* here lets a later layer blueprint activate with the exact operator pose.
*/
export function createRecordedRerunCameraJournal(
canvas: HTMLCanvasElement,
scope: Window & typeof globalThis,
) {
let eye = cloneEye(RECORDED_RERUN_ORBITAL_EYE);
let spatialViewportStart = 0;
let pointerId: number | null = null;
let dragMode: DragMode | null = null;
let previousPointer: readonly [number, number] | null = null;
let latestPointer: readonly [number, number] | null = null;
let maxOrbitalRadius = 1.0e17;
const isSpatialPoint = (event: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
return rect.width > 0 && (event.clientX - rect.left) / rect.width >= spatialViewportStart;
};
const forward = () => normalize(subtract(eye.lookTarget, eye.position), [0, 1, 0]);
const usableUp = () => {
const fwd = forward();
const fallbackRight: Vector3 = Math.abs(dot(fwd, [0, 0, 1])) > 0.9999
? [1, 0, 0]
: cross(fwd, [0, 0, 1]);
const fallback = normalize(cross(fwd, fallbackRight), [0, 0, 1]);
const candidate = normalize(eye.eyeUp, fallback);
return Math.abs(dot(candidate, fwd)) > 0.9999 ? fallback : candidate;
};
const orbitRadius = () => length(subtract(eye.position, eye.lookTarget));
const rotate = (deltaX: number, deltaY: number) => {
const radius = orbitRadius();
const up = usableUp();
let fwd = forward();
const oldPitch = Math.asin(clamp(dot(fwd, up), -1, 1));
fwd = normalize(
rotateAroundAxis(fwd, up, -ROTATION_RADIANS_PER_POINT * deltaX),
fwd,
);
const right = normalize(cross(fwd, up), [1, 0, 0]);
const maxPitch = 0.99 * Math.PI / 2;
const nextPitch = clamp(
oldPitch - ROTATION_RADIANS_PER_POINT * deltaY,
-maxPitch,
maxPitch,
);
fwd = normalize(rotateAroundAxis(fwd, right, nextPitch - oldPitch), fwd);
eye.position = subtract(eye.lookTarget, scale(fwd, radius));
};
const pan = (deltaX: number, deltaY: number) => {
const fwd = forward();
const right = normalize(cross(fwd, usableUp()), [1, 0, 0]);
const screenUp = normalize(cross(right, fwd), [0, 0, 1]);
const speed = 0.001 * orbitRadius();
const translation = add(scale(right, -deltaX * speed), scale(screenUp, deltaY * speed));
eye.position = add(eye.position, translation);
eye.lookTarget = add(eye.lookTarget, translation);
};
const roll = (event: PointerEvent, deltaX: number, deltaY: number) => {
const rect = canvas.getBoundingClientRect();
const left = rect.left + rect.width * spatialViewportStart;
const centerX = left + (rect.right - left) / 2;
const centerY = rect.top + rect.height / 2;
const relativeX = event.clientX - centerX;
const relativeY = event.clientY - centerY;
const divisor = relativeX * relativeX + relativeY * relativeY;
if (divisor <= Number.EPSILON) return;
const angle = (-deltaY * relativeX + deltaX * relativeY) / divisor;
eye.eyeUp = normalize(rotateAroundAxis(eye.eyeUp, scale(forward(), -1), angle), eye.eyeUp);
};
const pointerDown = (event: PointerEvent) => {
latestPointer = [event.clientX, event.clientY];
if (!isSpatialPoint(event)) return;
pointerId = event.pointerId;
previousPointer = [event.clientX, event.clientY];
dragMode = event.button === 2 ? "pan" : event.button === 1 || event.altKey ? "roll" : "rotate";
};
const pointerMove = (event: PointerEvent) => {
latestPointer = [event.clientX, event.clientY];
if (event.pointerId !== pointerId || !previousPointer || !dragMode) return;
const deltaX = event.clientX - previousPointer[0];
const deltaY = event.clientY - previousPointer[1];
previousPointer = [event.clientX, event.clientY];
if (dragMode === "rotate") rotate(deltaX, deltaY);
else if (dragMode === "pan") pan(deltaX, deltaY);
else roll(event, deltaX, deltaY);
};
const pointerEnd = (event: PointerEvent) => {
if (event.pointerId !== pointerId) return;
pointerMove(event);
pointerId = null;
previousPointer = null;
dragMode = null;
};
const wheel = (event: WheelEvent) => {
const rect = canvas.getBoundingClientRect();
const wheelX = event.clientX >= rect.left && event.clientX <= rect.right
? event.clientX
: latestPointer?.[0];
if (wheelX === undefined || rect.width <= 0) return;
if ((wheelX - rect.left) / rect.width < spatialViewportStart) return;
const unitMultiplier = event.deltaMode === DOM_WHEEL_DELTA_LINE
? RERUN_WEB_LINE_SCROLL_POINTS
: event.deltaMode === DOM_WHEEL_DELTA_PAGE
? rect.height
: 1;
const scrollPoints = (event.deltaX + event.deltaY) * unitMultiplier;
// eframe negates the DOM delta before Rerun applies
// radius / exp(smooth_scroll_delta / 200). Over a smoothed gesture the
// exponent products collapse to the raw accumulated DOM delta below.
const divisor = event.ctrlKey
? RERUN_WEB_PINCH_SCROLL_DIVISOR
: RERUN_ORBITAL_SCROLL_DIVISOR;
const radiusFactor = Math.exp(scrollPoints / divisor);
const offset = subtract(eye.position, eye.lookTarget);
const radius = length(offset);
// Keep the same orbital bounds as Rerun 0.36.3. An eye already outside
// the scene-derived cap is allowed to stay there and is never snapped in.
const nextRadius = clamp(
radius * radiusFactor,
RERUN_MIN_ORBIT_DISTANCE,
Math.max(radius, maxOrbitalRadius),
);
eye.position = add(
eye.lookTarget,
scale(offset, nextRadius / Math.max(radius, Number.EPSILON)),
);
};
canvas.addEventListener("pointerdown", pointerDown, true);
scope.addEventListener("pointermove", pointerMove, true);
scope.addEventListener("pointerup", pointerEnd, true);
scope.addEventListener("pointercancel", pointerEnd, true);
scope.addEventListener("wheel", wheel, { capture: true, passive: true });
return {
current(): RecordedRerunCameraEye {
return {
position: [...eye.position],
lookTarget: [...eye.lookTarget],
eyeUp: [...eye.eyeUp],
};
},
configure(nextEye: RecordedRerunCameraEye, nextSpatialViewportStart: number) {
eye = cloneEye(nextEye);
spatialViewportStart = clamp(nextSpatialViewportStart, 0, 0.9);
pointerId = null;
dragMode = null;
previousPointer = null;
},
setSpatialViewportStart(nextSpatialViewportStart: number) {
spatialViewportStart = clamp(nextSpatialViewportStart, 0, 0.9);
},
setMaxOrbitalRadius(nextMaxOrbitalRadius: number) {
if (
Number.isFinite(nextMaxOrbitalRadius) &&
nextMaxOrbitalRadius >= RERUN_MIN_ORBIT_DISTANCE
) {
maxOrbitalRadius = nextMaxOrbitalRadius;
}
},
dispose() {
canvas.removeEventListener("pointerdown", pointerDown, true);
scope.removeEventListener("pointermove", pointerMove, true);
scope.removeEventListener("pointerup", pointerEnd, true);
scope.removeEventListener("pointercancel", pointerEnd, true);
scope.removeEventListener("wheel", wheel, true);
},
};
}
@@ -0,0 +1,105 @@
import type { WebViewer } from "@rerun-io/web-viewer";
import type { RerunInvoke } from "./recordedRerunProtocol";
import type { RecordedRerunCameraEye } from "./recordedRerunCameraJournal";
type NativeChannel = ReturnType<WebViewer["open_channel"]>;
type Channel = Pick<NativeChannel, "ready" | "send_rrd" | "close">;
export type RecordedRerunViewer = Pick<WebViewer,
"start" | "stop" | "ready" | "on" | "open" | "close" | "override_panel_state" |
"get_active_recording_id" | "get_active_timeline" | "get_current_time" |
"get_playing" | "get_time_range" | "set_active_timeline" |
"set_current_time" | "set_playing"
> & {
open_channel: (name?: string) => Channel;
configure_camera_journal: (eye: RecordedRerunCameraEye, spatialViewportStart: number) => void;
get_camera_eye: () => RecordedRerunCameraEye;
set_camera_viewport_start: (spatialViewportStart: number) => void;
set_camera_max_orbital_radius: (maxOrbitalRadius: number) => void;
};
/** Parent-owned values only. No SDK Promise or foreign prototype escapes. */
export function createRecordedRerunFacade(invoke: RerunInvoke | null) {
let nextId = 0;
const callbacks = new Map<number, (...args: unknown[]) => void>();
const starts = new Map<number, { resolve: () => void; reject: (error: Error) => void }>();
const command = (method: string, args: unknown[] = [], id?: number, bytes?: Uint8Array) => {
if (!invoke) throw new Error("Recorded viewer was disposed");
const response = JSON.parse(invoke(JSON.stringify({ method, args, id }), bytes));
if (response.error) throw new Error(response.error);
return response.result;
};
const dispose = () => {
callbacks.clear();
for (const pending of starts.values()) pending.reject(new Error("Recorded viewer was disposed"));
starts.clear();
try { if (invoke) command("stop"); } finally { invoke = null; }
};
const notify = (message: string) => {
if (!invoke) return;
const event = JSON.parse(message);
if (event.type === "event") callbacks.get(event.id)?.(...event.values);
else {
const pending = starts.get(event.id);
starts.delete(event.id);
if (event.type === "started") pending?.resolve();
else pending?.reject(new Error(event.message));
}
};
const facade: RecordedRerunViewer = {
get ready() { return invoke ? command("ready") : false; },
start(source, _parent, options) {
return new Promise<void>((resolve, reject) => {
const id = ++nextId;
starts.set(id, { resolve, reject });
try { command("start", [source, options], id); }
catch (error) { starts.delete(id); reject(error); }
});
},
stop: dispose,
on: ((event: string, callback: (...args: unknown[]) => void) => {
const id = ++nextId;
callbacks.set(id, callback);
try { command("on", [event], id); }
catch (error) { callbacks.delete(id); throw error; }
return () => {
if (callbacks.delete(id) && invoke) command("off", [], id);
};
}) as WebViewer["on"],
open_channel(name) {
const id = ++nextId;
command("channel-open", name === undefined ? [] : [name], id);
let closed = false;
return {
get ready() { return !closed && invoke ? command("channel-ready", [], id) : false; },
send_rrd(bytes) { if (!closed && invoke) command("channel-send", [], id, bytes); },
close() {
if (closed) return;
closed = true;
if (invoke) command("channel-close", [], id);
},
};
},
open: (...args) => command("open", args),
close: (...args) => { if (invoke) command("close", args); },
override_panel_state: (...args) => command("override_panel_state", args),
get_active_recording_id: () => command("get_active_recording_id"),
get_active_timeline: (...args) => command("get_active_timeline", args),
get_current_time: (...args) => command("get_current_time", args),
get_playing: (...args) => command("get_playing", args),
get_time_range: (...args) => command("get_time_range", args),
set_active_timeline: (...args) => command("set_active_timeline", args),
set_current_time: (...args) => command("set_current_time", args),
set_playing: (...args) => command("set_playing", args),
configure_camera_journal: (eye, spatialViewportStart) => command(
"configure-camera-journal", [eye, spatialViewportStart],
),
get_camera_eye: () => command("get-camera-eye"),
set_camera_viewport_start: (spatialViewportStart) => command(
"set-camera-viewport-start", [spatialViewportStart],
),
set_camera_max_orbital_radius: (maxOrbitalRadius) => command(
"set-camera-max-orbital-radius", [maxOrbitalRadius],
),
};
return { facade, dispose, notify };
}
@@ -0,0 +1,115 @@
import type { WebViewer } from "@rerun-io/web-viewer";
import type { RerunFrameApi, RerunNotify } from "./recordedRerunProtocol";
import { createRecordedRerunCameraJournal } from "./recordedRerunCameraJournal";
/** Lives entirely inside the disposable iframe, including pending SDK starts. */
export function createRecordedRerunOwner(create: () => WebViewer, mount: HTMLElement): RerunFrameApi {
let native: WebViewer | null = null;
let notify: RerunNotify | null = null;
const subscriptions = new Map<number, () => void>();
const channels = new Map<number, ReturnType<WebViewer["open_channel"]>>();
let cameraJournal: ReturnType<typeof createRecordedRerunCameraJournal> | null = null;
const send = (message: object) => notify?.(JSON.stringify(message));
const stop = () => {
notify = null;
for (const unsubscribe of subscriptions.values()) {
try { unsubscribe(); } catch { /* Continue partial-start cleanup. */ }
}
subscriptions.clear();
for (const channel of channels.values()) {
try { channel.close(); } catch { /* Other channels must still close. */ }
}
channels.clear();
cameraJournal?.dispose();
cameraJournal = null;
const current = native;
native = null;
try { current?.stop(); } catch { /* Realm teardown remains authoritative. */ }
};
const required = () => {
if (!native) throw new Error("Recorded viewer was disposed");
return native;
};
const start = async (id: number, args: [string | string[] | null, Parameters<WebViewer["start"]>[2]]) => {
const starting = required();
try {
await starting.start(args[0], mount, args[1]);
if (native === starting && starting.canvas) {
cameraJournal?.dispose();
cameraJournal = createRecordedRerunCameraJournal(
starting.canvas,
window as Window & typeof globalThis,
);
}
if (native === starting) send({ type: "started", id });
} catch (error) {
if (native === starting) send({ type: "start-failed", id, message: String(error) });
} finally {
if (native !== starting) {
try { starting.stop(); } catch { /* Late completion after disposal. */ }
}
}
};
return {
version: 2,
connect(callback) {
if (native) throw new Error("Recorded viewer already has an owner");
native = create();
notify = callback;
},
invoke(request, bytes) {
try {
const { method, args = [], id } = JSON.parse(request);
let result: unknown;
switch (method) {
case "stop": stop(); break;
case "start": required(); void start(id, args); break;
case "ready": result = native?.ready ?? false; break;
case "on": {
const subscribe = required().on as (event: string, callback: (...values: unknown[]) => void) => () => void;
subscriptions.set(id, subscribe.call(required(), args[0], (...values) => send({ type: "event", id, values })));
break;
}
case "off":
try { subscriptions.get(id)?.(); } finally { subscriptions.delete(id); }
break;
case "channel-open": {
const name = args[0] as string | undefined;
channels.set(id, required().open_channel(name));
break;
}
case "channel-ready": result = channels.get(id)?.ready ?? false; break;
case "channel-send": if (bytes) {
channels.get(id)?.send_rrd(bytes);
} break;
case "channel-close":
try { channels.get(id)?.close(); } finally {
channels.delete(id);
}
break;
case "configure-camera-journal": cameraJournal?.configure(args[0], args[1]); break;
case "get-camera-eye": result = cameraJournal?.current(); break;
case "set-camera-viewport-start": cameraJournal?.setSpatialViewportStart(args[0]); break;
case "set-camera-max-orbital-radius": cameraJournal?.setMaxOrbitalRadius(args[0]); break;
case "open": required().open(args[0]); break;
case "close": required().close(args[0]); break;
case "override_panel_state": required().override_panel_state(args[0], args[1]); break;
case "get_active_recording_id": result = required().get_active_recording_id(); break;
case "get_active_timeline": result = required().get_active_timeline(args[0]); break;
case "get_current_time": result = required().get_current_time(args[0], args[1]); break;
case "get_playing": result = required().get_playing(args[0]); break;
case "get_time_range": result = required().get_time_range(args[0], args[1]); break;
case "set_active_timeline": required().set_active_timeline(args[0], args[1]); break;
case "set_current_time": required().set_current_time(args[0], args[1], args[2]); break;
case "set_playing": required().set_playing(args[0], args[1]); break;
default: throw new Error("Unsupported recorded viewer command");
}
return JSON.stringify({ result });
} catch (error) {
// A native Error also has a foreign prototype. Return text, never throw
// that object into a parent Promise/callback retained after disposal.
return JSON.stringify({ error: String(error) });
}
},
};
}
@@ -0,0 +1,12 @@
/** Only parent-owned inputs and primitive strings cross the recorded realm.
* An SDK instance, Promise, DOM node or native callback never becomes a value
* retained by React. This is not a second playback clock.
*/
export type RerunInvoke = (request: string, bytes?: Uint8Array) => string;
export type RerunNotify = (message: string) => void;
export interface RerunFrameApi {
version: 2;
connect: (notify: RerunNotify) => void;
invoke: RerunInvoke;
}
export type RerunFrameWindow = Window & { missionCoreRerun?: RerunFrameApi };
@@ -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,
@@ -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 };
}
@@ -23,26 +23,34 @@ export async function resolveCanonicalLabReplay(
origin = window.location.origin,
signal,
fetcher = globalThis.fetch,
sourceKind = "legacy-vegetation",
}: {
origin?: string;
signal?: AbortSignal;
fetcher?: typeof globalThis.fetch;
sourceKind?: "legacy-vegetation" | "portable-tgs" | "portable-semantic" | "portable-objects";
} = {},
): Promise<CanonicalLabReplayDescriptor> {
const base = new URL(origin);
if (
!SAFE_RESULT_ID.test(resultId)
!(sourceKind === "portable-tgs"
? /^m49-tgs-portable-review-[a-f0-9]{64}$/.test(resultId)
: sourceKind === "portable-semantic"
? /^(?:lab-v1-eomt-ddrnet|ai-layer-(?:ddrnet|eomt))-[a-f0-9]{64}$/.test(resultId)
: sourceKind === "portable-objects"
? /^ai-layer-(?:rf-detr|object-distance)-[a-f0-9]{64}$/.test(resultId)
: SAFE_RESULT_ID.test(resultId))
|| !SAFE_SESSION_SOURCE.test(launch.sourceUrl)
|| launch.viewerSourceUrl !== `${launch.sourceUrl}?generation=${launch.sha256}`
|| !/^[a-f0-9]{64}$/.test(launch.sha256)
) {
throw new Error("Канонический replay LAB имеет небезопасный descriptor.");
}
const sourceUrl =
`/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}`
+ "/canonical-replay.rrd";
const sourceUrl = sourceKind !== "legacy-vegetation"
? `/api/v1/observatory/portable-results/${resultId}/replays/${launch.sha256}/recording.rrd`
: `/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}/canonical-replay.rrd`;
const descriptorUrl = new URL(sourceUrl, `${base.origin}/`);
descriptorUrl.searchParams.set("base_generation", launch.sha256);
if (sourceKind === "legacy-vegetation") descriptorUrl.searchParams.set("base_generation", launch.sha256);
if (descriptorUrl.origin !== base.origin) {
throw new Error("Канонический replay LAB должен быть same-origin.");
}
@@ -74,3 +82,53 @@ export async function resolveCanonicalLabReplay(
blueprintSourceUrl: launch.sourceUrl.replace(/\/recording\.rrd$/, "/blueprint.rrd"),
};
}
export async function resolveAICompositionReplay(
runId: string,
launch: ObservationSessionReplayLaunch,
{
origin = window.location.origin,
signal,
fetcher = globalThis.fetch,
}: {
origin?: string;
signal?: AbortSignal;
fetcher?: typeof globalThis.fetch;
} = {},
): Promise<CanonicalLabReplayDescriptor> {
const base = new URL(origin);
if (
!/^ai-composition-[a-f0-9]{64}$/.test(runId)
|| !SAFE_SESSION_SOURCE.test(launch.sourceUrl)
|| launch.viewerSourceUrl !== `${launch.sourceUrl}?generation=${launch.sha256}`
|| !/^[a-f0-9]{64}$/.test(launch.sha256)
) {
throw new Error("Составной replay LAB имеет небезопасный descriptor.");
}
const sourceUrl = `/api/v1/observatory/ai-composition-runs/${runId}/replays/${launch.sha256}/recording.rrd`;
const descriptorUrl = new URL(sourceUrl, `${base.origin}/`);
const response = await fetcher(descriptorUrl.href, {
method: "HEAD",
credentials: "same-origin",
headers: { Accept: "application/vnd.rerun.rrd" },
signal,
});
const contentType = response.headers.get("Content-Type")?.split(";", 1)[0].trim();
const byteLength = Number(response.headers.get("Content-Length"));
const sha256 = response.headers.get("ETag")?.match(/^"([a-f0-9]{64})"$/)?.[1];
if (
response.status !== 200 || contentType !== "application/vnd.rerun.rrd"
|| response.headers.get("X-Rerun-Format") !== "RRF2" || !sha256
|| !Number.isSafeInteger(byteLength) || byteLength < 4
|| byteLength > MAX_CANONICAL_REPLAY_BYTES
) {
throw new Error("Составной replay LAB не прошёл проверку.");
}
return {
sourceUrl,
viewerSourceUrl: `${sourceUrl}?generation=${sha256}`,
byteLength,
sha256,
blueprintSourceUrl: launch.sourceUrl.replace(/\/recording\.rrd$/, "/blueprint.rrd"),
};
}
@@ -0,0 +1,53 @@
/** Small lease for the server's ephemeral blueprint, never for the recording. */
export function keepRecordedBlueprintSession({
endpointUrl,
origin,
applicationId,
recordingId,
ownerId,
fetcher = fetch,
schedule = (callback: () => void) => window.setInterval(callback, 30_000),
cancel = (handle: number) => window.clearInterval(handle),
}: {
endpointUrl: string;
origin: string;
applicationId: string;
recordingId: string;
ownerId: string;
fetcher?: typeof fetch;
schedule?: (callback: () => void) => number;
cancel?: (handle: number) => void;
}): () => void {
const endpoint = new URL(endpointUrl, origin);
if (endpoint.origin !== origin || endpoint.search || endpoint.hash ||
!/^\/api\/v1\/observation-sessions\/[A-Za-z0-9._:-]+\/blueprint\.rrd$/.test(endpoint.pathname)) {
throw new Error("Unsafe recorded blueprint lifecycle endpoint");
}
endpoint.pathname = endpoint.pathname.replace(/blueprint\.rrd$/, "blueprint-lifecycle");
const identity = { application_id: applicationId, recording_id: recordingId,
blueprint_session_id: ownerId };
let closed = false;
let pending: AbortController | null = null;
const renew = () => {
if (closed) return;
pending?.abort();
pending = new AbortController();
void fetcher(endpoint.href, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...identity, action: "renew" }), signal: pending.signal,
}).catch(() => { /* The TTL cleans up after a lost browser/network. */ });
};
const timer = schedule(renew);
return () => {
if (closed) return;
closed = true;
cancel(timer);
pending?.abort();
pending = null;
void fetcher(endpoint.href, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...identity, action: "release" }),
keepalive: true, signal: AbortSignal.timeout(5_000),
}).catch(() => { /* Terminal release is best-effort; the server also has TTL. */ });
};
}
@@ -0,0 +1,72 @@
import {
decodeObservationSessionCatalog,
ObservationSessionApiError,
ObservationSessionContractError,
type ObservationSessionCatalog,
type ObservationSessionFetch,
type ObservationSessionScope,
} from "./sessionArchive";
const PAGE_SCHEMA = "missioncore.observation-session-page/v1";
const CURSOR = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
export interface ObservationSessionCatalogPage extends ObservationSessionCatalog {
readonly nextCursor: string | null;
}
/** Opt-in paging leaves the legacy catalog response and consumers unchanged. */
export async function fetchObservationSessionCatalogPage({
scope,
limit = 100,
cursor = null,
signal,
fetcher = globalThis.fetch,
}: {
scope: ObservationSessionScope;
limit?: number;
cursor?: string | null;
signal?: AbortSignal;
fetcher?: ObservationSessionFetch;
}): Promise<ObservationSessionCatalogPage> {
if (!Number.isInteger(limit) || limit < 1 || limit > 100
|| (cursor !== null && !CURSOR.test(cursor))) {
throw new ObservationSessionContractError("Некорректная страница каталога сессий.");
}
signal?.throwIfAborted();
const query = new URLSearchParams({ limit: String(limit), scope });
if (scope === "laboratory") query.set("lab_contract", "v3");
query.set("pagination", "cursor-v1");
if (cursor !== null) query.set("cursor", cursor);
const response = await fetcher(`/api/v1/observation-sessions?${query}`, {
method: "GET", headers: { Accept: "application/json" }, signal,
});
if (!response.ok) {
throw new ObservationSessionApiError(
`Не удалось загрузить страницу каталога сессий (HTTP ${response.status}).`,
response.status,
);
}
let body: unknown;
try {
body = await response.json();
} catch {
throw new ObservationSessionContractError("Некорректный ответ каталога сессий.");
}
if (typeof body !== "object" || body === null || Array.isArray(body)) {
throw new ObservationSessionContractError("Страница каталога должна быть объектом.");
}
const page = body as Record<string, unknown>;
if (Object.keys(page).length !== 3
|| page.schema_version !== PAGE_SCHEMA
|| !Object.hasOwn(page, "items")
|| !(page.next_cursor === null
|| (typeof page.next_cursor === "string" && CURSOR.test(page.next_cursor)))) {
throw new ObservationSessionContractError("Нарушен контракт страницы каталога сессий.");
}
const catalog = decodeObservationSessionCatalog({ items: page.items });
if (catalog.items.length > limit) {
throw new ObservationSessionContractError("Страница каталога превысила запрошенный размер.");
}
signal?.throwIfAborted();
return { ...catalog, nextCursor: page.next_cursor };
}
@@ -24,9 +24,11 @@ export interface RerunPlaybackController {
export interface RecordedPerceptionLayers {
enabled: boolean;
cameraImage?: boolean;
detections2d: boolean;
segmentation: boolean;
cuboids3d: boolean;
costmap?: boolean;
}
export interface RecordedRrdArtifactDescriptor {
@@ -75,6 +77,8 @@ export interface RecordedSessionRerunProfile {
semanticLayer?: "city" | "vegetation";
/** Keeps one native Rerun store/viewer while presenting the accepted two-pane LAB layout. */
unifiedPerception?: boolean;
/** Controlled camera-column share for the native horizontal Rerun container. */
unifiedCameraShare?: number;
/** Requests the canonical top-down eye without changing the world coordinate frame. */
planView?: boolean;
perceptionRetryGeneration: number;
@@ -0,0 +1,347 @@
import {
decodeObservatoryRecordedJob,
type ObservatoryRecordedJob,
} from "./recordedJobs";
const COMPOSITION_SCHEMA = "missioncore.observatory-ai-composition/v1";
const CATALOG_SCHEMA = "missioncore.observatory-ai-module-catalog/v1";
const RECEIPT_SCHEMA = "missioncore.observatory-ai-composition-receipt/v3";
const SHA256 = /^[a-f0-9]{64}$/;
export type AIGroupId = "segmentation" | "detection" | "geometry" | "range" | "motion" | "policy";
export interface AIModule {
readonly moduleId: string;
readonly moduleSha256: string;
readonly label: string;
readonly dockerName: string;
readonly requires: readonly string[];
readonly provides: readonly string[];
readonly defaults: Readonly<Record<string, unknown>>;
}
export interface AIModuleGroup {
readonly group: AIGroupId;
readonly modules: readonly AIModule[];
}
export interface AIModuleCatalog {
readonly groups: readonly AIModuleGroup[];
}
export interface AICompositionReceipt {
readonly compositionSha256: string;
readonly created: boolean;
readonly dispatchReady: boolean;
readonly dispatchReason: string;
readonly setupIds: readonly string[];
readonly jobs: readonly ObservatoryRecordedJob[];
readonly run: AICompositionRun;
}
export interface AIViewerLayer {
readonly layerId: string;
readonly paneId: "camera" | "spatial";
readonly label: string;
readonly control: "toggle" | "toggle-with-settings";
readonly order: number;
}
export interface AICompositionPresentation {
readonly configurationLabel: string;
readonly modules: readonly { readonly moduleId: string; readonly label: string }[];
readonly viewerLayers: readonly AIViewerLayer[];
}
export interface AICompositionRun {
readonly runId: string;
readonly sourceSessionId: string;
readonly compositionSha256: string;
readonly moduleIds: readonly string[];
readonly setupIds: readonly string[];
readonly jobIds: readonly string[];
readonly resultIds: readonly string[];
readonly createdAtUtc: string;
readonly state: "running" | "ready" | "failed";
readonly configurationLabel: string;
readonly displayName: string | null;
readonly presentation: AICompositionPresentation;
readonly jobs: readonly ObservatoryRecordedJob[];
}
export const AI_MODULE_SETUP_IDS: Readonly<Record<string, string>> = Object.freeze({
ddrnet: "ai-segmentation-ddrnet-v1",
eomt: "ai-segmentation-eomt-v1",
tgs: "m49-tgs-portable-v2",
"rf-detr": "ai-detection-rf-detr-v1",
"object-distance": "ai-range-object-distance-v1",
});
export function aiModuleIdForSetup(setupId: string): string | null {
return Object.entries(AI_MODULE_SETUP_IDS).find(([, value]) => value === setupId)?.[0] ?? null;
}
export async function fetchAIModuleCatalog(signal?: AbortSignal): Promise<AIModuleCatalog> {
const response = await fetch("/api/v1/observatory/ai-module-catalog", {
headers: { Accept: "application/json" }, signal,
});
const body = await bodyOf(response);
if (!response.ok) throw apiError(body, response.status);
const root = record(body, "каталог AI-модулей");
exactKeys(root, ["authority", "groups", "schema_version"]);
if (root.schema_version !== CATALOG_SCHEMA) throw new Error("Версия каталога AI-модулей изменилась.");
const known = new Set<AIGroupId>(["segmentation", "detection", "geometry", "range", "motion", "policy"]);
const groups = array(root.groups).map((value): AIModuleGroup => {
const group = record(value, "группа AI-модулей");
exactKeys(group, ["group", "modules"]);
if (typeof group.group !== "string" || !known.has(group.group as AIGroupId)) {
throw new Error("Каталог вернул неизвестную группу AI-модулей.");
}
return { group: group.group as AIGroupId, modules: array(group.modules).map(moduleOf) };
});
if (new Set(groups.map((group) => group.group)).size !== groups.length) {
throw new Error("Каталог AI-модулей содержит повторяющиеся группы.");
}
return { groups };
}
export async function saveAIComposition(
sourceSessionId: string,
selections: readonly { group: AIGroupId; module: AIModule }[],
): Promise<AICompositionReceipt> {
const response = await fetch("/api/v1/observatory/ai-compositions", {
method: "POST",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({
schema_version: COMPOSITION_SCHEMA,
source_session_id: sourceSessionId,
idempotency_key: createIdempotencyKey(),
selections: selections.map(({ group, module }) => ({
group, module_id: module.moduleId, module_sha256: module.moduleSha256,
parameters: module.defaults,
})),
}),
});
const body = await bodyOf(response);
if (!response.ok) throw apiError(body, response.status);
const root = record(body, "конфигурация AI-слоя");
exactKeys(root, ["composition", "composition_sha256", "created", "dispatch", "run", "schema_version", "source_session_id"]);
if (root.schema_version !== RECEIPT_SCHEMA || root.source_session_id !== sourceSessionId) {
throw new Error("Сервер вернул конфигурацию для другой записи.");
}
const digest = text(root.composition_sha256);
if (!SHA256.test(digest)) throw new Error("Сервер вернул некорректную идентичность композиции.");
const dispatch = record(root.dispatch, "готовность композиции");
exactKeys(dispatch, ["jobs", "ready", "reason", "setup_ids"]);
if (dispatch.ready !== true) {
throw new Error(text(dispatch.reason));
}
const setupIds = array(dispatch.setup_ids).map(text);
const jobs = array(dispatch.jobs).map(decodeObservatoryRecordedJob);
if (!jobs.length || jobs.length !== setupIds.length
|| jobs.some((job, index) => job.setupId !== setupIds[index])) {
throw new Error("Сервер вернул неполную очередь композиции.");
}
const run = compositionRunOf(root.run);
return {
compositionSha256: digest,
created: boolean(dispatch && root.created),
dispatchReady: boolean(dispatch.ready),
dispatchReason: text(dispatch.reason),
setupIds,
jobs,
run,
};
}
export async function fetchAICompositionRuns(
sourceSessionId: string,
signal?: AbortSignal,
): Promise<readonly AICompositionRun[]> {
const query = new URLSearchParams({ source_session_id: sourceSessionId });
const response = await fetch(`/api/v1/observatory/ai-composition-runs?${query}`, {
headers: { Accept: "application/json" }, signal,
});
const body = await bodyOf(response);
if (!response.ok) throw apiError(body, response.status);
const root = record(body, "запуски композиций");
exactKeys(root, ["items", "schema_version"]);
if (root.schema_version !== "missioncore.observatory-ai-composition-run-list/v1") {
throw new Error("Версия запусков AI-композиций изменилась.");
}
return array(root.items).map(compositionRunOf);
}
export async function renameAICompositionRunProjection(
runId: string,
displayName: string,
): Promise<string> {
if (!/^ai-composition-[a-f0-9]{64}$/.test(runId)) {
throw new Error("Некорректная идентичность результата AI Inference.");
}
const normalized = displayName.trim();
if (!normalized || normalized.length > 160) {
throw new Error("Название результата должно содержать от 1 до 160 символов.");
}
const response = await fetch(`/api/v1/observatory/ai-composition-runs/${encodeURIComponent(runId)}`, {
method: "PATCH",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({
schema_version: "missioncore.observatory-ai-composition-run-rename/v1",
display_name: normalized,
}),
});
const body = await bodyOf(response);
if (!response.ok) throw apiError(body, response.status);
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.");
return normalized;
}
export async function deleteAICompositionRunProjection(runId: string): Promise<void> {
if (!/^ai-composition-[a-f0-9]{64}$/.test(runId)) {
throw new Error("Некорректная идентичность результата AI Inference.");
}
const response = await fetch(`/api/v1/observatory/ai-composition-runs/${encodeURIComponent(runId)}`, {
method: "DELETE",
headers: { Accept: "application/json" },
});
if (!response.ok || response.status !== 204) {
throw apiError(await bodyOf(response), response.status);
}
}
function compositionRunOf(value: unknown): AICompositionRun {
const row = record(value, "запуск AI-композиции");
const expected = [
"composition_sha256", "configuration_label", "created_at_utc", "display_name", "job_ids", "jobs",
"module_ids", "presentation", "result_ids", "run_id", "schema_version", "setup_ids",
"source_session_id", "state",
];
// The receipt embeds the immutable run before its live state projection.
const receiptShape = [
"composition_sha256", "created_at_utc", "job_ids", "module_ids", "presentation",
"run_id", "schema_version", "setup_ids", "source_session_id",
];
const keys = Object.keys(row);
const projected = keys.length === expected.length;
exactKeys(row, projected ? expected : receiptShape);
if (row.schema_version !== "missioncore.observatory-ai-composition-run/v1") {
throw new Error("Версия запуска AI-композиции изменилась.");
}
const sourceSessionId = text(row.source_session_id);
const compositionSha256 = text(row.composition_sha256);
const runId = text(row.run_id);
if (!/^ai-composition-[a-f0-9]{64}$/.test(runId) || !SHA256.test(compositionSha256)) {
throw new Error("Некорректная идентичность запуска AI-композиции.");
}
const presentation = presentationOf(row.presentation);
const state = projected ? text(row.state) : "running";
if (!new Set(["running", "ready", "failed"]).has(state)) {
throw new Error("Неизвестное состояние запуска AI-композиции.");
}
return {
runId,
sourceSessionId,
compositionSha256,
moduleIds: array(row.module_ids).map(text),
setupIds: array(row.setup_ids).map(text),
jobIds: array(row.job_ids).map(text),
resultIds: projected ? array(row.result_ids).map(text) : [],
createdAtUtc: text(row.created_at_utc),
state: state as AICompositionRun["state"],
configurationLabel: projected ? text(row.configuration_label) : presentation.configurationLabel,
displayName: projected
? row.display_name === null ? null : text(row.display_name)
: null,
presentation,
jobs: projected ? array(row.jobs).map(decodeObservatoryRecordedJob) : [],
};
}
function presentationOf(value: unknown): AICompositionPresentation {
const row = record(value, "проекция AI-композиции");
exactKeys(row, ["configuration_label", "modules", "schema_version", "viewer_layers"]);
if (row.schema_version !== "missioncore.observatory-presentation-projection/v1") {
throw new Error("Версия проекции AI-композиции изменилась.");
}
const modules = array(row.modules).map((value) => {
const module = record(value, "модуль проекции");
exactKeys(module, ["label", "module_id"]);
return { moduleId: text(module.module_id), label: text(module.label) };
});
const viewerLayers = array(row.viewer_layers).map((value): AIViewerLayer => {
const layer = record(value, "слой viewer");
exactKeys(layer, ["control", "label", "layer_id", "order", "pane_id"]);
const paneId = text(layer.pane_id);
const control = text(layer.control);
if (!new Set(["camera", "spatial"]).has(paneId)
|| !new Set(["toggle", "toggle-with-settings"]).has(control)
|| typeof layer.order !== "number") {
throw new Error("Некорректная проекция слоя viewer.");
}
return {
layerId: text(layer.layer_id), paneId: paneId as AIViewerLayer["paneId"],
label: text(layer.label), control: control as AIViewerLayer["control"], order: layer.order,
};
});
return { configurationLabel: text(row.configuration_label), modules, viewerLayers };
}
export async function fetchAICompositionJobs(
sourceSessionId: string,
signal?: AbortSignal,
): Promise<readonly ObservatoryRecordedJob[]> {
const query = new URLSearchParams({ source_session_id: sourceSessionId });
const response = await fetch(`/api/v1/observatory/ai-runs?${query}`, {
headers: { Accept: "application/json" }, signal,
});
const body = await bodyOf(response);
if (!response.ok) throw apiError(body, response.status);
const root = record(body, "очередь AI-слоёв");
exactKeys(root, ["authority", "items", "schema_version"]);
if (root.schema_version !== "missioncore.observatory-recorded-job-list/v1") {
throw new Error("Версия очереди AI-слоёв изменилась.");
}
return array(root.items).map(decodeObservatoryRecordedJob);
}
function moduleOf(value: unknown): AIModule {
const row = record(value, "AI-модуль");
exactKeys(row, ["defaults", "docker_name", "label", "module_id", "module_sha256", "parameter_choices", "provides", "requires"]);
const digest = text(row.module_sha256);
if (!SHA256.test(digest)) throw new Error("Каталог вернул некорректную версию AI-модуля.");
return {
moduleId: text(row.module_id), moduleSha256: digest, label: text(row.label),
dockerName: text(row.docker_name), requires: array(row.requires).map(text),
provides: array(row.provides).map(text), defaults: record(row.defaults, "параметры AI-модуля"),
};
}
function record(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Некорректный ${label}.`);
return value as Record<string, unknown>;
}
function array(value: unknown): unknown[] { if (!Array.isArray(value)) throw new Error("Ожидался список."); return value; }
function text(value: unknown): string { if (typeof value !== "string" || !value) throw new Error("Ожидался текст."); return value; }
function boolean(value: unknown): boolean { if (typeof value !== "boolean") throw new Error("Ожидалось логическое значение."); return value; }
function exactKeys(value: Record<string, unknown>, expected: string[]): void {
if (Object.keys(value).sort().join() !== [...expected].sort().join()) throw new Error("Контракт AI-слоя изменился.");
}
async function bodyOf(response: Response): Promise<unknown> { const value = await response.text(); try { return JSON.parse(value); } catch { return value; } }
function apiError(body: unknown, status: number): Error {
const detail = body && typeof body === "object" && !Array.isArray(body) ? (body as Record<string, unknown>).detail : null;
return new Error(typeof detail === "string" ? detail : `Observatory API вернул HTTP ${status}.`);
}
function createIdempotencyKey(): string {
const entropy = typeof globalThis.crypto?.randomUUID === "function"
? globalThis.crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `ai-layer:${entropy}`;
}
@@ -1,9 +1,9 @@
import {
fetchObservationSessionCatalog,
type ObservationLabInstance,
type ObservationSessionFetch,
type ObservationSessionSummary,
} from "../observation/sessionArchive";
import { fetchObservationSessionCatalogPage } from "../observation/sessionCatalogPage";
import {
observatoryRecordedRunBinding,
type ObservatoryRecordedRunBinding,
@@ -164,19 +164,23 @@ function boundedLimit(limit: number): number {
function evidenceFromSession(
session: ObservationSessionSummary,
): ObservatoryEvidence {
): ObservatoryEvidence | null {
if (!session.lab) {
throw new ObservatoryCatalogContractError(
`LAB-каталог вернул сессию ${session.id} без типизированной связи с источником.`,
);
}
// Historical LABs retain their archive and replay adapters. Observatory is
// the portable-profile product; a familiar LAB label is not admission.
if (session.lab.replayCapability?.kind !== "portable-result-review") return null;
const recordedRun = observatoryRecordedRunBinding(session.id, session.lab);
return {
sessionId: session.id,
label: session.label,
status: session.status,
publishedAtUtc: session.lab.publishedAtUtc,
lab: session.lab,
recordedRun: observatoryRecordedRunBinding(session.id, session.lab),
recordedRun,
};
}
@@ -208,11 +212,11 @@ export function buildObservatoryCatalog(
const unresolvedEvidence: ObservatoryEvidence[] = [];
for (const laboratorySession of laboratorySessions) {
const evidence = evidenceFromSession(laboratorySession);
if (!evidence) continue;
const sourceId = evidence.lab.sourceSessionId;
if (!sources.has(sourceId)) {
// Both API projections are bounded newest-first windows without a
// cursor. Absence from the source window is not proof of a broken
// relationship and must not be presented as an integrity fault.
// A bounded traversal may stop before this source. Absence alone is
// not proof of a broken relationship or permission to remove data.
unresolvedEvidence.push(evidence);
continue;
}
@@ -236,7 +240,8 @@ export function buildObservatoryCatalog(
window: {
limit: safeLimit,
sourceCount: sourceSessions.length,
laboratoryCount: laboratorySessions.length,
laboratoryCount: [...linked.values()].reduce((count, items) => count + items.length, 0)
+ unresolvedEvidence.length,
sourceLimitReached: sourceSessions.length >= safeLimit,
laboratoryLimitReached: laboratorySessions.length >= safeLimit,
},
@@ -253,9 +258,64 @@ export async function fetchObservatoryCatalog({
fetcher?: ObservationSessionFetch;
} = {}): Promise<ObservatoryCatalog> {
const safeLimit = boundedLimit(limit);
const [sourceCatalog, laboratoryCatalog] = await Promise.all([
fetchObservationSessionCatalog({ signal, limit: safeLimit, scope: "source", fetcher }),
fetchObservationSessionCatalog({ signal, limit: safeLimit, scope: "laboratory", fetcher }),
]);
return buildObservatoryCatalog(sourceCatalog.items, laboratoryCatalog.items, safeLimit);
const request = new AbortController();
const abort = () => request.abort(signal?.reason);
if (signal?.aborted) abort();
else signal?.addEventListener("abort", abort, { once: true });
try {
const [sources, laboratories] = await Promise.all([
readCatalogPages("source", safeLimit, fetcher, request.signal),
readCatalogPages("laboratory", safeLimit, fetcher, request.signal),
]);
const catalog = buildObservatoryCatalog(sources.items, laboratories.items, safeLimit);
return {
...catalog,
window: {
...catalog.window,
sourceLimitReached: sources.hasMore,
laboratoryLimitReached: laboratories.hasMore,
},
};
} finally {
request.abort();
signal?.removeEventListener("abort", abort);
}
}
// Metadata only, never recordings or result artifacts. The cap is an explicit
// partial window, not silent completeness or an unbounded background crawler.
const MAX_CATALOG_PAGES = 256;
async function readCatalogPages(
scope: "source" | "laboratory",
limit: number,
fetcher: ObservationSessionFetch,
signal: AbortSignal,
): Promise<{ items: ObservationSessionSummary[]; hasMore: boolean }> {
const items: ObservationSessionSummary[] = [];
const seenIds = new Set<string>();
const cursors = new Set<string>();
let cursor: string | null = null;
for (let index = 0; index < MAX_CATALOG_PAGES; index += 1) {
const page = await fetchObservationSessionCatalogPage({ scope, limit, cursor, signal, fetcher });
for (const item of page.items) {
if (seenIds.has(item.id)) {
throw new ObservatoryCatalogContractError("Каталог повторил сессию между страницами.");
}
seenIds.add(item.id);
// Do not accumulate legacy provenance while walking the shared catalog.
if (scope === "source" || item.lab?.replayCapability?.kind === "portable-result-review") {
items.push(item);
} else if (!item.lab) {
throw new ObservatoryCatalogContractError("LAB-каталог вернул исходную сессию.");
}
}
cursor = page.nextCursor;
if (cursor === null) return { items, hasMore: false };
if (cursors.has(cursor)) {
throw new ObservatoryCatalogContractError("Каталог повторил курсор страницы.");
}
cursors.add(cursor);
}
return { items, hasMore: true };
}
@@ -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;
@@ -0,0 +1,104 @@
import type { SceneSettings } from "../../sceneSettings";
const PROFILE_SCHEMA = "missioncore.observatory-lab-view-profile/v1";
export async function fetchLabViewProfile(
resultId: string,
signal?: AbortSignal,
): Promise<SceneSettings | null> {
const response = await fetch(`/api/v1/observatory/lab-view-profiles/${encodeURIComponent(resultId)}`, {
headers: { Accept: "application/json" },
signal,
});
if (response.status === 404) return null;
const body: unknown = await response.json();
if (!response.ok) throw new Error("Профиль отображения LAB не загрузился.");
return decodeProfile(body, resultId);
}
export async function saveLabViewProfile(
resultId: string,
settings: SceneSettings,
signal?: AbortSignal,
): Promise<SceneSettings> {
const response = await fetch(`/api/v1/observatory/lab-view-profiles/${encodeURIComponent(resultId)}`, {
method: "PUT",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({
schema_version: PROFILE_SCHEMA,
result_id: resultId,
scene_settings: {
point_size: settings.pointSize,
accumulation_seconds: settings.accumulationSeconds,
color_mode: settings.colorMode,
palette: settings.palette,
show_grid: settings.showGrid,
show_labels: settings.showLabels,
show_camera_frustums: settings.showCameraFrustums,
},
}),
signal,
});
const body: unknown = await response.json();
if (!response.ok) throw new Error("Настройки LAB не сохранились. Повторите закрытие окна.");
return decodeProfile(body, resultId);
}
function decodeProfile(value: unknown, expectedResultId: string): SceneSettings {
const row = record(value);
exactKeys(row, ["result_id", "scene_settings", "schema_version", "updated_at_utc"]);
if (row.schema_version !== PROFILE_SCHEMA || row.result_id !== expectedResultId) {
throw new Error("Сервер вернул профиль другой LAB.");
}
const settings = record(row.scene_settings);
exactKeys(settings, [
"accumulation_seconds", "color_mode", "palette", "point_size",
"show_camera_frustums", "show_grid", "show_labels",
]);
const pointSize = finite(settings.point_size);
const accumulationSeconds = finite(settings.accumulation_seconds);
const colorMode = settings.color_mode;
const palette = settings.palette;
if (
pointSize < 0.1
|| accumulationSeconds < 0
|| !new Set(["intensity", "height", "distance", "rgb", "class"]).has(String(colorMode))
|| !new Set(["turbo", "viridis", "plasma", "grayscale"]).has(String(palette))
|| typeof settings.show_grid !== "boolean"
|| typeof settings.show_labels !== "boolean"
|| typeof settings.show_camera_frustums !== "boolean"
) throw new Error("Сервер вернул повреждённый профиль LAB.");
return {
projection: "3d",
pointSize,
colorMode: colorMode as SceneSettings["colorMode"],
palette: palette as SceneSettings["palette"],
customColor: "#35d7c1",
accumulationSeconds,
showPoints: true,
showTrajectory: true,
showGrid: settings.show_grid,
showLabels: settings.show_labels,
showCameraFrustums: settings.show_camera_frustums,
};
}
function record(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("Некорректный профиль отображения LAB.");
}
return value as Record<string, unknown>;
}
function finite(value: unknown): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new Error("Некорректное числовое значение профиля LAB.");
}
return value;
}
function exactKeys(value: Record<string, unknown>, expected: string[]): void {
if (Object.keys(value).sort().join() !== [...expected].sort().join()) {
throw new Error("Контракт профиля отображения LAB изменился.");
}
}
@@ -26,6 +26,7 @@ export interface ObservatoryRecordedJob {
readonly sourceSessionId: string;
readonly setupId: string;
readonly definitionSha256: string;
readonly claimGeneration: number;
readonly state: ObservatoryRecordedJobState;
readonly restartFromZero: boolean;
readonly resultId: string | null;
@@ -85,7 +86,7 @@ export async function fetchObservatoryRecordedJobs(
exactKeys(row, ["authority", "items", "schema_version"], "список расчётов");
exact(row.schema_version, JOB_LIST_SCHEMA, "schema_version списка расчётов");
observationAuthority(row.authority);
return array(row.items, "items").map(decodeJob).filter((job) => (
return array(row.items, "items").map(decodeObservatoryRecordedJob).filter((job) => (
job.sourceSessionId === sourceSessionId && job.setupId === setupId
&& (definitionSha256 === undefined || job.definitionSha256 === definitionSha256)
));
@@ -124,7 +125,7 @@ export async function submitObservatoryRecordedJob(
});
const body = await responseBody(response);
if (!response.ok) throw apiError(body, response.status);
const job = decodeJob(body);
const job = decodeObservatoryRecordedJob(body);
if (job.sourceSessionId !== sourceSessionId || job.setupId !== setupId
|| (portableBinding !== null && job.definitionSha256 !== portableBinding.definitionSha256)) {
throw new ObservatoryRecordedJobContractError(
@@ -155,7 +156,7 @@ export async function retryObservatoryRecordedJobPublication(
);
const body = await responseBody(response);
if (!response.ok) throw apiError(body, response.status);
const job = decodeJob(body);
const job = decodeObservatoryRecordedJob(body);
if (job.jobId !== jobId) {
throw new ObservatoryRecordedJobContractError(
"Повтор публикации вернул другой расчёт.",
@@ -164,7 +165,7 @@ export async function retryObservatoryRecordedJobPublication(
return job;
}
function decodeJob(value: unknown): ObservatoryRecordedJob {
export function decodeObservatoryRecordedJob(value: unknown): ObservatoryRecordedJob {
const row = record(value, "расчёт");
exactKeys(row, [
"authority", "checkpoint_policy", "claim_generation", "claim_lease", "created_at_utc", "executor",
@@ -229,6 +230,7 @@ function decodeJob(value: unknown): ObservatoryRecordedJob {
sourceSessionId: text(source.session_id, "source.session_id"),
setupId: text(setup.setup_id, "setup.setup_id"),
definitionSha256: String(setup.definition_sha256),
claimGeneration: nonNegativeInteger(row.claim_generation, "claim_generation"),
state,
restartFromZero: boolean(row.restart_from_zero, "restart_from_zero"),
resultId: result === null ? null : text(result.result_id, "result.result_id"),
@@ -0,0 +1,124 @@
import type { ObservatoryRecordedJob } from "./recordedJobs";
const PHASES = {
"source-transfer": "Передаём входные данные",
"source-preparation": "Подготавливаем вход",
computing: "Обрабатываем запись",
"result-assembly": "Собираем результат",
"result-transfer": "Передаём результат",
} as const;
const UNITS = { frames: "кадров", members: "файлов", steps: "операций" } as const;
type Phase = keyof typeof PHASES;
type Unit = keyof typeof UNITS;
export interface RecordedProgressView {
readonly jobId: string;
readonly claimGeneration: number;
readonly sequence: number;
readonly phase: Phase;
readonly unit: Unit;
readonly completed: number;
readonly total: number | null;
readonly ageSeconds: number;
readonly elapsedSeconds: number;
readonly phaseElapsedSeconds: number;
}
export async function fetchRecordedProgress(
job: ObservatoryRecordedJob,
{ signal, fetcher = globalThis.fetch }: {
signal?: AbortSignal;
fetcher?: typeof globalThis.fetch;
} = {},
): Promise<RecordedProgressView | null> {
const request = new AbortController();
const abort = () => request.abort();
signal?.addEventListener("abort", abort, { once: true });
if (signal?.aborted) request.abort();
const timer = globalThis.setTimeout(abort, 2_500);
try {
const response = await fetcher(
`/api/v1/observatory/runs/${encodeURIComponent(job.jobId)}/progress`,
{ signal: request.signal, headers: { Accept: "application/json" } },
);
if (!response.ok) throw new Error("Прогресс расчёта недоступен.");
return decodeRecordedProgress(await response.json(), job);
} finally {
globalThis.clearTimeout(timer);
signal?.removeEventListener("abort", abort);
}
}
export function decodeRecordedProgress(
value: unknown, job: ObservatoryRecordedJob,
): RecordedProgressView | null {
const row = object(value);
keys(row, ["schema_version", "job_id", "source_session_id", "setup_id",
"definition_sha256", "claim_generation", "state", "received_at_utc", "age_seconds", "progress"]);
if (row.schema_version !== "missioncore.observatory-recorded-progress-view/v1"
|| row.job_id !== job.jobId || row.source_session_id !== job.sourceSessionId
|| row.setup_id !== job.setupId || row.definition_sha256 !== job.definitionSha256
|| row.claim_generation !== job.claimGeneration) throw new Error("Прогресс другой попытки.");
if (row.progress === null || row.state !== job.state) return null;
const progress = object(row.progress);
keys(progress, ["schema_version", "claim_generation", "sequence", "phase_index",
"phase", "unit", "completed", "total", "elapsed_seconds", "phase_elapsed_seconds"]);
if (progress.schema_version !== "missioncore.observatory-recorded-progress/v1"
|| progress.claim_generation !== row.claim_generation
|| !(typeof progress.phase === "string" && Object.hasOwn(PHASES, progress.phase))
|| !(typeof progress.unit === "string" && Object.hasOwn(UNITS, progress.unit))
|| typeof row.received_at_utc !== "string" || !Number.isFinite(Date.parse(row.received_at_utc))) {
throw new Error("Некорректный прогресс.");
}
const completed = integer(progress.completed);
const total = progress.total === null ? null : integer(progress.total, 1);
const elapsed = finite(progress.elapsed_seconds);
const phaseElapsed = finite(progress.phase_elapsed_seconds);
if ((total !== null && completed > total) || phaseElapsed > elapsed) {
throw new Error("Некорректные счётчики прогресса.");
}
integer(progress.phase_index);
return {
jobId: job.jobId, claimGeneration: integer(progress.claim_generation, 1),
sequence: integer(progress.sequence, 1), phase: progress.phase as Phase,
unit: progress.unit as Unit, completed, total, ageSeconds: finite(row.age_seconds),
elapsedSeconds: elapsed, phaseElapsedSeconds: phaseElapsed,
};
}
export function recordedProgressLabel(
job: ObservatoryRecordedJob | null, progress: RecordedProgressView | null,
): string {
if (job?.publication.state === "pending") return "Сохраняем результат";
if (!job || job.state === "accepted" || job.state === "queued") return "Ожидаем расчёт";
if (job.state === "failed") return "Ошибка расчёта";
if (job.state === "succeeded") return "Расчёт завершён";
if (job.state === "paused" || job.state === "preemption-pending") return "Расчёт приостановлен";
if (job.state === "reconciliation-required") return "Проверяем состояние расчёта";
if (!progress || progress.jobId !== job.jobId
|| progress.claimGeneration !== job.claimGeneration) return "Ожидаем данные расчёта";
if (progress.ageSeconds > 15) return "Ожидаем обновление прогресса";
const count = progress.total === null
? (progress.completed > 0 ? ` · ${progress.completed.toLocaleString("ru-RU")} ${UNITS[progress.unit]}` : "")
: ` · ${progress.completed.toLocaleString("ru-RU")} / ${progress.total.toLocaleString("ru-RU")} ${UNITS[progress.unit]}`;
return PHASES[progress.phase] + count;
}
function object(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Прогресс: ожидался объект.");
return value as Record<string, unknown>;
}
function keys(row: Record<string, unknown>, expected: string[]): void {
if (Object.keys(row).length !== expected.length || expected.some((key) => !Object.hasOwn(row, key))) {
throw new Error("Прогресс: неизвестные поля.");
}
}
function finite(value: unknown): number {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) throw new Error("Прогресс: некорректное число.");
return value;
}
function integer(value: unknown, minimum = 0): number {
const number = finite(value);
if (!Number.isSafeInteger(number) || number < minimum) throw new Error("Прогресс: некорректный счётчик.");
return number;
}
@@ -0,0 +1,98 @@
import { useCallback, useEffect, useState } from "react";
import {
AI_MODULE_SETUP_IDS,
fetchAIModuleCatalog,
fetchAICompositionJobs,
fetchAICompositionRuns,
type AICompositionRun,
} from "./aiComposition";
import { fetchRecordedProgress, type RecordedProgressView } from "./recordedProgress";
import type { ObservatoryRecordedJob } from "./recordedJobs";
const OPEN = new Set(["accepted", "queued", "claimed", "running", "paused",
"preemption-pending", "reconciliation-required"]);
interface Snapshot {
readonly sourceSessionId: string;
readonly jobs: readonly ObservatoryRecordedJob[];
readonly runs: readonly AICompositionRun[];
readonly moduleLabelsBySetup: Readonly<Record<string, string>>;
readonly progress: Readonly<Record<string, RecordedProgressView>>;
readonly error: string | null;
}
export function useAICompositionJobs(sourceSessionId: string) {
const [revision, setRevision] = useState(0);
const [snapshot, setSnapshot] = useState<Snapshot>({
sourceSessionId: "", jobs: [], runs: [], moduleLabelsBySetup: {}, progress: {}, error: null,
});
const current = snapshot.sourceSessionId === sourceSessionId ? snapshot : {
sourceSessionId, jobs: [], runs: [], moduleLabelsBySetup: {}, progress: {}, error: null,
};
useEffect(() => {
if (!sourceSessionId) {
setSnapshot({ sourceSessionId, jobs: [], runs: [], moduleLabelsBySetup: {}, progress: {}, error: null });
return;
}
const request = new AbortController();
void Promise.all([
fetchAICompositionJobs(sourceSessionId, request.signal),
fetchAICompositionRuns(sourceSessionId, request.signal),
fetchAIModuleCatalog(request.signal),
]).then(async ([jobs, runs, catalog]) => {
const active = jobs.filter((job) => OPEN.has(job.state));
const samples = await Promise.all(active.map(async (job) => [
job.jobId, await fetchRecordedProgress(job, { signal: request.signal }).catch(() => null),
] as const));
if (request.signal.aborted) return;
const catalogLabels = catalog.groups.flatMap(({ modules }) => (
modules.flatMap((module) => {
const setupId = AI_MODULE_SETUP_IDS[module.moduleId];
return setupId ? [[setupId, module.label] as const] : [];
})
));
const projectedLabels = runs.flatMap((run) => run.presentation.modules.flatMap((module) => {
const setupId = AI_MODULE_SETUP_IDS[module.moduleId];
return setupId ? [[setupId, module.label] as const] : [];
}));
setSnapshot({
sourceSessionId, jobs, runs,
moduleLabelsBySetup: Object.fromEntries([...catalogLabels, ...projectedLabels]),
progress: Object.fromEntries(samples.filter(
(sample): sample is readonly [string, RecordedProgressView] => sample[1] !== null,
)),
error: null,
});
}).catch((error: unknown) => {
if (request.signal.aborted) return;
setSnapshot((value) => ({
sourceSessionId, jobs: value.sourceSessionId === sourceSessionId ? value.jobs : [],
runs: value.sourceSessionId === sourceSessionId ? value.runs : [],
moduleLabelsBySetup: value.sourceSessionId === sourceSessionId
? value.moduleLabelsBySetup : {},
progress: value.sourceSessionId === sourceSessionId ? value.progress : {},
error: error instanceof Error ? error.message : "Очередь AI-слоёв недоступна.",
}));
});
return () => request.abort();
}, [revision, sourceSessionId]);
const active = current.runs.some((run) => run.state === "running")
|| current.jobs.some((job) => OPEN.has(job.state) || job.publication.state === "pending");
useEffect(() => {
if (!active) return;
const timer = globalThis.setTimeout(() => setRevision((value) => value + 1), 1_500);
return () => globalThis.clearTimeout(timer);
}, [active, revision]);
return {
jobs: current.jobs,
runs: current.runs,
moduleLabelsBySetup: current.moduleLabelsBySetup,
progress: current.progress,
error: current.error,
refresh: useCallback(() => setRevision((value) => value + 1), []),
};
}
@@ -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 {
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchRecordedProgress, type RecordedProgressView } from "./recordedProgress";
import {
fetchObservatoryRecordedJobs,
@@ -21,6 +22,7 @@ interface JobSnapshot {
readonly jobs: readonly ObservatoryRecordedJob[];
readonly state: RecordedJobsState;
readonly error: string | null;
readonly progress?: RecordedProgressView | null;
}
const EMPTY_JOBS = [] as const;
const OPEN_STATES = new Set([
@@ -63,14 +65,19 @@ export function useObservatoryRecordedJobs(
setSnapshot({
selectionKey, jobs,
state: jobs.length > 0 ? "refreshing" : "loading", error: null,
progress: current?.progress ?? null,
});
void fetchObservatoryRecordedJobs(sourceSessionId, setupId, {
definitionSha256, signal: request.signal,
}).then((next) => {
}).then(async (next) => {
if (request.signal.aborted || requestSequence.current !== sequence) return;
const pending = next.find((job) => OPEN_STATES.has(job.state));
const progress = pending
? await fetchRecordedProgress(pending, { signal: request.signal }).catch(() => null)
: null;
if (request.signal.aborted || requestSequence.current !== sequence) return;
if (pending) observedJobId.current = pending.jobId;
setSnapshot({ selectionKey, jobs: next, state: "ready", error: null });
setSnapshot({ selectionKey, jobs: next, state: "ready", error: null, progress });
}).catch((caught: unknown) => {
if (request.signal.aborted || requestSequence.current !== sequence) return;
setSnapshot({
@@ -95,7 +102,7 @@ export function useObservatoryRecordedJobs(
&& latestJob.jobId === observedJobId.current;
useEffect(() => {
if (state !== "ready" || (!activeJob && !publicationPending)) return;
if ((state !== "ready" && state !== "error") || (!activeJob && !publicationPending)) return;
const timer = globalThis.setTimeout(
() => setRevision((value) => value + 1),
POLL_INTERVAL_MS,
@@ -185,6 +192,7 @@ export function useObservatoryRecordedJobs(
return {
jobs, latestJob, activeJob, publicationPending, computationFailed,
progress: current?.progress ?? null,
state, error, refresh, submit, retryPublication,
};
}
+9 -19
View File
@@ -16,6 +16,7 @@ export type WorkspaceKind =
| "map"
| "timeline"
| "missions"
| "vehicles"
| "catalog"
| "contour-health"
| "compute-modules"
@@ -170,10 +171,10 @@ export const workspaces: WorkspaceDefinition[] = [
{
id: "observatory",
root: "polygon",
label: "Обсерватория",
title: "Проверка компьютерного зрения",
eyebrow: "ТЕСТОВЫЙ КОНТУР / ОБСЕРВАТОРИЯ",
description: "Сессии и квалификация компьютерного зрения без доступа к управлению.",
label: "AI Inference",
title: "AI Inference",
eyebrow: "ТЕСТОВЫЙ КОНТУР / AI INFERENCE",
description: "Сессии и квалификация без доступа к управлению.",
icon: "eye",
kind: "observatory",
groups: [],
@@ -193,23 +194,12 @@ 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",
@@ -89,16 +89,6 @@
min-height: 0;
}
.canonical-vegetation-rerun-replay__viewport-lock
.rerun-viewport__camera-lock {
width: var(--canonical-rerun-camera-pane, 100%);
}
.canonical-vegetation-rerun-replay__viewport-lock[data-split-view="true"]
.rerun-viewport__camera-lock {
width: calc(var(--canonical-rerun-camera-pane, 46%) - 0.75rem);
}
.canonical-vegetation-rerun-replay__loading {
position: absolute;
z-index: 6;
@@ -155,29 +145,16 @@
display: flex;
min-width: 0;
align-items: center;
align-content: flex-start;
flex-wrap: wrap;
gap: 0.35rem;
pointer-events: none;
}
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="media"] {
justify-content: flex-end;
}
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="media"][data-multi-semantic="true"] {
flex-wrap: wrap;
}
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="media"][data-multi-semantic="true"]
> .m4-replay-threat-visual__pane-layer-controls {
flex: 1 0 100%;
justify-content: flex-start;
}
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="media"][data-multi-semantic="true"]
> .m4-replay-threat-visual__pane-mode-controls {
margin-left: auto;
}
.m4-replay-threat-evidence-viewer[data-mode-controls="content"]:has(
.m4-replay-threat-visual__review-controls
) .m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="media"] {
@@ -185,12 +162,17 @@
}
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="spatial"] {
justify-content: space-between;
justify-content: flex-start;
}
.m4-replay-threat-evidence-viewer[data-mode-controls="content"]
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="spatial"] {
right: 3.8rem;
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="spatial"],
.m4-replay-threat-evidence-viewer[data-mode-controls="content"]
.m4-replay-threat-visual__deck:not([data-split="true"])
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="media"] {
/* The two viewer-wide actions stay at the deck's right edge. Keep their
complete hit area out of this viewport-owned toolbar at every split. */
right: 7rem;
}
.m4-replay-threat-visual__pane-toolbar > *,
@@ -199,18 +181,15 @@
}
.m4-replay-threat-visual__spatial-toolbar-end {
display: flex;
min-width: 0;
align-items: center;
justify-content: flex-end;
gap: 0.35rem;
overflow-x: auto;
overscroll-behavior-inline: contain;
scrollbar-width: none;
display: contents;
}
.m4-replay-threat-visual__spatial-toolbar-end::-webkit-scrollbar {
display: none;
.m4-replay-threat-visual__pane-toolbar > .m4-replay-threat-visual__pane-layer-controls,
.m4-replay-threat-visual__spatial-toolbar-end > .m4-replay-threat-visual__pane-layer-controls {
/* A viewport toolbar is one adaptive flex sequence. `display: contents`
keeps the semantic layer group while allowing each control to move to the
next row only when the viewport itself runs out of width. */
display: contents;
}
.m4-replay-threat-visual__pane-mode-controls {
@@ -259,7 +238,8 @@
display: flex;
align-items: center;
gap: 0.4rem;
border: 1px solid var(--nodedc-glass-outline);
border: 0;
outline: 0;
border-radius: var(--nodedc-radius-control-compact);
background: var(--nodedc-floating-surface);
padding: 0.42rem 0.58rem;
@@ -280,6 +260,15 @@
pointer-events: none;
}
.canonical-vegetation-rerun-replay
.m4-replay-threat-evidence-viewer[data-mode-controls="content"]
.laboratory-evidence-viewer__controls {
right: 0.6rem;
left: auto;
width: auto;
justify-content: flex-end;
}
.m4-replay-threat-evidence-viewer[data-mode-controls="content"] .laboratory-evidence-viewer__controls > .nodedc-icon-button {
pointer-events: auto;
}
@@ -304,6 +293,30 @@
scrollbar-width: none;
}
.m4-replay-threat-visual__layer-with-settings {
display: inline-flex;
flex: none;
align-items: center;
gap: 0.18rem;
border: 0;
border-radius: 999px;
background: var(--nodedc-glass-control-bg);
padding: 0.12rem;
}
.m4-replay-threat-visual__layer-with-settings > .nodedc-icon-button {
width: 1.75rem;
height: 1.75rem;
border: 0;
outline: 0;
box-shadow: none;
}
.m4-replay-threat-visual__layer-with-settings > .nodedc-icon-button:focus-visible {
background: var(--nodedc-focus-surface);
box-shadow: inset 0 0 0 1px rgb(var(--nodedc-accent-rgb) / 0.22);
}
.m4-replay-threat-visual__review-controls,
.m4-replay-threat-visual__review-controls > *,
.m4-replay-threat-visual__single-pane-controls,
+246 -7
View File
@@ -10,7 +10,6 @@
container-type: inline-size;
}
.observatory-lead,
.observatory-catalog-bar,
.observatory-catalog-bar__controls,
.observatory-notice,
@@ -21,18 +20,15 @@
gap: 1rem;
}
.observatory-lead,
.observatory-notice {
flex-wrap: wrap;
}
.observatory-lead > div,
.observatory-catalog-bar__copy,
.observatory-evidence header > div {
min-width: 0;
}
.observatory-lead h2,
.observatory-catalog-bar h3,
.observatory-session-summary h3,
.observatory-evidence h3 {
@@ -47,7 +43,6 @@
line-height: 1.15;
}
.observatory-lead p,
.observatory-state p,
.observatory-evidence-empty p {
max-width: 52rem;
@@ -197,6 +192,50 @@
padding: 0 0.2rem;
}
.observatory-evidence__ordering {
display: flex;
flex: none;
align-items: center;
gap: 0.55rem;
}
.observatory-evidence__sort {
display: grid;
width: 1.7rem;
height: 1.7rem;
place-items: center;
border: 0;
outline: 0;
border-radius: 0;
background: transparent;
padding: 0;
color: var(--nodedc-text-secondary);
cursor: pointer;
}
.observatory-evidence__sort:hover,
.observatory-evidence__sort:focus-visible {
background: var(--nodedc-glass-control-bg);
color: var(--nodedc-text-primary);
}
.observatory-evidence__sort svg {
width: 1.1rem;
height: 1.1rem;
transition: transform 160ms ease-out;
}
.observatory-evidence__sort path {
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-width: 1.5;
}
.observatory-evidence__sort[data-direction="oldest"] svg {
transform: rotate(180deg);
}
.observatory-evidence-list {
display: grid;
gap: 0.4rem;
@@ -241,6 +280,14 @@
white-space: nowrap;
}
.observatory-evidence-card__copy strong {
overflow: visible;
text-overflow: clip;
white-space: normal;
font-size: var(--nodedc-font-size-sm);
line-height: 1.3;
}
.observatory-evidence-card__copy span {
color: var(--nodedc-text-secondary);
font-size: var(--nodedc-font-size-sm);
@@ -251,6 +298,28 @@
font-size: var(--nodedc-font-size-xs);
}
.observatory-evidence-card--progress {
grid-template-columns: auto minmax(0, 1fr) auto auto;
border: 0;
outline: 0;
}
.observatory-evidence-card__progress {
height: 0.28rem;
margin: 0.18rem 0 0.08rem;
overflow: hidden;
border-radius: 999px;
background: rgb(var(--nodedc-accent-rgb) / 0.12);
}
.observatory-evidence-card__progress > span {
display: block;
height: 100%;
border-radius: inherit;
background: rgb(var(--nodedc-accent-rgb));
transition: width 180ms ease-out;
}
.observatory-evidence-card__actions,
.observatory-replay__header,
.observatory-replay-state,
@@ -282,6 +351,7 @@
.observatory-replay {
display: grid;
gap: 0.85rem;
font-family: var(--nodedc-font-family);
}
.observatory-replay__header {
@@ -293,10 +363,37 @@
margin: 0.3rem 0 0;
}
.observatory-replay__header h3 {
font-size: var(--nodedc-font-size-sm);
line-height: 1.3;
}
.observatory-replay__header p,
.observatory-replay-state p {
.observatory-replay-state p,
.observatory-replay__description {
margin: 0.35rem 0 0;
color: var(--nodedc-text-muted);
font-family: var(--nodedc-font-family);
font-size: var(--nodedc-font-size-sm);
font-weight: var(--nodedc-font-weight-regular);
line-height: 1.35;
}
.observatory-replay__description {
margin-right: 0.25rem;
margin-left: 0.25rem;
}
.observatory-replay .m4-replay-threat-visual__buffering {
border: 0;
outline: 0;
}
.observatory-replay .laboratory-evidence-viewer {
border: 0;
outline: 0;
border-radius: 0;
box-shadow: none;
}
.observatory-evidence-empty {
@@ -369,7 +466,6 @@
}
@container observatory-workspace (max-width: 920px) {
.observatory-lead,
.observatory-catalog-bar,
.observatory-catalog-bar__controls,
.observatory-notice {
@@ -399,3 +495,146 @@
flex-direction: column;
}
}
.observatory-ai-config {
display: grid;
gap: var(--nodedc-space-4);
}
.observatory-ai-config__lead,
.observatory-ai-config__error {
margin: 0;
color: var(--nodedc-text-secondary);
font-size: var(--nodedc-font-size-sm);
line-height: 1.45;
}
.observatory-ai-config__error {
color: rgb(var(--nodedc-danger-rgb));
}
.observatory-ai-config__duplicate {
margin-right: auto;
color: var(--nodedc-text-secondary);
font-size: var(--nodedc-font-size-sm);
line-height: 1.4;
}
@keyframes observatory-ai-calculate-pulse {
0%,
100% {
background-color: var(--nodedc-primary-action-bg);
}
50% {
background-color: color-mix(
in srgb,
var(--nodedc-primary-action-bg) 88%,
var(--nodedc-canvas-soft)
);
}
}
.observatory-ai-config__calculate--saving:disabled {
animation: observatory-ai-calculate-pulse 1.8s ease-in-out infinite;
cursor: progress;
opacity: 1;
}
.observatory-ai-config-window {
border: 0;
outline: 0;
box-shadow: var(--nodedc-shadow-window);
}
.observatory-ai-config-window::before,
.observatory-ai-config-window::after {
display: none;
}
.observatory-ai-module-group {
overflow: hidden;
border: 0;
outline: 0;
border-radius: var(--nodedc-radius-control-compact);
background: var(--nodedc-glass-control-bg);
}
.observatory-ai-module-group > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--nodedc-space-3);
padding: var(--nodedc-space-3) var(--nodedc-space-4);
border: 0;
outline: 0;
}
.observatory-ai-module-group > header > div {
display: grid;
min-width: 0;
gap: 0.2rem;
}
.observatory-ai-module-group > header .nodedc-status {
min-width: 0;
max-width: 55%;
overflow: hidden;
text-overflow: ellipsis;
}
.observatory-ai-module-group > header span {
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
}
.observatory-ai-module-group__body {
padding: var(--nodedc-space-4);
border: 0;
outline: 0;
}
.observatory-ai-module-group__body .nodedc-select-anchor {
width: 100%;
}
.observatory-ai-config-window .nodedc-select-trigger,
.observatory-ai-config-window .nodedc-window__close {
border: 0;
outline: 0;
box-shadow: none;
}
.observatory-ai-config-window .nodedc-select-trigger:focus-visible,
.observatory-ai-config-window .nodedc-window__close:focus-visible {
background-color: var(--nodedc-focus-surface);
box-shadow:
var(--nodedc-glass-control-shadow),
inset 0 0 0 1px rgb(var(--nodedc-accent-rgb) / 0.22);
}
@media (max-width: 560px) {
.observatory-ai-config-window .nodedc-window__title {
white-space: normal;
}
.observatory-ai-module-group > header {
align-items: flex-start;
flex-direction: column;
}
.observatory-ai-module-group > header .nodedc-status {
max-width: 100%;
justify-content: flex-start;
}
}
@media (prefers-reduced-motion: reduce) {
.observatory-ai-config__calculate--saving:disabled {
animation: none;
background-color: color-mix(
in srgb,
var(--nodedc-primary-action-bg) 94%,
var(--nodedc-canvas-soft)
);
}
}
@@ -88,6 +88,13 @@
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;
@@ -1,3 +1,4 @@
import { VehiclesWorkspace } from "./fleet/VehiclesWorkspace";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button, GlassSurface, Icon, StatusBadge } from "@nodedc/ui-react";
import {
@@ -1167,6 +1168,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,13 @@
import {useMemo} from 'react';
import {SensorWorkspace} from '../../../../../packages/sensor-ui/src/SensorWorkspace';
import type {SensorInventory,SensorTransport} from '../../../../../packages/sensor-ui/src/contracts';
import {fleetRequest} from '../../core/fleet/useFleet';
export function VehicleSensors({vehicleID,enabled,onDetailChange}:{vehicleID:string;enabled:boolean;onDetailChange:(open:boolean)=>void}){
const transport=useMemo<SensorTransport>(()=>({
inventory:async()=>{const fleet=await fleetRequest<{items:{id:string;connectivity:string;sensor_state:SensorInventory}[]}>();const value=fleet.items.find(v=>v.id===vehicleID);if(!value)throw new Error('Аппарат не найден.');return {...value.sensor_state,fresh:value.connectivity==='online'};},
subscribe:(receive,unavailable)=>{const events=new EventSource('/api/v1/fleet/events');events.onmessage=e=>{try{const value=JSON.parse(e.data).items.find((v:{id:string})=>v.id===vehicleID);if(value)receive({...value.sensor_state,fresh:value.connectivity==='online'});else unavailable();}catch{unavailable();}};events.onerror=unavailable;return()=>events.close();},
submit:value=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/operations`,'POST',value),
operation:id=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/operations/${encodeURIComponent(id)}`),
}),[vehicleID]);
return <SensorWorkspace key={vehicleID} transport={transport} enabled={enabled} onDetailChange={onDetailChange}/>;
}
@@ -0,0 +1,69 @@
import { useEffect, useRef, useState } from "react";
import { ActivityIndicator, Button, ConfirmationModal, Icon, IconButton, ResourceList, ResourceRow, Select, SettingsCard, StatusBadge, TextAreaField, TextField, Window, WindowFooterActions } from "@nodedc/ui-react";
import { 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 [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);
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>
{detail.enrollment !== "revoked" && <Button onClick={() => setRevoking(detail)}>Отозвать привязку БК</Button>}
</SettingsCard>}
<SettingsCard title="Устройства аппарата"><VehicleSensors onDetailChange={setSensorOpen} vehicleID={detail.id} enabled={!fleet.error && detail.enrollment === "paired" && detail.connectivity === "online"} /></SettingsCard>
</> : !fleet.items ? <ActivityIndicator label="Получаем аппараты" /> : fleet.items.length === 0 ? <SettingsCard title="Аппаратов пока нет" description="Добавьте аппарат по приглашению из Mission Core Node на его бортовом компьютере."><Button onClick={() => setAdding(true)}>Добавить аппарат</Button></SettingsCard> : <ResourceList aria-label="Аппараты">{fleet.items.map(item => <li key={item.id}><ResourceRow icon={<Icon name="apps" />} title={item.name} description={`${platformLabel(item.platform)} · бортовой компьютер`} status={<StatusBadge tone={!fleet.error && item.enrollment === "paired" && item.connectivity === "online" ? "success" : "neutral"}>{fleet.error ? "Нет свежих данных" : statusLabel(item)}</StatusBadge>} actions={<IconButton label={`Конфигурация: ${item.name}`} onClick={() => setSelected(item.id)}><Icon name="eye" /></IconButton>} /></li>)}</ResourceList>}
<Window open={adding} title="Добавить аппарат" subtitle="Подключить бортовой компьютер по приглашению Node" size="md" closeOnBackdrop={false} closeOnEscape={!pending} onClose={close} footer={<WindowFooterActions><Button disabled={pending} onClick={close}>Отмена</Button>{preview ? <Button disabled={pending || !name.trim()} onClick={() => void add()}>{pending ? "Добавляем…" : "Добавить аппарат"}</Button> : <Button type="submit" form="fleet-invitation" disabled={pending || !code.trim()}>{pending ? "Проверяем БК…" : "Проверить БК"}</Button>}</WindowFooterActions>}>
<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,9 @@
.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); }
@@ -15,6 +15,9 @@ import {
} from "@nodedc/ui-react";
import { CanonicalVegetationRerunReplay } from "../../components/laboratory/CanonicalVegetationRerunReplay";
import { AICompositionReplay } from "../../components/laboratory/AICompositionReplay";
import { PortableResultReplay } from "../../components/laboratory/PortableResultReplay";
import { AIConfigurationWindow } from "../../components/observatory/AIConfigurationWindow";
import type { ObservationSessionStatus } from "../../core/observation/sessionArchive";
import { createObservationReplayCoordinator } from "../../core/observation/replayCoordinator";
import {
@@ -31,8 +34,14 @@ import {
renameObservatoryLabProjection,
} from "../../core/observatory/catalogMutations";
import { useObservatoryCatalog } from "../../core/observatory/useObservatoryCatalog";
import { useObservatoryLaboratorySetups } from "../../core/observatory/useObservatoryLaboratorySetups";
import { useObservatoryRecordedJobs } from "../../core/observatory/useObservatoryRecordedJobs";
import { useAICompositionJobs } from "../../core/observatory/useAICompositionJobs";
import {
deleteAICompositionRunProjection,
renameAICompositionRunProjection,
type AICompositionRun,
} from "../../core/observatory/aiComposition";
import type { ObservatoryRecordedJob } from "../../core/observatory/recordedJobs";
import type { RecordedProgressView } from "../../core/observatory/recordedProgress";
import type { WorkspaceDefinition } from "../../productModel";
const EMPTY_OBSERVATORY_ITEMS = [] as const;
@@ -51,8 +60,31 @@ type ObservatoryMutationReconciliation =
readonly sessionId: string;
};
type EvidenceSortDirection = "newest" | "oldest";
type ObservatoryEvidenceRow =
| {
readonly kind: "composition";
readonly id: string;
readonly timestamp: string;
readonly run: AICompositionRun;
}
| {
readonly kind: "job";
readonly id: string;
readonly timestamp: string;
readonly job: ObservatoryRecordedJob;
}
| {
readonly kind: "evidence";
readonly id: string;
readonly timestamp: string;
readonly evidence: ObservatoryEvidence;
};
type ObservatoryReplayState =
| { readonly kind: "closed" }
| { readonly kind: "composition"; readonly run: AICompositionRun }
| {
readonly kind: "loading";
readonly binding: ObservatoryRecordedRunBinding;
@@ -112,15 +144,198 @@ function formatDuration(seconds: number): string {
: `${minutes}:${String(remainingSeconds).padStart(2, "0")}`;
}
function compositionEventTimestamp(run: AICompositionRun): string {
if (run.state === "running" || run.jobs.length === 0) return run.createdAtUtc;
return run.jobs.reduce((latest, job) => (
Date.parse(job.updatedAtUtc) > Date.parse(latest) ? job.updatedAtUtc : latest
), run.createdAtUtc);
}
function compositionProducedEvidence(
run: AICompositionRun,
evidence: ObservatoryEvidence,
): boolean {
if (!run.resultIds.includes(evidence.sessionId)) return false;
const publishedAt = Date.parse(evidence.publishedAtUtc);
const compositionCreatedAt = Date.parse(run.createdAtUtc);
return Number.isFinite(publishedAt) && Number.isFinite(compositionCreatedAt)
&& publishedAt >= compositionCreatedAt;
}
function mutationErrorMessage(error: unknown): string {
return error instanceof Error && error.message.trim()
? error.message
: "Не удалось изменить лабораторный результат в Обсерватории.";
: "Не удалось изменить лабораторный результат в AI Inference.";
}
function evidenceResultSubtitle(evidence: ObservatoryEvidence): string {
const profileName = evidence.lab.calculationProfile?.displayName;
return profileName ? `${evidence.label} · ${profileName}` : evidence.label;
function evidenceConfigurationLabel(
evidence: ObservatoryEvidence,
moduleLabelsBySetup: Readonly<Record<string, string>>,
jobs: readonly ObservatoryRecordedJob[],
): string {
const setupId = evidence.lab.calculationProfile?.setupId
?? jobs.find((job) => job.resultId === evidence.sessionId)?.setupId;
const profileName = setupId
? moduleLabelsBySetup[setupId] ?? evidence.lab.calculationProfile?.displayName
: evidence.lab.calculationProfile?.displayName;
return profileName ?? evidence.label;
}
function aiJobTone(job: ObservatoryRecordedJob): "accent" | "success" | "danger" | "warning" {
if (job.state === "failed" || job.publication.state === "failed") return "danger";
if (job.state === "paused" || job.state === "preemption-pending") return "warning";
if (job.state === "succeeded") return "success";
return "accent";
}
const AI_PROGRESS_PHASE_RANGES = {
"source-transfer": [0, 5],
"source-preparation": [5, 25],
computing: [25, 90],
"result-assembly": [90, 95],
"result-transfer": [95, 99],
} as const;
function aiJobProgressPercent(
job: ObservatoryRecordedJob,
progress: RecordedProgressView | undefined,
): number {
if (job.state === "succeeded") return 100;
if (!progress) return 0;
const [start, end] = AI_PROGRESS_PHASE_RANGES[progress.phase];
if (progress.total === null || progress.total === 0) return start;
const phaseFraction = Math.max(0, Math.min(1, progress.completed / progress.total));
return start + ((end - start) * phaseFraction);
}
function aiJobStage(
job: ObservatoryRecordedJob,
progress: RecordedProgressView | undefined,
): string {
if (job.state === "failed" || job.publication.state === "failed") return "Ошибка";
if (job.state === "accepted" || job.state === "queued" || job.state === "claimed" || !progress) {
return "Инициализация";
}
if (progress.phase === "source-transfer" || progress.phase === "source-preparation") {
return "Передача на сервер";
}
return "Просчёт";
}
function computingStageDetail(
job: ObservatoryRecordedJob,
progress: RecordedProgressView,
recordingDurationSeconds: number,
): string {
if (job.setupId === "ai-range-object-distance-v1") {
if (progress.completed === 0) {
return "Подготавливаем камеру и LiDAR";
}
if (progress.completed === 1) {
return "Собираем видеоряд для модели";
}
if (progress.completed === 2) {
return "RF-DETR обрабатывает запись · измеренная скорость Worker 006: 17–18 FPS";
}
if (progress.completed === 3) return "Сопоставляем рамки с LiDAR · осталось несколько минут";
return "Собираем и проверяем результат";
}
if (job.setupId === "ai-segmentation-ddrnet-v1") {
if (progress.completed === 0) return "Подготавливаем входные данные · обычно 5–10 минут";
if (progress.completed === 1) return "Собираем видеоряд для модели";
if (progress.completed === 2) {
return `DDRNet обрабатывает запись · ориентир ${formatDuration(recordingDurationSeconds)}`;
}
return "Собираем и проверяем результат";
}
if (job.setupId === "ai-segmentation-eomt-v1") {
if (progress.completed === 0) return "Подготавливаем входные данные · обычно 5–10 минут";
if (progress.completed === 1) return "Собираем видеоряд для модели";
if (progress.completed === 2) {
return `EoMT обрабатывает запись · ориентир ${formatDuration(recordingDurationSeconds)}`;
}
return "Собираем и проверяем результат";
}
if (job.setupId === "ai-detection-rf-detr-v1") {
if (progress.completed === 0) return "Подготавливаем входные данные · обычно 5–10 минут";
if (progress.completed === 1) return "Собираем видеоряд для модели";
if (progress.completed === 2) {
return `RF-DETR обрабатывает запись · ориентир ${formatDuration(recordingDurationSeconds)}`;
}
return "Собираем и проверяем результат";
}
return "Обрабатываем запись выбранным модулем";
}
function aiJobStageDetail(
job: ObservatoryRecordedJob,
progress: RecordedProgressView | undefined,
recordingDurationSeconds: number,
): string {
if (job.state === "failed") {
return "Не удалось запустить модель. Конфигурацию можно отправить повторно";
}
if (job.publication.state === "failed") {
return "Результат рассчитан, но не сохранён в AI Inference";
}
if (job.state === "succeeded") {
if (job.publication.state === "pending") return "Завершаем сохранение результата";
if (job.publication.state === "not-required") return "Публикация результата не требуется";
return "Результат сохранён в AI Inference";
}
if (!progress) {
return "Готовим конфигурацию и место в очереди";
}
if (progress.phase === "source-transfer") {
if (progress.total !== null && progress.total > 0) {
return `${progress.completed.toLocaleString("ru-RU")} / ${progress.total.toLocaleString("ru-RU")} файлов`;
}
return "Копируем исходную запись на Worker 006";
}
if (progress.phase === "source-preparation") return "Проверяем целостность переданных данных";
if (progress.phase === "computing") {
return computingStageDetail(job, progress, recordingDurationSeconds);
}
return "Собираем, передаём и сохраняем результат";
}
function presentAIJob(job: ObservatoryRecordedJob): boolean {
if (job.state === "failed") return true;
if (job.publication.state === "pending" || job.publication.state === "failed") return true;
return job.state !== "succeeded";
}
function presentLatestAIJobs(
jobs: readonly ObservatoryRecordedJob[],
): readonly ObservatoryRecordedJob[] {
const seenSetups = new Set<string>();
return jobs.filter((job) => {
if (seenSetups.has(job.setupId)) return false;
seenSetups.add(job.setupId);
return presentAIJob(job);
});
}
function compositionProgressPercent(
run: AICompositionRun,
progress: Readonly<Record<string, RecordedProgressView>>,
): number {
if (run.state === "ready") return 100;
if (run.jobs.length === 0) return 0;
return run.jobs.reduce(
(total, job) => total + aiJobProgressPercent(job, progress[job.jobId]),
0,
) / run.jobs.length;
}
function compositionStage(
run: AICompositionRun,
progress: Readonly<Record<string, RecordedProgressView>>,
): string {
if (run.state === "ready") return "Расчёт завершён";
if (run.state === "failed") return "Ошибка расчёта";
const current = run.jobs.find((job) => job.state !== "succeeded") ?? run.jobs[0];
return current ? aiJobStage(current, progress[current.jobId]) : "Инициализация";
}
export function ObservatoryWorkspace({
@@ -130,22 +345,21 @@ export function ObservatoryWorkspace({
}) {
const controller = useObservatoryCatalog();
const [selectedSessionId, setSelectedSessionId] = useState("");
const setupController = useObservatoryLaboratorySetups(selectedSessionId);
const recordedJobsController = useObservatoryRecordedJobs(
selectedSessionId,
setupController.selectedSetupId,
setupController.selectedSetup?.runDefinition?.definitionSha256 ?? "",
);
const [aiConfigurationOpen, setAIConfigurationOpen] = useState(false);
const [evidenceSortDirection, setEvidenceSortDirection] = useState<EvidenceSortDirection>("newest");
const aiJobsController = useAICompositionJobs(selectedSessionId);
const [replay, setReplay] = useState<ObservatoryReplayState>({ kind: "closed" });
const [renameTarget, setRenameTarget] = useState<ObservatoryEvidence | null>(null);
const [compositionRenameTarget, setCompositionRenameTarget] = useState<AICompositionRun | null>(null);
const [renameValue, setRenameValue] = useState("");
const [deleteTarget, setDeleteTarget] = useState<ObservatoryEvidence | null>(null);
const [compositionDeleteTarget, setCompositionDeleteTarget] = useState<AICompositionRun | null>(null);
const [mutationPending, setMutationPending] = useState<"rename" | "delete" | null>(null);
const [mutationError, setMutationError] = useState<string | null>(null);
const [mutationReconciliation, setMutationReconciliation] = useState<
ObservatoryMutationReconciliation | null
>(null);
const overviewRef = useRef<HTMLElement | null>(null);
const overviewRef = useRef<HTMLDivElement | null>(null);
const replayCoordinatorRef = useRef(createObservationReplayCoordinator());
const refreshedPublishedJobRef = useRef<string | null>(null);
const items = controller.catalog?.items ?? EMPTY_OBSERVATORY_ITEMS;
@@ -163,6 +377,7 @@ export function ObservatoryWorkspace({
useEffect(() => {
if (items.some((item) => item.source.id === selectedSessionId)) return;
closeReplay();
setAIConfigurationOpen(false);
setSelectedSessionId(items[0]?.source.id ?? "");
}, [closeReplay, items, selectedSessionId]);
@@ -171,62 +386,54 @@ export function ObservatoryWorkspace({
const selectedSession = items.find(
(item) => item.source.id === selectedSessionId,
) ?? null;
const presentedEvidence = selectedSession?.evidence ?? [];
const presentedCompositionRuns = aiJobsController.runs;
const presentedEvidence = (selectedSession?.evidence ?? []).filter(
(evidence) => !presentedCompositionRuns.some(
(run) => compositionProducedEvidence(run, evidence),
),
);
const options = useMemo(() => items.map(({ source, evidence }) => ({
value: source.id,
label: source.label,
description: `${formatTimestamp(source.startedAtUtc)} · ${formatDuration(source.durationSeconds)} · ${evidence.length} результатов`,
})), [items]);
const setupOptions = useMemo(() => (
setupController.selectableSetups.map((setup) => ({
value: setup.setupId,
label: setup.displayName,
description: setup.description,
}))
), [setupController.selectableSetups]);
const preflightCandidate = setupController.preflight.kind === "ready"
? setupController.preflight.value
: null;
const runPreflight = preflightCandidate
&& preflightCandidate.sourceSessionId === selectedSession?.source.id
&& preflightCandidate.setupId === setupController.selectedSetup?.setupId
&& preflightCandidate.definitionSha256
=== setupController.selectedSetup?.runDefinition?.definitionSha256
? preflightCandidate
: null;
const queueSubmissionAllowed = Boolean(
setupController.selectedSetup?.runDefinition
&& setupController.selectedSetup.compatibility.compatible
&& runPreflight?.outcome === "queueable"
&& runPreflight.submissionAllowed,
const compositionJobIds = useMemo(() => new Set(
presentedCompositionRuns.flatMap((run) => run.jobIds),
), [presentedCompositionRuns]);
const presentedAIJobs = presentLatestAIJobs(aiJobsController.jobs).filter(
(job) => !compositionJobIds.has(job.jobId),
);
const presentedJob = recordedJobsController.activeJob ?? recordedJobsController.latestJob;
const publicationFailed = presentedJob?.publication.state === "failed";
const calculationPending = recordedJobsController.activeJob !== null
|| recordedJobsController.publicationPending
|| recordedJobsController.state === "submitting"
|| recordedJobsController.state === "retrying-publication";
const showCalculate = setupController.selectedSetup !== null
&& !calculationPending && !publicationFailed;
const canSubmitRecordedJob = queueSubmissionAllowed
&& setupController.state === "ready"
&& recordedJobsController.state === "ready"
&& !calculationPending && !publicationFailed;
const queueStatusError = setupController.preflight.kind === "error"
? setupController.preflight.message
: recordedJobsController.error
?? (publicationFailed
? presentedJob?.publication.error ?? "Не удалось опубликовать результат."
: recordedJobsController.computationFailed
? "Не удалось выполнить расчёт. Можно повторить запуск."
: runPreflight?.outcome === "blocked"
? runPreflight.checks.filter((check) => check.outcome === "fail")
.map((check) => check.message).join(" ") || "Запуск профиля недоступен."
: null);
const presentedRows = useMemo<readonly ObservatoryEvidenceRow[]>(() => {
const rows: ObservatoryEvidenceRow[] = [
...presentedCompositionRuns.map((run): ObservatoryEvidenceRow => ({
kind: "composition",
id: run.runId,
timestamp: compositionEventTimestamp(run),
run,
})),
...presentedAIJobs.map((job): ObservatoryEvidenceRow => ({
kind: "job",
id: job.jobId,
timestamp: job.updatedAtUtc,
job,
})),
...presentedEvidence.map((evidence): ObservatoryEvidenceRow => ({
kind: "evidence",
id: evidence.sessionId,
timestamp: evidence.publishedAtUtc,
evidence,
})),
];
const direction = evidenceSortDirection === "newest" ? -1 : 1;
return rows.sort((left, right) => (
direction * (Date.parse(left.timestamp) - Date.parse(right.timestamp))
|| left.id.localeCompare(right.id)
));
}, [evidenceSortDirection, presentedAIJobs, presentedCompositionRuns, presentedEvidence]);
const initialLoading = !controller.catalog
&& ["idle", "loading"].includes(controller.state);
const unavailable = !controller.catalog && controller.state === "error";
const replayEvidenceId = replay.kind === "closed"
const replayEvidenceId = replay.kind === "closed" || replay.kind === "composition"
? null
: replay.binding.evidenceSessionId;
const replayEvidence = replayEvidenceId === null
@@ -236,14 +443,13 @@ export function ObservatoryWorkspace({
) ?? null;
useEffect(() => {
const publishedJob = recordedJobsController.jobs.find(
const publishedJob = aiJobsController.jobs.find(
(job) => job.publication.state === "published" && job.resultId !== null,
);
if (!publishedJob || refreshedPublishedJobRef.current === publishedJob.jobId) return;
refreshedPublishedJobRef.current = publishedJob.jobId;
void controller.refresh();
setupController.refresh();
}, [controller.refresh, recordedJobsController.jobs, setupController.refresh]);
}, [aiJobsController.jobs, controller.refresh]);
useEffect(() => {
if (
@@ -274,8 +480,7 @@ export function ObservatoryWorkspace({
setDeleteTarget(null);
setMutationError(null);
setMutationReconciliation(null);
if (mutationReconciliation.kind === "delete") setupController.refresh();
}, [controller.catalog, mutationPending, mutationReconciliation, setupController.refresh]);
}, [controller.catalog, mutationPending, mutationReconciliation]);
const openReplay = useCallback((binding: ObservatoryRecordedRunBinding) => {
const attempt = replayCoordinatorRef.current.begin();
@@ -305,6 +510,8 @@ export function ObservatoryWorkspace({
const openRename = useCallback((evidence: ObservatoryEvidence) => {
if (!evidence.recordedRun) return;
setCompositionRenameTarget(null);
setCompositionDeleteTarget(null);
setDeleteTarget(null);
setMutationError(null);
setMutationReconciliation(null);
@@ -314,13 +521,52 @@ export function ObservatoryWorkspace({
const openDelete = useCallback((evidence: ObservatoryEvidence) => {
if (!evidence.recordedRun) return;
setCompositionRenameTarget(null);
setCompositionDeleteTarget(null);
setRenameTarget(null);
setMutationError(null);
setMutationReconciliation(null);
setDeleteTarget(evidence);
}, []);
const openCompositionRename = useCallback((run: AICompositionRun) => {
setRenameTarget(null);
setDeleteTarget(null);
setCompositionDeleteTarget(null);
setMutationError(null);
setMutationReconciliation(null);
setRenameValue(run.displayName ?? run.presentation.configurationLabel);
setCompositionRenameTarget(run);
}, []);
const openCompositionDelete = useCallback((run: AICompositionRun) => {
setRenameTarget(null);
setDeleteTarget(null);
setCompositionRenameTarget(null);
setMutationError(null);
setMutationReconciliation(null);
setCompositionDeleteTarget(run);
}, []);
const submitRename = useCallback(async () => {
if (compositionRenameTarget) {
if (mutationPending !== null) return;
setMutationPending("rename");
setMutationError(null);
try {
await renameAICompositionRunProjection(
compositionRenameTarget.runId,
renameValue,
);
setCompositionRenameTarget(null);
aiJobsController.refresh();
} catch (error) {
setMutationError(mutationErrorMessage(error));
} finally {
setMutationPending(null);
}
return;
}
if (!renameTarget?.recordedRun || mutationPending !== null) return;
const requestedDisplayName = renameValue.trim();
const reconciliation: ObservatoryMutationReconciliation = {
@@ -354,9 +600,27 @@ export function ObservatoryWorkspace({
} finally {
setMutationPending(null);
}
}, [controller, mutationPending, renameTarget, renameValue]);
}, [aiJobsController, compositionRenameTarget, controller, mutationPending, renameTarget, renameValue]);
const confirmDelete = useCallback(async () => {
if (compositionDeleteTarget) {
if (mutationPending !== null) return;
setMutationPending("delete");
setMutationError(null);
try {
if (replay.kind === "composition" && replay.run.runId === compositionDeleteTarget.runId) {
flushSync(() => closeReplay());
}
await deleteAICompositionRunProjection(compositionDeleteTarget.runId);
setCompositionDeleteTarget(null);
aiJobsController.refresh();
} catch (error) {
setMutationError(mutationErrorMessage(error));
} finally {
setMutationPending(null);
}
return;
}
if (!deleteTarget?.recordedRun || mutationPending !== null) return;
const reconciliation: ObservatoryMutationReconciliation = {
kind: "delete",
@@ -368,6 +632,7 @@ export function ObservatoryWorkspace({
try {
if (
replay.kind !== "closed"
&& replay.kind !== "composition"
&& replay.binding.evidenceSessionId === deleteTarget.sessionId
) {
flushSync(() => {
@@ -376,7 +641,6 @@ export function ObservatoryWorkspace({
}
await deleteObservatoryLabProjection(deleteTarget.recordedRun);
controller.applyEvidenceDeletion(deleteTarget.sessionId);
setupController.refresh();
setDeleteTarget(null);
setMutationReconciliation(null);
void controller.refresh();
@@ -397,25 +661,16 @@ export function ObservatoryWorkspace({
} finally {
setMutationPending(null);
}
}, [closeReplay, controller, deleteTarget, mutationPending, replay, setupController.refresh]);
}, [aiJobsController, closeReplay, compositionDeleteTarget, controller, deleteTarget, mutationPending, replay]);
return (
<div
ref={overviewRef}
className="observatory-workspace"
data-workspace-id={definition.id}
data-observatory-authority="observation-only"
data-observatory-viewer={replay.kind === "ready" ? "attached" : "detached"}
data-observatory-viewer={replay.kind === "ready" || replay.kind === "composition" ? "attached" : "detached"}
>
<section ref={overviewRef} className="observatory-lead">
<div>
<span className="section-eyebrow">{definition.eyebrow}</span>
<h2>{definition.title}</h2>
<p>
Записанные источники и строго связанные результаты без запуска тяжёлого
визуализатора и без доступа к управлению аппаратом.
</p>
</div>
</section>
<GlassSurface className="observatory-catalog-bar" padding="sm">
<div className="observatory-catalog-bar__copy">
<span className="section-eyebrow">ИСТОЧНИК ДОКАЗАТЕЛЬСТВ</span>
@@ -434,78 +689,23 @@ export function ObservatoryWorkspace({
menuWidth={460}
onChange={selectSession}
/>
<Select
label="Выбрать сетап лаборатории"
value={setupController.selectedSetupId}
options={setupOptions}
disabled={!selectedSessionId || setupOptions.length === 0}
searchable
searchPlaceholder="Поиск по сетапам"
emptyLabel={setupController.state === "error" ? "Каталог профилей недоступен" : "Нет профилей для расчёта"}
minMenuWidth={360}
menuWidth={500}
onChange={setupController.selectSetup}
/>
<Button
size="compact"
variant="secondary"
disabled={
controller.state === "loading"
|| controller.state === "refreshing"
|| setupController.state === "loading"
|| setupController.state === "refreshing"
}
icon={<Icon name="refresh" />}
onClick={() => {
void controller.refresh();
setupController.refresh();
recordedJobsController.refresh();
}}
variant="primary"
disabled={!selectedSession}
onClick={() => setAIConfigurationOpen(true)}
>
Обновить
Сконфигурировать AI-слой
</Button>
<div className="observatory-catalog-bar__run" aria-busy={calculationPending}>
{calculationPending ? (
<ActivityIndicator size="compact" label="Ожидание результата расчёта" />
) : null}
{showCalculate ? (
<Button
size="compact"
variant="primary"
disabled={!canSubmitRecordedJob}
onClick={() => {
void recordedJobsController.submit(
setupController.selectedSetup?.origin === "portable-definition"
&& runPreflight?.definitionSha256
&& runPreflight.checkSha256
? {
definitionSha256: runPreflight.definitionSha256,
checkSha256: runPreflight.checkSha256,
}
: null,
);
}}
>
Рассчитать
</Button>
) : null}
</div>
</div>
</GlassSurface>
{queueStatusError ? (
{aiJobsController.error ? (
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="alert">
<span className="observatory-notice__copy">{queueStatusError}</span>
{publicationFailed ? (
<Button
size="compact"
variant="ghost"
disabled={recordedJobsController.state === "retrying-publication"}
onClick={() => void recordedJobsController.retryPublication()}
>
Повторить публикацию
</Button>
) : null}
<span className="observatory-notice__copy">{aiJobsController.error}</span>
<Button size="compact" variant="ghost" onClick={aiJobsController.refresh}>
Повторить
</Button>
</GlassSurface>
) : null}
@@ -517,13 +717,6 @@ export function ObservatoryWorkspace({
</GlassSurface>
) : null}
{setupController.error ? (
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="alert">
<span className="observatory-notice__copy">{setupController.error}</span>
<Button size="compact" variant="ghost" onClick={setupController.refresh}>Повторить</Button>
</GlassSurface>
) : null}
{controller.catalog
&& (controller.catalog.window.sourceLimitReached
|| controller.catalog.window.laboratoryLimitReached) ? (
@@ -609,30 +802,157 @@ export function ObservatoryWorkspace({
<span className="section-eyebrow">СВЯЗАННЫЕ РЕЗУЛЬТАТЫ</span>
<h3>Лабораторные доказательства</h3>
</div>
<StatusBadge tone={selectedSession.evidence.length > 0 ? "accent" : "neutral"}>
{selectedSession.evidence.length}
</StatusBadge>
<div className="observatory-evidence__ordering">
<StatusBadge tone={presentedRows.length > 0 ? "accent" : "neutral"}>
{presentedRows.length}
</StatusBadge>
<button
type="button"
className="observatory-evidence__sort"
data-direction={evidenceSortDirection}
aria-label={evidenceSortDirection === "newest"
? "Показать сначала старые результаты"
: "Показать сначала новые результаты"}
title={evidenceSortDirection === "newest"
? "Сначала новые"
: "Сначала старые"}
onClick={() => setEvidenceSortDirection((current) => (
current === "newest" ? "oldest" : "newest"
))}
>
<svg viewBox="0 0 18 18" aria-hidden="true">
<path d="M3 5h12M5 9h10M7 13h8" />
</svg>
</button>
</div>
</header>
{selectedSession.evidence.length > 0 ? (
{presentedRows.length > 0 ? (
<ol className="observatory-evidence-list">
{presentedEvidence.map((evidence) => (
{presentedRows.map((row) => {
if (row.kind === "composition") {
const { run } = row;
const failed = run.state === "failed";
const active = run.state === "running";
const progressPercent = compositionProgressPercent(run, aiJobsController.progress);
return (
<li key={run.runId}>
<div className={`observatory-evidence-card${active
? " observatory-evidence-card--progress" : ""}`}
aria-busy={active}>
<span className="observatory-evidence-card__icon" aria-hidden="true">
{failed ? <Icon name="alert" size={16} />
: run.state === "ready" ? <Icon name="clipboard" size={18} />
: <ActivityIndicator size="compact" />}
</span>
<div className="observatory-evidence-card__copy">
<strong>{run.displayName ?? run.presentation.configurationLabel}</strong>
{active ? (
<div className="observatory-evidence-card__progress" aria-hidden="true">
<span style={{ width: `${progressPercent}%` }} />
</div>
) : null}
<small>{run.state === "ready"
? formatTimestamp(row.timestamp)
: `${compositionStage(run, aiJobsController.progress)} · ${formatTimestamp(row.timestamp)}`}</small>
</div>
{active ? (
<StatusBadge tone="accent">{Math.round(progressPercent)}%</StatusBadge>
) : failed ? <StatusBadge tone="danger">Ошибка</StatusBadge> : null}
<div className="observatory-evidence-card__actions">
{run.state === "ready" ? (
<>
<IconButton
label={`Удалить ${run.displayName ?? run.presentation.configurationLabel} из AI Inference`}
onClick={() => openCompositionDelete(run)}
>
<Icon name="trash" size={16} />
</IconButton>
<IconButton
label={`Переименовать ${run.displayName ?? run.presentation.configurationLabel}`}
onClick={() => openCompositionRename(run)}
>
<Icon name="edit" size={16} />
</IconButton>
<IconButton label={`Открыть визуальный разбор: ${run.configurationLabel}`}
onClick={() => setReplay({ kind: "composition", run })}>
<Icon name="eye" size={16} />
</IconButton>
</>
) : null}
</div>
</div>
</li>
);
}
if (row.kind === "job") {
const { job } = row;
const progress = aiJobsController.progress[job.jobId];
const progressPercent = aiJobProgressPercent(job, progress);
const moduleLabel = aiJobsController.moduleLabelsBySetup[job.setupId] ?? job.setupId;
const failed = job.state === "failed" || job.publication.state === "failed";
const active = !failed && (
job.state !== "succeeded" || job.publication.state === "pending"
);
return (
<li key={job.jobId}>
<div
className={`observatory-evidence-card${active
? " observatory-evidence-card--progress" : ""}`}
aria-busy={active}
>
<span className="observatory-evidence-card__icon" aria-hidden="true">
{failed
? <Icon name="alert" size={16} />
: job.state === "succeeded"
? <Icon name="check" size={16} />
: <ActivityIndicator size="compact" />}
</span>
<div className="observatory-evidence-card__copy">
<strong>{moduleLabel}</strong>
{active ? (
<div className="observatory-evidence-card__progress" aria-hidden="true">
<span style={{ width: `${progressPercent}%` }} />
</div>
) : null}
<small>
{aiJobStage(job, progress)} · {aiJobStageDetail(
job,
progress,
selectedSession.source.durationSeconds,
)}{progress && !failed && job.state !== "succeeded"
? ` · прошло ${formatDuration(progress.elapsedSeconds)}`
: ""} · {formatTimestamp(row.timestamp)}
</small>
</div>
{active ? (
<StatusBadge tone={aiJobTone(job)}>{Math.round(progressPercent)}%</StatusBadge>
) : failed ? <StatusBadge tone="danger">Ошибка</StatusBadge> : null}
</div>
</li>
);
}
const { evidence } = row;
return (
<li key={evidence.sessionId}>
<div className="observatory-evidence-card">
<span className="observatory-evidence-card__icon" aria-hidden="true">
<Icon name="clipboard" size={18} />
</span>
<div className="observatory-evidence-card__copy">
<strong>{evidence.lab.labId}</strong>
<span>{evidenceResultSubtitle(evidence)}</span>
<strong>{evidenceConfigurationLabel(
evidence,
aiJobsController.moduleLabelsBySetup,
aiJobsController.jobs,
)}</strong>
<small>
{evidence.lab.resultKind} · {formatTimestamp(evidence.publishedAtUtc)}
{formatTimestamp(row.timestamp)}
</small>
</div>
<div className="observatory-evidence-card__actions">
{evidence.recordedRun ? (
<>
<IconButton
label={`Удалить ${evidence.lab.labId} из Обсерватории`}
label={`Удалить ${evidence.lab.labId} из AI Inference`}
onClick={() => openDelete(evidence)}
>
<Icon name="trash" size={16} />
@@ -661,7 +981,8 @@ export function ObservatoryWorkspace({
</div>
</div>
</li>
))}
);
})}
</ol>
) : (
<div className="observatory-evidence-empty">
@@ -704,21 +1025,34 @@ export function ObservatoryWorkspace({
<Button size="compact" variant="ghost" onClick={returnToOverview}>Закрыть</Button>
</div>
</GlassSurface>
) : replay.kind === "composition" ? (
<section className="observatory-replay" aria-label="Визуальный разбор конфигурации">
<header className="observatory-replay__header">
<div>
<span className="section-eyebrow">СОХРАНЁННАЯ КОНФИГУРАЦИЯ</span>
<h3>{replay.run.configurationLabel}</h3>
</div>
<Button size="compact" variant="secondary" icon={<Icon name="close" size={14} />}
onClick={returnToOverview}>
Закрыть разбор
</Button>
</header>
<AICompositionReplay run={replay.run} />
</section>
) : replay.kind === "ready" ? (
<section className="observatory-replay" aria-label="Визуальный разбор результата">
<header className="observatory-replay__header">
<div>
<span className="section-eyebrow">
{replay.review.kind === "canonical-recorded-rerun"
? "ВИЗУАЛЬНЫЙ РАЗБОР / ЗАПИСАННАЯ СЕССИЯ"
: "РЕЗУЛЬТАТ / ПРОВЕРЕННЫЙ ДОКУМЕНТ"}
СОХРАНЁННЫЙ РЕЗУЛЬТАТ / ЗАПИСАННАЯ СЕССИЯ
</span>
<h3>{replayEvidence?.label ?? replay.binding.resultId}</h3>
<p>
{replay.review.kind === "canonical-recorded-rerun"
? "Записанный маршрут синхронизирован по общей временной шкале."
: "Показан проверенный документ результата и связанные с ним артефакты."}
</p>
<h3>{replayEvidence && selectedSession
? evidenceConfigurationLabel(
replayEvidence,
aiJobsController.moduleLabelsBySetup,
aiJobsController.jobs,
)
: replay.binding.resultId}</h3>
</div>
<Button
size="compact"
@@ -735,17 +1069,14 @@ export function ObservatoryWorkspace({
review={replay.review.review}
/>
) : (
<GlassSurface className="observatory-replay-state" padding="lg">
<div>
<StatusBadge tone="success">Проверено</StatusBadge>
<h3>{replay.review.resultKind}</h3>
<p>Связанных артефактов: {replay.review.artifacts.length}</p>
<details>
<summary>Документ результата</summary>
<pre>{JSON.stringify(replay.review.resultDocument, null, 2)}</pre>
</details>
</div>
</GlassSurface>
<PortableResultReplay review={replay.review}
label={replayEvidence && selectedSession
? evidenceConfigurationLabel(
replayEvidence,
aiJobsController.moduleLabelsBySetup,
aiJobsController.jobs,
)
: replay.binding.resultId} />
)}
</section>
) : null}
@@ -761,15 +1092,16 @@ export function ObservatoryWorkspace({
) : null}
<Window
open={renameTarget !== null}
open={renameTarget !== null || compositionRenameTarget !== null}
title="Переименовать лабораторный результат"
subtitle="Меняется только отображаемое название в Обсерватории"
subtitle="Меняется только отображаемое название в AI Inference"
size="sm"
closeOnBackdrop={mutationPending !== "rename"}
closeOnEscape={mutationPending !== "rename"}
onClose={() => {
if (mutationPending === "rename") return;
setRenameTarget(null);
setCompositionRenameTarget(null);
setMutationError(null);
setMutationReconciliation(null);
}}
@@ -779,6 +1111,7 @@ export function ObservatoryWorkspace({
disabled={mutationPending === "rename"}
onClick={() => {
setRenameTarget(null);
setCompositionRenameTarget(null);
setMutationError(null);
setMutationReconciliation(null);
}}
@@ -813,20 +1146,27 @@ export function ObservatoryWorkspace({
disabled={mutationPending === "rename"}
onChange={(event) => setRenameValue(event.currentTarget.value)}
/>
{mutationError && renameTarget ? (
{mutationError && (renameTarget || compositionRenameTarget) ? (
<p className="observatory-mutation-error" role="alert">{mutationError}</p>
) : null}
</form>
</Window>
<AIConfigurationWindow
open={aiConfigurationOpen && selectedSession !== null}
sourceSessionId={selectedSession?.source.id ?? ""}
sourceLabel={selectedSession?.source.label ?? ""}
onClose={() => setAIConfigurationOpen(false)}
onCalculated={() => aiJobsController.refresh()}
/>
<ConfirmationModal
open={deleteTarget !== null}
title="Удалить результат из Обсерватории?"
description={deleteTarget ? (
open={deleteTarget !== null || compositionDeleteTarget !== null}
title="Удалить результат из AI Inference?"
description={deleteTarget || compositionDeleteTarget ? (
<div className="observatory-delete-confirmation">
<p>
Будут удалены только каталожная проекция <strong>{deleteTarget.label}</strong>
{" "}и её отображаемое название в Обсерватории.
Будут удалены только каталожная проекция <strong>{deleteTarget?.label ?? compositionDeleteTarget?.displayName ?? compositionDeleteTarget?.presentation.configurationLabel}</strong>
{" "}и её отображаемое название в AI Inference.
</p>
<p>
Исходная сессия, запечатанный лабораторный результат и файлы доказательств
@@ -837,12 +1177,13 @@ export function ObservatoryWorkspace({
) : null}
</div>
) : null}
confirmLabel="Удалить из Обсерватории"
confirmLabel="Удалить из AI Inference"
pendingLabel="Удаляем…"
danger
onClose={() => {
if (mutationPending === "delete") return;
setDeleteTarget(null);
setCompositionDeleteTarget(null);
setMutationError(null);
setMutationReconciliation(null);
}}
@@ -111,7 +111,7 @@ test("Observatory owns a bounded explicit-open lifecycle around the shared recor
const observatoryCore = await read("core/observatory/catalog.ts");
const recordedRun = await read("core/observatory/recordedRun.ts");
const sharedReplay = await read(
"components/laboratory/CanonicalVegetationRerunReplay.tsx",
"components/laboratory/CanonicalResultRerunReplay.tsx",
);
const workspaceCss = await read("styles/workspaces.css");
const observatoryCss = await read("styles/observatory.css");
@@ -125,7 +125,7 @@ test("Observatory owns a bounded explicit-open lifecycle around the shared recor
assert.match(observatory, /data-observatory-authority="observation-only"/);
assert.match(
observatory,
/data-observatory-viewer={replay\.kind === "ready" \? "attached" : "detached"}/,
/data-observatory-viewer={replay\.kind === "ready" \|\| replay\.kind === "composition" \? "attached" : "detached"}/,
);
assert.doesNotMatch(
`${observatory}\n${observatoryCore}`,
@@ -144,6 +144,13 @@ test("Observatory owns a bounded explicit-open lifecycle around the shared recor
);
assert.equal(sharedReplay.match(/<RerunViewport\b/g)?.length, 1);
assert.match(sharedReplay, /recordedSessionRerunProfile/);
assert.match(sharedReplay, /useState<0 \| 1>\(hasTgs \? 1 : 0\)/);
assert.match(sharedReplay, /hasTgs \? 0\.000001 : 0/);
for (const adapter of ["CanonicalVegetationRerunReplay", "PortableResultReplay"]) {
const source = await read(`components/laboratory/${adapter}.tsx`);
assert.match(source, /<CanonicalResultRerunReplay/);
assert.doesNotMatch(source, /<RerunViewport|<video|setInterval|Canvas|THREE\./);
}
assert.doesNotMatch(workspaceCss, /\.observatory-/);
assert.match(observatoryCss, /\.observatory-workspace/);
});
@@ -132,3 +132,32 @@ test("canonical LAB resolves one generation-bound merged RRD", async () => {
blueprintSourceUrl: "/api/v1/observation-sessions/session-001/blueprint.rrd",
});
});
for (const [prefix, sourceKind] of [
["m49-tgs-portable-review", "portable-tgs"],
["lab-v1-eomt-ddrnet", "portable-semantic"],
]) test(`${sourceKind} resolves Core cache for the exact result and base, never a legacy LAB`, async () => {
const base = "a".repeat(64), generation = "b".repeat(64);
const resultId = `${prefix}-${"c".repeat(64)}`;
const launch = { sourceUrl: "/api/v1/observation-sessions/source/recording.rrd",
viewerSourceUrl: `/api/v1/observation-sessions/source/recording.rrd?generation=${base}`,
sha256: base };
let calls = 0;
const fetcher = async (url, options) => {
calls++;
assert.equal(url, `http://mission-core.test/api/v1/observatory/portable-results/${resultId}/replays/${base}/recording.rrd`);
assert.equal(options.method, "HEAD");
return new Response(null, { headers: { "Content-Type": "application/vnd.rerun.rrd",
"Content-Length": "100", "ETag": `"${generation}"`, "X-Rerun-Format": "RRF2" } });
};
const options = { sourceKind, origin: "http://mission-core.test", fetcher };
const replay = await resolveCanonicalLabReplay(resultId, launch, options);
assert.equal(replay.viewerSourceUrl, `${replay.sourceUrl}?generation=${generation}`);
assert.equal(replay.blueprintSourceUrl, "/api/v1/observation-sessions/source/blueprint.rrd");
await assert.rejects(resolveCanonicalLabReplay(`lab-v1-vegetation-shadow-${"c".repeat(64)}`, launch, options));
await assert.rejects(resolveCanonicalLabReplay(resultId, launch, {
...options, sourceKind: sourceKind === "portable-tgs" ? "portable-semantic" : "portable-tgs",
}));
await assert.rejects(resolveCanonicalLabReplay(resultId, { ...launch, sha256: "d".repeat(64) }, options));
assert.equal(calls, 1);
});
@@ -0,0 +1,97 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server, fetchLabViewProfile, saveLabViewProfile, normalizeLabSceneSettings;
before(async () => {
server = await createServer({ appType: "custom", logLevel: "silent",
server: { middlewareMode: true } });
({ fetchLabViewProfile, saveLabViewProfile } = await server.ssrLoadModule(
"/src/core/observatory/labViewProfile.ts"));
({ normalizeLabSceneSettings } = await server.ssrLoadModule(
"/src/components/laboratory/CanonicalResultRerunReplay.tsx"));
});
after(async () => { await server?.close(); });
const serverProfile = {
schema_version: "missioncore.observatory-lab-view-profile/v1",
result_id: "result-1",
scene_settings: {
point_size: 4.7,
accumulation_seconds: 8,
color_mode: "height",
palette: "viridis",
show_grid: false,
show_labels: true,
show_camera_frustums: false,
},
updated_at_utc: "2026-09-04T09:30:00.000Z",
};
test("LAB view profile round-trips through the result-scoped server endpoint", async () => {
const originalFetch = globalThis.fetch;
const requests = [];
globalThis.fetch = async (url, init = {}) => {
requests.push({ url, init });
return new Response(JSON.stringify(serverProfile), {
status: 200, headers: { "Content-Type": "application/json" },
});
};
try {
const settings = await fetchLabViewProfile("result-1");
assert.equal(settings.pointSize, 4.7);
assert.equal(settings.palette, "viridis");
const saved = await saveLabViewProfile("result-1", settings);
assert.equal(saved.accumulationSeconds, 8);
assert.equal(requests[0].url, "/api/v1/observatory/lab-view-profiles/result-1");
assert.equal(requests[1].init.method, "PUT");
assert.deepEqual(JSON.parse(requests[1].init.body).scene_settings, serverProfile.scene_settings);
} finally {
globalThis.fetch = originalFetch;
}
});
test("LAB view profile fails closed on a foreign result identity", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => new Response(JSON.stringify({
...serverProfile, result_id: "foreign-result",
}), { status: 200, headers: { "Content-Type": "application/json" } });
try {
await assert.rejects(fetchLabViewProfile("result-1"), /другой LAB/);
} finally {
globalThis.fetch = originalFetch;
}
});
test("LAB scene settings normalize typed values before persistence", () => {
assert.deepEqual(
normalizeLabSceneSettings({
pointSize: 28,
accumulationSeconds: 305,
colorMode: "height",
palette: "viridis",
customColor: "#ffffff",
showGrid: false,
showPoints: true,
showTrajectory: true,
showLabels: true,
showCameraFrustums: false,
projection: "3d",
}),
{
pointSize: 28,
accumulationSeconds: 305,
colorMode: "height",
palette: "viridis",
customColor: "#ffffff",
showGrid: false,
showPoints: true,
showTrajectory: true,
showLabels: true,
showCameraFrustums: false,
projection: "3d",
},
);
});
@@ -886,7 +886,7 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
assert.match(canonical, /primary=\{mediaPane\}/);
assert.match(canonical, /secondary=\{spatialPane/);
assert.match(canonical, /primarySize=\{splitView \? splitPrimarySize : mediaMode !== "none" \? 100 : 0\}/);
assert.match(canonical, /resizable=\{splitView && !unifiedContent\}/);
assert.match(canonical, /resizable=\{splitView\}/);
assert.match(canonical, /separatorLabel="Изменить размер видео\/камеры и 3D\/плана"/);
assert.match(canonical, /secondaryMode=\{\{/);
assert.match(visual, /playback=\{playbackController\.playback\}/);
@@ -40,6 +40,7 @@ let formatAccumulationDuration;
let resolveRerunSourceUrl;
let resolveRecordedBlueprintUrl;
let fetchRecordedBlueprintRrd;
let recordedCameraJournalContract;
let resolveRecordedPerceptionUrl;
let fetchRecordedPerceptionRrd;
let resolveRecordedPerceptionViewerSourceUrl;
@@ -110,6 +111,7 @@ before(async () => {
resolveRerunSourceUrl,
resolveRecordedBlueprintUrl,
fetchRecordedBlueprintRrd,
recordedCameraJournalContract,
resolveRecordedPerceptionUrl,
fetchRecordedPerceptionRrd,
resolveRecordedPerceptionViewerSourceUrl,
@@ -118,7 +120,7 @@ before(async () => {
fetchRecordedPointColorsRrd,
recordedPointColorKey,
isRecordedPlaybackFullyBuffered,
} = await server.ssrLoadModule(
} = await server.ssrLoadModule(
"/src/components/RerunViewport.tsx",
));
({ recordedObservationSources } = await server.ssrLoadModule(
@@ -129,6 +131,25 @@ before(async () => {
));
});
test("follow toggles retain the current recorded camera journal", () => {
const before = recordedCameraJournalContract({
activeView: "spatial", viewResetGeneration: 0, planView: false, followTrajectory: false,
});
const afterFollowToggle = recordedCameraJournalContract({
activeView: "spatial", viewResetGeneration: 0, planView: false, followTrajectory: true,
});
const afterPlanToggle = recordedCameraJournalContract({
activeView: "spatial", viewResetGeneration: 0, planView: true, followTrajectory: true,
});
const afterReset = recordedCameraJournalContract({
activeView: "spatial", viewResetGeneration: 1, planView: false, followTrajectory: false,
});
assert.equal(afterFollowToggle, before);
assert.notEqual(afterPlanToggle, before);
assert.notEqual(afterReset, before);
});
test("observation camera windows tile from the bottom-right above the live timeline", () => {
const bounds = { width: 1280, height: 720 };
const left = initialObservationWindowRect(0, 2, bounds);
@@ -444,6 +465,7 @@ test("recorded replay becomes ready only after the complete declared timeline is
test("recorded blueprint fetch is bounded, strict and sends only display settings", async () => {
const calls = [];
const cameraLimits = [];
const payload = Uint8Array.from([0x52, 0x52, 0x46, 0x32, 0x01]);
const result = await fetchRecordedBlueprintRrd(
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/blueprint.rrd",
@@ -464,6 +486,15 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
activeView: "perception3d",
viewResetGeneration: 1,
followTrajectory: true,
cameraEye: {
position: [3, 4, 5],
lookTarget: [1, 2, 0],
eyeUp: [0, 0, 1],
},
eyeRelativeToTracking: true,
currentTimeNs: 39_215_263_458,
onCameraMaxOrbitalRadius: value => cameraLimits.push(value),
unifiedCameraShare: 0.73,
perceptionLayers: {
enabled: true,
detections2d: true,
@@ -474,7 +505,10 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
calls.push({ input: String(input), init, body: JSON.parse(String(init.body)) });
return new Response(payload, {
status: 200,
headers: { "Content-Type": "application/vnd.rerun.rrd" },
headers: {
"Content-Type": "application/vnd.rerun.rrd",
"X-MissionCore-Camera-Max-Orbital-Radius": "686.024231",
},
});
},
},
@@ -497,13 +531,21 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
active_view: "perception3d",
view_reset_generation: 1,
follow_trajectory: true,
current_time_ns: 39_215_263_458,
unified_perception: true,
unified_camera_share: 0.73,
semantic_layer: null,
plan_view: false,
eye_position: [3, 4, 5],
eye_look_target: [1, 2, 0],
eye_up: [0, 0, 1],
eye_relative_to_tracking: true,
show_camera_image: true,
show_detections_2d: true,
show_segmentation: false,
show_cuboids_3d: true,
});
assert.deepEqual(cameraLimits, [686.024231]);
await fetchRecordedBlueprintRrd(
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/blueprint.rrd",
@@ -537,6 +579,7 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
},
);
assert.equal(calls[1].body.unified_perception, false);
assert.equal(calls[1].body.unified_camera_share, 0.46);
assert.equal(calls[1].body.show_cuboids_3d, true);
await assert.rejects(
@@ -63,14 +63,31 @@ function evidence(id, sourceSessionId, publishedAtUtc) {
labId: `LAB-${id}`,
sourceSessionId,
resultKind: "recorded-evidence",
resultId: `result-${id}`,
resultId: id,
sourceResultId: null,
configSha256: "a".repeat(64),
runCreatedAtUtc: publishedAtUtc,
publishedAtUtc,
replayCapability: null,
replayCapability: {
schemaVersion: "missioncore.observation-lab-replay-capability/v2",
kind: "portable-result-review", viewerProfile: "portable-result",
timeline: "result-defined", activation: "explicit", commandsEnabled: false,
},
calculationProfile: null,
provenance: { verdict: "must-not-be-inferred" },
provenance: {
schema_version: "missioncore.observatory-portable-result-publication/v1",
authority: { commands_enabled: false },
calculation_profile: null, calculation_profile_sha256: "1".repeat(64),
job: {}, method: {}, storage: {},
source: { session_id: sourceSessionId },
run_definition: { definition_sha256: "a".repeat(64) },
result_package: { manifest_sha256: "b".repeat(64), artifact_manifest_id: "c".repeat(64) },
replay_capability: {
schema_version: "missioncore.observation-lab-replay-capability/v2",
kind: "portable-result-review", viewer_profile: "portable-result",
timeline: "result-defined", activation: "explicit", commands_enabled: false,
},
},
},
};
}
@@ -154,7 +171,9 @@ test("Observatory fetches disjoint read-only source and laboratory projections",
const calls = [];
const fetcher = async (input, init) => {
calls.push({ input: String(input), method: init?.method });
return new Response(JSON.stringify({ items: [] }), {
return new Response(JSON.stringify({
schema_version: "missioncore.observation-session-page/v1", items: [], next_cursor: null,
}), {
status: 200,
headers: { "Content-Type": "application/json" },
});
@@ -175,8 +194,8 @@ test("Observatory fetches disjoint read-only source and laboratory projections",
assert.deepEqual(
calls.map(({ input }) => input).sort(),
[
"/api/v1/observation-sessions?limit=50&scope=laboratory&lab_contract=v3",
"/api/v1/observation-sessions?limit=50&scope=source",
"/api/v1/observation-sessions?limit=50&scope=laboratory&lab_contract=v3&pagination=cursor-v1",
"/api/v1/observation-sessions?limit=50&scope=source&pagination=cursor-v1",
],
);
assert.deepEqual(new Set(calls.map(({ method }) => method)), new Set(["GET"]));
@@ -203,7 +222,7 @@ test("Observatory exposes bounded-window uncertainty without inventing a broken
);
});
test("Observatory projects a typed canonical run only through its exact sourceSessionId", () => {
test("historical canonical replay stays out of Observatory without modifying its archive record", () => {
const canonicalResultId = `lab-v1-vegetation-shadow-${"8".repeat(64)}`;
const canonical = evidence(
canonicalResultId,
@@ -227,20 +246,144 @@ test("Observatory projects a typed canonical run only through its exact sourceSe
commandsEnabled: false,
},
};
const before = structuredClone(canonical);
const catalog = buildObservatoryCatalog(
[source("20260828T130511Z_viewer_live", "2026-08-28T13:05:11Z")],
[canonical],
);
assert.deepEqual(catalog.items[0].evidence[0].recordedRun, {
kind: "canonical-recorded-rerun",
evidenceSessionId: canonicalResultId,
sourceSessionId: "20260828T130511Z_viewer_live",
resultId: canonicalResultId,
viewerProfile: "recorded-session",
timeline: "session_time",
activation: "explicit",
assert.deepEqual(catalog.items[0].evidence, []);
assert.equal(catalog.window.laboratoryCount, 0);
assert.deepEqual(canonical, before);
});
test("only admitted portable evidence is visible regardless of LAB label, age or installed version", () => {
const legacy = evidence("legacy", "source", "2026-09-03T13:00:00Z");
legacy.lab.labId = "LAB M4.9T5";
legacy.lab.replayCapability = null;
const historical = evidence("old-portable", "source", "2026-07-20T10:00:00Z");
historical.lab.labId = "LAB E24"; // Labels must never become an allow/deny list.
const current = evidence("new-portable", "source", "2026-09-03T12:00:00Z");
const catalog = buildObservatoryCatalog(
[source("source", "2026-07-19T10:00:00Z")], [legacy, historical, current],
);
assert.deepEqual(catalog.items[0].evidence.map((item) => item.sessionId), ["new-portable", "old-portable"]);
assert.equal(catalog.window.laboratoryCount, 2);
assert.equal(findObservatoryEvidence(catalog, "legacy"), null);
});
test("a malformed portable binding errors instead of silently disappearing as legacy", () => {
const broken = evidence("broken", "source", "2026-09-03T12:00:00Z");
broken.lab.provenance.source.session_id = "another-source";
assert.throws(() => buildObservatoryCatalog(
[source("source", "2026-07-19T10:00:00Z")], [broken],
), /identity/);
});
function wire(item) {
const lab = item.lab;
return {
id: item.id, label: item.label, started_at_utc: item.startedAtUtc,
completed_at_utc: item.completedAtUtc, status: item.status,
modalities: item.modalities, duration_seconds: item.durationSeconds, replayable: item.replayable,
...(lab ? { lab: {
lab_id: "LAB M4.9T5", source_session_id: lab.sourceSessionId, result_kind: lab.resultKind,
result_id: lab.resultId, source_result_id: lab.sourceResultId, config_sha256: lab.configSha256,
run_created_at_utc: lab.runCreatedAtUtc, published_at_utc: lab.publishedAtUtc,
provenance: lab.provenance, replay_capability: lab.replayCapability ? lab.provenance.replay_capability : null,
calculation_profile: null,
} } : {}),
};
}
function pageResponse(items, nextCursor = null) {
return new Response(JSON.stringify({
schema_version: "missioncore.observation-session-page/v1",
items: items.map(wire), next_cursor: nextCursor,
}), { headers: { "Content-Type": "application/json" } });
}
test("500 sources and later-page results are searchable metadata, with no archive or viewer requests", async () => {
const sources = Array.from({ length: 500 }, (_, index) => source(`source-${index}`, "2026-08-29T10:00:00Z"));
const labs = Array.from({ length: 101 }, (_, index) => {
const item = evidence(`legacy-${index}`, sources[0].id, "2026-08-29T11:00:00Z");
item.lab.replayCapability = null;
return item;
});
labs.push(evidence("portable-after-legacy", sources[499].id, "2026-08-29T12:00:00Z"));
const calls = [];
const catalog = await fetchObservatoryCatalog({ fetcher: async (input, init) => {
const url = new URL(String(input), "http://localhost");
calls.push(url);
assert.equal(init.method, "GET");
assert.equal(url.pathname, "/api/v1/observation-sessions");
const rows = url.searchParams.get("scope") === "source" ? sources : labs;
const cursor = url.searchParams.get("cursor");
const start = cursor === null ? 0 : rows.findIndex((item) => item.id === cursor) + 1;
const page = rows.slice(start, start + 100);
return pageResponse(page, start + page.length < rows.length ? page.at(-1).id : null);
} });
assert.equal(calls.length, 7);
assert.equal(catalog.items.length, 500);
assert.equal(catalog.window.laboratoryCount, 1);
assert.equal(catalog.window.sourceLimitReached, false);
assert.equal(catalog.window.laboratoryLimitReached, false);
assert.equal(catalog.items.find((item) => item.source.id === "source-499").evidence[0].sessionId, "portable-after-legacy");
assert.deepEqual(catalog.unresolvedEvidence, []);
});
test("exactly one full page with no cursor is complete, not a guessed truncated window", async () => {
const catalog = await fetchObservatoryCatalog({ limit: 1, fetcher: async (url) =>
String(url).includes("scope=source")
? pageResponse([source("source", "2026-08-29T10:00:00Z")])
: pageResponse([evidence("result", "source", "2026-08-29T11:00:00Z")]),
});
assert.equal(catalog.window.sourceLimitReached, false);
assert.equal(catalog.window.laboratoryLimitReached, false);
});
test("paging rejects repeated records, cursor cycles, unsafe cursors and unversioned responses", async () => {
for (const failure of ["duplicate", "cycle", "unsafe", "unversioned", "too-large"]) {
let index = 0;
await assert.rejects(fetchObservatoryCatalog({ limit: 1, fetcher: async (url) => {
if (String(url).includes("scope=laboratory")) return pageResponse([]);
index += 1;
if (failure === "unversioned") return new Response(JSON.stringify({ items: [] }));
if (failure === "unsafe") return pageResponse([], "../../private");
const id = failure === "duplicate" ? "same" : `source-${index}`;
const rows = [source(id, "2026-08-29T10:00:00Z")];
if (failure === "too-large") rows.push(source("extra", "2026-08-29T10:00:00Z"));
return pageResponse(rows, failure === "cycle" ? "cycle" : id);
} }));
assert.ok(index <= 2);
}
});
test("the traversal cap is explicit and cannot confirm absence of a result", async () => {
let count = 0;
const catalog = await fetchObservatoryCatalog({ limit: 1, fetcher: async (url) => {
if (String(url).includes("scope=source")) return pageResponse([]);
count += 1;
return pageResponse([], `cursor-${count}`);
} });
assert.equal(count, 256);
assert.equal(catalog.window.laboratoryLimitReached, true);
assert.equal(observatoryCatalogConfirmsEvidenceDeletion(catalog, "missing"), false);
});
test("failure aborts the sibling traversal and an already aborted caller starts no requests", async () => {
const signals = [];
await assert.rejects(fetchObservatoryCatalog({ fetcher: async (url, init) => {
signals.push(init.signal);
if (String(url).includes("scope=source")) return new Response("unavailable", { status: 503 });
return pageResponse([]);
} }), /503/);
assert.ok(signals.every((signal) => signal.aborted));
const request = new AbortController();
request.abort();
await assert.rejects(fetchObservatoryCatalog({ signal: request.signal, fetcher: async () => {
assert.fail("aborted catalog cannot start a request");
} }), { name: "AbortError" });
});
test("Observatory applies an exact rename locally without mutating canonical identity", () => {
@@ -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.",
);
});
@@ -0,0 +1,83 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let decode;
let label;
let fetchProgress;
const job = {
jobId: "observatory-run-" + "1".repeat(32),
sourceSessionId: "source-a", setupId: "m49-tgs",
definitionSha256: "a".repeat(64), claimGeneration: 2, state: "running",
publication: { state: "not-required" },
};
function view() {
return {
schema_version: "missioncore.observatory-recorded-progress-view/v1",
job_id: job.jobId, source_session_id: job.sourceSessionId, setup_id: job.setupId,
definition_sha256: job.definitionSha256, claim_generation: 2, state: "running",
received_at_utc: "2026-09-03T12:00:00Z", age_seconds: 0.5,
progress: {
schema_version: "missioncore.observatory-recorded-progress/v1",
claim_generation: 2, sequence: 4, phase_index: 2,
phase: "computing", unit: "frames", completed: 3, total: 10,
elapsed_seconds: 8, phase_elapsed_seconds: 5,
},
};
}
before(async () => {
server = await createServer({ configFile: false, logLevel: "silent", server: { middlewareMode: true } });
const module = await server.ssrLoadModule("/src/core/observatory/recordedProgress.ts");
decode = module.decodeRecordedProgress;
label = module.recordedProgressLabel;
fetchProgress = module.fetchRecordedProgress;
});
after(async () => { await server?.close(); });
test("actual frame count is separate from completion and publication", () => {
const progress = decode(view(), job);
assert.equal(label(job, progress), "Обрабатываем запись · 3 / 10 кадров");
assert.equal(label({ ...job, publication: { state: "pending" } }, progress), "Сохраняем результат");
assert.doesNotMatch(label(job, progress), /%|завершён|FPS|ETA/);
});
test("unknown total never becomes a synthetic percentage", () => {
const value = view();
value.progress.total = null;
assert.equal(label(job, decode(value, job)), "Обрабатываем запись · 3 кадров");
value.progress.completed = 0;
assert.equal(label(job, decode(value, job)), "Обрабатываем запись");
});
test("old source definition or attempt cannot flash as current progress", () => {
for (const delta of [
{ job_id: "other" }, { source_session_id: "other" }, { setup_id: "other" },
{ definition_sha256: "b".repeat(64) }, { claim_generation: 1 },
]) assert.throws(() => decode({ ...view(), ...delta }, job));
assert.equal(decode({ ...view(), state: "succeeded" }, job), null);
assert.equal(label({ ...job, claimGeneration: 3 }, decode(view(), job)), "Ожидаем данные расчёта");
});
test("invalid counters, invented phase and extra fields fail closed", () => {
for (const delta of [
{ completed: true }, { completed: 11 }, { completed: -1 }, { total: 0 },
{ completed: Number.MAX_SAFE_INTEGER + 1 }, { phase: "done" }, { path: "/private" },
{ phase_elapsed_seconds: 9 }, { claim_generation: 3 },
]) assert.throws(() => decode({ ...view(), progress: { ...view().progress, ...delta } }, job));
});
test("missing and stale telemetry show no fabricated advancement", () => {
assert.equal(decode({ ...view(), progress: null, age_seconds: null, received_at_utc: null }, job), null);
assert.equal(label(job, null), "Ожидаем данные расчёта");
assert.equal(label(job, decode({ ...view(), age_seconds: 30 }, job)), "Ожидаем обновление прогресса");
});
test("terminal jobs keep their real state when progress telemetry is absent", () => {
assert.equal(label({ ...job, state: "failed" }, null), "Ошибка расчёта");
assert.equal(label({ ...job, state: "succeeded" }, null), "Расчёт завершён");
});
test("progress is a read-only request bound to the active job", async () => {
const calls = [];
const progress = await fetchProgress(job, { fetcher: async (path, init) => {
calls.push([path, init.method ?? "GET"]);
return new Response(JSON.stringify(view()));
} });
assert.deepEqual(calls, [[`/api/v1/observatory/runs/${job.jobId}/progress`, "GET"]]);
assert.equal(progress.completed, 3);
});
@@ -34,10 +34,10 @@ test("Observatory is the third independent Polygon workspace", () => {
{
id: "observatory",
root: "polygon",
label: "Обсерватория",
title: "Проверка компьютерного зрения",
eyebrow: "ТЕСТОВЫЙ КОНТУР / ОБСЕРВАТОРИЯ",
description: "Сессии и квалификация компьютерного зрения без доступа к управлению.",
label: "AI Inference",
title: "AI Inference",
eyebrow: "ТЕСТОВЫЙ КОНТУР / AI INFERENCE",
description: "Сессии и квалификация без доступа к управлению.",
icon: "eye",
kind: "observatory",
groups: [],
@@ -63,7 +63,7 @@ test("Observatory mounts the one shared canonical replay only after explicit adm
read("workspaces/observatory/ObservatoryWorkspace.tsx"),
read("core/observatory/useObservatoryCatalog.ts"),
read("core/observatory/recordedRun.ts"),
read("components/laboratory/CanonicalVegetationRerunReplay.tsx"),
read("components/laboratory/CanonicalResultRerunReplay.tsx"),
read("workspaces/laboratory/CanonicalVegetationRerunReplay.tsx"),
read("core/observation/viewerProfile.ts"),
read("styles/observatory.css"),
@@ -73,9 +73,15 @@ test("Observatory mounts the one shared canonical replay only after explicit adm
assert.doesNotMatch(app, /\["recordings", "lab-archive", "observatory"\]/);
assert.match(workspaceHub, /case "observatory":[\s\S]*<ObservatoryWorkspace/);
assert.match(workspace, /useObservatoryCatalog/);
assert.doesNotMatch(workspace, /observatory-lead/);
assert.doesNotMatch(workspace, /Расчёт записанных маршрутов выбранными профилями/);
assert.match(workspace, /Связанных результатов нет/);
assert.match(workspace, /не является выводом о качестве/);
assert.match(workspace, /presentedEvidence = selectedSession\?\.evidence \?\? \[\]/);
assert.match(workspace, /function compositionProducedEvidence\(/);
assert.match(workspace, /publishedAt >= compositionCreatedAt/);
assert.match(workspace, /const presentedCompositionRuns = aiJobsController\.runs/);
assert.match(workspace, /presentedEvidence = \(selectedSession\?\.evidence \?\? \[\]\)\.filter/);
assert.match(workspace, /!presentedCompositionRuns\.some\([\s\S]*compositionProducedEvidence\(run, evidence\)/);
assert.doesNotMatch(workspace, /MAX_PRESENTED_EVIDENCE|\.evidence\.slice\(/);
assert.match(workspace, /Полнота исторических/);
assert.match(workspace, /вне текущего загруженного среза/);
@@ -86,12 +92,13 @@ test("Observatory mounts the one shared canonical replay only after explicit adm
assert.match(workspace, /observatory-evidence-card__copy/);
assert.match(
workspace,
/function evidenceResultSubtitle[\s\S]*calculationProfile\?\.displayName[\s\S]*`\$\{evidence\.label\} · \$\{profileName\}`[\s\S]*: evidence\.label/,
/function evidenceConfigurationLabel[\s\S]*calculationProfile\?\.displayName[\s\S]*return profileName \?\? evidence\.label/,
);
assert.match(
workspace,
/<span>\{evidenceResultSubtitle\(evidence\)\}<\/span>/,
/<strong>\{evidenceConfigurationLabel\([\s\S]*evidence,[\s\S]*aiJobsController\.moduleLabelsBySetup,[\s\S]*aiJobsController\.jobs,[\s\S]*\)\}<\/strong>/,
);
assert.doesNotMatch(workspace, /evidenceConfigurationLabel\([^)]*,\s*selectedSession\.source\.label/);
assert.match(
workspace,
/evidence\.recordedRun \? \([\s\S]*name="trash"[\s\S]*name="edit"[\s\S]*Открыть визуальный разбор:[\s\S]*name="eye"/,
@@ -99,10 +106,17 @@ test("Observatory mounts the one shared canonical replay only after explicit adm
assert.doesNotMatch(workspace, /observatory-evidence-card[^>]*tone="soft"/);
assert.match(workspace, /Открыть визуальный разбор/);
assert.match(workspace, /replay\.kind === "ready"[\s\S]*<CanonicalVegetationRerunReplay/);
assert.match(workspace, /replay\.review\.kind === "canonical-recorded-rerun"[\s\S]*РЕЗУЛЬТАТ \/ ПРОВЕРЕННЫЙ ДОКУМЕНТ/);
assert.match(workspace, /Связанных артефактов: \{replay\.review\.artifacts\.length\}/);
assert.match(workspace, /<summary>Документ результата<\/summary>/);
assert.match(workspace, /<PortableResultReplay review=\{replay\.review\}/);
const portable = await read("components/laboratory/PortableResultReplay.tsx");
assert.doesNotMatch(portable, /Документ результата|JSON.stringify|StatusBadge/);
assert.match(portable, /missioncore.recorded-eomt-ddrnet-review\/v2/);
assert.match(portable, /sourceKind: "portable-semantic"/);
assert.match(portable, /sourceKind: "portable-objects"/);
assert.match(portable, /costmap=\{tgs\} semantics=\{semantics\}/);
assert.match(portable, /detections=\{objects\}/);
assert.match(portable, /<CanonicalResultRerunReplay/);
assert.doesNotMatch(workspace, /UNIVERSAL VIEWER|content-addressed artifacts/);
assert.doesNotMatch(workspace, /evidence\.lab\.resultKind|без запуска тяжёлого/);
assert.match(workspace, /Проверяем точную связь результата/);
assert.match(workspace, /role="alert"/);
assert.match(workspace, /Повторить/);
@@ -111,7 +125,10 @@ test("Observatory mounts the one shared canonical replay only after explicit adm
workspace,
/const returnToOverview[\s\S]*closeReplay\(\);[\s\S]*scrollIntoView\(\{ block: "start" \}\)/,
);
assert.match(workspace, /<h3>\{replayEvidence\?\.label \?\? replay\.binding\.resultId\}<\/h3>/);
assert.match(
workspace,
/<h3>\{replayEvidence && selectedSession[\s\S]*evidenceConfigurationLabel\([\s\S]*replayEvidence,[\s\S]*aiJobsController\.moduleLabelsBySetup,[\s\S]*aiJobsController\.jobs,[\s\S]*\)[\s\S]*replay\.binding\.resultId\}<\/h3>/,
);
assert.doesNotMatch(workspace, /RAVNOVES004TREE · полный маршрут восприятия/);
assert.match(workspace, /const selectSession[\s\S]*closeReplay\(\);[\s\S]*setSelectedSessionId/);
assert.match(workspace, /data-observatory-authority="observation-only"/);
@@ -183,8 +200,12 @@ 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/);
assert.match(workspace, /openCompositionRename\(run\)/);
assert.match(workspace, /openCompositionDelete\(run\)/);
assert.match(
workspace,
/const result = await renameObservatoryLabProjection\([\s\S]*controller\.applyEvidenceRename\(result\.sessionId, result\.displayName\);[\s\S]*setRenameTarget\(null\);[\s\S]*void controller\.refresh\(\);/,
@@ -195,9 +216,12 @@ test("Observatory rename and delete use admitted projection mutations and canoni
);
const deleteFlowStart = workspace.indexOf("const confirmDelete = useCallback");
const deleteFlowEnd = workspace.indexOf("}, [closeReplay, controller", deleteFlowStart);
const deleteFlowEnd = workspace.indexOf("}, [aiJobsController, closeReplay", deleteFlowStart);
assert.ok(deleteFlowStart >= 0 && deleteFlowEnd > deleteFlowStart);
const deleteFlow = workspace.slice(deleteFlowStart, deleteFlowEnd);
const wholeDeleteFlow = workspace.slice(deleteFlowStart, deleteFlowEnd);
const evidenceFlowStart = wholeDeleteFlow.indexOf("if (!deleteTarget?.recordedRun");
assert.ok(evidenceFlowStart >= 0);
const deleteFlow = wholeDeleteFlow.slice(evidenceFlowStart);
const teardown = deleteFlow.indexOf("closeReplay();");
const remove = deleteFlow.indexOf("await deleteObservatoryLabProjection");
const tombstone = deleteFlow.indexOf("controller.applyEvidenceDeletion");
@@ -218,77 +242,128 @@ test("Observatory rename and delete use admitted projection mutations and canoni
assert.match(hook, /applyObservatoryCatalogMutationOverlay\(/);
});
test("Observatory keeps one compact selector axis without the obsolete setup detail", async () => {
const [workspace, styles, setupHook, jobsHook] = await Promise.all([
test("Observatory configures independent AI modules and shows queued evidence progress", async () => {
const [workspace, styles, configWindow, jobsHook, sharedReplay] = await Promise.all([
read("workspaces/observatory/ObservatoryWorkspace.tsx"),
read("styles/observatory.css"),
read("core/observatory/useObservatoryLaboratorySetups.ts"),
read("core/observatory/useObservatoryRecordedJobs.ts"),
read("components/observatory/AIConfigurationWindow.tsx"),
read("core/observatory/useAICompositionJobs.ts"),
read("components/laboratory/CanonicalResultRerunReplay.tsx"),
]);
assert.match(workspace, /className="observatory-catalog-bar" padding="sm"/);
assert.match(workspace, /label="Выбрать сохранённую сессию"/);
assert.match(workspace, /label="Выбрать сетап лаборатории"/);
assert.match(workspace, /setupController\.error \? \(/);
assert.match(workspace, />\s*Сконфигурировать AI-слой\s*<\/Button>/);
assert.doesNotMatch(workspace, /label="Выбрать сетап лаборатории"/);
assert.doesNotMatch(workspace, />\s*Обновить\s*<\/Button>/);
assert.doesNotMatch(workspace, /Выбор меняет только читаемую карточку/);
assert.doesNotMatch(workspace, /ObservatorySetupDetail|observatory-setup-detail/);
assert.doesNotMatch(styles, /observatory-setup-detail|observatory-setup-results/);
assert.match(workspace, /useObservatoryRecordedJobs/);
assert.doesNotMatch(workspace, /useObservatoryRecordedJobs|useObservatoryLaboratorySetups/);
assert.match(configWindow, /title="Сконфигурировать AI-слой"/);
assert.match(configWindow, /label: "Сегментация"/);
assert.match(configWindow, /Зависимые блоки подключаются явно/);
assert.match(configWindow, /label: "Облако точек \/ TGS"/);
assert.match(configWindow, /label: "Дистанция"/);
assert.match(configWindow, /Дистанция использует рамки детектора и синхронное облако точек/);
assert.match(configWindow, /TGS обрабатывает LiDAR независимо от сегментации и детектора/);
assert.match(configWindow, /providers\.length === 1/);
assert.match(
workspace,
/runPreflight\?\.outcome === "queueable"[\s\S]*runPreflight\.submissionAllowed/,
configWindow,
/<WindowFooterActions>[\s\S]*"Отправляем на Worker…" : "Рассчитать"/,
);
assert.match(configWindow, /className=\{state === "saving" \? "observatory-ai-config__calculate--saving"/);
assert.match(configWindow, /aria-busy=\{state === "saving"\}/);
assert.match(styles, /@keyframes observatory-ai-calculate-pulse/);
assert.match(
workspace,
/showCalculate \? \([\s\S]*>\s*Рассчитать\s*<\/Button>/,
styles,
/\.observatory-ai-config__calculate--saving:disabled \{[\s\S]*animation: observatory-ai-calculate-pulse 1\.8s ease-in-out infinite;[\s\S]*opacity: 1;/,
);
assert.doesNotMatch(workspace, /recordedJobStatus|presentedJobStatus|Расчёт завершён|Результат опубликован|Вычислено ·/);
assert.match(workspace, /setupController\.selectableSetups\.map/);
assert.match(workspace, /showCalculate = setupController\.selectedSetup !== null/);
assert.match(workspace, /disabled=\{!canSubmitRecordedJob\}/);
assert.match(workspace, /aria-busy=\{calculationPending\}/);
const runBar = workspace.slice(workspace.indexOf('<div className="observatory-catalog-bar__run"'), workspace.indexOf("{queueStatusError ?"));
assert.doesNotMatch(runBar, /StatusBadge/);
assert.match(setupHook, /fetchObservatoryPortableLaboratorySetups/);
assert.doesNotMatch(setupHook, /fetchObservatoryLaboratorySetups|mergeSetupCatalogs|legacyResult/);
assert.match(setupHook, /selectableObservatorySetups\(next\)/);
assert.match(setupHook, /selectable\[0\]\?\.setupId \?\? ""/);
assert.match(setupHook, /selectedSetupId, sourceSessionId, selectedDefinitionSha256/);
assert.match(workspace, /useAICompositionJobs\(selectedSessionId\)/);
assert.match(workspace, /observatory-evidence-card--progress/);
assert.match(workspace, /const active = run\.state === "running"/);
assert.match(workspace, /\{active \? \([\s\S]*observatory-evidence-card__progress/);
assert.match(workspace, /run\.state === "ready" \? <Icon name="clipboard"/);
assert.match(workspace, /function aiJobStage\(/);
assert.match(workspace, /return "Инициализация"/);
assert.match(workspace, /return "Передача на сервер"/);
assert.match(workspace, /return "Просчёт"/);
assert.match(workspace, /Собираем видеоряд для модели/);
assert.match(workspace, /измеренная скорость Worker 006: 17–18 FPS/);
assert.doesNotMatch(workspace, /phaseElapsedSeconds \/ progress\.completed/);
assert.match(workspace, /AI_PROGRESS_PHASE_RANGES/);
assert.match(workspace, /"source-preparation": \[5, 25\]/);
assert.match(workspace, /computing: \[25, 90\]/);
assert.match(workspace, /Подготавливаем камеру и LiDAR/);
assert.match(workspace, /прошло.*formatDuration\(progress\.elapsedSeconds\)/s);
assert.match(workspace, /Не удалось запустить модель/);
assert.doesNotMatch(workspace, /job\.terminalMessage|job\.publication\.error/);
assert.doesNotMatch(workspace, /function aiModuleLabel\(/);
assert.match(workspace, /aiJobsController\.moduleLabelsBySetup\[job\.setupId\] \?\? job\.setupId/);
assert.match(jobsHook, /fetchAIModuleCatalog\(request\.signal\)/);
assert.match(jobsHook, /AI_MODULE_SETUP_IDS\[module\.moduleId\]/);
assert.match(jobsHook, /run\.presentation\.modules\.flatMap/);
assert.match(jobsHook, /Object\.fromEntries\(\[\.\.\.catalogLabels, \.\.\.projectedLabels\]\)/);
assert.match(workspace, /<strong>\{moduleLabel\}<\/strong>/);
assert.match(workspace, /<strong>\{run\.displayName \?\? run\.presentation\.configurationLabel\}<\/strong>/);
assert.doesNotMatch(workspace, /<strong>\{selectedSession\.source\.label\} ·/);
assert.match(workspace, /type EvidenceSortDirection = "newest" \| "oldest"/);
assert.match(workspace, /const presentedRows = useMemo<readonly ObservatoryEvidenceRow\[]>/);
assert.match(workspace, /direction \* \(Date\.parse\(left\.timestamp\) - Date\.parse\(right\.timestamp\)\)/);
assert.match(workspace, /className="observatory-evidence__sort"/);
assert.match(workspace, /data-direction=\{evidenceSortDirection\}/);
assert.match(workspace, /formatTimestamp\(row\.timestamp\)/);
assert.match(styles, /\.observatory-evidence-card\s*\{[\s\S]*grid-template-columns:\s*auto minmax\(0, 1fr\) auto auto/);
assert.match(styles, /\.observatory-evidence__sort\s*\{[\s\S]*border:\s*0;[\s\S]*outline:\s*0/);
assert.match(
workspace,
/preflightCandidate\.definitionSha256[\s\S]*selectedSetup\?\.runDefinition\?\.definitionSha256/,
styles,
/\.observatory-evidence-card__copy strong \{[\s\S]*font-size: var\(--nodedc-font-size-sm\);/,
);
assert.match(sharedReplay, /LAB_SCENE_SETTINGS_STORAGE_PREFIX/);
assert.match(sharedReplay, /window\.localStorage\.getItem/);
assert.match(sharedReplay, /window\.localStorage\.setItem/);
assert.match(sharedReplay, /unifiedCameraShare: blueprintSplitPrimarySize \/ 100/);
assert.match(sharedReplay, /setSettingsOpen\(false\);[\s\S]*void saveLabViewProfile/);
assert.match(
jobsHook,
/OPEN_STATES[\s\S]*"preemption-pending",[\s\S]*"reconciliation-required"/,
sharedReplay,
/showPoints: spatialMode !== null && \(showSourcePoints \|\| showLocalSlam\)/,
);
assert.match(jobsHook, /return `observatory-ui:\$\{entropy\}`;/);
assert.match(jobsHook, /JSON\.stringify\(\[sourceSessionId, setupId, definitionSha256\]\)/);
assert.match(jobsHook, /snapshot\.selectionKey === selectionKey \? snapshot : null/);
assert.doesNotMatch(jobsHook, /observatory-ui:[^`]*sourceSessionId|\.slice\(0, 160\)/);
assert.match(workspace, /recordedJobsController\.refresh\(\)/);
assert.match(jobsHook, /POLL_INTERVAL_MS = 1_500/);
assert.match(sharedReplay, /showTrajectory: spatialMode !== null && showLocalSlam/);
assert.match(
sharedReplay,
/accumulationSeconds: showLocalSlam \? sceneDraft\.accumulationSeconds : 0/,
);
assert.match(workspace, /<AICompositionReplay run=\{replay\.run\} \/>/);
assert.match(workspace, /function presentAIJob\(job: ObservatoryRecordedJob\)/);
assert.match(workspace, /if \(job\.state === "failed"\) return true/);
assert.match(workspace, /return job\.state !== "succeeded"/);
assert.match(workspace, /function presentLatestAIJobs/);
assert.match(workspace, /seenSetups\.has\(job\.setupId\)/);
assert.match(configWindow, /fetchAICompositionJobs\(sourceSessionId, request\.signal\)/);
assert.match(
configWindow,
/if \(job\.state === "failed" \|\| job\.publication\.state === "failed"\) continue;/,
);
assert.match(configWindow, /Текущая конфигурация уже рассчитана/);
assert.match(configWindow, /configurationAlreadyExists/);
assert.match(configWindow, /Готовый результат будет использован без повторного расчёта/);
assert.doesNotMatch(configWindow, /disabled: existingByModule\.has\(module\.moduleId\)/);
assert.doesNotMatch(configWindow, /selections\.some\(\(\{ module \}\) => existingByModule/);
assert.match(jobsHook, /const OPEN = new Set/);
assert.match(jobsHook, /1_500/);
assert.match(jobsHook, /globalThis\.setTimeout/);
assert.doesNotMatch(jobsHook, /setInterval/);
assert.match(workspace, /job\.publication\.state === "published"/);
assert.match(workspace, /void controller\.refresh\(\);[\s\S]*setupController\.refresh\(\);/);
assert.match(workspace, /onCalculated=\{\(\) => aiJobsController\.refresh\(\)\}/);
assert.match(
styles,
/\.observatory-catalog-bar__controls \{[\s\S]*min-width: 0;[\s\S]*flex: 1 1 auto;[\s\S]*flex-wrap: nowrap;[\s\S]*justify-content: flex-end;/,
);
assert.match(
styles,
/\.observatory-catalog-bar__run \{[\s\S]*display: flex;[\s\S]*align-items: center;/,
);
assert.match(styles, /\.observatory-evidence-card__progress \{/);
assert.match(styles, /\.observatory-ai-module-group > header \{/);
assert.match(styles, /\.observatory-ai-module-group \{[\s\S]*border: 0;[\s\S]*outline: 0;/);
assert.match(
styles,
/@container observatory-workspace \(max-width: 920px\)[\s\S]*\.observatory-catalog-bar,[\s\S]*\.observatory-catalog-bar__controls,[\s\S]*flex-direction: column;/,
);
assert.match(setupHook, /catalog\?\.sourceSessionId === sourceSessionId/);
assert.match(setupHook, /preflightRequest\.current\?\.abort\(\)/);
assert.match(setupHook, /preflightRequest\.current !== request/);
assert.match(
setupHook,
/state !== "ready" \|\| !selectedSetup \|\| preflight\.kind !== "idle"[\s\S]*void check\(\)/,
);
});
@@ -0,0 +1,101 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server, createRecordedRerunCameraJournal, initialEye;
before(async () => {
server = await createServer({ appType: "custom", logLevel: "silent",
server: { middlewareMode: true } });
const module = await server.ssrLoadModule(
"/src/components/rerun/recordedRerunCameraJournal.ts");
createRecordedRerunCameraJournal = module.createRecordedRerunCameraJournal;
initialEye = module.RECORDED_RERUN_ORBITAL_EYE;
});
after(async () => { await server?.close(); });
class FakeEvent {
constructor(type, init = {}) { this.type = type; Object.assign(this, init); }
}
class FakeTarget {
listeners = new Map();
emitted = [];
addEventListener(type, listener) {
const listeners = this.listeners.get(type) ?? [];
listeners.push(listener); this.listeners.set(type, listeners);
}
removeEventListener(type, listener) {
this.listeners.set(type, (this.listeners.get(type) ?? []).filter(item => item !== listener));
}
dispatchEvent(event) {
this.emitted.push(event);
for (const listener of this.listeners.get(event.type) ?? []) listener(event);
return true;
}
}
const radius = (eye) => Math.hypot(
eye.position[0] - eye.lookTarget[0],
eye.position[1] - eye.lookTarget[1],
eye.position[2] - eye.lookTarget[2],
);
test("recorded orbital eye tracks only 3D viewport navigation", () => {
const scope = new FakeTarget();
const timers = [];
Object.assign(scope, {
WheelEvent: FakeEvent,
requestAnimationFrame(callback) { callback(); return 1; },
setTimeout(callback) { timers.push(callback); return timers.length; },
});
const canvas = new FakeTarget();
Object.assign(canvas, {
clientHeight: 200,
isConnected: true,
getBoundingClientRect: () => ({
left: 100, right: 500, top: 50, bottom: 250, width: 400, height: 200,
}),
});
const journal = createRecordedRerunCameraJournal(canvas, scope);
journal.configure(initialEye, 0.46);
const pointer = (type, x, y, buttons) => new FakeEvent(type, {
clientX: x, clientY: y, button: 0, buttons, pointerId: 7, pointerType: "mouse",
altKey: false, ctrlKey: false, metaKey: false, shiftKey: false,
});
canvas.dispatchEvent(pointer("pointerdown", 200, 100, 1));
scope.dispatchEvent(pointer("pointermove", 240, 120, 1));
scope.dispatchEvent(pointer("pointerup", 240, 120, 0));
assert.deepEqual(journal.current(), initialEye);
canvas.dispatchEvent(pointer("pointerdown", 380, 100, 1));
scope.dispatchEvent(pointer("pointermove", 420, 120, 1));
scope.dispatchEvent(pointer("pointerup", 420, 120, 0));
const rotated = journal.current();
assert.notDeepEqual(rotated.position, initialEye.position);
assert.ok(Math.abs(radius(rotated) - radius(initialEye)) < 1e-9);
journal.setMaxOrbitalRadius(40);
scope.dispatchEvent(new FakeEvent("wheel", {
clientX: 380, clientY: 130, deltaX: 0, deltaY: 2_000, deltaMode: 0,
altKey: false, ctrlKey: false, metaKey: false, shiftKey: false,
}));
assert.ok(Math.abs(radius(journal.current()) - 40) < 1e-9);
journal.configure(rotated, 0.46);
scope.dispatchEvent(new FakeEvent("wheel", {
clientX: 0, clientY: 0, deltaX: 0, deltaY: -20, deltaMode: 0,
altKey: false, ctrlKey: false, metaKey: false, shiftKey: false,
}));
const zoomed = journal.current();
assert.ok(Math.abs(radius(zoomed) - radius(rotated) * Math.exp(-20 / 200)) < 1e-9);
const snapshot = journal.current();
journal.setSpatialViewportStart(0.8);
scope.dispatchEvent(new FakeEvent("wheel", {
clientX: 380, clientY: 130, deltaX: 0, deltaY: -20, deltaMode: 0,
}));
assert.deepEqual(journal.current(), snapshot);
journal.dispose();
});
@@ -0,0 +1,261 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server, createIsolatedRerunHost, keepRecordedBlueprintSession, createRecordedRerunFacade, createRecordedRerunOwner, relayIsolatedRerunInput;
before(async () => {
server = await createServer({ appType: "custom", logLevel: "silent",
server: { middlewareMode: true } });
({ createIsolatedRerunHost } = await server.ssrLoadModule(
"/src/components/rerun/isolatedRerunHost.ts"));
({ keepRecordedBlueprintSession } = await server.ssrLoadModule(
"/src/core/observation/recordedBlueprintLifecycle.ts"));
({ createRecordedRerunFacade } = await server.ssrLoadModule(
"/src/components/rerun/recordedRerunFacade.ts"));
({ createRecordedRerunOwner } = await server.ssrLoadModule(
"/src/components/rerun/recordedRerunOwner.ts"));
({ relayIsolatedRerunInput } = await server.ssrLoadModule(
"/src/components/rerun/isolatedRerunInput.ts"));
});
after(async () => { await server?.close(); });
function fixture(t, { stopFails = false } = {}) {
const timers = new Map();
let timerId = 0, creates = 0, stops = 0, removals = 0;
t.mock.method(globalThis, "setTimeout", (fn) => {
timers.set(++timerId, fn); return timerId;
});
t.mock.method(globalThis, "clearTimeout", (id) => timers.delete(id));
const listeners = new Map();
const viewer = { stop() { stops++; if (stopFails) throw new Error("partial start"); } };
const mount = {};
const frame = {
contentWindow: {
missionCoreRerun: createRecordedRerunOwner(() => { creates++; return viewer; }, mount),
addEventListener: (name, fn) => listeners.set(name, fn),
removeEventListener: (name) => listeners.delete(name),
},
remove() { removals++; },
};
const host = { dataset: {}, ownerDocument: { createElement(name) {
assert.equal(name, "iframe"); return frame;
} }, append(child) { assert.equal(child, frame); } };
return { host, frame, viewer, mount, timers, listeners,
counts: () => ({ creates, stops, removals }) };
}
function bridge(native, mount = {}) {
const child = createRecordedRerunOwner(() => native, mount);
const parent = createRecordedRerunFacade((request, bytes) => {
assert.equal(typeof request, "string");
const result = child.invoke(request, bytes);
assert.equal(typeof result, "string");
return result;
});
child.connect(message => {
assert.equal(typeof message, "string");
parent.notify(message);
});
return parent;
}
test("recorded realm terminates on close even if upstream stop throws", async (t) => {
const f = fixture(t, { stopFails: true });
const scope = createIsolatedRerunHost(f.host);
assert.equal(f.frame.src, "/rerun-runtime.html");
f.frame.onload();
const ready = await scope.ready;
assert.notEqual(ready.viewer, f.viewer);
assert.equal(ready.mount, f.host);
assert.equal(f.timers.size, 0);
assert.equal(f.listeners.size, 1);
scope.dispose(); scope.dispose();
assert.deepEqual(f.counts(), { creates: 1, stops: 1, removals: 1 });
assert.equal(f.listeners.size, 0);
assert.equal(f.frame.onload, null);
assert.equal(ready.viewer.ready, false);
});
test("native frame input relays Escape and leaves pointer ownership inside Rerun", () => {
const listeners = new Map(), events = [];
class ParentEvent { constructor(type, init) { this.type = type; Object.assign(this, init); } }
const host = { dataset: {}, ownerDocument: { defaultView: {
KeyboardEvent: ParentEvent, PointerEvent: ParentEvent,
} }, dispatchEvent: event => events.push(event) };
const child = {
addEventListener: (name, fn) => listeners.set(name, fn),
removeEventListener: name => listeners.delete(name),
};
const release = relayIsolatedRerunInput(host, child, null);
assert.equal(listeners.has("pointerdown"), false);
listeners.get("keydown")({ key: "Escape", defaultPrevented: false });
assert.equal(events[0].key, "Escape");
release(); release();
assert.equal(listeners.size, 0); assert.equal(events.length, 1);
});
test("disposed facade, channel and unsubscribe reject native use and copy event values", async () => {
let eventCallback, unsubscribed = 0, sends = 0, starts = 0;
const range = { min: 1, max: 2 }, mount = {};
const native = {
ready: true, stop() {},
async start(source, parent) { assert.equal(parent, mount); starts++; },
get_time_range: () => range,
on(event, callback) { eventCallback = callback; return () => { unsubscribed++; }; },
open_channel: () => ({ ready: true, send_rrd() { sends++; }, close() {} }),
};
const { facade, dispose } = bridge(native, mount);
await facade.start(null, {}, null);
assert.equal(starts, 1);
let event;
const unsubscribe = facade.on("time_update", value => { event = value; });
eventCallback(range);
assert.deepEqual(event, range); assert.notEqual(event, range);
assert.notEqual(facade.get_time_range(), range);
const channel = facade.open_channel();
channel.send_rrd(new Uint8Array());
dispose(); dispose(); unsubscribe();
assert.equal(unsubscribed, 1);
assert.equal(channel.ready, false);
channel.send_rrd(new Uint8Array());
eventCallback({ min: 3, max: 4 });
assert.deepEqual(event, range);
assert.equal(sends, 1);
await assert.rejects(facade.start(null, {}, null), /disposed/);
});
test("close before frame load cancels startup and cannot reopen", async (t) => {
const f = fixture(t);
const scope = createIsolatedRerunHost(f.host);
const lateLoad = f.frame.onload;
const rejected = assert.rejects(scope.ready, /disposed/);
scope.dispose(); lateLoad();
await rejected;
assert.deepEqual(f.counts(), { creates: 0, stops: 0, removals: 1 });
assert.equal(f.timers.size, 0);
});
test("closing during SDK startup also releases a late successful start", async () => {
let finish, stops = 0;
const owner = bridge({
start: () => new Promise(resolve => { finish = resolve; }),
stop() { stops++; },
}, {});
const pending = owner.facade.start(null, {}, null);
const rejected = assert.rejects(pending, /disposed/);
owner.dispose();
assert.equal(stops, 1);
finish();
await rejected;
await Promise.resolve();
assert.equal(stops, 2);
assert.equal(owner.facade.ready, false);
});
test("late failed startup cannot revive a disposed parent and still finishes cleanup", async () => {
let fail, stops = 0;
const owner = bridge({
start: () => new Promise((_resolve, reject) => { fail = reject; }),
stop() { stops++; if (stops > 1) throw new Error("already gone"); },
}, {});
const pending = owner.facade.start(null, {}, null);
const rejected = assert.rejects(pending, /disposed/);
owner.dispose();
fail(new Error("startup failed"));
await rejected;
await Promise.resolve();
assert.equal(stops, 2);
assert.equal(owner.facade.ready, false);
});
test("primitive bridge preserves all public clock/control arguments and errors", async () => {
const calls = [];
const methods = {
open: [["one.rrd", "two.rrd"]], close: ["one.rrd"],
override_panel_state: ["time", "hidden"],
get_active_recording_id: [], get_active_timeline: ["recording"],
get_current_time: ["recording", "session_time"], get_playing: ["recording"],
get_time_range: ["recording", "session_time"],
set_active_timeline: ["recording", "session_time"],
set_current_time: ["recording", "session_time", 123.456],
set_playing: ["recording", false],
};
const native = { stop() {}, start: async () => { throw new Error("native startup failure"); } };
for (const name of Object.keys(methods)) native[name] = (...args) => { calls.push([name, args]); return 1; };
const { facade, dispose } = bridge(native);
for (const [name, args] of Object.entries(methods)) facade[name](...args);
assert.deepEqual(calls, Object.entries(methods));
await assert.rejects(facade.start(null, {}, null), /native startup failure/);
dispose();
});
test("sync native failures become parent-owned errors and optional channel names stay undefined", () => {
const nativeError = new Error("native failure");
let channelName = "unobserved";
const { facade, dispose } = bridge({
stop() {},
open() { throw nativeError; },
open_channel(name) { channelName = name; return { close() {} }; },
});
assert.throws(() => facade.open("rrd"), error => error !== nativeError && /native failure/.test(error.message));
facade.open_channel();
assert.equal(channelName, undefined);
dispose();
});
test("dispose closes every auxiliary channel even if one channel fails", () => {
const closed = [];
let stops = 0;
const owner = bridge({
stop() { stops++; },
open_channel: name => ({ close() {
closed.push(name);
if (name === "first") throw new Error("already gone");
} }),
}, {});
const first = owner.facade.open_channel("first");
const second = owner.facade.open_channel("second");
owner.dispose(); owner.dispose(); first.close(); second.close();
assert.deepEqual(closed, ["first", "second"]);
assert.equal(stops, 1);
});
test("host load timeout removes the frame and clears its timer", async (t) => {
const f = fixture(t);
const scope = createIsolatedRerunHost(f.host);
const rejected = assert.rejects(scope.ready, /timed out/);
[...f.timers.values()][0]();
await rejected;
assert.equal(f.counts().removals, 1);
assert.equal(f.timers.size, 0);
});
test("blueprint termination cancels renewal and sends one bounded keepalive release", async () => {
let tick, canceled = 0;
const requests = [];
const release = keepRecordedBlueprintSession({
endpointUrl: "/api/v1/observation-sessions/source/blueprint.rrd",
origin: "http://core.test", applicationId: "nodedc_mission_core_recorded",
recordingId: "recording", ownerId: "a".repeat(32),
fetcher: async (url, init) => { requests.push({ url, ...init }); return {}; },
schedule(fn) { tick = fn; return 1; }, cancel() { canceled++; },
});
tick();
const renewal = requests[0];
release(); release(); tick();
assert.equal(canceled, 1);
assert.equal(requests.length, 2);
assert.equal(renewal.signal.aborted, true);
assert.equal(requests[1].keepalive, true);
assert.equal(requests[1].url, "http://core.test/api/v1/observation-sessions/source/blueprint-lifecycle");
assert.deepEqual(JSON.parse(requests[1].body), {
action: "release", application_id: "nodedc_mission_core_recorded",
recording_id: "recording", blueprint_session_id: "a".repeat(32),
});
});
test("blueprint lease refuses a foreign endpoint", () => {
assert.throws(() => keepRecordedBlueprintSession({ endpointUrl: "https://other.test/blueprint.rrd",
origin: "http://core.test", applicationId: "app", recordingId: "recording", ownerId: "owner",
}), /Unsafe/);
});
@@ -14,6 +14,8 @@ let liveRerunReceiverBindingIdentity;
let recordedOpenWatchdogTimeoutMs;
let rerunViewerInitialSource;
let resolveRecordedViewerSourceUrl;
let isRecordedRrdSource;
let resolveRecordedBlueprintUrl;
before(async () => {
server = await createServer({
@@ -31,6 +33,8 @@ before(async () => {
recordedOpenWatchdogTimeoutMs,
rerunViewerInitialSource,
resolveRecordedViewerSourceUrl,
isRecordedRrdSource,
resolveRecordedBlueprintUrl,
} = await server.ssrLoadModule("/src/components/RerunViewport.tsx"));
});
@@ -86,6 +90,25 @@ test("one canonical LAB replay generation reaches the same native receiver", ()
);
});
for (const prefix of [
"m49-tgs-portable-review",
"lab-v1-eomt-ddrnet",
"ai-layer-ddrnet",
"ai-layer-eomt",
"ai-layer-rf-detr",
"ai-layer-object-distance",
]) test(`${prefix} replay uses recorded admission and the shared source blueprint`, () => {
const source = `/api/v1/observatory/portable-results/${prefix}-${"c".repeat(64)}/replays/${"d".repeat(64)}/recording.rrd`;
const blueprint = "/api/v1/observation-sessions/source/blueprint.rrd";
assert.equal(isRecordedRrdSource(source), true);
assert.equal(isRecordedRrdSource(source.replace("/replays/", "/untrusted/")), false);
assert.equal(resolveRecordedViewerSourceUrl({ sourceUrl: source,
viewerSourceUrl: `${source}?generation=${sha256}`, sha256, byteLength: 100 },
"http://mission-core.test"), `http://mission-core.test${source}?generation=${sha256}`);
assert.equal(resolveRecordedBlueprintUrl(source, "http://mission-core.test", blueprint),
`http://mission-core.test${blueprint}`);
});
test("live presentation waits for the exact receiver to expose a usable range", () => {
assert.equal(isLiveRerunPresentationReady(false, { min: 1, max: 2 }, 1), false);
assert.equal(isLiveRerunPresentationReady(true, null, 1), false);
@@ -225,6 +248,9 @@ test("recorded RRD bytes are never split across LogChannel.send_rrd calls", asyn
assert.doesNotMatch(source, /streamVerifiedRecordedRrd/);
assert.doesNotMatch(source, /missioncore\/recorded-recording/);
assert.doesNotMatch(source, /recordedChannel/);
assert.match(source, /recordedPerceptionLayers\.costmap,/);
assert.match(source, /recordedPerceptionLayers\.costmap !== undefined && status !== "ready"/);
assert.match(source, /perceptionLayers\.costmap === undefined \? \{\} : \{\s*show_costmap: perceptionLayers\.costmap,\s*reactivate_updates: true/s);
assert.match(source, /viewer\.start\(\s*rerunViewerInitialSource\(resolvedSource\)/s);
assert.doesNotMatch(source, /rerunViewerOpenOptions/);
assert.match(
@@ -97,7 +97,7 @@ test("M4 keeps independent semantic controls in media and spatial panes", async
assert.match(source, /aria-label="Слои 3D и плана"/);
assert.match(canonical, /data-pane-mode="media"/);
assert.match(canonical, /data-pane-mode="spatial"/);
assert.match(canonical, /modeControlsVisible=\{!splitView\}/);
assert.match(canonical, /modeControlsVisible=\{!splitView && !paneToolbarsAlwaysVisible\}/);
assert.match(source, /semanticOverlay=\{mediaMode === "video" \? semanticOverlay : 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'});
});
@@ -470,7 +470,7 @@ test("vegetation realtime LAB uses one upstream Rerun clock and keeps archival r
"utf8",
),
readFile(
new URL("../src/components/laboratory/CanonicalVegetationRerunReplay.tsx", import.meta.url),
new URL("../src/components/laboratory/CanonicalResultRerunReplay.tsx", import.meta.url),
"utf8",
),
readFile(
@@ -494,17 +494,20 @@ test("vegetation realtime LAB uses one upstream Rerun clock and keeps archival r
assert.match(rerunSource, /<RerunViewport/);
assert.match(rerunSource, /ИСХ\. ТОЧКИ/);
assert.match(rerunSource, /ЛОК\. SLAM/);
assert.match(rerunSource, /resolveCanonicalLabReplay/);
assert.match(rerunSource, /resolveReplay\(resultId, value/);
assert.doesNotMatch(rerunSource, /canonical-overlay\.rrd/);
assert.match(rerunSource, /unifiedPerception: splitView/);
assert.match(rerunSource, /lockPerceptionCameraInteraction: mediaMode !== null/);
assert.match(rerunSource, /!splitView[\s\S]*event\.button !== 0/);
assert.match(rerunSource, /if \(!splitView\) stopNativeSplitTracking\(\)/);
assert.match(rerunSource, /event\.target instanceof HTMLCanvasElement/);
assert.match(rerunSource, /--canonical-rerun-camera-pane/);
assert.match(rerunSource, /onPointerDownCapture=\{splitView \? trackNativeSplit : undefined\}/);
assert.match(rerunSource, /lockPerceptionCameraInteraction: false/);
assert.match(rerunSource, /&& !semantics[\s\S]*&& !detections[\s\S]*&& !costmap/);
assert.doesNotMatch(rerunSource, /trackNativeSplit|rerunNativeCursor|rerunNativeInput/);
assert.match(canonicalSource, /resizable=\{splitView\}/);
assert.match(rerunSource, /unifiedCameraShare: blueprintSplitPrimarySize \/ 100/);
assert.match(rerunSource, /data-split-view=\{splitView \? "true" : undefined\}/);
assert.match(rerunSource, /mediaMode === null[\s\S]*\? 0[\s\S]*: splitView[\s\S]*\? nativeSplitPercentRef\.current[\s\S]*: 100/);
assert.match(rerunSource, /max=\{500\}/);
assert.match(rerunSource, /exactValueBounds=\{\{ min: 0 \}\}/);
assert.match(replayStyles, /m4-replay-threat-visual__pane-toolbar \{[\s\S]*flex-wrap: wrap;/);
assert.match(replayStyles, /m4-replay-threat-visual__spatial-toolbar-end \{[\s\S]*display: contents;/);
assert.match(rerunSource, /<ToastStack items=\{toasts\}/);
assert.match(rerunSource, /isRecordedPlaybackPresentationReady\(viewerStatus, playback\)/);
assert.match(rerunSource, /data-presentation-state=\{presentationState\}/);
assert.match(rerunSource, /<ActivityIndicator label="Загружаем синхронизированную запись"/);
@@ -517,22 +520,14 @@ test("vegetation realtime LAB uses one upstream Rerun clock and keeps archival r
replayStyles,
/canonical-vegetation-rerun-replay__timeline \.observation-timeline__playback \{[\s\S]*grid-template-columns: auto auto minmax\(0, 1fr\) auto;/,
);
assert.match(
replayStyles,
/canonical-vegetation-rerun-replay__viewport-lock[\s\S]*rerun-viewport__camera-lock \{[\s\S]*width: var\(--canonical-rerun-camera-pane, 100%\);/,
);
assert.match(
replayStyles,
/canonical-vegetation-rerun-replay__viewport-lock\[data-split-view="true"\][\s\S]*width: calc\(var\(--canonical-rerun-camera-pane, 46%\) - 0\.75rem\);/,
);
assert.doesNotMatch(rerunSource, /LaboratoryRecordedClipPlayer|LaboratoryMetricEvidenceScene/);
assert.match(resultSource, /point-aligned 3D semantics пока не запечатаны/);
assert.match(rerunSource, /value: "tgs", label: "TGS", disabled: true/);
assert.match(rerunSource, /hasTgs \? toggle\("TGS", showTgs/);
assert.match(canonicalSource, /primary=\{mediaPane\}/);
assert.match(canonicalSource, /secondary=\{spatialPane/);
assert.match(canonicalSource, /missioncore\.canonical-recorded-lab-replay\/v1/);
assert.match(canonicalSource, /separatorLabel="Изменить размер видео\/камеры и 3D\/плана"/);
assert.match(canonicalSource, /resizable=\{splitView && !unifiedContent\}/);
assert.match(canonicalSource, /resizable=\{splitView\}/);
assert.match(resultSource, /linked canonical M4\.9 TGS evidence/);
assert.match(resultSource, /linkedTgsResultId/);
assert.match(benchmarkSource, /M48MaskComparisonVisual/);
+6
View File
@@ -91,6 +91,12 @@ export default defineConfig(({ mode }) => {
},
build: {
target: "esnext",
rollupOptions: {
input: {
app: fileURLToPath(new URL("./index.html", import.meta.url)),
rerun: fileURLToPath(new URL("./rerun-runtime.html", import.meta.url)),
},
},
},
server: {
host: "127.0.0.1",
+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.2–0.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))
}
+157
View File
@@ -0,0 +1,157 @@
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("/") }}
pairing, err := node.OpenPairing(store, *dir, version, app.Inventory)
if err != nil {
return err
}
app.Pairing = pairing
nodeID, _ := store.Public()
app.Sensors, err = node.OpenSensors(*dir, nodeID)
if err != nil {
return err
}
pairing.Sensors = app.Sensors
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.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,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)
}
}
+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")
}
}
+233
View File
@@ -0,0 +1,233 @@
package node
import (
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"os"
"path/filepath"
"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 {
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 {
Sensors *Sensors
mu sync.Mutex
path string
store *Store
state PairState
now func() time.Time
inventory func() Inventory
version string
lastSeen int64
connection string
listenError string
failureWindow int64
failures int
clients map[string]*http.Client
}
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)
}
}
}
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,168 @@
package node
import (
"crypto/ed25519"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/json"
"encoding/pem"
"math/big"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
)
func testPairing(t *testing.T) (*Pairing, map[string]any) {
t.Helper()
dir := t.TempDir()
store, e := OpenStore(dir)
if e != nil {
t.Fatal(e)
}
p, e := OpenPairing(store, dir, "test", func() Inventory {
return Inventory{Networks: []Network{{Name: "test", Up: true, Addresses: []string{"192.168.10.4/24"}}}}
})
if e != nil {
t.Fatal(e)
}
out, e := p.invite("192.168.10.4")
if e != nil {
t.Fatal(e)
}
raw, e := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(out["code"].(string), "MCN1."))
if e != nil {
t.Fatal(e)
}
var invitation map[string]any
if json.Unmarshal(raw, &invitation) != nil {
t.Fatal("invitation")
}
return p, invitation
}
func testCoreBinding(t *testing.T, p *Pairing) CoreBinding {
t.Helper()
pub, key, e := ed25519.GenerateKey(rand.Reader)
if e != nil {
t.Fatal(e)
}
ca := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test Core"}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature}
der, e := x509.CreateCertificate(rand.Reader, ca, ca, pub, key)
if e != nil {
t.Fatal(e)
}
ca, _ = x509.ParseCertificate(der)
cert := &x509.Certificate{SerialNumber: big.NewInt(2), NotBefore: ca.NotBefore, NotAfter: ca.NotAfter, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}}
leaf, e := x509.CreateCertificate(rand.Reader, cert, ca, p.store.pairingKey().Public(), key)
if e != nil {
t.Fatal(e)
}
return 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}))}
}
func pairCall(p *Pairing, path string, body any) *httptest.ResponseRecorder {
raw, _ := json.Marshal(body)
r := httptest.NewRequest("POST", path, strings.NewReader(string(raw)))
r.RemoteAddr = "192.168.10.5:42000"
r.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
p.remoteHandler().ServeHTTP(w, r)
return w
}
func TestPairingDurableCommitConflictAndReplay(t *testing.T) {
p, i := testPairing(t)
b := testCoreBinding(t, p)
offer := map[string]any{"id": i["id"], "secret": i["secret"], "binding": b}
first := pairCall(p, "/v1/pair/offer", offer)
if first.Code != 200 {
t.Fatal(first.Code, first.Body.String())
}
second := pairCall(p, "/v1/pair/offer", offer)
if second.Code != 200 || first.Body.String() != second.Body.String() {
t.Fatal("retry changed receipt")
}
b.BindingID = token()
offer["binding"] = b
if pairCall(p, "/v1/pair/offer", offer).Code != 409 {
t.Fatal("conflicting owner accepted")
}
// A process restart retains the pending receipt and finishes the same binding.
restored, e := OpenPairing(p.store, strings.TrimSuffix(p.path, "/core-binding.json"), "test", p.inventory)
if e != nil {
t.Fatal(e)
}
commit := map[string]string{"id": restored.state.Binding.BindingID, "receipt": restored.state.Binding.Receipt}
if pairCall(restored, "/v1/pair/commit", commit).Code != 200 {
t.Fatal("commit failed")
}
if pairCall(restored, "/v1/pair/inspect", map[string]any{"id": i["id"], "secret": i["secret"]}).Code != 410 {
t.Fatal("consumed code admitted")
}
if _, e = restored.invite("192.168.10.4"); e == nil {
t.Fatal("paired Node offered another invitation")
}
if restored.state.Invitation != nil {
t.Fatal("consumed invitation retained")
}
raw, _ := json.Marshal(restored.status())
if strings.Contains(string(raw), i["secret"].(string)) || strings.Contains(string(raw), "client_pem") {
t.Fatal("status leaked trust")
}
if e = restored.cancel(); e != nil {
t.Fatal(e)
}
if pairCall(restored, "/v1/pair/commit", commit).Code == 200 {
t.Fatal("cancelled binding resurrected")
}
if len(restored.state.Revocations) != 1 {
t.Fatal("revocation not durable")
}
}
func TestPairingExpiryAndPrivateAddressAdmission(t *testing.T) {
p, i := testPairing(t)
for _, address := range []string{"127.0.0.1", "0.0.0.0", "8.8.8.8", "192.168.10.99", "::1"} {
if _, e := p.invite(address); e == nil {
t.Fatal("nonlocal address accepted", address)
}
}
p.now = func() time.Time { return time.Unix(int64(i["expires_at"].(float64))+1, 0) }
if pairCall(p, "/v1/pair/inspect", map[string]any{"id": i["id"], "secret": i["secret"]}).Code != 410 {
t.Fatal("expired code accepted")
}
if p.state.Phase != "unpaired" {
t.Fatal(p.state.Phase)
}
}
func TestPairingRejectsForeignCertificateAndOpenPermissions(t *testing.T) {
p, _ := testPairing(t)
b := testCoreBinding(t, p)
if _, e := bindingTLS(b, p.store.pairingKey()); e != nil {
t.Fatal(e)
}
b.CoreID = "core_" + strings.Repeat("0", 64)
if _, e := bindingTLS(b, p.store.pairingKey()); e == nil {
t.Fatal("unmatched Core pin")
}
b = testCoreBinding(t, p)
_, other, _ := ed25519.GenerateKey(rand.Reader)
if _, e := bindingTLS(b, other); e == nil {
t.Fatal("foreign Node certificate")
}
os.Chmod(p.path, 0644)
if _, e := OpenPairing(p.store, strings.TrimSuffix(p.path, "/core-binding.json"), "test", p.inventory); e == nil {
t.Fatal("open trust file accepted")
}
}
func TestPairingLocalRoutesRequireOperatorSession(t *testing.T) {
s := newTestServer(t)
p, _ := testPairing(t)
s.Pairing = p
if call(s, "GET", "/api/core", "", nil).Code != 401 {
t.Fatal("unauthenticated access")
}
if call(s, "GET", "/api/core", "", login(t, s)).Code != 200 {
t.Fatal("operator denied")
}
}
@@ -0,0 +1,352 @@
package node
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"net/url"
"time"
)
func (p *Pairing) localRoutes(mux *http.ServeMux, s *Server) {
mux.HandleFunc("GET /api/core", func(w http.ResponseWriter, r *http.Request) {
if s.authorized(w, r) {
reply(w, 200, p.status())
}
})
mux.HandleFunc("POST /api/core/invitation", func(w http.ResponseWriter, r *http.Request) {
if !s.authorized(w, r) {
return
}
var body struct {
Address string `json:"address"`
}
if !decode(w, r, &body) {
return
}
out, e := p.invite(body.Address)
if e != nil {
reply(w, 409, map[string]string{"error": e.Error()})
return
}
reply(w, 200, out)
})
mux.HandleFunc("DELETE /api/core", func(w http.ResponseWriter, r *http.Request) {
if !s.authorized(w, r) {
return
}
if e := p.cancel(); e != nil {
reply(w, 409, map[string]string{"error": "Не удалось отменить привязку. Повторите действие."})
return
}
reply(w, 200, map[string]bool{"ok": true})
})
}
func pairDecode(w http.ResponseWriter, r *http.Request, value any) bool {
if r.Method != "POST" || r.Header.Get("Content-Type") != "application/json" || r.Header.Get("Origin") != "" {
http.Error(w, "Invalid request", 400)
return false
}
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16384))
decoder.DisallowUnknownFields()
if decoder.Decode(value) != nil || decoder.Decode(new(any)) != io.EOF {
http.Error(w, "Invalid request", 400)
return false
}
return true
}
func (p *Pairing) remoteHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
host, _, e := net.SplitHostPort(r.RemoteAddr)
if e != nil || !PrivateAddress(host) {
http.Error(w, "Private peer required", 403)
return
}
var body struct {
ID string `json:"id"`
Secret string `json:"secret"`
Binding *CoreBinding `json:"binding,omitempty"`
Receipt string `json:"receipt,omitempty"`
}
if !pairDecode(w, r, &body) {
return
}
p.mu.Lock()
defer p.mu.Unlock()
if p.expire() != nil {
http.Error(w, "State unavailable", 503)
return
}
id, name := p.store.Public()
switch r.URL.Path {
case "/v1/pair/inspect":
if p.state.Phase != "inviting" || !p.checkInvitation(body.ID, body.Secret) {
http.Error(w, "Invitation expired, consumed or cancelled", 410)
return
}
reply(w, 200, map[string]any{"schema": PairSchema, "node_id": id, "name": name, "version": p.version, "host": p.inventory()})
case "/v1/pair/offer":
if !p.checkInvitation(body.ID, body.Secret) || body.Binding == nil {
http.Error(w, "Invitation expired, consumed or cancelled", 410)
return
}
b := *body.Binding
if len(b.BindingID) != 43 || len(b.CoreName) < 1 || len(b.CoreName) > 128 || len(b.CoreID) != 69 || len(b.CAPEM) > 8192 || len(b.ClientPEM) > 8192 || b.Receipt != "" || b.OfferHash != "" || b.ExpiresAt != 0 {
http.Error(w, "Invalid binding", 400)
return
}
raw, _ := json.Marshal(b)
hash := digest(string(raw))
if p.state.Phase == "pending" || p.state.Phase == "paired" {
if p.state.Binding.OfferHash != hash {
http.Error(w, "Another Core already claimed this Node", 409)
return
}
reply(w, 200, map[string]string{"receipt": p.state.Binding.Receipt, "node_id": id})
return
}
if p.state.Phase != "inviting" {
http.Error(w, "Invitation consumed", 410)
return
}
if _, e = bindingTLS(b, p.store.pairingKey()); e != nil {
http.Error(w, "Invalid Core trust", 400)
return
}
b.Receipt = token()
b.OfferHash = hash
b.ExpiresAt = p.state.Invitation.ExpiresAt
next := p.state
next.Phase = "pending"
next.Binding = &b
if p.save(next) != nil {
http.Error(w, "State unavailable", 503)
return
}
reply(w, 200, map[string]string{"receipt": b.Receipt, "node_id": id})
case "/v1/pair/commit":
b := p.state.Binding
if b == nil || body.ID != b.BindingID || body.Receipt == "" || digest(body.Receipt) != digest(b.Receipt) || (p.state.Phase != "pending" && p.state.Phase != "paired") {
http.Error(w, "No matching pending binding", 409)
return
}
next := p.state
next.Phase = "paired"
next.Invitation = nil
if p.save(next) != nil {
http.Error(w, "State unavailable", 503)
return
}
reply(w, 200, map[string]any{"node_id": id, "binding_id": b.BindingID, "phase": "paired"})
default:
http.NotFound(w, r)
}
})
}
func (p *Pairing) Run(ctx context.Context) {
go p.channel(ctx)
var server *http.Server
endpoint := ""
closeServer := func() {
if server != nil {
timeout, cancel := context.WithTimeout(context.Background(), time.Second)
_ = server.Shutdown(timeout)
cancel()
server = nil
}
endpoint = ""
}
defer closeServer()
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
p.mu.Lock()
_ = p.expire()
desired := ""
if (p.state.Phase == "inviting" || p.state.Phase == "pending") && p.state.Invitation != nil {
desired = p.state.Invitation.Endpoint
}
p.mu.Unlock()
if desired != endpoint {
closeServer()
if desired != "" {
u, _ := url.Parse(desired)
cert, e := bootstrapCertificate(p.store.pairingKey(), u.Hostname())
var listener net.Listener
if e == nil {
listener, e = net.Listen("tcp4", u.Host)
}
p.mu.Lock()
if e != nil {
p.listenError = "Не удалось открыть частное подключение. Проверьте адрес и создайте приглашение повторно."
} else {
p.listenError = ""
}
p.mu.Unlock()
if e == nil {
server = &http.Server{Handler: p.remoteHandler(), ReadHeaderTimeout: 3 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 5 * time.Second, MaxHeaderBytes: 8192, TLSConfig: &tls.Config{MinVersion: tls.VersionTLS13, Certificates: []tls.Certificate{cert}}}
endpoint = desired
go func(s *http.Server, l net.Listener) { _ = s.Serve(tls.NewListener(l, s.TLSConfig)) }(server, &pairingListener{Listener: listener, slots: make(chan struct{}, 16), done: make(chan struct{})})
}
}
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func (p *Pairing) send(ctx context.Context, b CoreBinding, path string, payload any) (map[string]json.RawMessage, int, error) {
config, e := bindingTLS(b, p.store.pairingKey())
if e != nil {
return nil, 0, e
}
cacheKey := b.BindingID + digest(b.ClientPEM)
client := p.clients[cacheKey]
if client == nil {
transport := &http.Transport{TLSClientConfig: config, Proxy: nil, MaxConnsPerHost: 1, MaxIdleConnsPerHost: 1, IdleConnTimeout: 15 * time.Second, TLSHandshakeTimeout: 4 * time.Second, DialContext: (&net.Dialer{Timeout: 4 * time.Second}).DialContext}
client = &http.Client{Transport: transport, Timeout: 8 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return errors.New("redirects forbidden") }}
p.clients[cacheKey] = client
}
data, e := json.Marshal(payload)
if e != nil {
return nil, 0, e
}
request, e := http.NewRequestWithContext(ctx, "POST", b.Endpoint+path, bytes.NewReader(data))
if e != nil {
return nil, 0, e
}
request.Header.Set("Content-Type", "application/json")
response, e := client.Do(request)
if e != nil {
return nil, 0, e
}
defer response.Body.Close()
var out map[string]json.RawMessage
if json.NewDecoder(io.LimitReader(response.Body, 1048576)).Decode(&out) != nil {
return nil, response.StatusCode, errors.New("invalid Core response")
}
return out, response.StatusCode, nil
}
func (p *Pairing) channel(ctx context.Context) {
instance := "agent_" + token()
var changed <-chan struct{}
if p.Sensors != nil {
var unsubscribe func()
changed, unsubscribe = p.Sensors.events.subscribe()
defer unsubscribe()
}
timer := time.NewTicker(5 * time.Second)
defer timer.Stop()
defer func() {
for _, c := range p.clients {
c.CloseIdleConnections()
}
}()
for {
p.mu.Lock()
var binding *CoreBinding
if p.state.Phase == "paired" && p.state.Binding != nil {
copy := *p.state.Binding
binding = &copy
}
revocations := append([]CoreBinding(nil), p.state.Revocations...)
p.mu.Unlock()
wanted := make(map[string]bool)
if binding != nil {
wanted[binding.BindingID+digest(binding.ClientPEM)] = true
}
for _, b := range revocations {
wanted[b.BindingID+digest(b.ClientPEM)] = true
}
for key, c := range p.clients {
if !wanted[key] {
c.CloseIdleConnections()
delete(p.clients, key)
}
}
if binding != nil {
id, name := p.store.Public()
payload := map[string]any{"schema": PairSchema, "binding_id": binding.BindingID, "node_id": id, "name": name, "version": p.version, "execution_binding": map[string]string{"node_id": id, "agent_instance_id": instance, "platform": "linux"}, "host": p.inventory(), "devices": []any{}}
if p.Sensors != nil {
inv := p.Sensors.Inventory()
payload["devices"] = inv["items"]
payload["sensor_state"] = inv
payload["sensor_results"] = p.Sensors.RemoteResults()
}
result, status, e := p.send(ctx, *binding, "/v1/node/heartbeat", payload)
p.mu.Lock()
if p.state.Phase == "paired" && p.state.Binding != nil && p.state.Binding.BindingID == binding.BindingID {
if e == nil && status == 200 {
p.connection = "online"
p.lastSeen = p.now().Unix()
if p.Sensors != nil {
var ack []string
if json.Unmarshal(result["sensor_acknowledgements"], &ack) == nil {
p.Sensors.Acknowledge(ack)
}
var commands []SensorCommand
if json.Unmarshal(result["sensor_commands"], &commands) == nil {
for _, c := range commands {
_, _ = p.Sensors.Submit(c, true)
}
}
}
var cert string
if json.Unmarshal(result["client_pem"], &cert) == nil && cert != "" && cert != binding.ClientPEM {
next := *binding
next.ClientPEM = cert
if _, e := bindingTLS(next, p.store.pairingKey()); e == nil {
state := p.state
state.Binding = &next
_ = p.save(state)
}
}
} else if e == nil && status == 410 {
next := p.state
next.Phase = "revoked"
_ = p.save(next)
p.connection = "revoked"
} else {
p.connection = "offline"
}
}
p.mu.Unlock()
}
for _, b := range revocations {
id, _ := p.store.Public()
_, status, e := p.send(ctx, b, "/v1/node/unpair", map[string]string{"schema": PairSchema, "binding_id": b.BindingID, "node_id": id})
if (e == nil && (status == 200 || status == 410)) || clientExpired(b) {
p.mu.Lock()
next := p.state
next.Revocations = nil
for _, item := range p.state.Revocations {
if item.BindingID != b.BindingID {
next.Revocations = append(next.Revocations, item)
}
}
_ = p.save(next)
p.mu.Unlock()
}
}
select {
case <-ctx.Done():
return
case <-timer.C:
case <-changed:
select {
case <-ctx.Done():
return
case <-time.After(250 * time.Millisecond):
}
}
}
}
@@ -0,0 +1,137 @@
package node
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os/exec"
"strings"
"sync"
"time"
)
// Events are hints to reconcile OS/SDK state, never commands or trusted inventory.
// A bounded latest-state signal prevents a slow viewer from blocking discovery.
type sensorEvents struct {
mu sync.Mutex
listeners map[chan struct{}]bool
}
func (e *sensorEvents) subscribe() (<-chan struct{}, func()) {
e.mu.Lock()
defer e.mu.Unlock()
if e.listeners == nil {
e.listeners = map[chan struct{}]bool{}
}
c := make(chan struct{}, 1)
e.listeners[c] = true
return c, func() { e.mu.Lock(); delete(e.listeners, c); e.mu.Unlock() }
}
func (e *sensorEvents) notify() {
e.mu.Lock()
defer e.mu.Unlock()
for c := range e.listeners {
select {
case c <- struct{}{}:
default:
}
}
}
func usbEvents(input io.Reader, changed func()) {
scanner := bufio.NewScanner(input)
fields := map[string]string{}
for scanner.Scan() {
line := scanner.Text()
if line == "" {
if fields["SUBSYSTEM"] == "usb" && fields["DEVTYPE"] == "usb_device" &&
(fields["ACTION"] == "add" || fields["ACTION"] == "remove" || fields["ACTION"] == "change") {
changed()
}
fields = map[string]string{}
} else if key, value, ok := strings.Cut(line, "="); ok && len(fields) < 128 {
fields[key] = value
}
}
}
func (s *Sensors) WatchUSB(ctx context.Context) {
for ctx.Err() == nil {
cmd := exec.CommandContext(ctx, "/usr/bin/udevadm", "monitor", "--udev", "--subsystem-match=usb", "--property")
cmd.Env = []string{"PATH=/usr/bin:/bin", "LANG=C"}
pipe, err := cmd.StdoutPipe()
if err == nil && cmd.Start() == nil {
usbEvents(pipe, s.events.notify)
_ = cmd.Wait()
}
// A failed monitor cannot disable the existing heartbeat reconciliation.
select {
case <-ctx.Done():
return
case <-time.After(30 * time.Second):
}
}
}
func (s *Sensors) stream(w http.ResponseWriter, r *http.Request, server *Server) {
if !server.authorized(w, r) {
return
}
if _, ok := w.(http.Flusher); !ok {
http.Error(w, "Streaming unavailable", 503)
return
}
changed, unsubscribe := s.events.subscribe()
defer unsubscribe()
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("X-Accel-Buffering", "no")
controller := http.NewResponseController(w)
timer := time.NewTimer(0)
defer timer.Stop()
for {
select {
case <-r.Context().Done():
return
case <-changed:
// USB devices expose several interfaces; coalesce the enumeration burst.
select {
case <-r.Context().Done():
return
case <-time.After(250 * time.Millisecond):
}
case <-timer.C:
}
if !server.authorized(w, r) {
return
}
value := s.Inventory()
data, err := json.Marshal(value)
if err != nil {
return
}
_ = controller.SetWriteDeadline(time.Now().Add(10 * time.Second))
if _, err = fmt.Fprintf(w, "retry: 3000\ndata: %s\n\n", data); err != nil {
return
}
if controller.Flush() != nil {
return
}
delay := 15 * time.Second
for _, raw := range value["operations"].([]any) {
if raw.(map[string]any)["state"] == "running" {
delay = 2 * time.Second
break
}
}
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
timer.Reset(delay)
}
}
@@ -0,0 +1,36 @@
package node
import (
"strings"
"testing"
)
func TestUSBEventsAreHintsAndDoNotMultiplyInterfaces(t *testing.T) {
input := "UDEV [1] add /devices/example (usb)\nACTION=add\nSUBSYSTEM=usb\nDEVTYPE=usb_device\n\n" +
"ACTION=add\nSUBSYSTEM=usb\nDEVTYPE=usb_interface\n\n" +
"ACTION=remove\nSUBSYSTEM=usb\nDEVTYPE=usb_device\n\n" +
"ACTION=add\nSUBSYSTEM=net\nDEVTYPE=usb_device\n\n"
e := sensorEvents{}
first, closeFirst := e.subscribe()
second, closeSecond := e.subscribe()
defer closeSecond()
count := 0
usbEvents(strings.NewReader(input), func() { count++; e.notify() })
if count != 2 || len(first) != 1 || len(second) != 1 {
t.Fatalf("events=%d first=%d second=%d", count, len(first), len(second))
}
<-first
closeFirst()
e.notify()
if len(first) != 0 {
t.Fatal("closed viewer still receives events")
}
}
func TestSensorEventStreamRequiresLocalSession(t *testing.T) {
s := newTestServer(t)
s.Sensors, _ = OpenSensors(t.TempDir(), "node_test")
if response := call(s, "GET", "/api/devices/events", "", nil); response.Code != 401 {
t.Fatal("unauthenticated device stream", response.Code)
}
}
+493
View File
@@ -0,0 +1,493 @@
package node
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
)
const SensorSchema = "missioncore.nodedc/plugin-sdk/v0alpha2"
type SensorSession struct {
SessionID string `json:"session_id"`
DeviceID string `json:"device_id"`
}
type SensorCommand struct {
APIVersion string `json:"api_version"`
Kind string `json:"kind"`
ID string `json:"operation_id"`
Session SensorSession `json:"session"`
Action string `json:"action_id"`
Requested string `json:"requested_at"`
Deadline string `json:"deadline_at"`
Idempotency string `json:"idempotency_key"`
Parameters map[string]any `json:"parameters"`
}
type SensorOperation struct {
Command SensorCommand `json:"command"`
State string `json:"state"`
Error string `json:"error,omitempty"`
Result any `json:"result,omitempty"`
Remote bool `json:"remote,omitempty"`
Updated int64 `json:"updated_at"`
}
type Sensors struct {
events sensorEvents
mu sync.Mutex
prepareMu sync.Mutex
root string
nodeID string
instance string
client *http.Client
operations map[string]*SensorOperation
names map[string]string
initialized map[string]bool
}
var sensorID = regexp.MustCompile(`^rsd455_[0-9a-f]{32}$`)
var operationID = regexp.MustCompile(`^op_[0-9a-f]{32}$`)
func OpenSensors(root, nodeID string) (*Sensors, error) {
dir := filepath.Join(root, "sensors")
if e := os.MkdirAll(dir, 0700); e != nil {
return nil, e
}
s := &Sensors{root: dir, nodeID: nodeID, instance: "discovery_" + digest(token())[:24], operations: map[string]*SensorOperation{}, names: map[string]string{}, initialized: map[string]bool{}}
s.client = &http.Client{Timeout: 25 * time.Second, Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", "/run/mission-core-sensors/driver.sock")
}}}
files, _ := filepath.Glob(filepath.Join(dir, "op_*.json"))
for _, p := range files {
data, e := os.ReadFile(p)
if e != nil {
return nil, e
}
var v SensorOperation
if json.Unmarshal(data, &v) != nil {
return nil, errors.New("invalid sensor operation journal")
}
if v.State == "running" {
v.State = "unknown"
v.Error = "Результат операции неизвестен после перезапуска. Проверьте состояние устройства."
}
s.operations[v.Command.ID] = &v
}
data, _ := os.ReadFile(filepath.Join(dir, "names.json"))
_ = json.Unmarshal(data, &s.names)
data, _ = os.ReadFile(filepath.Join(dir, "initialized.json"))
_ = json.Unmarshal(data, &s.initialized)
if s.initialized == nil {
s.initialized = map[string]bool{}
}
for _, op := range s.operations {
if op.Command.Action == "prepare" && op.State == "complete" {
s.initialized[op.Command.Session.DeviceID] = true
}
}
if e := s.write("initialized.json", s.initialized); e != nil {
return nil, e
}
return s, nil
}
func (s *Sensors) write(name string, value any) error {
data, e := json.Marshal(value)
if e != nil {
return e
}
f, e := os.CreateTemp(s.root, ".sensor-")
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
}
f.Close()
if e = os.Rename(f.Name(), filepath.Join(s.root, name)); e != nil {
return e
}
d, e := os.Open(s.root)
if e != nil {
return e
}
defer d.Close()
return d.Sync()
}
func (s *Sensors) driver(path string, body any) (map[string]any, error) {
method := "GET"
var reader io.Reader
if body != nil {
method = "POST"
data, e := json.Marshal(body)
if e != nil {
return nil, e
}
reader = bytes.NewReader(data)
}
req, e := http.NewRequest(method, "http://driver"+path, reader)
if e != nil {
return nil, e
}
req.Header.Set("X-Node-Id", s.nodeID)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
response, e := s.client.Do(req)
if e != nil {
return nil, errors.New("Служба камеры недоступна. Подготовьте устройство.")
}
defer response.Body.Close()
var result map[string]any
if json.NewDecoder(io.LimitReader(response.Body, 1024*1024)).Decode(&result) != nil {
return nil, errors.New("Не удалось прочитать результат драйвера.")
}
if response.StatusCode != 200 {
message, _ := result["error"].(string)
return nil, errors.New(message)
}
return result, nil
}
func (s *Sensors) Inventory() map[string]any {
items := []any{}
seen := map[string]bool{}
if result, e := s.driver("/inventory", nil); e == nil {
if found, ok := result["items"].([]any); ok {
for _, v := range found {
item, ok := v.(map[string]any)
if !ok {
continue
}
id, _ := item["id"].(string)
seen[id] = true
s.mu.Lock()
item["configured"] = s.initialized[id]
if n := s.names[id]; n != "" {
item["name"] = n
}
s.mu.Unlock()
items = append(items, item)
}
}
}
paths, _ := filepath.Glob("/sys/bus/usb/devices/*")
for _, path := range paths {
read := func(n string) string {
b, _ := os.ReadFile(filepath.Join(path, n))
return strings.TrimSpace(string(b))
}
if read("idVendor") != "8086" || read("idProduct") != "0b5c" {
continue
}
serial := read("serial")
if serial == "" {
continue
}
h := sha256.Sum256([]byte(serial))
id := "rsd455_" + hex.EncodeToString(h[:])[:32]
if seen[id] {
continue
}
seen[id] = true
items = append(items, s.discovery(id, read("speed")+" Мбит/с", true))
}
s.mu.Lock()
configured := []string{}
for id, ready := range s.initialized {
if ready && sensorID.MatchString(id) && !seen[id] {
configured = append(configured, id)
}
}
s.mu.Unlock()
for _, id := range configured {
items = append(items, s.discovery(id, "—", false))
}
var preparation any
if data, e := os.ReadFile("/var/lib/mission-core-node-drivers/preparation.json"); e == nil && len(data) < 32768 {
_ = json.Unmarshal(data, &preparation)
}
s.mu.Lock()
operations := []any{}
for _, v := range s.operations {
if time.Now().Unix()-v.Updated < 600 {
operations = append(operations, map[string]any{"operation_id": v.Command.ID, "device_id": v.Command.Session.DeviceID, "action_id": v.Command.Action, "requested_at": v.Command.Requested, "state": v.State, "error": v.Error})
}
}
s.mu.Unlock()
return map[string]any{"schema": "missioncore.node.devices/v1", "items": items, "preparation": preparation, "operations": operations}
}
func (s *Sensors) discovery(id, speed string, online bool) map[string]any {
now := time.Now().UTC().Format(time.RFC3339Nano)
s.mu.Lock()
name, configured := s.names[id], s.initialized[id]
s.mu.Unlock()
if name == "" {
name = "RealSense D455"
}
connectivity, enrollment := "offline", "empty"
if online {
connectivity = "connected"
}
if configured {
enrollment = "enrolled"
}
return map[string]any{"id": id, "name": name, "model": "RealSense D455", "configured": configured, "prepared": false, "verified": false, "online": online, "usb": speed, "layers": []any{}, "snapshot": map[string]any{
"context": map[string]any{"session_id": s.instance + "_" + id, "device": map[string]any{"device_id": id, "model": map[string]string{"plugin_id": "missioncore.realsense", "plugin_version": "0.6.6", "model_id": "realsense.d455"}, "stability": "stable", "basis": "hardware-identifier"}, "execution": map[string]string{"node_id": s.nodeID, "agent_instance_id": s.instance, "platform": "linux"}, "opened_at": now},
"revision": 0, "enrollment": enrollment, "connectivity": connectivity, "acquisition": "idle", "observed_at": now}}
}
func sensorViewAction(action string) bool {
return action == "details" || action == "offer" || action == "close-peer"
}
func (s *Sensors) Submit(c SensorCommand, remote bool) (*SensorOperation, error) {
if c.APIVersion != SensorSchema || c.Kind != "OperationRequest" || !operationID.MatchString(c.ID) || c.Idempotency != c.ID || !sensorID.MatchString(c.Session.DeviceID) || len(c.Session.SessionID) > 192 {
return nil, errors.New("Некорректная команда устройства.")
}
if !map[string]bool{"prepare": true, "details": true, "rename": true, "verify": true, "start": true, "replay": true, "stop": true, "option": true, "offer": true, "close-peer": true}[c.Action] {
return nil, errors.New("Операция не поддерживается.")
}
deadline, e := time.Parse(time.RFC3339Nano, c.Deadline)
requested, e2 := time.Parse(time.RFC3339Nano, c.Requested)
if e != nil || e2 != nil || !deadline.After(requested) || deadline.Sub(requested) > 6*time.Minute {
return nil, errors.New("Некорректный срок команды.")
}
s.mu.Lock()
defer s.mu.Unlock()
if old := s.operations[c.ID]; old != nil {
a, _ := json.Marshal(old.Command)
b, _ := json.Marshal(c)
if !bytes.Equal(a, b) {
return nil, errors.New("Идентификатор операции уже использован.")
}
copy := *old
return &copy, nil
}
if !deadline.After(time.Now()) {
return nil, errors.New("Срок команды истёк. Устройство не изменено.")
}
for _, v := range s.operations {
if v.State == "running" && v.Command.Action == "prepare" {
return nil, errors.New("Подготовка модели ещё выполняется.")
}
if v.State == "running" && v.Command.Session.DeviceID == c.Session.DeviceID && !sensorViewAction(c.Action) && !sensorViewAction(v.Command.Action) {
return nil, errors.New("Другая операция устройства ещё выполняется.")
}
}
if len(s.operations) > 2000 {
for id, v := range s.operations {
if v.State != "running" && time.Now().Unix()-v.Updated > 86400 {
delete(s.operations, id)
os.Remove(filepath.Join(s.root, id+".json"))
}
}
}
if len(s.operations) > 2000 {
return nil, errors.New("Журнал операций заполнен. Повторите позже.")
}
value := &SensorOperation{Command: c, State: "running", Remote: remote, Updated: time.Now().Unix()}
if e = s.write(c.ID+".json", value); e != nil {
return nil, e
}
s.operations[c.ID] = value
copy := *value
s.events.notify()
go s.execute(c)
return &copy, nil
}
func (s *Sensors) execute(c SensorCommand) {
defer s.events.notify()
var result any
var err error
uncertain := false
inv := s.Inventory()
var item map[string]any
for _, v := range inv["items"].([]any) {
i := v.(map[string]any)
if i["id"] == c.Session.DeviceID {
item = i
break
}
}
if item == nil {
err = errors.New("Камера не обнаружена. Проверьте подключение.")
} else if c.Action == "prepare" {
result, err = s.prepare(c)
} else if c.Action == "rename" {
name, ok := c.Parameters["name"].(string)
if !ok || strings.TrimSpace(name) == "" || len([]rune(name)) > 80 || strings.ContainsAny(name, "\n\r\t") {
err = errors.New("Введите название до 80 символов.")
} else {
s.mu.Lock()
s.names[c.Session.DeviceID] = strings.TrimSpace(name)
err = s.write("names.json", s.names)
s.mu.Unlock()
result = map[string]bool{"ok": err == nil}
}
} else {
var v map[string]any
v, err = s.driver("/operation", c)
uncertain = err != nil || v["state"] == "unknown"
if err == nil {
if v["state"] == "complete" {
result = v["result"]
} else {
message, _ := v["error"].(string)
err = errors.New(message)
}
}
}
s.mu.Lock()
defer s.mu.Unlock()
if err == nil && c.Action == "prepare" {
s.initialized[c.Session.DeviceID] = true
if e := s.write("initialized.json", s.initialized); e != nil {
err = e
uncertain = true
}
}
v := s.operations[c.ID]
v.Updated = time.Now().Unix()
if err != nil {
v.State = "error"
if uncertain {
v.State = "unknown"
}
v.Error = err.Error()
} else {
v.State = "complete"
v.Result = result
}
if s.write(c.ID+".json", v) != nil {
v.State = "unknown"
v.Error = "Не удалось сохранить результат операции. Обновите состояние устройства."
}
}
func (s *Sensors) prepare(c SensorCommand) (any, error) {
s.prepareMu.Lock()
defer s.prepareMu.Unlock()
for _, raw := range s.Inventory()["items"].([]any) {
item := raw.(map[string]any)
snap := item["snapshot"].(map[string]any)
if state := snap["acquisition"]; state != "idle" && state != "failed" {
return nil, errors.New("Остановите захват камер перед подготовкой модели.")
}
}
ctx, cancel := context.WithTimeout(context.Background(), 310*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "/usr/bin/systemctl", "start", "mission-core-node-realsense-prepare.service")
cmd.Env = []string{"PATH=/usr/bin:/bin", "LANG=C", "DBUS_SYSTEM_BUS_ADDRESS=unix:path=/run/dbus/system_bus_socket"}
if cmd.Run() != nil {
return nil, errors.New("Подготовка драйвера не завершена. Проверьте этапы и повторите действие.")
}
for i := 0; i < 12; i++ {
inv := s.Inventory()
for _, v := range inv["items"].([]any) {
item := v.(map[string]any)
if item["id"] == c.Session.DeviceID && item["prepared"] == true {
snap := item["snapshot"].(map[string]any)
sc := snap["context"].(map[string]any)
verify := c
verify.Action = "verify"
verify.Session.SessionID = sc["session_id"].(string)
result, e := s.driver("/operation", verify)
if e != nil {
return nil, e
}
if result["state"] != "complete" {
message, _ := result["error"].(string)
return nil, errors.New(message)
}
return result["result"], nil
}
}
time.Sleep(time.Second)
}
return nil, errors.New("Драйвер установлен, но камера не открылась. Проверьте USB-подключение.")
}
func (s *Sensors) Get(id string) *SensorOperation {
s.mu.Lock()
defer s.mu.Unlock()
if v := s.operations[id]; v != nil {
copy := *v
return &copy
}
return nil
}
func (s *Sensors) RemoteResults() []any {
s.mu.Lock()
defer s.mu.Unlock()
out := []any{}
for _, v := range s.operations {
if v.Remote && time.Now().Unix()-v.Updated < 600 {
copy := *v
out = append(out, copy)
}
}
return out
}
func (s *Sensors) Routes(mux *http.ServeMux, server *Server) {
mux.HandleFunc("GET /api/devices/events", func(w http.ResponseWriter, r *http.Request) { s.stream(w, r, server) })
mux.HandleFunc("GET /api/devices", func(w http.ResponseWriter, r *http.Request) {
if server.authorized(w, r) {
reply(w, 200, s.Inventory())
}
})
mux.HandleFunc("POST /api/devices/operations", func(w http.ResponseWriter, r *http.Request) {
if !server.authorized(w, r) {
return
}
var c SensorCommand
r.Body = http.MaxBytesReader(w, r.Body, 65536)
if r.Header.Get("Content-Type") != "application/json" || json.NewDecoder(r.Body).Decode(&c) != nil {
reply(w, 400, map[string]string{"error": "Некорректная команда"})
return
}
v, e := s.Submit(c, false)
if e != nil {
reply(w, 409, map[string]string{"error": e.Error()})
return
}
reply(w, 202, v)
})
mux.HandleFunc("GET /api/devices/operations/{id}", func(w http.ResponseWriter, r *http.Request) {
if !server.authorized(w, r) {
return
}
v := s.Get(r.PathValue("id"))
if v == nil {
reply(w, 404, map[string]string{"error": "Операция не найдена"})
return
}
reply(w, 200, v)
})
}
func (s *Sensors) Acknowledge(ids []string) {
s.mu.Lock()
defer s.mu.Unlock()
for _, id := range ids {
if v := s.operations[id]; v != nil && v.State != "running" {
v.Remote = false
_ = s.write(id+".json", v)
}
}
}
@@ -0,0 +1,111 @@
package node
import (
"errors"
"io"
"net/http"
"strings"
"testing"
"time"
)
func sensorTestCommand() SensorCommand {
now := time.Now()
id := "op_01234567890123456789012345678901"
return SensorCommand{APIVersion: SensorSchema, Kind: "OperationRequest", ID: id, Idempotency: id, Session: SensorSession{SessionID: "session_test", DeviceID: "rsd455_01234567890123456789012345678901"}, Action: "start", Requested: now.UTC().Format(time.RFC3339Nano), Deadline: now.Add(time.Minute).UTC().Format(time.RFC3339Nano), Parameters: map[string]any{}}
}
func TestSensorRejectsAuthorityAndExpiredRequests(t *testing.T) {
s, e := OpenSensors(t.TempDir(), "node_test")
if e != nil {
t.Fatal(e)
}
c := sensorTestCommand()
c.Action = "shell"
if _, e = s.Submit(c, false); e == nil {
t.Fatal("arbitrary action admitted")
}
c = sensorTestCommand()
c.ID = "../../owned"
if _, e = s.Submit(c, false); e == nil {
t.Fatal("path admitted")
}
c = sensorTestCommand()
c.Requested = time.Now().Add(-2 * time.Minute).Format(time.RFC3339Nano)
c.Deadline = time.Now().Add(-time.Minute).Format(time.RFC3339Nano)
if _, e = s.Submit(c, false); e == nil {
t.Fatal("expired command admitted")
}
}
func TestSensorUncertainCrashDoesNotReplay(t *testing.T) {
root := t.TempDir()
s, _ := OpenSensors(root, "node_test")
c := sensorTestCommand()
old := &SensorOperation{Command: c, State: "running", Updated: time.Now().Unix()}
if e := s.write(c.ID+".json", old); e != nil {
t.Fatal(e)
}
s, e := OpenSensors(root, "node_test")
if e != nil {
t.Fatal(e)
}
v, e := s.Submit(c, false)
if e != nil || v.State != "unknown" {
t.Fatalf("replay: %+v %v", v, e)
}
c.Parameters = map[string]any{"record": true}
if _, e = s.Submit(c, false); e == nil {
t.Fatal("id collision did not reject different command")
}
}
type sensorRoundTrip func(*http.Request) (*http.Response, error)
func (f sensorRoundTrip) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func TestSensorPreservesUncertainDriverOutcome(t *testing.T) {
for _, response := range []string{`{"state":"unknown","error":"uncertain"}`, "transport-failure"} {
t.Run(response, func(t *testing.T) {
s, _ := OpenSensors(t.TempDir(), "node_test")
c := sensorTestCommand()
s.operations[c.ID] = &SensorOperation{Command: c, State: "running"}
s.client = &http.Client{Transport: sensorRoundTrip(func(r *http.Request) (*http.Response, error) {
body := `{"items":[{"id":"` + c.Session.DeviceID + `"}]}`
if r.URL.Path == "/operation" {
if response == "transport-failure" {
return nil, errors.New("connection lost")
}
body = response
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: http.Header{}}, nil
})}
s.execute(c)
if s.Get(c.ID).State != "unknown" {
t.Fatal("uncertain hardware effect reported as definite failure")
}
})
}
}
func TestSensorConfiguredIdentitySurvivesRestartAndDisconnect(t *testing.T) {
root := t.TempDir()
s, _ := OpenSensors(root, "node_test")
c := sensorTestCommand()
s.initialized[c.Session.DeviceID] = true
if e := s.write("initialized.json", s.initialized); e != nil {
t.Fatal(e)
}
s, e := OpenSensors(root, "node_test")
if e != nil {
t.Fatal(e)
}
s.client = &http.Client{Transport: sensorRoundTrip(func(r *http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"items":[]}`)), Header: http.Header{}}, nil
})}
items := s.Inventory()["items"].([]any)
if len(items) != 1 {
t.Fatalf("configured camera disappeared: %d", len(items))
}
item := items[0].(map[string]any)
if item["id"] != c.Session.DeviceID || item["online"] != false || item["configured"] != true {
t.Fatalf("incorrect offline identity: %+v", item)
}
}
+250
View File
@@ -0,0 +1,250 @@
package node
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"io"
"io/fs"
"net/http"
"strings"
"sync"
"time"
)
type Server struct {
Store *Store
Pairing *Pairing
Sensors *Sensors
Assets fs.FS
Origin string
Version string
Inventory func() Inventory
Access *AccessStore
Tailscale func() TailscaleStatus
Environment func() EnvironmentStatus
mu sync.Mutex
logins map[string]time.Time
sessions map[string]time.Time
Now func() time.Time
}
func token() string {
b := make([]byte, 32)
if _, e := rand.Read(b); e != nil {
panic(e)
}
return base64.RawURLEncoding.EncodeToString(b)
}
func (s *Server) now() time.Time {
if s.Now != nil {
return s.Now()
}
return time.Now()
}
func prune(m map[string]time.Time, now time.Time) {
for k, v := range m {
if !v.After(now) {
delete(m, k)
}
}
}
// IssueLogin is reachable through the private Unix socket, never the web API.
// OS authentication belongs to the fixed polkit launcher, not a web password.
func (s *Server) IssueLogin() string {
s.mu.Lock()
defer s.mu.Unlock()
if s.logins == nil {
s.logins = make(map[string]time.Time)
}
prune(s.logins, s.now())
// Cap abandoned desktop launches; newest launches supersede the oldest.
if len(s.logins) >= 16 {
for k := range s.logins {
delete(s.logins, k)
break
}
}
t := token()
s.logins[t] = s.now().Add(time.Minute)
return s.Origin + "/#login=" + t
}
func reply(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
if s.Sensors != nil {
s.Sensors.Routes(mux, s)
}
if s.Pairing != nil {
s.Pairing.localRoutes(mux, s)
}
if s.Access != nil {
s.accessRoutes(mux)
}
mux.HandleFunc("POST /api/session", s.login)
mux.HandleFunc("GET /api/environment", func(w http.ResponseWriter, r *http.Request) {
if !s.authorized(w, r) {
return
}
read := s.Environment
if read == nil {
read = ReadEnvironment
}
reply(w, 200, read())
})
mux.HandleFunc("GET /api/network/tailscale", func(w http.ResponseWriter, r *http.Request) {
if !s.authorized(w, r) {
return
}
probe := s.Tailscale
if probe == nil {
probe = ReadTailscale
}
reply(w, 200, probe())
})
mux.HandleFunc("POST /api/logout", func(w http.ResponseWriter, r *http.Request) {
if !s.authorized(w, r) {
return
}
c, _ := r.Cookie("mc_node")
s.mu.Lock()
delete(s.sessions, c.Value)
s.mu.Unlock()
http.SetCookie(w, &http.Cookie{Name: "mc_node", Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteStrictMode})
reply(w, 200, map[string]bool{"ok": true})
})
mux.HandleFunc("GET /api/status", func(w http.ResponseWriter, r *http.Request) {
if !s.authorized(w, r) {
return
}
id, name := s.Store.Public()
reply(w, 200, map[string]any{"version": s.Version, "node_id": id, "name": name, "host": s.Inventory()})
})
mux.HandleFunc("PUT /api/name", func(w http.ResponseWriter, r *http.Request) {
if !s.authorized(w, r) {
return
}
var body struct {
Name string `json:"name"`
}
if !decode(w, r, &body) {
return
}
if err := s.Store.Rename(body.Name); err != nil {
reply(w, 400, map[string]string{"error": err.Error()})
return
}
reply(w, 200, map[string]bool{"ok": true})
})
mux.HandleFunc("GET /api/report", func(w http.ResponseWriter, r *http.Request) {
if !s.authorized(w, r) {
return
}
v := s.Inventory()
// Export is deliberately redacted even though the authenticated UI shows LAN addresses.
v.Hostname = "[redacted]"
for i := range v.Networks {
v.Networks[i].Addresses = []string{}
}
w.Header().Set("Content-Disposition", `attachment; filename="mission-core-node-report.json"`)
reply(w, 200, map[string]any{"schema": "missioncore.node.inventory-report/v1", "version": s.Version, "host": v})
})
mux.Handle("GET /", http.FileServerFS(s.Assets))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
if "http://"+r.Host != s.Origin {
http.Error(w, "Invalid host", http.StatusForbidden)
return
}
if origin := r.Header.Get("Origin"); origin != "" && origin != s.Origin {
http.Error(w, "Invalid origin", http.StatusForbidden)
return
}
if site := r.Header.Get("Sec-Fetch-Site"); site != "" && site != "same-origin" && site != "none" {
http.Error(w, "Cross-site request denied", http.StatusForbidden)
return
}
if r.Method != "GET" && r.Method != "HEAD" && r.Header.Get("Origin") != s.Origin {
http.Error(w, "Origin required", http.StatusForbidden)
return
}
mux.ServeHTTP(w, r)
})
}
func decode(w http.ResponseWriter, r *http.Request, v any) bool {
if r.Header.Get("Content-Type") != "application/json" {
reply(w, 415, map[string]string{"error": "Ожидался JSON"})
return false
}
d := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096))
d.DisallowUnknownFields()
if err := d.Decode(v); err != nil {
reply(w, 400, map[string]string{"error": "Некорректный запрос"})
return false
}
if err := d.Decode(new(any)); err != io.EOF {
reply(w, 400, map[string]string{"error": "Некорректный запрос"})
return false
}
return true
}
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
var body struct {
Token string `json:"token"`
}
if !decode(w, r, &body) {
return
}
s.mu.Lock()
defer s.mu.Unlock()
prune(s.logins, s.now())
_, ok := s.logins[body.Token]
delete(s.logins, body.Token)
if !ok {
reply(w, 401, map[string]string{"error": "Повторно откройте приложение через меню приложений"})
return
}
if s.sessions == nil {
s.sessions = make(map[string]time.Time)
}
prune(s.sessions, s.now())
if len(s.sessions) >= 32 {
for k := range s.sessions {
delete(s.sessions, k)
break
}
}
t := token()
s.sessions[t] = s.now().Add(8 * time.Hour)
// Loopback HTTP is intentionally local-only; never expose this cookie on LAN.
http.SetCookie(w, &http.Cookie{Name: "mc_node", Value: t, Path: "/", HttpOnly: true, SameSite: http.SameSiteStrictMode, MaxAge: 28800})
reply(w, 200, map[string]bool{"ok": true})
}
func (s *Server) authorized(w http.ResponseWriter, r *http.Request) bool {
c, err := r.Cookie("mc_node")
if err != nil || strings.TrimSpace(c.Value) == "" {
reply(w, 401, map[string]string{"error": "Откройте Mission Core Node через меню приложений"})
return false
}
s.mu.Lock()
defer s.mu.Unlock()
prune(s.sessions, s.now())
if _, ok := s.sessions[c.Value]; !ok {
reply(w, 401, map[string]string{"error": "Сеанс завершён. Откройте приложение через меню приложений"})
return false
}
return true
}
+122
View File
@@ -0,0 +1,122 @@
package node
import (
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"sync"
"unicode"
"unicode/utf8"
)
type State struct {
Version int `json:"version"`
PrivateKey []byte `json:"private_key"`
Name string `json:"name"`
}
type Store struct {
mu sync.Mutex
path string
state State
}
func OpenStore(dir string) (*Store, error) {
if err := os.MkdirAll(dir, 0700); err != nil {
return nil, err
}
s := &Store{path: filepath.Join(dir, "identity.json")}
b, err := os.ReadFile(s.path)
if err == nil {
if err = json.Unmarshal(b, &s.state); err != nil {
return nil, errors.New("invalid identity; recovery required")
}
if s.state.Version != 1 || len(s.state.PrivateKey) != ed25519.PrivateKeySize {
return nil, errors.New("unsupported identity; recovery required")
}
derived := ed25519.NewKeyFromSeed(s.state.PrivateKey[:ed25519.SeedSize])
if !equalKey(derived, s.state.PrivateKey) {
return nil, errors.New("corrupt identity; recovery required")
}
if info, e := os.Stat(s.path); e != nil || info.Mode().Perm()&0077 != 0 {
return nil, errors.New("identity permissions must be private")
}
return s, nil
}
if !errors.Is(err, os.ErrNotExist) {
return nil, err
}
_, key, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, err
}
s.state = State{Version: 1, PrivateKey: key, Name: "Моя нода"}
if err := s.write(s.state); err != nil {
return nil, err
}
return s, nil
}
func equalKey(a, b []byte) bool { return string(a) == string(b) }
func (s *Store) write(state State) error {
b, err := json.Marshal(state)
if err != nil {
return err
}
f, err := os.CreateTemp(filepath.Dir(s.path), ".identity-*")
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
}
if err = os.Rename(f.Name(), s.path); err != nil {
return err
}
d, err := os.Open(filepath.Dir(s.path))
if err != nil {
return err
}
defer d.Close()
return d.Sync()
}
func (s *Store) Public() (string, string) {
s.mu.Lock()
defer s.mu.Unlock()
pub := ed25519.PrivateKey(s.state.PrivateKey).Public().(ed25519.PublicKey)
hash := sha256.Sum256(pub)
return "node_" + hex.EncodeToString(hash[:]), s.state.Name
}
func (s *Store) Rename(name string) error {
name = strings.TrimSpace(name)
if name == "" || !utf8.ValidString(name) || utf8.RuneCountInString(name) > 64 || strings.ContainsFunc(name, unicode.IsControl) {
return errors.New("Название должно содержать от 1 до 64 символов без управляющих знаков")
}
s.mu.Lock()
defer s.mu.Unlock()
next := s.state
next.Name = name
if err := s.write(next); err != nil {
return errors.New("Не удалось сохранить название")
}
s.state = next
return nil
}
@@ -0,0 +1,76 @@
package node
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/netip"
"os"
"os/exec"
"time"
)
type TailscaleStatus struct {
Installed bool `json:"installed"`
State string `json:"state"`
Online bool `json:"online"`
Addresses []string `json:"addresses"`
}
type boundedProviderOutput struct{ bytes.Buffer }
func (b *boundedProviderOutput) Write(data []byte) (int, error) {
if b.Len()+len(data) > 1024*1024 {
return 0, errors.New("provider status too large")
}
return b.Buffer.Write(data)
}
func ReadTailscale() TailscaleStatus {
value := TailscaleStatus{State: "not_installed", Addresses: []string{}}
if _, err := os.Stat("/usr/bin/tailscale"); err != nil {
return value
}
value.Installed = true
value.State = "unavailable"
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "/usr/bin/tailscale", "status", "--json", "--peers=false")
// Do not request peer inventory or expose auth URLs, user identities, keys,
// provider diagnostics or profile objects in the product API.
var output boundedProviderOutput
cmd.Stdout = &output
if err := cmd.Run(); err != nil {
return value
}
return parseTailscale(output.Bytes())
}
func parseTailscale(data []byte) TailscaleStatus {
value := TailscaleStatus{Installed: true, State: "unavailable", Addresses: []string{}}
if len(data) > 1024*1024 {
return value
}
var raw struct {
BackendState string
TailscaleIPs []string
Self *struct{ Online bool }
}
if json.Unmarshal(data, &raw) != nil {
return value
}
switch raw.BackendState {
case "Running", "Stopped", "NeedsLogin", "NeedsMachineAuth", "Starting", "NoState":
value.State = raw.BackendState
default:
return value
}
value.Online = raw.BackendState == "Running" && raw.Self != nil && raw.Self.Online
for _, address := range raw.TailscaleIPs {
if ip, err := netip.ParseAddr(address); err == nil {
value.Addresses = append(value.Addresses, ip.String())
}
}
return value
}
@@ -0,0 +1,60 @@
package node
import (
"encoding/json"
"strings"
"testing"
)
func TestTailscaleDoesNotExposeProviderCredentialsOrPeers(t *testing.T) {
status := parseTailscale([]byte(`{"BackendState":"Running","TailscaleIPs":["100.64.0.10","invalid"],"Self":{"Online":true,"PublicKey":"synthetic-key"},"AuthURL":"https://login.tailscale.com/a/synthetic","User":{"1":{"LoginName":"synthetic@example.test"}},"Peer":{"synthetic":{"HostName":"another-computer"}}}`))
if !status.Online || status.State != "Running" || len(status.Addresses) != 1 {
t.Fatalf("wrong connection status: %+v", status)
}
encoded, _ := json.Marshal(status)
for _, forbidden := range []string{"synthetic", "AuthURL", "User", "Peer", "PublicKey"} {
if strings.Contains(string(encoded), forbidden) {
t.Fatalf("provider data leaked: %s", forbidden)
}
}
}
func TestTailscaleDoesNotClaimUnknownOrOfflineConnection(t *testing.T) {
for _, input := range []string{`{`, `null`, `{}`, `{"BackendState":"FutureState","Self":{"Online":true}}`} {
got := parseTailscale([]byte(input))
if got.Online || got.State != "unavailable" {
t.Fatalf("unknown state was accepted: %+v", got)
}
}
for _, state := range []string{"Stopped", "NeedsLogin", "NeedsMachineAuth", "Starting", "NoState"} {
got := parseTailscale([]byte(`{"BackendState":"` + state + `","Self":{"Online":true}}`))
if got.Online || got.State != state {
t.Fatalf("not connected: %+v", got)
}
}
if parseTailscale([]byte(`{"BackendState":"Running","Self":{"Online":false}}`)).Online {
t.Fatal("offline peer shown as connected")
}
}
func TestProviderOutputIsBounded(t *testing.T) {
var buffer boundedProviderOutput
if _, err := buffer.Write(make([]byte, 1024*1024+1)); err == nil || buffer.Len() != 0 {
t.Fatal("oversized provider output accepted")
}
}
func TestTailscaleStatusRequiresLocalLoginBeforeProbe(t *testing.T) {
s := newTestServer(t)
probes := 0
s.Tailscale = func() TailscaleStatus {
probes++
return TailscaleStatus{State: "not_installed", Addresses: []string{}}
}
if call(s, "GET", "/api/network/tailscale", "", nil).Code != 401 || probes != 0 {
t.Fatal("unauthenticated provider probe")
}
if call(s, "GET", "/api/network/tailscale", "", login(t, s)).Code != 200 || probes != 1 {
t.Fatal("authenticated status unavailable")
}
}
@@ -0,0 +1,6 @@
// An authenticated Node action may start only this fixed model job.
polkit.addRule(function(action, subject) {
if (subject.user === "mission-core-node" && action.id === "org.freedesktop.systemd1.manage-units" && action.lookup("unit") === "mission-core-node-realsense-prepare.service" && action.lookup("verb") === "start") {
return polkit.Result.YES;
}
});
@@ -0,0 +1,3 @@
# Managed by Mission Core Node environment profile ubuntu-24.04-amd64/1.
[Service]
RestrictAddressFamilies=AF_NETLINK
@@ -0,0 +1,5 @@
# Node-managed public keys supplement existing per-user authorized_keys.
# The command can only return GUI-enrolled Ed25519 keys for local sudo users.
AuthorizedKeysCommand /usr/lib/mission-core-node/node-agent ssh-keys %u
AuthorizedKeysCommandUser mission-core-node
PermitEmptyPasswords no
@@ -0,0 +1,5 @@
# Reviewed D455 only. No firmware/DFU IDs, unrelated cameras or world-writable devices.
SUBSYSTEM=="usb", ATTR{idVendor}=="8086", ATTR{idProduct}=="0b5c", MODE="0660", GROUP="mission-core-sensors"
SUBSYSTEM=="video4linux", ATTRS{idVendor}=="8086", ATTRS{idProduct}=="0b5c", MODE="0660", GROUP="mission-core-sensors"
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="8086", ATTRS{idProduct}=="0b5c", MODE="0660", GROUP="mission-core-sensors"
SUBSYSTEM=="iio", ATTRS{idVendor}=="8086", ATTRS{idProduct}=="0b5c", MODE="0660", GROUP="mission-core-sensors", RUN+="/usr/bin/python3 -I /usr/lib/mission-core-node/realsense_iio_access.py %p"
+2
View File
@@ -0,0 +1,2 @@
#!/bin/sh
exec /usr/lib/mission-core-node/node-agent authorize
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""Engineering-only, sequential build. Never run by an Ubuntu operator."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
from build_deb import build, VERSION, BRAND_SHA256
ROOT = Path(__file__).resolve().parents[1]
DG_COMMIT = "999864e5b0a81555823cfa1ea6e8cf8a417c37f1"
def guideline_sources():
dg = ROOT.parents[2] / "NODEDC_DESIGN_GUIDELINE"
paths = list((dg / "packages/ui-react/src").glob("*"))
paths += list((dg / "packages/ui-react/dist").glob("*"))
paths += [dg / "packages/ui-core/styles.css", dg / "packages/tokens/tokens.css", dg / "packages/tokens/themes.css"]
return {str(p.relative_to(dg)): hashlib.sha256(p.read_bytes()).hexdigest()
for p in sorted(paths) if p.is_file()}
def provenance():
files = {str(p.relative_to(ROOT)): hashlib.sha256(p.read_bytes()).hexdigest()
for p in sorted(ROOT.rglob("*")) if p.is_file()
and not any(x in p.relative_to(ROOT).parts for x in ("node_modules", "build", "__pycache__"))}
return {"package": "mission-core-node", "version": VERSION,
"brand_mark_sha256": BRAND_SHA256,
"base_commit": subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip(),
"design_guideline_commit": DG_COMMIT,
"design_guideline_files": guideline_sources(),
"toolchain": json.loads((ROOT / "toolchain.json").read_text()), "files": files}
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--go", type=Path, required=True)
args = parser.parse_args()
go = args.go.resolve()
expected = json.loads((ROOT / "toolchain.json").read_text())["version"]
if subprocess.check_output([str(go), "version"], text=True).split()[2] != expected:
sys.exit("Go version does not match toolchain.json")
dg = ROOT.parents[2] / "NODEDC_DESIGN_GUIDELINE"
if subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=dg, text=True).strip() != DG_COMMIT:
sys.exit("Design Guideline revision does not match the admitted build")
subprocess.run(["npm", "run", "build"], cwd=ROOT / "ui", check=True)
assets = ROOT / "web/dist"
if assets.exists():
shutil.rmtree(assets)
shutil.copytree(ROOT / "ui/dist", assets)
output = ROOT / "build"
output.mkdir(exist_ok=True)
env = dict(os.environ, GOMAXPROCS="2", CGO_ENABLED="0", GOOS="linux", GOARCH="amd64")
subprocess.run([str(go), "build", "-trimpath", f"-ldflags=-s -w -X main.version={VERSION}", "-o",
str(output / "node-agent-linux-amd64"), "./cmd/node-agent"], cwd=ROOT, env=env, check=True)
(output / "provenance.json").write_text(json.dumps(provenance(), indent=2) + "\n")
build(output / "node-agent-linux-amd64", output / f"mission-core-node_{VERSION}_amd64.deb")
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""Build a deterministic Debian package on macOS/Linux from reviewed artifacts.
No install operation, sudo, container, package-manager mutation or network I/O.
"""
import argparse
import gzip
import hashlib
import io
import json
from pathlib import Path
import tarfile
ROOT = Path(__file__).resolve().parents[1]
VERSION = "0.6.11"
BRAND_SHA256 = "8bfee8ca9f98e0db48d98aae3af4b32493b8593e18b064a0239d513d824182af"
def desktop_icon(brand):
"""Give desktop loaders a square canvas without distorting the brand mark.
The canonical SVG remains an unchanged nested document. Its default
xMidYMid meet preserves the mark's aspect ratio inside this square viewport.
Explicit intrinsic dimensions also keep GTK's pixbuf square.
"""
if hashlib.sha256(brand).hexdigest() != BRAND_SHA256:
raise ValueError("Brand mark differs from the admitted Design Guideline asset")
return (b'<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" '
b'viewBox="0 0 256 256" preserveAspectRatio="xMidYMid meet">\n'
+ brand + b'</svg>\n')
def tarball(files):
stream = io.BytesIO()
with tarfile.open(fileobj=stream, mode="w", format=tarfile.GNU_FORMAT) as archive:
directories = {str(parent) for name, _, _ in files for parent in Path(name).parents if str(parent) != "."}
for name in sorted(directories):
item = tarfile.TarInfo(name + "/")
item.type, item.mode = tarfile.DIRTYPE, 0o755
item.uname = item.gname = "root"
archive.addfile(item)
for name, data, mode in sorted(files):
item = tarfile.TarInfo(name)
item.size, item.mode, item.uid, item.gid = len(data), mode, 0, 0
item.uname = item.gname = "root"
archive.addfile(item, io.BytesIO(data))
return gzip.compress(stream.getvalue(), mtime=0)
def ar_member(name, data):
header = f"{name + '/':<16}{0:<12}{0:<6}{0:<6}{'100644':<8}{len(data):<10}`\n".encode()
assert len(header) == 60
return header + data + (b"\n" if len(data) % 2 else b"")
def build(binary, destination):
payload = binary.read_bytes()
if payload[:4] != b"\x7fELF" or payload[4:6] != b"\x02\x01" or payload[18:20] != b"\x3e\x00":
raise ValueError("Expected a Linux amd64 ELF binary")
p = ROOT / "packaging"
control = f"""Package: mission-core-node
Version: {VERSION}
Architecture: amd64
Maintainer: NODE.DC local build <noreply@example.invalid>
Section: admin
Priority: optional
Depends: adduser, systemd, python3, python3-gi, gir1.2-gtk-3.0, gir1.2-webkit2-4.1, pkexec, polkitd, ca-certificates, hicolor-icon-theme
Description: Mission Core onboard computer configuration
Local graphical setup, host inventory, SSH access and persistent node identity.
""".encode()
controls = [("control", control, 0o644)]
controls += [(name, (p / name).read_bytes(), 0o755) for name in ["preinst", "postinst", "prerm", "postrm"]]
files = [("usr/lib/mission-core-node/node-agent", payload, 0o755)]
brand = (ROOT.parents[2] / "NODEDC_DESIGN_GUIDELINE/apps/catalog/public/nodedc-mark.svg").read_bytes()
files.append(("usr/share/icons/hicolor/scalable/apps/org.nodedc.MissionCoreNode.svg", desktop_icon(brand), 0o644))
for source, path, mode in [
("launcher.py", "usr/bin/mission-core-node", 0o755),
("authorize", "usr/lib/mission-core-node/authorize", 0o755),
("mission-core-node.desktop", "usr/share/applications/org.nodedc.MissionCoreNode.desktop", 0o644),
("mission-core-node.service", "usr/lib/systemd/system/mission-core-node.service", 0o644),
("org.nodedc.mission-core-node.policy", "usr/share/polkit-1/actions/org.nodedc.mission-core-node.policy", 0o644),
("60-mission-core-node.conf", "usr/share/mission-core-node/60-mission-core-node.conf", 0o644),
("network_helper.py", "usr/lib/mission-core-node/network_helper.py", 0o644),
("install-tailscale", "usr/lib/mission-core-node/install-tailscale", 0o755),
("connect-tailscale", "usr/lib/mission-core-node/connect-tailscale", 0o755),
("tailscale-release.json", "usr/share/mission-core-node/tailscale-release.json", 0o644),
("configure-system", "usr/lib/mission-core-node/configure-system", 0o755),
("environment_helper.py", "usr/lib/mission-core-node/environment_helper.py", 0o644),
("mission-core-node-environment.service", "usr/lib/systemd/system/mission-core-node-environment.service", 0o644),
("60-environment.conf", "usr/share/mission-core-node/60-environment.conf", 0o644),
]:
files.append((path, (p / source).read_bytes(), mode))
files.append(("usr/share/mission-core-node/environment-profile.json", (ROOT / "internal/node/environment-profile.json").read_bytes(), 0o644))
for name in ("realsense_prepare.py", "realsense_iio_access.py"):
files.append(("usr/lib/mission-core-node/" + name, (p / name).read_bytes(), 0o644))
for name in ("mission-core-realsense.service", "mission-core-node-realsense-prepare.service"):
files.append(("usr/lib/systemd/system/" + name, (p / name).read_bytes(), 0o644))
files.append(("usr/share/polkit-1/rules.d/50-mission-core-device-prepare.rules", (p / "50-mission-core-device-prepare.rules").read_bytes(), 0o644))
files.append(("usr/share/mission-core-node/realsense/70-mission-core-realsense.rules", (p / "70-mission-core-realsense.rules").read_bytes(), 0o644))
bundle = json.loads((p / "realsense-bundle.json").read_text())
files.append(("usr/share/mission-core-node/realsense/bundle.json", (p / "realsense-bundle.json").read_bytes(), 0o644))
for item in bundle["wheels"]:
data = (ROOT / "build/realsense-wheels" / item["name"]).read_bytes()
if hashlib.sha256(data).hexdigest() != item["sha256"]:
raise ValueError("Driver bundle hash mismatch")
files.append(("usr/share/mission-core-node/realsense/" + item["name"], data, 0o644))
for path in (ROOT / "sensors").glob("*.py"):
files.append(("usr/lib/mission-core-node/sensors/" + path.name, path.read_bytes(), 0o644))
sdk = ROOT.parents[1] / "packages/plugin-sdk/python/missioncore_plugin_sdk"
for path in sdk.rglob("*.py"):
files.append(("usr/lib/mission-core-node/sdk/missioncore_plugin_sdk/" + str(path.relative_to(sdk)), path.read_bytes(), 0o644))
if (ROOT / "build/provenance.json").exists():
files.append(("usr/share/doc/mission-core-node/provenance.json", (ROOT / "build/provenance.json").read_bytes(), 0o644))
archive = b"!<arch>\n" + ar_member("debian-binary", b"2.0\n") + ar_member("control.tar.gz", tarball(controls)) + ar_member("data.tar.gz", tarball(files))
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(archive)
digest = hashlib.sha256(archive).hexdigest()
destination.with_suffix(destination.suffix + ".sha256").write_text(f"{digest} {destination.name}\n")
print(json.dumps({"file": str(destination), "bytes": len(archive), "sha256": digest}))
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--binary", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
build(args.binary, args.output)
@@ -0,0 +1,2 @@
#!/bin/sh
exec /usr/bin/python3 -I /usr/lib/mission-core-node/environment_helper.py start
@@ -0,0 +1,2 @@
#!/bin/sh
exec /usr/bin/python3 -I /usr/lib/mission-core-node/network_helper.py connect
@@ -0,0 +1,292 @@
#!/usr/bin/python3
"""Fixed, versioned environment workflow. Called only by the installed UI.
The privileged dispatcher starts a durable systemd job. It accepts no command,
path, package name, address, key or other configuration from JavaScript.
"""
import fcntl
import http.client
import json
import os
from pathlib import Path
import re
import stat
import subprocess
import sys
import tempfile
import time
import uuid
ENV = {"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LANG": "C.UTF-8", "DEBIAN_FRONTEND": "noninteractive"}
PROFILE = Path("/usr/share/mission-core-node/environment-profile.json")
STATE = Path("/var/lib/mission-core-node-environment")
UNIT = "mission-core-node-environment.service"
NODE_UNIT = "mission-core-node.service"
ORIGIN = "http://127.0.0.1:8780"
LOGIN = re.compile(r"http://127\.0\.0\.1:8780/#login=([A-Za-z0-9_-]{43})")
class SetupError(Exception):
pass
def command(argv, *, timeout=15):
result = subprocess.run(argv, env=ENV, capture_output=True, text=True, timeout=timeout)
if result.returncode:
raise SetupError("Системное действие не завершено. Повторите настройку; если ошибка сохранится, откройте диагностику.")
return result.stdout.strip()
def trusted_directory(path, mode=0o755):
created = not path.exists() and not path.is_symlink()
path.mkdir(mode=mode, parents=True, exist_ok=True)
info = path.lstat()
if not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022:
raise SetupError("Каталог настройки имеет неподходящие права. Переустановите пакет Node через интерфейс системы.")
# umask 0077 must protect working files, but this nonsensitive report and
# newly created configuration directories must be traversable by readers.
# The only existing directory repaired here is our dedicated report store.
if created or path == STATE:
path.chmod(mode)
def publish(path, data):
trusted_directory(path.parent)
if path.is_symlink():
raise SetupError("Конфликт системного файла: существующая ссылка сохранена.")
with tempfile.NamedTemporaryFile(dir=path.parent, prefix=".node-env-", delete=False) as output:
temporary = Path(output.name)
try:
output.write(data)
output.flush()
os.fchmod(output.fileno(), 0o644)
os.fsync(output.fileno())
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)
def owned_config(template, destination):
expected = template.read_bytes()
trusted_directory(destination.parent)
if destination.is_symlink():
raise SetupError("Конфликт с существующей настройкой. Она сохранена без изменений.")
if destination.exists():
info = destination.stat()
if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022 or destination.read_bytes() != expected:
raise SetupError("Конфликт с существующей настройкой. Она сохранена; проверьте конфигурацию перед повтором.")
return False
publish(destination, expected)
return True
def authorize():
uri = command(["/usr/lib/mission-core-node/node-agent", "authorize"])
if not LOGIN.fullmatch(uri):
raise SetupError("Не удалось проверить локальную службу БК.")
return uri
def probe_node():
# Validate the actual sandboxed service, not the root helper's own access.
token = LOGIN.fullmatch(authorize()).group(1)
connection = http.client.HTTPConnection("127.0.0.1", 8780, timeout=10)
cookie = None
try:
connection.request("POST", "/api/session", json.dumps({"token": token}), {"Origin": ORIGIN, "Content-Type": "application/json"})
response = connection.getresponse()
if response.status != 200:
raise SetupError("Служба БК не подтвердила доступ для проверки.")
cookie = response.getheader("Set-Cookie", "").split(";", 1)[0]
response.read()
connection.request("GET", "/api/status", headers={"Cookie": cookie})
response = connection.getresponse()
if response.status != 200:
raise SetupError("Не удалось получить сведения из службы БК.")
data = response.read(2 * 1024 * 1024)
return json.loads(data)["host"]
finally:
if cookie:
try:
connection.request("POST", "/api/logout", "{}", {"Cookie": cookie, "Origin": ORIGIN, "Content-Type": "application/json"})
connection.getresponse().read()
except (OSError, http.client.HTTPException):
pass
connection.close()
def platform():
release = dict(line.split("=", 1) for line in Path("/etc/os-release").read_text().splitlines() if "=" in line)
if release.get("ID", "").strip('"') != "ubuntu" or release.get("VERSION_ID", "").strip('"') != "24.04" or command(["/usr/bin/dpkg", "--print-architecture"]) != "amd64":
raise SetupError("Этот профиль не поддерживает установленную систему или архитектуру. Сведения о системе доступны в обзоре БК.")
return "Система и архитектура соответствуют профилю."
def packages():
missing = []
for name in ["openssh-server", "ca-certificates"]:
result = subprocess.run(["/usr/bin/dpkg-query", "-W", "-f=${db:Status-Status}", name], env=ENV, capture_output=True, text=True, timeout=10)
if result.returncode or result.stdout.strip() != "installed":
missing.append(name)
if missing:
options = ["-o", "DPkg::Lock::Timeout=30", "-o", "Acquire::Retries=1", "-o", "Acquire::http::Timeout=30", "-o", "Acquire::https::Timeout=30"]
# Never kill APT/dpkg in the middle of a transaction or delete its lock.
for argv in [["/usr/bin/apt-get", *options, "update"], ["/usr/bin/apt-get", *options, "--no-remove", "--no-install-recommends", "install", "-y", *missing]]:
result = subprocess.run(argv, env=ENV, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if result.returncode:
raise SetupError("Не удалось установить пакеты. Проверьте интернет, закройте другие системные установщики и повторите настройку.")
for name in ["openssh-server", "ca-certificates"]:
if command(["/usr/bin/dpkg-query", "-W", "-f=${db:Status-Status}", name]) != "installed":
raise SetupError("Проверка установленных пакетов не пройдена.")
return "OpenSSH Server и системные зависимости установлены."
def node_service():
changed = owned_config(Path("/usr/share/mission-core-node/60-environment.conf"), Path("/etc/systemd/system/mission-core-node.service.d/60-environment.conf"))
command(["/usr/bin/systemctl", "daemon-reload"])
if command(["/usr/bin/systemctl", "show", NODE_UNIT, "--property=User", "--value"]) != "mission-core-node" or command(["/usr/bin/systemctl", "show", NODE_UNIT, "--property=CapabilityBoundingSet", "--value"]):
raise SetupError("Права службы отличаются от профиля. Настройка остановлена без изменения чужих разрешений.")
command(["/usr/bin/systemctl", "enable", "--now", NODE_UNIT])
needs_restart = changed
if not needs_restart:
try:
needs_restart = not probe_node().get("networks_readable")
except (SetupError, OSError, ValueError, KeyError, http.client.HTTPException, subprocess.SubprocessError):
needs_restart = True
if needs_restart:
command(["/usr/bin/systemctl", "restart", NODE_UNIT])
families = command(["/usr/bin/systemctl", "show", NODE_UNIT, "--property=RestrictAddressFamilies", "--value"])
if "AF_NETLINK" not in families.split():
raise SetupError("Существующая настройка службы запрещает получение сетевых данных. Она сохранена; требуется устранить конфликт профиля.")
command(["/usr/bin/systemctl", "is-active", NODE_UNIT])
wait_for_node()
return "Служба БК запущена; автозапуск и системный профиль проверены."
def wait_for_node():
# Type=simple starts before the local socket/listener is ready. Retry only
# read-only readiness, never package/service changes or user actions.
deadline = time.monotonic() + 10
while True:
try:
probe_node()
return
except (SetupError, OSError, ValueError, KeyError, http.client.HTTPException, subprocess.SubprocessError):
if time.monotonic() >= deadline:
raise SetupError("Служба БК не подтвердила готовность после запуска. Повторите настройку.")
time.sleep(0.25)
def network_inventory():
host = probe_node()
if not host.get("networks_readable") or any(not item.get("addresses_readable") for item in host["networks"]):
raise SetupError("Служба БК не смогла получить интерфейсы или адреса. Проверьте этап настройки службы и повторите.")
return f"Получено сетевых интерфейсов: {len(host['networks'])}."
def usb_inventory():
host = probe_node()
if not host.get("usb_readable"):
raise SetupError("Служба БК не смогла получить USB-устройства. Повторите настройку.")
return f"Получено USB-устройств: {len(host['usb'])}. Это системное обнаружение."
def ssh_service():
owned_config(Path("/usr/share/mission-core-node/60-mission-core-node.conf"), Path("/etc/ssh/sshd_config.d/60-mission-core-node.conf"))
trusted_directory(Path("/run/sshd"))
command(["/usr/sbin/sshd", "-t"])
config = command(["/usr/sbin/sshd", "-T"]).splitlines()
if "authorizedkeyscommand /usr/lib/mission-core-node/node-agent ssh-keys %u" not in config or "authorizedkeyscommanduser mission-core-node" not in config:
raise SetupError("Другая конфигурация SSH переопределяет доступ Node. Она сохранена; устраните конфликт и повторите.")
command(["/usr/bin/systemctl", "enable", "--now", "ssh.service"])
command(["/usr/bin/systemctl", "try-reload-or-restart", "ssh.service"])
import socket
with socket.create_connection(("127.0.0.1", 22), timeout=3) as connection:
if not connection.recv(256).startswith(b"SSH-2.0-"):
raise SetupError("SSH запущен, но не подтвердил локальную готовность.")
return "SSH отвечает локально; реестр доверенных ключей подключён."
def tailscale_install():
# Reuse the existing pinned provider installer, checksum and operation lock.
result = subprocess.run(["/usr/lib/mission-core-node/install-tailscale"], env=ENV, capture_output=True, text=True)
if result.returncode:
raise SetupError("Не удалось запустить установку Tailscale.")
value = json.loads(result.stdout)
if value.get("ok") is not True:
raise SetupError(str(value.get("error", "Установка Tailscale не завершена."))[:1024])
command(["/usr/bin/systemctl", "is-active", "tailscaled.service"])
return "Tailscale установлен; системная служба запущена. Вход проверяется отдельно."
OPERATIONS = {"platform": platform, "packages": packages, "node-service": node_service, "network-inventory": network_inventory, "usb-inventory": usb_inventory, "ssh-service": ssh_service, "tailscale-install": tailscale_install}
def run_steps(profile, operations, save):
record = {"schema": profile["schema"], "profile_revision": profile["revision"], "run_id": str(uuid.uuid4()), "state": "running", "started_at": time.time(), "steps": [{"id": step["id"], "state": "pending", "detail": ""} for step in profile["steps"]]}
def update():
record["updated_at"] = time.time()
save(record)
update()
for specification, step in zip(profile["steps"], record["steps"]):
states = {item["id"]: item["state"] for item in record["steps"]}
if any(states.get(dependency) != "complete" for dependency in specification["requires"]):
step.update(state="blocked", detail="Сначала завершите предыдущие необходимые этапы.")
update()
continue
step.update(state="running", detail="")
update()
try:
step.update(state="complete", detail=operations[step["id"]]())
except SetupError as error:
step.update(state="error", detail=str(error))
except (OSError, ValueError, KeyError, TypeError, http.client.HTTPException, subprocess.SubprocessError):
step.update(state="error", detail="Не удалось завершить этап. Повторите настройку; сведения о проблеме сохранены в этом списке.")
update()
record["state"] = "complete" if all(step["state"] == "complete" for step in record["steps"]) else "error"
update()
return record
def start():
trusted_directory(STATE)
fd = os.open(STATE / "dispatch.lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
with os.fdopen(fd, "w") as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise SetupError("Настройка уже выполняется. Дождитесь её завершения.")
result = subprocess.run(["/usr/bin/systemctl", "start", UNIT], env=ENV, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if result.returncode:
raise SetupError("Задание настройки не завершилось. Посмотрите этапы и повторите действие.")
record = json.loads((STATE / "last-run.json").read_text())
# Renew the ordinary local UI session after a service restart. The
# capability is returned only to the native launcher, never to reports.
return {"ok": record["state"] == "complete", "login_uri": authorize()}
def main():
if os.geteuid() != 0 or sys.argv[1:] not in (["start"], ["run"]):
raise SystemExit("Use the installed application's environment setup")
os.environ.clear()
os.environ.update(ENV)
os.umask(0o077)
try:
if sys.argv[1] == "run":
profile = json.loads(PROFILE.read_text())
if {step["id"] for step in profile["steps"]} != set(OPERATIONS):
raise SetupError("Профиль окружения не соответствует установленной версии.")
run_steps(profile, OPERATIONS, lambda record: publish(STATE / "last-run.json", (json.dumps(record) + "\n").encode()))
return
result = start()
except SetupError as error:
result = {"ok": False, "error": str(error)}
except (OSError, ValueError, KeyError, subprocess.SubprocessError):
result = {"ok": False, "error": "Не удалось выполнить настройку окружения. Повторите действие."}
if sys.argv[1] == "run":
raise SystemExit(1)
print(json.dumps(result))
if __name__ == "__main__":
main()
@@ -0,0 +1,30 @@
"""Engineering build input, never run on an operator board. Exact PyPI hashes only."""
import hashlib
import json
from pathlib import Path
from urllib.request import urlopen
root = Path(__file__).resolve().parents[1]
manifest = json.loads((root / "packaging/realsense-bundle.json").read_text())
output = root / "build/realsense-wheels"
output.mkdir(parents=True, exist_ok=True)
for item in manifest["wheels"]:
target = output / item["name"]
if target.exists() and hashlib.sha256(target.read_bytes()).hexdigest() == item["sha256"]:
continue
package, version = item["name"].split("-")[:2]
with urlopen(f"https://pypi.org/pypi/{package}/{version}/json", timeout=30) as response:
metadata = json.load(response)
source = next(
v
for v in metadata["urls"]
if v["filename"] == item["name"] and v["digests"]["sha256"] == item["sha256"]
)
if not source["url"].startswith("https://files.pythonhosted.org/"):
raise ValueError("Unexpected package origin")
with urlopen(source["url"], timeout=120) as response:
data = response.read(item["bytes"] + 1)
if len(data) != item["bytes"] or hashlib.sha256(data).hexdigest() != item["sha256"]:
raise ValueError("Driver checksum mismatch")
target.write_bytes(data)
@@ -0,0 +1,2 @@
#!/bin/sh
exec /usr/bin/python3 -I /usr/lib/mission-core-node/network_helper.py install
+326
View File
@@ -0,0 +1,326 @@
#!/usr/bin/python3
"""Standalone GTK application. Only the fixed polkit helper runs as root."""
import argparse
import http.client
import json
import os
from pathlib import Path
import re
import socket
import subprocess
import threading
from urllib.parse import urlsplit
import gi
gi.require_version("Gtk", "3.0")
gi.require_version("WebKit2", "4.1")
from gi.repository import Gio, GLib, Gtk, WebKit2
ORIGIN = "http://127.0.0.1:8780"
LOGIN = re.compile(r"http://127\.0\.0\.1:8780/#login=[A-Za-z0-9_-]{43}")
def local_url(uri):
try:
u = urlsplit(uri)
return (u.scheme, u.hostname, u.port) == ("http", "127.0.0.1", 8780) and not u.username and not u.password
except ValueError:
return False
def authorize(development_socket=None):
if development_socket:
# Engineering-only, unprivileged service. Cannot read the deployed
# service's protected Unix socket and never grants OS privileges.
connection = http.client.HTTPConnection("local", timeout=5)
connection.sock = socket.socket(socket.AF_UNIX)
connection.sock.settimeout(5)
try:
connection.sock.connect(development_socket)
connection.request("POST", "/login", headers={"Content-Type": "application/json"})
response = connection.getresponse()
if response.status != 200:
raise ValueError("Local authorization failed")
uri = json.loads(response.read(1024))["url"]
finally:
connection.close()
else:
result = subprocess.run(
["/usr/bin/pkexec", "/usr/lib/mission-core-node/authorize"],
check=True, capture_output=True, text=True, timeout=180,
)
uri = result.stdout.strip()
if not LOGIN.fullmatch(uri):
raise ValueError("Unexpected launcher response")
return uri
class NodeApplication(Gtk.Application):
def __init__(self, development_socket=None):
super().__init__(application_id="org.nodedc.MissionCoreNode", flags=Gio.ApplicationFlags.FLAGS_NONE)
self.development_socket = development_socket
self.window = None
self.pending = False
self.initial_login = False
self.cancelled_downloads = set()
self.environment_timer = None
self.environment_previous = None
def do_activate(self):
if self.window:
self.window.present()
return
self.window = Gtk.ApplicationWindow(application=self)
self.window.set_title("Mission Core Node")
self.window.set_default_size(1100, 780)
self.window.set_icon_name("org.nodedc.MissionCoreNode")
context = WebKit2.WebContext.new_ephemeral()
context.connect("download-started", self.download_started)
self.view = WebKit2.WebView.new_with_context(context)
self.view.get_settings().set_enable_developer_extras(False)
self.view.connect("context-menu", lambda *_: True)
self.view.connect("decide-policy", self.decide_policy)
self.view.connect("permission-request", self.deny_permission)
self.view.connect("load-failed", self.load_failed)
self.view.connect("load-changed", self.loaded)
self.view.connect("web-process-terminated", self.process_failed)
manager = self.view.get_user_content_manager()
manager.add_script(WebKit2.UserScript.new(
"Object.defineProperty(window, 'missionCoreDesktop', {value: Object.freeze({networkSetup: true, environmentSetup: true})});",
WebKit2.UserContentInjectedFrames.TOP_FRAME, WebKit2.UserScriptInjectionTime.START, None, None,
))
manager.register_script_message_handler("node")
manager.connect("script-message-received::node", self.message)
self.window.add(self.view)
self.window.connect("destroy", self.destroyed)
self.window.show_all()
self.view.load_uri(ORIGIN)
def loaded(self, _view, event):
if event == WebKit2.LoadEvent.FINISHED and not self.initial_login:
self.initial_login = True
self.login()
def destroyed(self, *_):
self.window = None
def message(self, _manager, result):
if not local_url(self.view.get_uri() or ""):
return
action = result.get_js_value().to_string()
if action == "authorize":
self.login()
elif action == "configure-system":
self.configure_environment()
elif action in ("install-tailscale", "connect-tailscale"):
self.network_action(action)
def environment_record(self):
try:
path = Path("/var/lib/mission-core-node-environment/last-run.json")
if path.stat().st_size > 32768:
return None
value = json.loads(path.read_text())
if value.get("schema") == "missioncore.node.environment/v1":
return value
except (OSError, ValueError, TypeError):
pass
return None
def environment_progress(self):
if not self.window or not self.pending:
self.environment_timer = None
return False
record = self.environment_record()
if record and record.get("run_id") != self.environment_previous:
script = "window.dispatchEvent(new CustomEvent('mission-core-environment-progress', {detail: " + json.dumps(record) + "}));"
self.view.evaluate_javascript(script, -1, None, None, None, None, None)
return True
def configure_environment(self):
if self.pending:
return
self.pending = True
previous = self.environment_record()
self.environment_previous = previous.get("run_id") if previous else None
self.environment_timer = GLib.timeout_add(1000, self.environment_progress)
def work():
value = {"ok": False}
try:
process = subprocess.run(["/usr/bin/pkexec", "/usr/lib/mission-core-node/configure-system"], capture_output=True, text=True)
if process.returncode:
value["error"] = "Системное подтверждение отменено или недоступно. Повторите действие."
else:
value = json.loads(process.stdout)
if type(value.get("ok")) is not bool:
raise ValueError("Unexpected environment result")
if value.get("login_uri") and not LOGIN.fullmatch(value["login_uri"]):
raise ValueError("Unexpected local login")
except (OSError, ValueError, TypeError, subprocess.SubprocessError):
value = {"ok": False, "error": "Не удалось завершить настройку окружения. Повторите действие."}
GLib.idle_add(self.environment_finished, value)
threading.Thread(target=work, daemon=True).start()
def environment_finished(self, value):
self.pending = False
if self.environment_timer:
GLib.source_remove(self.environment_timer)
self.environment_timer = None
uri = value.pop("login_uri", None)
value["reloading"] = bool(uri)
if self.window:
script = "window.dispatchEvent(new CustomEvent('mission-core-environment-result', {detail: " + json.dumps(value) + "}));"
self.view.evaluate_javascript(script, -1, None, None, None, None, None)
if uri:
self.view.load_uri(uri)
return False
def network_action(self, action):
if self.pending:
self.network_result({"action": action, "ok": False, "error": "Другая операция ещё выполняется."}, completed=False)
return
self.pending = True
def work():
value = {"action": action, "ok": False}
try:
# The action is selected from the allowlist above. No command,
# URL, credential, path or network option is accepted from JS.
process = subprocess.run(["/usr/bin/pkexec", "/usr/lib/mission-core-node/" + action],
capture_output=True, text=True)
if process.returncode:
value["error"] = "Системное подтверждение отменено или недоступно. Повторите действие."
else:
result = json.loads(process.stdout)
if not isinstance(result, dict) or type(result.get("ok")) is not bool:
raise ValueError("Unexpected helper response")
value["ok"] = result["ok"]
if result.get("url"):
uri = result["url"]
if not re.fullmatch(r"https://login\.tailscale\.com/a/[A-Za-z0-9_-]{1,256}", uri):
raise ValueError("Unexpected login destination")
# Keep the provider credential inside the native process;
# no auth URL is persisted or returned to the web API/JS.
value["login_uri"] = uri
if not value["ok"]:
value["error"] = str(result.get("error", "Настройка Tailscale не завершена."))[:1024]
except (OSError, ValueError, TypeError, subprocess.SubprocessError):
value = {"action": action, "ok": False, "error": "Не удалось выполнить настройку Tailscale. Повторите действие."}
GLib.idle_add(self.network_result, value)
threading.Thread(target=work, daemon=True).start()
def network_result(self, value, completed=True):
if completed:
self.pending = False
uri = value.pop("login_uri", None)
if not self.window:
return False
if uri:
try:
Gio.AppInfo.launch_default_for_uri(uri, None)
value["browser_opened"] = True
except GLib.Error:
value["ok"] = False
value["error"] = "Не удалось открыть браузер. Проверьте системный браузер по умолчанию и повторите вход."
script = "window.dispatchEvent(new CustomEvent('mission-core-network-result', {detail: " + json.dumps(value) + "}));"
self.view.evaluate_javascript(script, -1, None, None, None, None, None)
return False
def login(self):
if self.pending:
return
self.pending = True
def work():
try:
uri = authorize(self.development_socket)
GLib.idle_add(self.login_ready, uri)
except (OSError, ValueError, KeyError, http.client.HTTPException, subprocess.SubprocessError):
GLib.idle_add(self.problem, "Не удалось подтвердить доступ. Повторите вход и подтвердите системный запрос.")
finally:
GLib.idle_add(self.login_finished)
threading.Thread(target=work, daemon=True).start()
def login_ready(self, uri):
if self.window:
self.view.load_uri(uri)
return False
def login_finished(self):
self.pending = False
return False
def problem(self, message):
if not self.window:
return False
dialog = Gtk.MessageDialog(transient_for=self.window, modal=True,
message_type=Gtk.MessageType.ERROR,
buttons=Gtk.ButtonsType.CLOSE,
text="Mission Core Node")
dialog.format_secondary_text(message)
dialog.connect("response", lambda d, _: d.destroy())
dialog.show()
return False
def load_failed(self, _view, _event, uri, _error):
if local_url(uri):
self.problem("Служба ноды недоступна. Повторно откройте приложение после восстановления службы.")
return True
def process_failed(self, *_):
self.problem("Окно приложения остановилось. Закройте и повторно откройте Mission Core Node. Служба борта продолжает работать отдельно.")
def deny_permission(self, _view, permission):
permission.deny()
return True
def decide_policy(self, _view, decision, kind):
if kind in (WebKit2.PolicyDecisionType.NAVIGATION_ACTION, WebKit2.PolicyDecisionType.NEW_WINDOW_ACTION):
uri = decision.get_navigation_action().get_request().get_uri()
if kind == WebKit2.PolicyDecisionType.NEW_WINDOW_ACTION or not local_url(uri):
decision.ignore()
return True
elif kind == WebKit2.PolicyDecisionType.RESPONSE:
uri = decision.get_request().get_uri()
if not local_url(uri):
decision.ignore()
return True
if urlsplit(uri).path == "/api/report" and decision.get_response().get_status_code() == 200:
decision.download()
return True
return False
def download_started(self, _context, download):
uri = download.get_request().get_uri()
if not local_url(uri) or urlsplit(uri).path != "/api/report":
download.cancel()
return
download.connect("decide-destination", self.download_destination)
download.connect("failed", self.download_failed)
def download_failed(self, download, _error):
if download in self.cancelled_downloads:
self.cancelled_downloads.discard(download)
return False
return self.problem("Не удалось сохранить отчёт.")
def download_destination(self, download, _suggested):
chooser = Gtk.FileChooserNative.new("Сохранить отчёт", self.window,
Gtk.FileChooserAction.SAVE, "Сохранить", "Отмена")
chooser.set_current_name("mission-core-node-report.json")
chooser.set_do_overwrite_confirmation(True)
if chooser.run() == Gtk.ResponseType.ACCEPT:
download.set_allow_overwrite(True)
download.set_destination(Path(chooser.get_filename()).as_uri())
else:
self.cancelled_downloads.add(download)
download.cancel()
chooser.destroy()
return True
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--development-socket", help="Engineering-only: private socket of an unprivileged development service")
arguments = parser.parse_args()
if os.geteuid() == 0:
raise SystemExit("Run the desktop application as your normal system user")
raise SystemExit(NodeApplication(arguments.development_socket).run([]))
@@ -0,0 +1,15 @@
[Unit]
Description=Mission Core Node explicit environment configuration
After=network.target
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 -I /usr/lib/mission-core-node/environment_helper.py run
Environment=PATH=/usr/sbin:/usr/bin:/sbin:/bin
Environment=LANG=C.UTF-8
Environment=DEBIAN_FRONTEND=noninteractive
UMask=0077
PrivateTmp=yes
ProtectHome=yes
# A package transaction must finish even if the operator closes the UI.
TimeoutStartSec=0
@@ -0,0 +1,8 @@
[Unit]
Description=Mission Core fixed RealSense model preparation
After=systemd-udevd.service
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 -I /usr/lib/mission-core-node/realsense_prepare.py
TimeoutStartSec=300
UMask=0022
@@ -0,0 +1,10 @@
[Desktop Entry]
Version=1.0
Type=Application
Name=Mission Core Node
Comment=Настройка и диагностика бортового компьютера
Exec=/usr/bin/mission-core-node
Icon=org.nodedc.MissionCoreNode
Terminal=false
Categories=System;
StartupNotify=true
@@ -0,0 +1,35 @@
[Unit]
Description=Mission Core Node local device host
After=network.target
[Service]
Type=simple
User=mission-core-node
Group=mission-core-node
ExecStart=/usr/lib/mission-core-node/node-agent
StateDirectory=mission-core-node
StateDirectoryMode=0700
RuntimeDirectory=mission-core-node
RuntimeDirectoryMode=0700
UMask=0077
Restart=on-failure
RestartSec=3
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
# The explicit environment workflow admits read-only route netlink metadata.
RestrictAddressFamilies=AF_UNIX AF_INET
CapabilityBoundingSet=
LockPersonality=yes
LimitNOFILE=1024
TasksMax=64
MemoryMax=256M
[Install]
WantedBy=multi-user.target

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