Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
020a878915 | ||
|
|
404285419f | ||
|
|
ba0c948ca1 | ||
|
|
429b4681fe | ||
|
|
22d69107ba | ||
|
|
a8647c4d87 | ||
|
|
b1aaa40508 | ||
|
|
3616acc648 | ||
|
|
e82d012907 | ||
|
|
fc545f8440 | ||
|
|
a98ef339de | ||
|
|
9062126913 | ||
|
|
59e14c5bc0 | ||
|
|
15bb793e5e | ||
|
|
79911eb316 | ||
|
|
d696842f5d | ||
|
|
57f2537af3 | ||
|
|
e56eb42507 | ||
|
|
b2a1b23131 | ||
|
|
cada687173 | ||
|
|
eff60e490a | ||
|
|
9db29bcdc6 | ||
|
|
27979854a7 | ||
|
|
3ea8d987a7 | ||
|
|
bd947b49f4 | ||
|
|
54c8d82884 | ||
|
|
bee8552003 | ||
|
|
4e0565eb4b | ||
|
|
44afadb021 |
@@ -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>
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
/build/
|
||||
/ui/node_modules/
|
||||
/ui/dist/
|
||||
/web/dist/
|
||||
@@ -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.
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module nodedc.local/mission-core/node-agent
|
||||
|
||||
go 1.26.0
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Network struct {
|
||||
Name string `json:"name"`
|
||||
Up bool `json:"up"`
|
||||
Addresses []string `json:"addresses"`
|
||||
AddressesReadable bool `json:"addresses_readable"`
|
||||
}
|
||||
type USB struct {
|
||||
Port string `json:"port"`
|
||||
Vendor string `json:"vendor"`
|
||||
ProductID string `json:"product_id"`
|
||||
Product string `json:"product"`
|
||||
Speed string `json:"speed_mbps"`
|
||||
}
|
||||
type Inventory struct {
|
||||
CollectedAt string `json:"collected_at"`
|
||||
Hostname string `json:"hostname"`
|
||||
OS string `json:"os"`
|
||||
Architecture string `json:"architecture"`
|
||||
CPUs int `json:"cpus"`
|
||||
MemoryKiB *uint64 `json:"memory_kib"`
|
||||
AvailableKiB *uint64 `json:"available_kib"`
|
||||
Networks []Network `json:"networks"`
|
||||
NetworksReadable bool `json:"networks_readable"`
|
||||
USB []USB `json:"usb"`
|
||||
USBReadable bool `json:"usb_readable"`
|
||||
Warnings []string `json:"warnings"`
|
||||
}
|
||||
|
||||
// Host reads only local kernel/OS metadata. It never probes network devices,
|
||||
// opens camera streams, reads device serials, or changes a network interface.
|
||||
func Host(root string) Inventory {
|
||||
read := func(p string) string {
|
||||
b, _ := os.ReadFile(filepath.Join(root, p))
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
host, _ := os.Hostname()
|
||||
v := Inventory{CollectedAt: time.Now().UTC().Format(time.RFC3339), Hostname: host, OS: runtime.GOOS, Architecture: runtime.GOARCH, CPUs: runtime.NumCPU(), Networks: []Network{}, USB: []USB{}, Warnings: []string{}}
|
||||
for _, line := range strings.Split(read("etc/os-release"), "\n") {
|
||||
if x, ok := strings.CutPrefix(line, "PRETTY_NAME="); ok {
|
||||
v.OS = strings.Trim(x, "\"")
|
||||
}
|
||||
}
|
||||
for _, line := range strings.Split(read("proc/meminfo"), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
n, e := strconv.ParseUint(fields[1], 10, 64)
|
||||
if e != nil {
|
||||
continue
|
||||
}
|
||||
if fields[0] == "MemTotal:" {
|
||||
v.MemoryKiB = &n
|
||||
}
|
||||
if fields[0] == "MemAvailable:" {
|
||||
v.AvailableKiB = &n
|
||||
}
|
||||
}
|
||||
if v.MemoryKiB == nil {
|
||||
v.Warnings = append(v.Warnings, "Сведения о памяти недоступны")
|
||||
}
|
||||
var warnings []string
|
||||
v.Networks, v.NetworksReadable, warnings = readNetworks(net.Interfaces, func(it net.Interface) ([]net.Addr, error) { return it.Addrs() })
|
||||
v.Warnings = append(v.Warnings, warnings...)
|
||||
entries, err := os.ReadDir(filepath.Join(root, "sys/bus/usb/devices"))
|
||||
v.USBReadable = err == nil
|
||||
if err != nil {
|
||||
v.Warnings = append(v.Warnings, "Сведения об USB недоступны")
|
||||
}
|
||||
for _, e := range entries {
|
||||
prefix := filepath.Join("sys/bus/usb/devices", e.Name())
|
||||
vendor := read(filepath.Join(prefix, "idVendor"))
|
||||
if vendor == "" {
|
||||
continue
|
||||
}
|
||||
v.USB = append(v.USB, USB{Port: e.Name(), Vendor: vendor, ProductID: read(filepath.Join(prefix, "idProduct")), Product: read(filepath.Join(prefix, "product")), Speed: read(filepath.Join(prefix, "speed"))})
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func readNetworks(list func() ([]net.Interface, error), addrs func(net.Interface) ([]net.Addr, error)) ([]Network, bool, []string) {
|
||||
result, warnings := []Network{}, []string{}
|
||||
interfaces, err := list()
|
||||
if err != nil {
|
||||
return result, false, []string{"Не удалось прочитать сетевые интерфейсы"}
|
||||
}
|
||||
for _, it := range interfaces {
|
||||
if it.Flags&net.FlagLoopback != 0 {
|
||||
continue
|
||||
}
|
||||
addresses, err := addrs(it)
|
||||
n := Network{Name: it.Name, Up: it.Flags&net.FlagUp != 0, Addresses: []string{}, AddressesReadable: err == nil}
|
||||
if err != nil {
|
||||
warnings = append(warnings, "Адреса интерфейса "+it.Name+" недоступны")
|
||||
} else {
|
||||
for _, a := range addresses {
|
||||
n.Addresses = append(n.Addresses, a.String())
|
||||
}
|
||||
}
|
||||
sort.Strings(n.Addresses)
|
||||
result = append(result, n)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name })
|
||||
return result, true, warnings
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNetworkInventoryDistinguishesFailureFromEmpty(t *testing.T) {
|
||||
denied := errors.New("read denied")
|
||||
for _, failure := range []error{nil, denied} {
|
||||
rows, readable, warnings := readNetworks(func() ([]net.Interface, error) { return nil, failure }, func(net.Interface) ([]net.Addr, error) { t.Fatal("no interface to read"); return nil, nil })
|
||||
if len(rows) != 0 || readable != (failure == nil) || (len(warnings) > 0) != (failure != nil) {
|
||||
t.Fatal(rows, readable, warnings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkAddressFailureKeepsInterfaceWithoutInventingAddresses(t *testing.T) {
|
||||
rows, readable, warnings := readNetworks(func() ([]net.Interface, error) {
|
||||
return []net.Interface{{Name: "loop", Flags: net.FlagLoopback}, {Name: "ethernet", Flags: net.FlagUp}}, nil
|
||||
}, func(it net.Interface) ([]net.Addr, error) {
|
||||
if it.Name != "ethernet" {
|
||||
t.Fatal("read loopback")
|
||||
}
|
||||
return []net.Addr{&net.IPAddr{IP: net.ParseIP("192.0.2.1")}}, errors.New("partial result")
|
||||
})
|
||||
if !readable || len(rows) != 1 || rows[0].Name != "ethernet" || !rows[0].Up || rows[0].AddressesReadable || len(rows[0].Addresses) != 0 || len(warnings) != 1 {
|
||||
t.Fatal(rows, readable, warnings)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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")
|
||||
}
|
||||
}
|
||||
@@ -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 = ©
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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 ©, 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 ©, 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 ©
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/bin/sh
|
||||
exec /usr/lib/mission-core-node/node-agent authorize
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user