fix(observatory): refine catalog and replay UX
This commit is contained in:
@@ -241,7 +241,7 @@ export function CanonicalRecordedLabReplay<
|
|||||||
orientation={splitOrientation}
|
orientation={splitOrientation}
|
||||||
minPrimarySize={splitView ? 24 : 0}
|
minPrimarySize={splitView ? 24 : 0}
|
||||||
minSecondarySize={splitView ? 24 : 0}
|
minSecondarySize={splitView ? 24 : 0}
|
||||||
resizable={splitView}
|
resizable={splitView && !unifiedContent}
|
||||||
separatorLabel="Изменить размер видео/камеры и 3D/плана"
|
separatorLabel="Изменить размер видео/камеры и 3D/плана"
|
||||||
/>
|
/>
|
||||||
{mediaMode === "none" && spatialMode === "none" ? (
|
{mediaMode === "none" && spatialMode === "none" ? (
|
||||||
|
|||||||
+180
-46
@@ -1,11 +1,26 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import {
|
||||||
import { Button, Icon, SegmentedControl } from "@nodedc/ui-react";
|
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 { ObservationTimeline } from "../ObservationTimeline";
|
||||||
import {
|
import {
|
||||||
RerunViewport,
|
RerunViewport,
|
||||||
|
isRecordedPlaybackPresentationReady,
|
||||||
type RerunPlaybackController,
|
type RerunPlaybackController,
|
||||||
type RerunPlaybackState,
|
type RerunPlaybackState,
|
||||||
|
type RerunViewportStatus,
|
||||||
} from "../RerunViewport";
|
} from "../RerunViewport";
|
||||||
import {
|
import {
|
||||||
CanonicalRecordedLabReplay,
|
CanonicalRecordedLabReplay,
|
||||||
@@ -31,6 +46,9 @@ interface CanonicalReplayLaunch {
|
|||||||
replay: CanonicalLabReplayDescriptor;
|
replay: CanonicalLabReplayDescriptor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RERUN_UNIFIED_CAMERA_SHARE_PERCENT = 46;
|
||||||
|
const RERUN_NATIVE_DIVIDER_HIT_SLOP_PX = 10;
|
||||||
|
|
||||||
export function CanonicalVegetationRerunReplay({
|
export function CanonicalVegetationRerunReplay({
|
||||||
resultId,
|
resultId,
|
||||||
review,
|
review,
|
||||||
@@ -52,6 +70,7 @@ export function CanonicalVegetationRerunReplay({
|
|||||||
initialMediaMode: "video",
|
initialMediaMode: "video",
|
||||||
initialSpatialMode: "3d",
|
initialSpatialMode: "3d",
|
||||||
});
|
});
|
||||||
|
const splitView = mediaMode !== null && spatialMode !== null;
|
||||||
const [semanticLayer, setSemanticLayer] = useState<SemanticLayer>("vegetation");
|
const [semanticLayer, setSemanticLayer] = useState<SemanticLayer>("vegetation");
|
||||||
const [showSemantics, setShowSemantics] = useState(true);
|
const [showSemantics, setShowSemantics] = useState(true);
|
||||||
const [spatialLayer, setSpatialLayer] = useState<SpatialLayer>("source");
|
const [spatialLayer, setSpatialLayer] = useState<SpatialLayer>("source");
|
||||||
@@ -59,13 +78,92 @@ export function CanonicalVegetationRerunReplay({
|
|||||||
const [playback, setPlayback] = useState<RerunPlaybackState | null>(null);
|
const [playback, setPlayback] = useState<RerunPlaybackState | null>(null);
|
||||||
const [playbackController, setPlaybackController] =
|
const [playbackController, setPlaybackController] =
|
||||||
useState<RerunPlaybackController | null>(null);
|
useState<RerunPlaybackController | null>(null);
|
||||||
|
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>("idle");
|
||||||
const [launch, setLaunch] = useState<CanonicalReplayLaunch | null>(null);
|
const [launch, setLaunch] = useState<CanonicalReplayLaunch | null>(null);
|
||||||
const [launchError, setLaunchError] = useState<string | 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(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
setLaunch(null);
|
setLaunch(null);
|
||||||
setLaunchError(null);
|
setLaunchError(null);
|
||||||
|
setPlayback(null);
|
||||||
|
setPlaybackController(null);
|
||||||
|
setViewerStatus("idle");
|
||||||
void resolveObservationSessionReplay(review.sessionId, {
|
void resolveObservationSessionReplay(review.sessionId, {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
maximumWaitMs: 30 * 60 * 1000,
|
maximumWaitMs: 30 * 60 * 1000,
|
||||||
@@ -87,7 +185,13 @@ export function CanonicalVegetationRerunReplay({
|
|||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [resultId, review.sessionId]);
|
}, [resultId, review.sessionId]);
|
||||||
|
|
||||||
const splitView = mediaMode !== null && spatialMode !== null;
|
const presentationReady = playbackController !== null
|
||||||
|
&& isRecordedPlaybackPresentationReady(viewerStatus, playback);
|
||||||
|
const presentationState = launchError || viewerStatus === "error"
|
||||||
|
? "error"
|
||||||
|
: presentationReady
|
||||||
|
? "ready"
|
||||||
|
: "loading";
|
||||||
const sceneSettings = useMemo(() => ({
|
const sceneSettings = useMemo(() => ({
|
||||||
...defaultSceneSettings,
|
...defaultSceneSettings,
|
||||||
accumulationSeconds: spatialLayer === "local" ? 5 : 0,
|
accumulationSeconds: spatialLayer === "local" ? 5 : 0,
|
||||||
@@ -123,7 +227,7 @@ export function CanonicalVegetationRerunReplay({
|
|||||||
cuboids3d: false,
|
cuboids3d: false,
|
||||||
},
|
},
|
||||||
perceptionRetryGeneration: 0,
|
perceptionRetryGeneration: 0,
|
||||||
lockPerceptionCameraInteraction: false,
|
lockPerceptionCameraInteraction: mediaMode !== null,
|
||||||
}) : null;
|
}) : null;
|
||||||
|
|
||||||
const mediaLayerControls = (
|
const mediaLayerControls = (
|
||||||
@@ -188,9 +292,9 @@ export function CanonicalVegetationRerunReplay({
|
|||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|
||||||
const transport = playback && playbackController ? (
|
const transport = presentationReady && playback && playbackController ? (
|
||||||
<ObservationTimeline
|
<ObservationTimeline
|
||||||
className="m4-replay-threat-visual__timeline"
|
className="m4-replay-threat-visual__timeline canonical-vegetation-rerun-replay__timeline"
|
||||||
active
|
active
|
||||||
sourceCount={3}
|
sourceCount={3}
|
||||||
mode="recorded"
|
mode="recorded"
|
||||||
@@ -205,45 +309,75 @@ export function CanonicalVegetationRerunReplay({
|
|||||||
/>
|
/>
|
||||||
) : undefined;
|
) : undefined;
|
||||||
return (
|
return (
|
||||||
<CanonicalRecordedLabReplay
|
<div
|
||||||
label="RAVNOVES004TREE · канонический повтор Rerun"
|
className="canonical-vegetation-rerun-replay"
|
||||||
mediaMode={mediaMode ?? "none"}
|
data-presentation-state={presentationState}
|
||||||
mediaModes={[
|
aria-busy={presentationState === "loading"}
|
||||||
{ value: "video", label: "ВИДЕО" },
|
>
|
||||||
{ value: "camera", label: "КАМЕРА" },
|
<CanonicalRecordedLabReplay
|
||||||
]}
|
label="RAVNOVES004TREE · канонический повтор Rerun"
|
||||||
spatialMode={spatialMode ?? "none"}
|
mediaMode={mediaMode ?? "none"}
|
||||||
spatialModes={[
|
mediaModes={[
|
||||||
{ value: "3d", label: "3D" },
|
{ value: "video", label: "ВИДЕО" },
|
||||||
{ value: "plan", label: "ПЛАН" },
|
{ value: "camera", label: "КАМЕРА" },
|
||||||
]}
|
]}
|
||||||
expanded={expanded}
|
spatialMode={spatialMode ?? "none"}
|
||||||
splitPrimarySize={splitPrimarySize}
|
spatialModes={[
|
||||||
splitOrientation={splitOrientation}
|
{ value: "3d", label: "3D" },
|
||||||
mediaAriaLabel={mediaMode === "camera" ? "Камера" : "Видео и семантика"}
|
{ value: "plan", label: "ПЛАН" },
|
||||||
spatialAriaLabel={spatialMode === "plan" ? "Вид сверху" : "Трёхмерная сцена"}
|
]}
|
||||||
mediaLayerControls={mediaLayerControls}
|
expanded={expanded}
|
||||||
spatialLayerControls={spatialLayerControls}
|
splitPrimarySize={splitPrimarySize}
|
||||||
spatialLeadingControl={resetSpatialView}
|
splitOrientation={splitOrientation}
|
||||||
mediaMultiLayer
|
mediaAriaLabel={mediaMode === "camera" ? "Камера" : "Видео и семантика"}
|
||||||
unifiedContent={profile ? (
|
spatialAriaLabel={spatialMode === "plan" ? "Вид сверху" : "Трёхмерная сцена"}
|
||||||
<RerunViewport
|
mediaLayerControls={mediaLayerControls}
|
||||||
profile={profile}
|
spatialLayerControls={spatialLayerControls}
|
||||||
sceneSettings={sceneSettings}
|
spatialLeadingControl={resetSpatialView}
|
||||||
onPlaybackChange={setPlayback}
|
mediaMultiLayer
|
||||||
onPlaybackControllerChange={setPlaybackController}
|
unifiedContent={profile ? (
|
||||||
/>
|
<div
|
||||||
) : (
|
ref={viewerFrameRef}
|
||||||
<div className="l3-visual-audit__state" role={launchError ? "alert" : "status"}>
|
className="canonical-vegetation-rerun-replay__viewport-lock"
|
||||||
{launchError ?? "Готовим единый кэш канонического повтора RAV004…"}
|
data-split-view={splitView ? "true" : undefined}
|
||||||
</div>
|
style={{
|
||||||
)}
|
"--canonical-rerun-camera-pane": `${
|
||||||
emptyMessage="Выберите ВИДЕО/КАМЕРА или 3D/ПЛАН. Общие часы Rerun останутся на месте."
|
mediaMode === null
|
||||||
transport={transport}
|
? 0
|
||||||
onMediaModeChange={onMediaModeChange}
|
: splitView
|
||||||
onSpatialModeChange={onSpatialModeChange}
|
? nativeSplitPercentRef.current
|
||||||
onExpandedChange={onExpandedChange}
|
: 100
|
||||||
onSplitPrimarySizeChange={onSplitPrimarySizeChange}
|
}%`,
|
||||||
/>
|
} 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,27 @@ export interface ObservatoryCatalog {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ObservatoryCatalogMutation =
|
||||||
|
| {
|
||||||
|
readonly kind: "rename";
|
||||||
|
readonly displayName: string;
|
||||||
|
readonly revision: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly kind: "delete";
|
||||||
|
readonly revision: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ObservatoryCatalogMutationOverlay = ReadonlyMap<
|
||||||
|
string,
|
||||||
|
ObservatoryCatalogMutation
|
||||||
|
>;
|
||||||
|
|
||||||
|
export interface ObservatoryCatalogReconciliation {
|
||||||
|
readonly catalog: ObservatoryCatalog;
|
||||||
|
readonly overlay: ObservatoryCatalogMutationOverlay;
|
||||||
|
}
|
||||||
|
|
||||||
export class ObservatoryCatalogContractError extends Error {
|
export class ObservatoryCatalogContractError extends Error {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
super(message);
|
super(message);
|
||||||
@@ -42,6 +63,95 @@ export class ObservatoryCatalogContractError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function findObservatoryEvidence(
|
||||||
|
catalog: ObservatoryCatalog,
|
||||||
|
sessionId: string,
|
||||||
|
): ObservatoryEvidence | null {
|
||||||
|
for (const item of catalog.items) {
|
||||||
|
const evidence = item.evidence.find(
|
||||||
|
(candidate) => candidate.sessionId === sessionId,
|
||||||
|
);
|
||||||
|
if (evidence) return evidence;
|
||||||
|
}
|
||||||
|
return catalog.unresolvedEvidence.find(
|
||||||
|
(candidate) => candidate.sessionId === sessionId,
|
||||||
|
) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function observatoryCatalogConfirmsEvidenceDeletion(
|
||||||
|
catalog: ObservatoryCatalog,
|
||||||
|
sessionId: string,
|
||||||
|
): boolean {
|
||||||
|
return !catalog.window.laboratoryLimitReached
|
||||||
|
&& findObservatoryEvidence(catalog, sessionId) === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyObservatoryCatalogMutationOverlay(
|
||||||
|
catalog: ObservatoryCatalog,
|
||||||
|
overlay: ObservatoryCatalogMutationOverlay,
|
||||||
|
): ObservatoryCatalog {
|
||||||
|
if (overlay.size === 0) return catalog;
|
||||||
|
|
||||||
|
const removedSessionIds = new Set<string>();
|
||||||
|
const projectEvidence = (
|
||||||
|
evidence: ObservatoryEvidence,
|
||||||
|
): ObservatoryEvidence | null => {
|
||||||
|
const mutation = overlay.get(evidence.sessionId);
|
||||||
|
if (!mutation) return evidence;
|
||||||
|
if (mutation.kind === "delete") {
|
||||||
|
removedSessionIds.add(evidence.sessionId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return evidence.label === mutation.displayName
|
||||||
|
? evidence
|
||||||
|
: { ...evidence, label: mutation.displayName };
|
||||||
|
};
|
||||||
|
const projectEvidenceList = (
|
||||||
|
evidence: readonly ObservatoryEvidence[],
|
||||||
|
): ObservatoryEvidence[] => evidence.flatMap((candidate) => {
|
||||||
|
const projected = projectEvidence(candidate);
|
||||||
|
return projected ? [projected] : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = catalog.items.map((item) => ({
|
||||||
|
...item,
|
||||||
|
evidence: projectEvidenceList(item.evidence),
|
||||||
|
}));
|
||||||
|
const unresolvedEvidence = projectEvidenceList(catalog.unresolvedEvidence);
|
||||||
|
return {
|
||||||
|
...catalog,
|
||||||
|
items,
|
||||||
|
unresolvedEvidence,
|
||||||
|
window: {
|
||||||
|
...catalog.window,
|
||||||
|
laboratoryCount: Math.max(
|
||||||
|
0,
|
||||||
|
catalog.window.laboratoryCount - removedSessionIds.size,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reconcileObservatoryCatalogMutationOverlay(
|
||||||
|
serverCatalog: ObservatoryCatalog,
|
||||||
|
overlay: ObservatoryCatalogMutationOverlay,
|
||||||
|
requestMutationRevision: number,
|
||||||
|
): ObservatoryCatalogReconciliation {
|
||||||
|
const remaining = new Map(overlay);
|
||||||
|
for (const [sessionId, mutation] of overlay) {
|
||||||
|
if (mutation.revision > requestMutationRevision) continue;
|
||||||
|
const evidence = findObservatoryEvidence(serverCatalog, sessionId);
|
||||||
|
const confirmed = mutation.kind === "rename"
|
||||||
|
? evidence?.label === mutation.displayName
|
||||||
|
: observatoryCatalogConfirmsEvidenceDeletion(serverCatalog, sessionId);
|
||||||
|
if (confirmed) remaining.delete(sessionId);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
catalog: applyObservatoryCatalogMutationOverlay(serverCatalog, remaining),
|
||||||
|
overlay: remaining,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function newestFirst(left: string, right: string): number {
|
function newestFirst(left: string, right: string): number {
|
||||||
return Date.parse(right) - Date.parse(left);
|
return Date.parse(right) - Date.parse(left);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import type { ObservatoryRecordedRunBinding } from "./recordedRun";
|
||||||
|
|
||||||
|
const SAFE_SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||||
|
const RENAME_SCHEMA = "missioncore.observatory-lab-projection-rename/v1";
|
||||||
|
const PROJECTION_SCHEMA = "missioncore.observatory-lab-projection/v1";
|
||||||
|
|
||||||
|
export type ObservatoryCatalogMutationFetch = (
|
||||||
|
input: RequestInfo | URL,
|
||||||
|
init?: RequestInit,
|
||||||
|
) => Promise<Response>;
|
||||||
|
|
||||||
|
export interface ObservatoryLabProjectionMutationResult {
|
||||||
|
readonly schemaVersion: typeof PROJECTION_SCHEMA;
|
||||||
|
readonly sessionId: string;
|
||||||
|
readonly displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ObservatoryCatalogMutationError extends Error {
|
||||||
|
readonly status: number | null;
|
||||||
|
|
||||||
|
constructor(message: string, status: number | null = null) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ObservatoryCatalogMutationError";
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameObservatoryLabProjection(
|
||||||
|
binding: ObservatoryRecordedRunBinding,
|
||||||
|
displayName: string,
|
||||||
|
{
|
||||||
|
signal,
|
||||||
|
fetcher = globalThis.fetch,
|
||||||
|
}: {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
fetcher?: ObservatoryCatalogMutationFetch;
|
||||||
|
} = {},
|
||||||
|
): Promise<ObservatoryLabProjectionMutationResult> {
|
||||||
|
const sessionId = admittedProjectionId(binding);
|
||||||
|
const normalizedName = displayName.trim();
|
||||||
|
if (normalizedName.length < 1 || normalizedName.length > 160) {
|
||||||
|
throw new ObservatoryCatalogMutationError(
|
||||||
|
"Название лабораторного результата должно содержать от 1 до 160 символов.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const response = await request(
|
||||||
|
fetcher,
|
||||||
|
`/api/v1/observatory/lab-projections/${encodeURIComponent(sessionId)}`,
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
schema_version: RENAME_SCHEMA,
|
||||||
|
display_name: normalizedName,
|
||||||
|
}),
|
||||||
|
signal,
|
||||||
|
},
|
||||||
|
"Не удалось переименовать лабораторный результат.",
|
||||||
|
);
|
||||||
|
const body = await responseBody(response);
|
||||||
|
if (!response.ok || response.status !== 200) {
|
||||||
|
throw apiError(
|
||||||
|
body,
|
||||||
|
`Переименование лабораторного результата вернуло HTTP ${response.status}.`,
|
||||||
|
response.status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const decoded = decodeProjection(body);
|
||||||
|
if (decoded.sessionId !== sessionId || decoded.displayName !== normalizedName) {
|
||||||
|
throw new ObservatoryCatalogMutationError(
|
||||||
|
"Сервер не подтвердил точное переименование выбранного результата.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteObservatoryLabProjection(
|
||||||
|
binding: ObservatoryRecordedRunBinding,
|
||||||
|
{
|
||||||
|
signal,
|
||||||
|
fetcher = globalThis.fetch,
|
||||||
|
}: {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
fetcher?: ObservatoryCatalogMutationFetch;
|
||||||
|
} = {},
|
||||||
|
): Promise<void> {
|
||||||
|
const sessionId = admittedProjectionId(binding);
|
||||||
|
const response = await request(
|
||||||
|
fetcher,
|
||||||
|
`/api/v1/observatory/lab-projections/${encodeURIComponent(sessionId)}`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
signal,
|
||||||
|
},
|
||||||
|
"Не удалось удалить лабораторный результат из Обсерватории.",
|
||||||
|
);
|
||||||
|
const body = await responseBody(response);
|
||||||
|
if (!response.ok || response.status !== 204) {
|
||||||
|
throw apiError(
|
||||||
|
body,
|
||||||
|
`Удаление лабораторного результата вернуло HTTP ${response.status}.`,
|
||||||
|
response.status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (body !== undefined) {
|
||||||
|
throw new ObservatoryCatalogMutationError(
|
||||||
|
"Сервер вернул данные после подтверждённого удаления лабораторного результата.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function admittedProjectionId(binding: ObservatoryRecordedRunBinding): string {
|
||||||
|
const sessionId = binding.evidenceSessionId;
|
||||||
|
if (
|
||||||
|
binding.kind !== "canonical-recorded-rerun"
|
||||||
|
|| binding.activation !== "explicit"
|
||||||
|
|| binding.viewerProfile !== "recorded-session"
|
||||||
|
|| binding.timeline !== "session_time"
|
||||||
|
|| binding.resultId !== sessionId
|
||||||
|
|| binding.sourceSessionId === sessionId
|
||||||
|
|| !SAFE_SESSION_ID.test(sessionId)
|
||||||
|
) {
|
||||||
|
throw new ObservatoryCatalogMutationError(
|
||||||
|
"Выбранный результат не допущен к изменению каталога Обсерватории.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(
|
||||||
|
fetcher: ObservatoryCatalogMutationFetch,
|
||||||
|
input: string,
|
||||||
|
init: RequestInit,
|
||||||
|
fallback: string,
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
return await fetcher(input, init);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DOMException && error.name === "AbortError") throw error;
|
||||||
|
throw new ObservatoryCatalogMutationError(fallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function responseBody(response: Response): Promise<unknown> {
|
||||||
|
const text = await response.text();
|
||||||
|
if (!text) return undefined;
|
||||||
|
try {
|
||||||
|
return JSON.parse(text) as unknown;
|
||||||
|
} catch {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiError(body: unknown, fallback: string, status: number): Error {
|
||||||
|
if (isRecord(body) && typeof body.detail === "string" && body.detail.trim()) {
|
||||||
|
return new ObservatoryCatalogMutationError(body.detail.trim(), status);
|
||||||
|
}
|
||||||
|
if (typeof body === "string" && body.trim()) {
|
||||||
|
return new ObservatoryCatalogMutationError(body.trim(), status);
|
||||||
|
}
|
||||||
|
return new ObservatoryCatalogMutationError(fallback, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeProjection(value: unknown): ObservatoryLabProjectionMutationResult {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
throw new ObservatoryCatalogMutationError(
|
||||||
|
"Сервер вернул некорректное подтверждение лабораторного результата.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const keys = Object.keys(value).sort();
|
||||||
|
const expected = ["display_name", "schema_version", "session_id"];
|
||||||
|
if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) {
|
||||||
|
throw new ObservatoryCatalogMutationError(
|
||||||
|
"Подтверждение лабораторного результата содержит неизвестные поля.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
value.schema_version !== PROJECTION_SCHEMA
|
||||||
|
|| typeof value.session_id !== "string"
|
||||||
|
|| !SAFE_SESSION_ID.test(value.session_id)
|
||||||
|
|| typeof value.display_name !== "string"
|
||||||
|
|| value.display_name.trim() !== value.display_name
|
||||||
|
|| value.display_name.length < 1
|
||||||
|
|| value.display_name.length > 160
|
||||||
|
) {
|
||||||
|
throw new ObservatoryCatalogMutationError(
|
||||||
|
"Сервер вернул неподдерживаемый контракт лабораторного результата.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
schemaVersion: PROJECTION_SCHEMA,
|
||||||
|
sessionId: value.session_id,
|
||||||
|
displayName: value.display_name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
applyObservatoryCatalogMutationOverlay,
|
||||||
fetchObservatoryCatalog,
|
fetchObservatoryCatalog,
|
||||||
|
reconcileObservatoryCatalogMutationOverlay,
|
||||||
type ObservatoryCatalog,
|
type ObservatoryCatalog,
|
||||||
|
type ObservatoryCatalogMutationOverlay,
|
||||||
} from "./catalog";
|
} from "./catalog";
|
||||||
|
|
||||||
export type ObservatoryCatalogState =
|
export type ObservatoryCatalogState =
|
||||||
@@ -16,9 +19,23 @@ export interface ObservatoryCatalogController {
|
|||||||
readonly catalog: ObservatoryCatalog | null;
|
readonly catalog: ObservatoryCatalog | null;
|
||||||
readonly state: ObservatoryCatalogState;
|
readonly state: ObservatoryCatalogState;
|
||||||
readonly error: string | null;
|
readonly error: string | null;
|
||||||
readonly refresh: () => void;
|
readonly refresh: () => Promise<ObservatoryCatalog | null>;
|
||||||
|
readonly applyEvidenceRename: (
|
||||||
|
sessionId: string,
|
||||||
|
displayName: string,
|
||||||
|
) => void;
|
||||||
|
readonly applyEvidenceDeletion: (sessionId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ActiveCatalogRequest {
|
||||||
|
readonly id: number;
|
||||||
|
readonly controller: AbortController;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ObservatoryCatalogMutationDraft =
|
||||||
|
| { readonly kind: "rename"; readonly displayName: string }
|
||||||
|
| { readonly kind: "delete" };
|
||||||
|
|
||||||
function errorMessage(error: unknown): string {
|
function errorMessage(error: unknown): string {
|
||||||
return error instanceof Error && error.message.trim()
|
return error instanceof Error && error.message.trim()
|
||||||
? error.message
|
? error.message
|
||||||
@@ -33,31 +50,99 @@ export function useObservatoryCatalog(): ObservatoryCatalogController {
|
|||||||
const [catalog, setCatalog] = useState<ObservatoryCatalog | null>(null);
|
const [catalog, setCatalog] = useState<ObservatoryCatalog | null>(null);
|
||||||
const [state, setState] = useState<ObservatoryCatalogState>("idle");
|
const [state, setState] = useState<ObservatoryCatalogState>("idle");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [generation, setGeneration] = useState(0);
|
|
||||||
const catalogRef = useRef<ObservatoryCatalog | null>(null);
|
const catalogRef = useRef<ObservatoryCatalog | null>(null);
|
||||||
|
const overlayRef = useRef<ObservatoryCatalogMutationOverlay>(new Map());
|
||||||
|
const mutationRevisionRef = useRef(0);
|
||||||
|
const requestSequenceRef = useRef(0);
|
||||||
|
const activeRequestRef = useRef<ActiveCatalogRequest | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const loadCatalog = useCallback(async (): Promise<ObservatoryCatalog | null> => {
|
||||||
|
const id = requestSequenceRef.current + 1;
|
||||||
|
requestSequenceRef.current = id;
|
||||||
|
activeRequestRef.current?.controller.abort();
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
activeRequestRef.current = { id, controller };
|
||||||
|
const requestMutationRevision = mutationRevisionRef.current;
|
||||||
|
|
||||||
setState(catalogRef.current ? "refreshing" : "loading");
|
setState(catalogRef.current ? "refreshing" : "loading");
|
||||||
setError(null);
|
setError(null);
|
||||||
void fetchObservatoryCatalog({ signal: controller.signal })
|
try {
|
||||||
.then((next) => {
|
const serverCatalog = await fetchObservatoryCatalog({
|
||||||
if (controller.signal.aborted) return;
|
signal: controller.signal,
|
||||||
catalogRef.current = next;
|
|
||||||
setCatalog(next);
|
|
||||||
setState("ready");
|
|
||||||
})
|
|
||||||
.catch((loadError: unknown) => {
|
|
||||||
if (controller.signal.aborted || isAbortError(loadError)) return;
|
|
||||||
setError(errorMessage(loadError));
|
|
||||||
setState("error");
|
|
||||||
});
|
});
|
||||||
return () => controller.abort();
|
if (controller.signal.aborted || requestSequenceRef.current !== id) {
|
||||||
}, [generation]);
|
return null;
|
||||||
|
}
|
||||||
const refresh = useCallback(() => {
|
const reconciled = reconcileObservatoryCatalogMutationOverlay(
|
||||||
setGeneration((current) => current + 1);
|
serverCatalog,
|
||||||
|
overlayRef.current,
|
||||||
|
requestMutationRevision,
|
||||||
|
);
|
||||||
|
overlayRef.current = reconciled.overlay;
|
||||||
|
catalogRef.current = reconciled.catalog;
|
||||||
|
activeRequestRef.current = null;
|
||||||
|
setCatalog(reconciled.catalog);
|
||||||
|
setState("ready");
|
||||||
|
return reconciled.catalog;
|
||||||
|
} catch (loadError: unknown) {
|
||||||
|
if (
|
||||||
|
controller.signal.aborted
|
||||||
|
|| requestSequenceRef.current !== id
|
||||||
|
|| isAbortError(loadError)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
activeRequestRef.current = null;
|
||||||
|
setError(errorMessage(loadError));
|
||||||
|
setState("error");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { catalog, state, error, refresh };
|
useEffect(() => {
|
||||||
|
void loadCatalog();
|
||||||
|
return () => {
|
||||||
|
requestSequenceRef.current += 1;
|
||||||
|
activeRequestRef.current?.controller.abort();
|
||||||
|
activeRequestRef.current = null;
|
||||||
|
};
|
||||||
|
}, [loadCatalog]);
|
||||||
|
|
||||||
|
const applyMutation = useCallback((
|
||||||
|
sessionId: string,
|
||||||
|
mutation: ObservatoryCatalogMutationDraft,
|
||||||
|
) => {
|
||||||
|
const revision = mutationRevisionRef.current + 1;
|
||||||
|
mutationRevisionRef.current = revision;
|
||||||
|
const overlay = new Map(overlayRef.current);
|
||||||
|
overlay.set(sessionId, { ...mutation, revision });
|
||||||
|
overlayRef.current = overlay;
|
||||||
|
if (!catalogRef.current) return;
|
||||||
|
const projected = applyObservatoryCatalogMutationOverlay(
|
||||||
|
catalogRef.current,
|
||||||
|
overlay,
|
||||||
|
);
|
||||||
|
catalogRef.current = projected;
|
||||||
|
setCatalog(projected);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applyEvidenceRename = useCallback((
|
||||||
|
sessionId: string,
|
||||||
|
displayName: string,
|
||||||
|
) => {
|
||||||
|
applyMutation(sessionId, { kind: "rename", displayName });
|
||||||
|
}, [applyMutation]);
|
||||||
|
|
||||||
|
const applyEvidenceDeletion = useCallback((sessionId: string) => {
|
||||||
|
applyMutation(sessionId, { kind: "delete" });
|
||||||
|
}, [applyMutation]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
catalog,
|
||||||
|
state,
|
||||||
|
error,
|
||||||
|
refresh: loadCatalog,
|
||||||
|
applyEvidenceRename,
|
||||||
|
applyEvidenceDeletion,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,53 @@
|
|||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.canonical-vegetation-rerun-replay {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canonical-vegetation-rerun-replay:not([data-presentation-state="ready"])
|
||||||
|
.m4-replay-threat-visual__pane-toolbar,
|
||||||
|
.canonical-vegetation-rerun-replay:not([data-presentation-state="ready"])
|
||||||
|
.laboratory-evidence-viewer__controls,
|
||||||
|
.canonical-vegetation-rerun-replay:not([data-presentation-state="ready"])
|
||||||
|
.laboratory-evidence-viewer__transport {
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canonical-vegetation-rerun-replay[data-presentation-state="loading"]
|
||||||
|
.m4-replay-threat-visual__unified-content {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canonical-vegetation-rerun-replay__viewport-lock {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
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;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: var(--nodedc-canvas);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.m4-replay-threat-visual__deck[data-empty="true"] > .l3-visual-audit__state {
|
.m4-replay-threat-visual__deck[data-empty="true"] > .l3-visual-audit__state {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
@@ -282,6 +329,10 @@
|
|||||||
grid-template-columns: auto auto auto minmax(0, 1fr) auto;
|
grid-template-columns: auto auto auto minmax(0, 1fr) auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.canonical-vegetation-rerun-replay__timeline .observation-timeline__playback {
|
||||||
|
grid-template-columns: auto auto minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
.m4-replay-threat-visual__overlay {
|
.m4-replay-threat-visual__overlay {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
.observatory-catalog-bar,
|
.observatory-catalog-bar,
|
||||||
.observatory-catalog-bar__controls,
|
.observatory-catalog-bar__controls,
|
||||||
.observatory-notice,
|
.observatory-notice,
|
||||||
.observatory-session-summary > header,
|
|
||||||
.observatory-evidence > header {
|
.observatory-evidence > header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -27,7 +26,6 @@
|
|||||||
|
|
||||||
.observatory-lead > div,
|
.observatory-lead > div,
|
||||||
.observatory-catalog-bar__copy,
|
.observatory-catalog-bar__copy,
|
||||||
.observatory-session-summary header > div,
|
|
||||||
.observatory-evidence header > div {
|
.observatory-evidence header > div {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -77,64 +75,91 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.observatory-session-grid {
|
.observatory-session-stack {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(min(24rem, 100%), 1fr));
|
gap: 0.9rem;
|
||||||
gap: 1rem;
|
container-name: observatory-session;
|
||||||
align-items: start;
|
container-type: inline-size;
|
||||||
}
|
}
|
||||||
|
|
||||||
.observatory-session-summary,
|
.observatory-session-summary {
|
||||||
.observatory-evidence {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
|
grid-template-columns: minmax(14rem, 1.1fr) minmax(22rem, 1.6fr) minmax(14rem, auto);
|
||||||
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.observatory-session-summary dl,
|
.observatory-session-summary__identity {
|
||||||
.observatory-evidence-list dl {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.75rem;
|
min-width: 0;
|
||||||
|
gap: 0.22rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-session-summary__identity h3 {
|
||||||
|
margin: 0.1rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-session-summary__identity code {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: var(--nodedc-font-size-xs);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-session-summary__facts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(6.5rem, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.observatory-session-summary dl > div,
|
.observatory-session-summary__facts > div {
|
||||||
.observatory-evidence-list dl > div {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(7.5rem, 0.42fr) minmax(0, 1fr);
|
min-width: 0;
|
||||||
gap: 0.85rem;
|
gap: 0.24rem;
|
||||||
align-items: baseline;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.observatory-session-summary dt,
|
.observatory-session-summary__facts dt {
|
||||||
.observatory-evidence-list dt {
|
|
||||||
color: var(--nodedc-text-muted);
|
color: var(--nodedc-text-muted);
|
||||||
font-size: var(--nodedc-font-size-xs);
|
font-size: var(--nodedc-font-size-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.observatory-session-summary dd,
|
.observatory-session-summary__facts dd {
|
||||||
.observatory-evidence-list dd {
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
color: var(--nodedc-text-secondary);
|
color: var(--nodedc-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.observatory-session-summary code,
|
.observatory-session-summary__end {
|
||||||
.observatory-evidence-list code {
|
display: grid;
|
||||||
font-size: var(--nodedc-font-size-xs);
|
min-width: 0;
|
||||||
|
justify-items: end;
|
||||||
|
gap: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.observatory-modalities {
|
.observatory-modalities {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
color: var(--nodedc-text-muted);
|
color: var(--nodedc-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.observatory-evidence {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-evidence > header {
|
||||||
|
padding: 0 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
.observatory-evidence-list {
|
.observatory-evidence-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.75rem;
|
gap: 0.4rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -142,11 +167,51 @@
|
|||||||
|
|
||||||
.observatory-evidence-card {
|
.observatory-evidence-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.85rem;
|
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.8rem;
|
||||||
|
min-height: 4.25rem;
|
||||||
|
padding: 0.7rem 0.85rem;
|
||||||
|
border-radius: var(--nodedc-radius-control-compact);
|
||||||
|
background: var(--nodedc-glass-control-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.observatory-evidence-card__heading,
|
.observatory-evidence-card__icon {
|
||||||
.observatory-evidence-card__action,
|
display: grid;
|
||||||
|
width: 2.15rem;
|
||||||
|
height: 2.15rem;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: var(--nodedc-radius-circle);
|
||||||
|
background: var(--nodedc-panel-icon-bg);
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-evidence-card__copy {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 0.16rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-evidence-card__copy strong,
|
||||||
|
.observatory-evidence-card__copy span,
|
||||||
|
.observatory-evidence-card__copy small {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-evidence-card__copy span {
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
font-size: var(--nodedc-font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-evidence-card__copy small {
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: var(--nodedc-font-size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-evidence-card__actions,
|
||||||
.observatory-replay__header,
|
.observatory-replay__header,
|
||||||
.observatory-replay-state,
|
.observatory-replay-state,
|
||||||
.observatory-replay-state__actions {
|
.observatory-replay-state__actions {
|
||||||
@@ -156,28 +221,17 @@
|
|||||||
gap: 0.85rem;
|
gap: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.observatory-evidence-card__heading,
|
|
||||||
.observatory-evidence-card__action,
|
|
||||||
.observatory-replay__header,
|
|
||||||
.observatory-replay-state {
|
.observatory-replay-state {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.observatory-evidence-card__heading > div,
|
|
||||||
.observatory-evidence-card__action > span,
|
|
||||||
.observatory-replay__header > div,
|
.observatory-replay__header > div,
|
||||||
.observatory-replay-state > div {
|
.observatory-replay-state > div {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.observatory-evidence-card__action {
|
.observatory-evidence-card__actions:empty {
|
||||||
padding-top: 0.85rem;
|
display: none;
|
||||||
border-top: 1px solid rgba(var(--nodedc-accent-rgb), 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.observatory-evidence-card__action > span {
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
font-size: var(--nodedc-font-size-xs);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.observatory-replay,
|
.observatory-replay,
|
||||||
@@ -205,17 +259,6 @@
|
|||||||
color: var(--nodedc-text-muted);
|
color: var(--nodedc-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.observatory-evidence-list strong,
|
|
||||||
.observatory-evidence-list span {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.observatory-evidence-list span {
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
font-size: var(--nodedc-font-size-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.observatory-evidence-empty {
|
.observatory-evidence-empty {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-height: 13rem;
|
min-height: 13rem;
|
||||||
@@ -242,6 +285,56 @@
|
|||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.observatory-rename-form,
|
||||||
|
.observatory-delete-confirmation {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-delete-confirmation p,
|
||||||
|
.observatory-mutation-error {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-mutation-error {
|
||||||
|
color: rgb(var(--nodedc-danger-rgb));
|
||||||
|
font-size: var(--nodedc-font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
@container observatory-session (max-width: 56rem) {
|
||||||
|
.observatory-session-summary {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-session-summary__end,
|
||||||
|
.observatory-modalities {
|
||||||
|
justify-items: start;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@container observatory-session (max-width: 48rem) {
|
||||||
|
.observatory-evidence-card {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-evidence-card > .nodedc-status {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@container observatory-session (max-width: 38rem) {
|
||||||
|
.observatory-session-summary__facts {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-evidence-card__copy small {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 920px) {
|
@media (max-width: 920px) {
|
||||||
.observatory-lead,
|
.observatory-lead,
|
||||||
.observatory-catalog-bar,
|
.observatory-catalog-bar,
|
||||||
@@ -251,7 +344,6 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.observatory-evidence-card__action,
|
|
||||||
.observatory-replay__header,
|
.observatory-replay__header,
|
||||||
.observatory-replay-state {
|
.observatory-replay-state {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
@@ -268,7 +360,4 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.observatory-session-grid {
|
|
||||||
grid-template-columns: minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { flushSync } from "react-dom";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Button,
|
Button,
|
||||||
|
ConfirmationModal,
|
||||||
GlassSurface,
|
GlassSurface,
|
||||||
Icon,
|
Icon,
|
||||||
|
IconButton,
|
||||||
Select,
|
Select,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
|
TextField,
|
||||||
|
Window,
|
||||||
|
WindowFooterActions,
|
||||||
} from "@nodedc/ui-react";
|
} from "@nodedc/ui-react";
|
||||||
|
|
||||||
import { CanonicalVegetationRerunReplay } from "../../components/laboratory/CanonicalVegetationRerunReplay";
|
import { CanonicalVegetationRerunReplay } from "../../components/laboratory/CanonicalVegetationRerunReplay";
|
||||||
@@ -15,6 +21,15 @@ import {
|
|||||||
fetchObservatoryRecordedRunReview,
|
fetchObservatoryRecordedRunReview,
|
||||||
type ObservatoryRecordedRunBinding,
|
type ObservatoryRecordedRunBinding,
|
||||||
} from "../../core/observatory/recordedRun";
|
} from "../../core/observatory/recordedRun";
|
||||||
|
import {
|
||||||
|
findObservatoryEvidence,
|
||||||
|
observatoryCatalogConfirmsEvidenceDeletion,
|
||||||
|
type ObservatoryEvidence,
|
||||||
|
} from "../../core/observatory/catalog";
|
||||||
|
import {
|
||||||
|
deleteObservatoryLabProjection,
|
||||||
|
renameObservatoryLabProjection,
|
||||||
|
} from "../../core/observatory/catalogMutations";
|
||||||
import { useObservatoryCatalog } from "../../core/observatory/useObservatoryCatalog";
|
import { useObservatoryCatalog } from "../../core/observatory/useObservatoryCatalog";
|
||||||
import type { WorkspaceDefinition } from "../../productModel";
|
import type { WorkspaceDefinition } from "../../productModel";
|
||||||
|
|
||||||
@@ -24,6 +39,17 @@ type ObservatoryRecordedRunReview = Awaited<
|
|||||||
ReturnType<typeof fetchObservatoryRecordedRunReview>
|
ReturnType<typeof fetchObservatoryRecordedRunReview>
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
type ObservatoryMutationReconciliation =
|
||||||
|
| {
|
||||||
|
readonly kind: "rename";
|
||||||
|
readonly sessionId: string;
|
||||||
|
readonly displayName: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly kind: "delete";
|
||||||
|
readonly sessionId: string;
|
||||||
|
};
|
||||||
|
|
||||||
type ObservatoryReplayState =
|
type ObservatoryReplayState =
|
||||||
| { readonly kind: "closed" }
|
| { readonly kind: "closed" }
|
||||||
| {
|
| {
|
||||||
@@ -94,6 +120,12 @@ function catalogStateLabel(
|
|||||||
return "Читаем каталог";
|
return "Читаем каталог";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mutationErrorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error && error.message.trim()
|
||||||
|
? error.message
|
||||||
|
: "Не удалось изменить лабораторный результат в Обсерватории.";
|
||||||
|
}
|
||||||
|
|
||||||
export function ObservatoryWorkspace({
|
export function ObservatoryWorkspace({
|
||||||
definition,
|
definition,
|
||||||
}: {
|
}: {
|
||||||
@@ -102,6 +134,14 @@ export function ObservatoryWorkspace({
|
|||||||
const controller = useObservatoryCatalog();
|
const controller = useObservatoryCatalog();
|
||||||
const [selectedSessionId, setSelectedSessionId] = useState("");
|
const [selectedSessionId, setSelectedSessionId] = useState("");
|
||||||
const [replay, setReplay] = useState<ObservatoryReplayState>({ kind: "closed" });
|
const [replay, setReplay] = useState<ObservatoryReplayState>({ kind: "closed" });
|
||||||
|
const [renameTarget, setRenameTarget] = useState<ObservatoryEvidence | null>(null);
|
||||||
|
const [renameValue, setRenameValue] = useState("");
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<ObservatoryEvidence | 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 replayCoordinatorRef = useRef(createObservationReplayCoordinator());
|
const replayCoordinatorRef = useRef(createObservationReplayCoordinator());
|
||||||
const items = controller.catalog?.items ?? EMPTY_OBSERVATORY_ITEMS;
|
const items = controller.catalog?.items ?? EMPTY_OBSERVATORY_ITEMS;
|
||||||
|
|
||||||
@@ -136,16 +176,42 @@ export function ObservatoryWorkspace({
|
|||||||
const replayEvidenceId = replay.kind === "closed"
|
const replayEvidenceId = replay.kind === "closed"
|
||||||
? null
|
? null
|
||||||
: replay.binding.evidenceSessionId;
|
: replay.binding.evidenceSessionId;
|
||||||
|
const replayEvidence = replayEvidenceId === null
|
||||||
|
? null
|
||||||
|
: selectedSession?.evidence.find(
|
||||||
|
(evidence) => evidence.sessionId === replayEvidenceId,
|
||||||
|
) ?? null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
replayEvidenceId === null
|
replayEvidenceId === null
|
||||||
|| selectedSession?.evidence.some(
|
|| replayEvidence
|
||||||
(evidence) => evidence.sessionId === replayEvidenceId,
|
|
||||||
)
|
|
||||||
) return;
|
) return;
|
||||||
closeReplay();
|
closeReplay();
|
||||||
}, [closeReplay, replayEvidenceId, selectedSession]);
|
}, [closeReplay, replayEvidence, replayEvidenceId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
mutationPending !== null
|
||||||
|
|| mutationReconciliation === null
|
||||||
|
|| controller.catalog === null
|
||||||
|
) return;
|
||||||
|
const evidence = findObservatoryEvidence(
|
||||||
|
controller.catalog,
|
||||||
|
mutationReconciliation.sessionId,
|
||||||
|
);
|
||||||
|
const confirmed = mutationReconciliation.kind === "rename"
|
||||||
|
? evidence?.label === mutationReconciliation.displayName
|
||||||
|
: observatoryCatalogConfirmsEvidenceDeletion(
|
||||||
|
controller.catalog,
|
||||||
|
mutationReconciliation.sessionId,
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
setRenameTarget(null);
|
||||||
|
setDeleteTarget(null);
|
||||||
|
setMutationError(null);
|
||||||
|
setMutationReconciliation(null);
|
||||||
|
}, [controller.catalog, mutationPending, mutationReconciliation]);
|
||||||
|
|
||||||
const openReplay = useCallback((binding: ObservatoryRecordedRunBinding) => {
|
const openReplay = useCallback((binding: ObservatoryRecordedRunBinding) => {
|
||||||
const attempt = replayCoordinatorRef.current.begin();
|
const attempt = replayCoordinatorRef.current.begin();
|
||||||
@@ -173,6 +239,101 @@ export function ObservatoryWorkspace({
|
|||||||
setSelectedSessionId(sessionId);
|
setSelectedSessionId(sessionId);
|
||||||
}, [closeReplay]);
|
}, [closeReplay]);
|
||||||
|
|
||||||
|
const openRename = useCallback((evidence: ObservatoryEvidence) => {
|
||||||
|
if (!evidence.recordedRun) return;
|
||||||
|
setDeleteTarget(null);
|
||||||
|
setMutationError(null);
|
||||||
|
setMutationReconciliation(null);
|
||||||
|
setRenameValue(evidence.label);
|
||||||
|
setRenameTarget(evidence);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openDelete = useCallback((evidence: ObservatoryEvidence) => {
|
||||||
|
if (!evidence.recordedRun) return;
|
||||||
|
setRenameTarget(null);
|
||||||
|
setMutationError(null);
|
||||||
|
setMutationReconciliation(null);
|
||||||
|
setDeleteTarget(evidence);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const submitRename = useCallback(async () => {
|
||||||
|
if (!renameTarget?.recordedRun || mutationPending !== null) return;
|
||||||
|
const requestedDisplayName = renameValue.trim();
|
||||||
|
const reconciliation: ObservatoryMutationReconciliation = {
|
||||||
|
kind: "rename",
|
||||||
|
sessionId: renameTarget.sessionId,
|
||||||
|
displayName: requestedDisplayName,
|
||||||
|
};
|
||||||
|
setMutationPending("rename");
|
||||||
|
setMutationError(null);
|
||||||
|
setMutationReconciliation(reconciliation);
|
||||||
|
try {
|
||||||
|
const result = await renameObservatoryLabProjection(
|
||||||
|
renameTarget.recordedRun,
|
||||||
|
renameValue,
|
||||||
|
);
|
||||||
|
controller.applyEvidenceRename(result.sessionId, result.displayName);
|
||||||
|
setRenameTarget(null);
|
||||||
|
setMutationReconciliation(null);
|
||||||
|
void controller.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
const reconciled = await controller.refresh();
|
||||||
|
const evidence = reconciled
|
||||||
|
? findObservatoryEvidence(reconciled, reconciliation.sessionId)
|
||||||
|
: null;
|
||||||
|
if (reconciled && evidence?.label === reconciliation.displayName) {
|
||||||
|
setRenameTarget(null);
|
||||||
|
setMutationReconciliation(null);
|
||||||
|
} else {
|
||||||
|
setMutationError(mutationErrorMessage(error));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setMutationPending(null);
|
||||||
|
}
|
||||||
|
}, [controller, mutationPending, renameTarget, renameValue]);
|
||||||
|
|
||||||
|
const confirmDelete = useCallback(async () => {
|
||||||
|
if (!deleteTarget?.recordedRun || mutationPending !== null) return;
|
||||||
|
const reconciliation: ObservatoryMutationReconciliation = {
|
||||||
|
kind: "delete",
|
||||||
|
sessionId: deleteTarget.sessionId,
|
||||||
|
};
|
||||||
|
setMutationPending("delete");
|
||||||
|
setMutationError(null);
|
||||||
|
setMutationReconciliation(reconciliation);
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
replay.kind !== "closed"
|
||||||
|
&& replay.binding.evidenceSessionId === deleteTarget.sessionId
|
||||||
|
) {
|
||||||
|
flushSync(() => {
|
||||||
|
closeReplay();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await deleteObservatoryLabProjection(deleteTarget.recordedRun);
|
||||||
|
controller.applyEvidenceDeletion(deleteTarget.sessionId);
|
||||||
|
setDeleteTarget(null);
|
||||||
|
setMutationReconciliation(null);
|
||||||
|
void controller.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
const reconciled = await controller.refresh();
|
||||||
|
if (
|
||||||
|
reconciled
|
||||||
|
&& observatoryCatalogConfirmsEvidenceDeletion(
|
||||||
|
reconciled,
|
||||||
|
reconciliation.sessionId,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
setDeleteTarget(null);
|
||||||
|
setMutationReconciliation(null);
|
||||||
|
} else {
|
||||||
|
setMutationError(mutationErrorMessage(error));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setMutationPending(null);
|
||||||
|
}
|
||||||
|
}, [closeReplay, controller, deleteTarget, mutationPending, replay]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="observatory-workspace"
|
className="observatory-workspace"
|
||||||
@@ -268,22 +429,14 @@ export function ObservatoryWorkspace({
|
|||||||
<p>После завершения записи источник появится здесь без создания демонстрационных данных.</p>
|
<p>После завершения записи источник появится здесь без создания демонстрационных данных.</p>
|
||||||
</GlassSurface>
|
</GlassSurface>
|
||||||
) : selectedSession ? (
|
) : selectedSession ? (
|
||||||
<section className="observatory-session-grid" aria-label="Выбранная сессия и связанные результаты">
|
<section className="observatory-session-stack" aria-label="Выбранная сессия и связанные результаты">
|
||||||
<GlassSurface className="observatory-session-summary" padding="lg">
|
<GlassSurface className="observatory-session-summary" padding="md">
|
||||||
<header>
|
<div className="observatory-session-summary__identity">
|
||||||
<div>
|
<span className="section-eyebrow">ИСХОДНАЯ СЕССИЯ</span>
|
||||||
<span className="section-eyebrow">ИСХОДНАЯ СЕССИЯ</span>
|
<h3>{selectedSession.source.label}</h3>
|
||||||
<h3>{selectedSession.source.label}</h3>
|
<code>{selectedSession.source.id}</code>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge tone={statusTone(selectedSession.source.status)}>
|
<dl className="observatory-session-summary__facts">
|
||||||
{statusLabel[selectedSession.source.status]}
|
|
||||||
</StatusBadge>
|
|
||||||
</header>
|
|
||||||
<dl>
|
|
||||||
<div>
|
|
||||||
<dt>Идентификатор</dt>
|
|
||||||
<dd><code>{selectedSession.source.id}</code></dd>
|
|
||||||
</div>
|
|
||||||
<div><dt>Начало</dt><dd>{formatTimestamp(selectedSession.source.startedAtUtc)}</dd></div>
|
<div><dt>Начало</dt><dd>{formatTimestamp(selectedSession.source.startedAtUtc)}</dd></div>
|
||||||
<div><dt>Длительность</dt><dd>{formatDuration(selectedSession.source.durationSeconds)}</dd></div>
|
<div><dt>Длительность</dt><dd>{formatDuration(selectedSession.source.durationSeconds)}</dd></div>
|
||||||
<div>
|
<div>
|
||||||
@@ -291,18 +444,23 @@ export function ObservatoryWorkspace({
|
|||||||
<dd>{selectedSession.source.replayable ? "Доступна" : "Не подготовлена"}</dd>
|
<dd>{selectedSession.source.replayable ? "Доступна" : "Не подготовлена"}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
<div className="observatory-modalities" aria-label="Каналы сессии">
|
<div className="observatory-session-summary__end">
|
||||||
{selectedSession.source.modalities.length > 0
|
<StatusBadge tone={statusTone(selectedSession.source.status)}>
|
||||||
? selectedSession.source.modalities.map((modality) => (
|
{statusLabel[selectedSession.source.status]}
|
||||||
<StatusBadge key={modality} tone="neutral">
|
</StatusBadge>
|
||||||
{modalityLabel[modality] ?? modality}
|
<div className="observatory-modalities" aria-label="Каналы сессии">
|
||||||
</StatusBadge>
|
{selectedSession.source.modalities.length > 0
|
||||||
))
|
? selectedSession.source.modalities.map((modality) => (
|
||||||
: <span>Каналы не зафиксированы</span>}
|
<StatusBadge key={modality} tone="neutral">
|
||||||
|
{modalityLabel[modality] ?? modality}
|
||||||
|
</StatusBadge>
|
||||||
|
))
|
||||||
|
: <span>Каналы не зафиксированы</span>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</GlassSurface>
|
</GlassSurface>
|
||||||
|
|
||||||
<GlassSurface className="observatory-evidence" padding="lg">
|
<section className="observatory-evidence">
|
||||||
<header>
|
<header>
|
||||||
<div>
|
<div>
|
||||||
<span className="section-eyebrow">СВЯЗАННЫЕ РЕЗУЛЬТАТЫ</span>
|
<span className="section-eyebrow">СВЯЗАННЫЕ РЕЗУЛЬТАТЫ</span>
|
||||||
@@ -316,53 +474,52 @@ export function ObservatoryWorkspace({
|
|||||||
<ol className="observatory-evidence-list">
|
<ol className="observatory-evidence-list">
|
||||||
{presentedEvidence.map((evidence) => (
|
{presentedEvidence.map((evidence) => (
|
||||||
<li key={evidence.sessionId}>
|
<li key={evidence.sessionId}>
|
||||||
<GlassSurface className="observatory-evidence-card" padding="md" tone="soft">
|
<div className="observatory-evidence-card">
|
||||||
<div className="observatory-evidence-card__heading">
|
<span className="observatory-evidence-card__icon" aria-hidden="true">
|
||||||
<div>
|
<Icon name="clipboard" size={18} />
|
||||||
<strong>{evidence.lab.labId}</strong>
|
</span>
|
||||||
<span>{evidence.label}</span>
|
<div className="observatory-evidence-card__copy">
|
||||||
</div>
|
<strong>{evidence.lab.labId}</strong>
|
||||||
|
<span>{evidence.label}</span>
|
||||||
|
<small>
|
||||||
|
{evidence.lab.resultKind} · {formatTimestamp(evidence.publishedAtUtc)}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
{evidence.recordedRun ? (
|
||||||
|
<StatusBadge tone="accent">Записанный разбор</StatusBadge>
|
||||||
|
) : null}
|
||||||
|
<div className="observatory-evidence-card__actions">
|
||||||
{evidence.recordedRun ? (
|
{evidence.recordedRun ? (
|
||||||
<StatusBadge tone="accent">Записанный разбор</StatusBadge>
|
<>
|
||||||
|
<IconButton
|
||||||
|
label={`Удалить ${evidence.lab.labId} из Обсерватории`}
|
||||||
|
onClick={() => openDelete(evidence)}
|
||||||
|
>
|
||||||
|
<Icon name="trash" size={16} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
label={`Переименовать ${evidence.lab.labId}`}
|
||||||
|
onClick={() => openRename(evidence)}
|
||||||
|
>
|
||||||
|
<Icon name="edit" size={16} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
label={`Открыть визуальный разбор: ${evidence.lab.labId}`}
|
||||||
|
disabled={
|
||||||
|
replay.kind === "loading"
|
||||||
|
&& replay.binding.evidenceSessionId === evidence.sessionId
|
||||||
|
}
|
||||||
|
onClick={() => openReplay(evidence.recordedRun!)}
|
||||||
|
>
|
||||||
|
{replay.kind === "loading"
|
||||||
|
&& replay.binding.evidenceSessionId === evidence.sessionId
|
||||||
|
? <ActivityIndicator size="compact" />
|
||||||
|
: <Icon name="eye" size={16} />}
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<dl>
|
</div>
|
||||||
<div><dt>Тип результата</dt><dd>{evidence.lab.resultKind}</dd></div>
|
|
||||||
<div>
|
|
||||||
<dt>Result ID</dt>
|
|
||||||
<dd><code>{evidence.lab.resultId}</code></dd>
|
|
||||||
</div>
|
|
||||||
<div><dt>Опубликован</dt><dd>{formatTimestamp(evidence.publishedAtUtc)}</dd></div>
|
|
||||||
</dl>
|
|
||||||
{evidence.recordedRun ? (
|
|
||||||
<div className="observatory-evidence-card__action">
|
|
||||||
<span>
|
|
||||||
Записанный маршрут · единая временная шкала · только наблюдение
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
size="compact"
|
|
||||||
variant="primary"
|
|
||||||
icon={<Icon name="play" size={14} />}
|
|
||||||
disabled={
|
|
||||||
replay.kind === "loading"
|
|
||||||
&& replay.binding.evidenceSessionId === evidence.sessionId
|
|
||||||
}
|
|
||||||
onClick={() => openReplay(evidence.recordedRun!)}
|
|
||||||
>
|
|
||||||
{replay.kind === "loading"
|
|
||||||
&& replay.binding.evidenceSessionId === evidence.sessionId
|
|
||||||
? "Проверяем результат"
|
|
||||||
: replay.kind === "ready"
|
|
||||||
&& replay.binding.evidenceSessionId === evidence.sessionId
|
|
||||||
? "Открыть заново"
|
|
||||||
: replay.kind === "error"
|
|
||||||
&& replay.binding.evidenceSessionId === evidence.sessionId
|
|
||||||
? "Повторить открытие"
|
|
||||||
: "Открыть визуальный разбор"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</GlassSurface>
|
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
@@ -382,7 +539,7 @@ export function ObservatoryWorkspace({
|
|||||||
{" "}связанных результатов. Полный архив остаётся в legacy LAB.
|
{" "}связанных результатов. Полный архив остаётся в legacy LAB.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</GlassSurface>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -418,7 +575,7 @@ export function ObservatoryWorkspace({
|
|||||||
<header className="observatory-replay__header">
|
<header className="observatory-replay__header">
|
||||||
<div>
|
<div>
|
||||||
<span className="section-eyebrow">ВИЗУАЛЬНЫЙ РАЗБОР / ЗАПИСАННАЯ СЕССИЯ</span>
|
<span className="section-eyebrow">ВИЗУАЛЬНЫЙ РАЗБОР / ЗАПИСАННАЯ СЕССИЯ</span>
|
||||||
<h3>RAVNOVES004TREE · полный маршрут восприятия</h3>
|
<h3>{replayEvidence?.label ?? replay.binding.resultId}</h3>
|
||||||
<p>Записанный маршрут синхронизирован по общей временной шкале.</p>
|
<p>Записанный маршрут синхронизирован по общей временной шкале.</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -446,6 +603,95 @@ export function ObservatoryWorkspace({
|
|||||||
</span>
|
</span>
|
||||||
</GlassSurface>
|
</GlassSurface>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<Window
|
||||||
|
open={renameTarget !== null}
|
||||||
|
title="Переименовать лабораторный результат"
|
||||||
|
subtitle="Меняется только отображаемое название в Обсерватории"
|
||||||
|
size="sm"
|
||||||
|
closeOnBackdrop={mutationPending !== "rename"}
|
||||||
|
closeOnEscape={mutationPending !== "rename"}
|
||||||
|
onClose={() => {
|
||||||
|
if (mutationPending === "rename") return;
|
||||||
|
setRenameTarget(null);
|
||||||
|
setMutationError(null);
|
||||||
|
setMutationReconciliation(null);
|
||||||
|
}}
|
||||||
|
footer={(
|
||||||
|
<WindowFooterActions>
|
||||||
|
<Button
|
||||||
|
disabled={mutationPending === "rename"}
|
||||||
|
onClick={() => {
|
||||||
|
setRenameTarget(null);
|
||||||
|
setMutationError(null);
|
||||||
|
setMutationReconciliation(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="observatory-rename-form"
|
||||||
|
variant="primary"
|
||||||
|
disabled={mutationPending === "rename" || renameValue.trim().length === 0}
|
||||||
|
>
|
||||||
|
{mutationPending === "rename" ? "Сохраняем…" : "Сохранить"}
|
||||||
|
</Button>
|
||||||
|
</WindowFooterActions>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
id="observatory-rename-form"
|
||||||
|
className="observatory-rename-form"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void submitRename();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
label="Название"
|
||||||
|
value={renameValue}
|
||||||
|
maxLength={160}
|
||||||
|
autoComplete="off"
|
||||||
|
autoFocus
|
||||||
|
disabled={mutationPending === "rename"}
|
||||||
|
onChange={(event) => setRenameValue(event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
{mutationError && renameTarget ? (
|
||||||
|
<p className="observatory-mutation-error" role="alert">{mutationError}</p>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
</Window>
|
||||||
|
|
||||||
|
<ConfirmationModal
|
||||||
|
open={deleteTarget !== null}
|
||||||
|
title="Удалить результат из Обсерватории?"
|
||||||
|
description={deleteTarget ? (
|
||||||
|
<div className="observatory-delete-confirmation">
|
||||||
|
<p>
|
||||||
|
Будут удалены только каталожная проекция <strong>{deleteTarget.label}</strong>
|
||||||
|
{" "}и её отображаемое название в Обсерватории.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Исходная сессия, запечатанный лабораторный результат и файлы доказательств
|
||||||
|
останутся неизменными.
|
||||||
|
</p>
|
||||||
|
{mutationError ? (
|
||||||
|
<p className="observatory-mutation-error" role="alert">{mutationError}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
confirmLabel="Удалить из Обсерватории"
|
||||||
|
pendingLabel="Удаляем…"
|
||||||
|
danger
|
||||||
|
onClose={() => {
|
||||||
|
if (mutationPending === "delete") return;
|
||||||
|
setDeleteTarget(null);
|
||||||
|
setMutationError(null);
|
||||||
|
setMutationReconciliation(null);
|
||||||
|
}}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -886,8 +886,8 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
|
|||||||
assert.match(canonical, /primary=\{mediaPane\}/);
|
assert.match(canonical, /primary=\{mediaPane\}/);
|
||||||
assert.match(canonical, /secondary=\{spatialPane/);
|
assert.match(canonical, /secondary=\{spatialPane/);
|
||||||
assert.match(canonical, /primarySize=\{splitView \? splitPrimarySize : mediaMode !== "none" \? 100 : 0\}/);
|
assert.match(canonical, /primarySize=\{splitView \? splitPrimarySize : mediaMode !== "none" \? 100 : 0\}/);
|
||||||
assert.match(canonical, /resizable=\{splitView\}/);
|
assert.match(canonical, /resizable=\{splitView && !unifiedContent\}/);
|
||||||
assert.match(canonical, /separatorLabel="Изменить размер VIDEO\/CAMERA и 3D\/PLAN"/);
|
assert.match(canonical, /separatorLabel="Изменить размер видео\/камеры и 3D\/плана"/);
|
||||||
assert.match(canonical, /secondaryMode=\{\{/);
|
assert.match(canonical, /secondaryMode=\{\{/);
|
||||||
assert.match(visual, /playback=\{playbackController\.playback\}/);
|
assert.match(visual, /playback=\{playbackController\.playback\}/);
|
||||||
assert.match(visual, /clock: "external"/);
|
assert.match(visual, /clock: "external"/);
|
||||||
|
|||||||
@@ -4,8 +4,12 @@ import { after, before, test } from "node:test";
|
|||||||
import { createServer } from "vite";
|
import { createServer } from "vite";
|
||||||
|
|
||||||
let server;
|
let server;
|
||||||
|
let applyObservatoryCatalogMutationOverlay;
|
||||||
let buildObservatoryCatalog;
|
let buildObservatoryCatalog;
|
||||||
let fetchObservatoryCatalog;
|
let fetchObservatoryCatalog;
|
||||||
|
let findObservatoryEvidence;
|
||||||
|
let observatoryCatalogConfirmsEvidenceDeletion;
|
||||||
|
let reconcileObservatoryCatalogMutationOverlay;
|
||||||
let ObservatoryCatalogContractError;
|
let ObservatoryCatalogContractError;
|
||||||
|
|
||||||
before(async () => {
|
before(async () => {
|
||||||
@@ -15,8 +19,12 @@ before(async () => {
|
|||||||
server: { middlewareMode: true },
|
server: { middlewareMode: true },
|
||||||
});
|
});
|
||||||
({
|
({
|
||||||
|
applyObservatoryCatalogMutationOverlay,
|
||||||
buildObservatoryCatalog,
|
buildObservatoryCatalog,
|
||||||
fetchObservatoryCatalog,
|
fetchObservatoryCatalog,
|
||||||
|
findObservatoryEvidence,
|
||||||
|
observatoryCatalogConfirmsEvidenceDeletion,
|
||||||
|
reconcileObservatoryCatalogMutationOverlay,
|
||||||
ObservatoryCatalogContractError,
|
ObservatoryCatalogContractError,
|
||||||
} = await server.ssrLoadModule("/src/core/observatory/catalog.ts"));
|
} = await server.ssrLoadModule("/src/core/observatory/catalog.ts"));
|
||||||
});
|
});
|
||||||
@@ -233,3 +241,134 @@ test("Observatory projects a typed canonical run only through its exact sourceSe
|
|||||||
activation: "explicit",
|
activation: "explicit",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Observatory applies an exact rename locally without mutating canonical identity", () => {
|
||||||
|
const catalog = buildObservatoryCatalog(
|
||||||
|
[source("source", "2026-08-29T10:00:00Z")],
|
||||||
|
[evidence("result", "source", "2026-08-29T11:00:00Z")],
|
||||||
|
);
|
||||||
|
const original = catalog.items[0].evidence[0];
|
||||||
|
const projected = applyObservatoryCatalogMutationOverlay(
|
||||||
|
catalog,
|
||||||
|
new Map([["result", {
|
||||||
|
kind: "rename",
|
||||||
|
displayName: "Операторское имя",
|
||||||
|
revision: 1,
|
||||||
|
}]]),
|
||||||
|
);
|
||||||
|
const renamed = findObservatoryEvidence(projected, "result");
|
||||||
|
|
||||||
|
assert.equal(renamed.label, "Операторское имя");
|
||||||
|
assert.equal(renamed.sessionId, original.sessionId);
|
||||||
|
assert.equal(renamed.lab, original.lab);
|
||||||
|
assert.equal(renamed.recordedRun, original.recordedRun);
|
||||||
|
assert.equal(projected.items[0].source, catalog.items[0].source);
|
||||||
|
assert.equal(projected.window.laboratoryCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Observatory tombstone removes only its catalog evidence projection", () => {
|
||||||
|
const catalog = buildObservatoryCatalog(
|
||||||
|
[source("source", "2026-08-29T10:00:00Z")],
|
||||||
|
[
|
||||||
|
evidence("remove", "source", "2026-08-29T12:00:00Z"),
|
||||||
|
evidence("keep", "source", "2026-08-29T11:00:00Z"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const projected = applyObservatoryCatalogMutationOverlay(
|
||||||
|
catalog,
|
||||||
|
new Map([["remove", { kind: "delete", revision: 1 }]]),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(projected.items.length, 1);
|
||||||
|
assert.equal(projected.items[0].source, catalog.items[0].source);
|
||||||
|
assert.deepEqual(
|
||||||
|
projected.items[0].evidence.map(({ sessionId }) => sessionId),
|
||||||
|
["keep"],
|
||||||
|
);
|
||||||
|
assert.equal(projected.window.sourceCount, 1);
|
||||||
|
assert.equal(projected.window.laboratoryCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Observatory confirms an ambiguous delete only from a complete LAB window", () => {
|
||||||
|
const bounded = buildObservatoryCatalog(
|
||||||
|
[source("source", "2026-08-29T10:00:00Z")],
|
||||||
|
[evidence("other", "source", "2026-08-29T11:00:00Z")],
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
const complete = buildObservatoryCatalog(
|
||||||
|
[source("source", "2026-08-29T10:00:00Z")],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(observatoryCatalogConfirmsEvidenceDeletion(bounded, "result"), false);
|
||||||
|
assert.equal(observatoryCatalogConfirmsEvidenceDeletion(complete, "result"), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Observatory reconciliation cannot let a stale in-flight fetch undo a local mutation", () => {
|
||||||
|
const oldCatalog = buildObservatoryCatalog(
|
||||||
|
[source("source", "2026-08-29T10:00:00Z")],
|
||||||
|
[evidence("result", "source", "2026-08-29T11:00:00Z")],
|
||||||
|
);
|
||||||
|
const renameOverlay = new Map([["result", {
|
||||||
|
kind: "rename",
|
||||||
|
displayName: "Новое имя",
|
||||||
|
revision: 1,
|
||||||
|
}]]);
|
||||||
|
|
||||||
|
const staleRename = reconcileObservatoryCatalogMutationOverlay(
|
||||||
|
oldCatalog,
|
||||||
|
renameOverlay,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assert.equal(findObservatoryEvidence(staleRename.catalog, "result").label, "Новое имя");
|
||||||
|
assert.equal(staleRename.overlay.has("result"), true);
|
||||||
|
|
||||||
|
const reconciledOldRename = reconcileObservatoryCatalogMutationOverlay(
|
||||||
|
oldCatalog,
|
||||||
|
renameOverlay,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
assert.equal(findObservatoryEvidence(reconciledOldRename.catalog, "result").label, "Новое имя");
|
||||||
|
assert.equal(reconciledOldRename.overlay.has("result"), true);
|
||||||
|
|
||||||
|
const renamedEvidence = evidence("result", "source", "2026-08-29T11:00:00Z");
|
||||||
|
renamedEvidence.label = "Новое имя";
|
||||||
|
const confirmedRename = reconcileObservatoryCatalogMutationOverlay(
|
||||||
|
buildObservatoryCatalog(
|
||||||
|
[source("source", "2026-08-29T10:00:00Z")],
|
||||||
|
[renamedEvidence],
|
||||||
|
),
|
||||||
|
staleRename.overlay,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
assert.equal(findObservatoryEvidence(confirmedRename.catalog, "result").label, "Новое имя");
|
||||||
|
assert.equal(confirmedRename.overlay.size, 0);
|
||||||
|
|
||||||
|
const deleteOverlay = new Map([["result", { kind: "delete", revision: 2 }]]);
|
||||||
|
const staleDelete = reconcileObservatoryCatalogMutationOverlay(
|
||||||
|
oldCatalog,
|
||||||
|
deleteOverlay,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
assert.equal(findObservatoryEvidence(staleDelete.catalog, "result"), null);
|
||||||
|
assert.equal(staleDelete.overlay.has("result"), true);
|
||||||
|
|
||||||
|
const reconciledOldDelete = reconcileObservatoryCatalogMutationOverlay(
|
||||||
|
oldCatalog,
|
||||||
|
deleteOverlay,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
assert.equal(findObservatoryEvidence(reconciledOldDelete.catalog, "result"), null);
|
||||||
|
assert.equal(reconciledOldDelete.overlay.has("result"), true);
|
||||||
|
|
||||||
|
const confirmedDelete = reconcileObservatoryCatalogMutationOverlay(
|
||||||
|
buildObservatoryCatalog(
|
||||||
|
[source("source", "2026-08-29T10:00:00Z")],
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
staleDelete.overlay,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
assert.equal(findObservatoryEvidence(confirmedDelete.catalog, "result"), null);
|
||||||
|
assert.equal(confirmedDelete.overlay.size, 0);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { after, before, test } from "node:test";
|
||||||
|
|
||||||
|
import { createServer } from "vite";
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let deleteObservatoryLabProjection;
|
||||||
|
let renameObservatoryLabProjection;
|
||||||
|
let ObservatoryCatalogMutationError;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
server = await createServer({
|
||||||
|
appType: "custom",
|
||||||
|
logLevel: "silent",
|
||||||
|
server: { middlewareMode: true },
|
||||||
|
});
|
||||||
|
({
|
||||||
|
deleteObservatoryLabProjection,
|
||||||
|
renameObservatoryLabProjection,
|
||||||
|
ObservatoryCatalogMutationError,
|
||||||
|
} = await server.ssrLoadModule("/src/core/observatory/catalogMutations.ts"));
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await server?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionId = `lab-v1-vegetation-shadow-${"8".repeat(64)}`;
|
||||||
|
const binding = {
|
||||||
|
kind: "canonical-recorded-rerun",
|
||||||
|
evidenceSessionId: sessionId,
|
||||||
|
sourceSessionId: "20260828T130511Z_viewer_live",
|
||||||
|
resultId: sessionId,
|
||||||
|
viewerProfile: "recorded-session",
|
||||||
|
timeline: "session_time",
|
||||||
|
activation: "explicit",
|
||||||
|
};
|
||||||
|
|
||||||
|
test("Observatory rename sends the closed projection-only contract", async () => {
|
||||||
|
const calls = [];
|
||||||
|
const result = await renameObservatoryLabProjection(binding, " Новый разбор ", {
|
||||||
|
fetcher: async (input, init) => {
|
||||||
|
calls.push({ input, init });
|
||||||
|
return Response.json({
|
||||||
|
schema_version: "missioncore.observatory-lab-projection/v1",
|
||||||
|
session_id: sessionId,
|
||||||
|
display_name: "Новый разбор",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
schemaVersion: "missioncore.observatory-lab-projection/v1",
|
||||||
|
sessionId,
|
||||||
|
displayName: "Новый разбор",
|
||||||
|
});
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.equal(calls[0].input, `/api/v1/observatory/lab-projections/${sessionId}`);
|
||||||
|
assert.equal(calls[0].init.method, "PATCH");
|
||||||
|
assert.equal(calls[0].init.headers.Accept, "application/json");
|
||||||
|
assert.equal(calls[0].init.headers["Content-Type"], "application/json");
|
||||||
|
assert.deepEqual(JSON.parse(calls[0].init.body), {
|
||||||
|
schema_version: "missioncore.observatory-lab-projection-rename/v1",
|
||||||
|
display_name: "Новый разбор",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Observatory delete accepts only an empty 204 response", async () => {
|
||||||
|
const calls = [];
|
||||||
|
await deleteObservatoryLabProjection(binding, {
|
||||||
|
fetcher: async (input, init) => {
|
||||||
|
calls.push({ input, init });
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.equal(calls[0].input, `/api/v1/observatory/lab-projections/${sessionId}`);
|
||||||
|
assert.equal(calls[0].init.method, "DELETE");
|
||||||
|
await assert.rejects(
|
||||||
|
deleteObservatoryLabProjection(binding, {
|
||||||
|
fetcher: async () => new Response("unexpected", { status: 200 }),
|
||||||
|
}),
|
||||||
|
ObservatoryCatalogMutationError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Observatory mutations fail closed before network access", async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const fetcher = async () => {
|
||||||
|
calls += 1;
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
};
|
||||||
|
await assert.rejects(
|
||||||
|
renameObservatoryLabProjection(binding, " ", { fetcher }),
|
||||||
|
/от 1 до 160/,
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
deleteObservatoryLabProjection({ ...binding, resultId: "different" }, { fetcher }),
|
||||||
|
/не допущен/,
|
||||||
|
);
|
||||||
|
assert.equal(calls, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Observatory mutations reject response drift and preserve API detail", async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
renameObservatoryLabProjection(binding, "Разбор", {
|
||||||
|
fetcher: async () => Response.json({
|
||||||
|
schema_version: "missioncore.observatory-lab-projection/v1",
|
||||||
|
session_id: sessionId,
|
||||||
|
display_name: "Разбор",
|
||||||
|
unexpected: true,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
/неизвестные поля/,
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
deleteObservatoryLabProjection(binding, {
|
||||||
|
fetcher: async () => Response.json({ detail: "Проекция не принадлежит Обсерватории." }, { status: 409 }),
|
||||||
|
}),
|
||||||
|
(error) => error instanceof ObservatoryCatalogMutationError
|
||||||
|
&& error.status === 409
|
||||||
|
&& error.message === "Проекция не принадлежит Обсерватории.",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -81,12 +81,22 @@ test("Observatory mounts the one shared canonical replay only after explicit adm
|
|||||||
assert.match(workspace, /вне текущего загруженного среза/);
|
assert.match(workspace, /вне текущего загруженного среза/);
|
||||||
assert.match(workspace, /observatory-notice__copy/);
|
assert.match(workspace, /observatory-notice__copy/);
|
||||||
assert.match(workspace, /observatory-evidence-card/);
|
assert.match(workspace, /observatory-evidence-card/);
|
||||||
|
assert.match(workspace, /observatory-session-stack/);
|
||||||
|
assert.match(workspace, /observatory-session-summary__facts/);
|
||||||
|
assert.match(workspace, /observatory-evidence-card__copy/);
|
||||||
|
assert.match(
|
||||||
|
workspace,
|
||||||
|
/evidence\.recordedRun \? \([\s\S]*name="trash"[\s\S]*name="edit"[\s\S]*Открыть визуальный разбор:[\s\S]*name="eye"/,
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(workspace, /observatory-evidence-card[^>]*tone="soft"/);
|
||||||
assert.match(workspace, /Открыть визуальный разбор/);
|
assert.match(workspace, /Открыть визуальный разбор/);
|
||||||
assert.match(workspace, /replay\.kind === "ready"[\s\S]*<CanonicalVegetationRerunReplay/);
|
assert.match(workspace, /replay\.kind === "ready"[\s\S]*<CanonicalVegetationRerunReplay/);
|
||||||
assert.match(workspace, /Проверяем точную связь результата/);
|
assert.match(workspace, /Проверяем точную связь результата/);
|
||||||
assert.match(workspace, /role="alert"/);
|
assert.match(workspace, /role="alert"/);
|
||||||
assert.match(workspace, /Повторить/);
|
assert.match(workspace, /Повторить/);
|
||||||
assert.match(workspace, /Закрыть разбор/);
|
assert.match(workspace, /Закрыть разбор/);
|
||||||
|
assert.match(workspace, /<h3>\{replayEvidence\?\.label \?\? replay\.binding\.resultId\}<\/h3>/);
|
||||||
|
assert.doesNotMatch(workspace, /RAVNOVES004TREE · полный маршрут восприятия/);
|
||||||
assert.match(workspace, /const selectSession[\s\S]*closeReplay\(\);[\s\S]*setSelectedSessionId/);
|
assert.match(workspace, /const selectSession[\s\S]*closeReplay\(\);[\s\S]*setSelectedSessionId/);
|
||||||
assert.match(workspace, /data-observatory-authority="observation-only"/);
|
assert.match(workspace, /data-observatory-authority="observation-only"/);
|
||||||
assert.doesNotMatch(workspace, /Нарушена связь|compactIdentity/);
|
assert.doesNotMatch(workspace, /Нарушена связь|compactIdentity/);
|
||||||
@@ -112,5 +122,70 @@ test("Observatory mounts the one shared canonical replay only after explicit adm
|
|||||||
styles,
|
styles,
|
||||||
/@media \(max-width: 920px\)[\s\S]*\.observatory-catalog-bar__controls \.nodedc-select-anchor \{[\s\S]*flex: 0 0 auto;/,
|
/@media \(max-width: 920px\)[\s\S]*\.observatory-catalog-bar__controls \.nodedc-select-anchor \{[\s\S]*flex: 0 0 auto;/,
|
||||||
);
|
);
|
||||||
|
assert.match(
|
||||||
|
styles,
|
||||||
|
/\.observatory-session-summary \{[\s\S]*grid-template-columns:[\s\S]*\.observatory-evidence-card \{[\s\S]*background: var\(--nodedc-glass-control-bg\);/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
styles,
|
||||||
|
/\.observatory-session-stack \{[\s\S]*container-name: observatory-session;[\s\S]*container-type: inline-size;/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
styles,
|
||||||
|
/@container observatory-session \(max-width: 56rem\) \{[\s\S]*\.observatory-session-summary \{[\s\S]*grid-template-columns: minmax\(0, 1fr\);/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
styles,
|
||||||
|
/@container observatory-session \(max-width: 38rem\) \{[\s\S]*\.observatory-session-summary__facts \{[\s\S]*grid-template-columns: minmax\(0, 1fr\);/,
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(styles, /\.observatory-evidence-card[\s\S]*background:\s*(?:#0{3,6}|black|rgb\(0[ ,])/i);
|
||||||
assert.doesNotMatch(styles, /nodedc-glass-surface|nodedc-status-badge/);
|
assert.doesNotMatch(styles, /nodedc-glass-surface|nodedc-status-badge/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Observatory rename and delete use admitted projection mutations and canonical windows", async () => {
|
||||||
|
const [workspace, hook] = await Promise.all([
|
||||||
|
read("workspaces/observatory/ObservatoryWorkspace.tsx"),
|
||||||
|
read("core/observatory/useObservatoryCatalog.ts"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.match(
|
||||||
|
workspace,
|
||||||
|
/deleteObservatoryLabProjection,[\s\S]*renameObservatoryLabProjection,[\s\S]*from "\.\.\/\.\.\/core\/observatory\/catalogMutations"/,
|
||||||
|
);
|
||||||
|
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, /Исходная сессия, запечатанный лабораторный результат и файлы доказательств/);
|
||||||
|
assert.match(
|
||||||
|
workspace,
|
||||||
|
/const result = await renameObservatoryLabProjection\([\s\S]*controller\.applyEvidenceRename\(result\.sessionId, result\.displayName\);[\s\S]*setRenameTarget\(null\);[\s\S]*void controller\.refresh\(\);/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
workspace,
|
||||||
|
/catch \(error\) \{[\s\S]*const reconciled = await controller\.refresh\(\);[\s\S]*evidence\?\.label === reconciliation\.displayName[\s\S]*setRenameTarget\(null\);/,
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteFlowStart = workspace.indexOf("const confirmDelete = useCallback");
|
||||||
|
const deleteFlowEnd = workspace.indexOf("}, [closeReplay, controller", deleteFlowStart);
|
||||||
|
assert.ok(deleteFlowStart >= 0 && deleteFlowEnd > deleteFlowStart);
|
||||||
|
const deleteFlow = workspace.slice(deleteFlowStart, deleteFlowEnd);
|
||||||
|
const teardown = deleteFlow.indexOf("closeReplay();");
|
||||||
|
const remove = deleteFlow.indexOf("await deleteObservatoryLabProjection");
|
||||||
|
const tombstone = deleteFlow.indexOf("controller.applyEvidenceDeletion");
|
||||||
|
const refresh = deleteFlow.indexOf("void controller.refresh();");
|
||||||
|
assert.ok(teardown >= 0 && teardown < remove, "active replay must unmount before projection delete");
|
||||||
|
assert.ok(remove < tombstone, "local tombstone must follow the exact empty 204");
|
||||||
|
assert.ok(tombstone < refresh, "reconciliation refresh must follow the local tombstone");
|
||||||
|
assert.match(deleteFlow, /flushSync\(\(\) => \{[\s\S]*closeReplay\(\);[\s\S]*\}\);/);
|
||||||
|
assert.match(
|
||||||
|
deleteFlow,
|
||||||
|
/catch \(error\) \{[\s\S]*const reconciled = await controller\.refresh\(\);[\s\S]*observatoryCatalogConfirmsEvidenceDeletion\([\s\S]*setDeleteTarget\(null\);/,
|
||||||
|
);
|
||||||
|
assert.match(workspace, /mutationError[\s\S]*role="alert"/);
|
||||||
|
assert.match(hook, /activeRequestRef\.current\?\.controller\.abort\(\)/);
|
||||||
|
assert.match(hook, /requestSequenceRef\.current !== id/);
|
||||||
|
assert.match(hook, /const requestMutationRevision = mutationRevisionRef\.current/);
|
||||||
|
assert.match(hook, /reconcileObservatoryCatalogMutationOverlay\(/);
|
||||||
|
assert.match(hook, /applyObservatoryCatalogMutationOverlay\(/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -452,7 +452,7 @@ test("canonical recorded LAB spatial frame keeps source, SLAM and body identity
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("vegetation realtime LAB uses one upstream Rerun clock and keeps archival review separate", async () => {
|
test("vegetation realtime LAB uses one upstream Rerun clock and keeps archival review separate", async () => {
|
||||||
const [resultSource, benchmarkSource, m49Source, canonicalSource, rerunSource] = await Promise.all([
|
const [resultSource, benchmarkSource, m49Source, canonicalSource, rerunSource, replayStyles] = await Promise.all([
|
||||||
readFile(
|
readFile(
|
||||||
new URL("../src/workspaces/laboratory/VegetationShadowResult.tsx", import.meta.url),
|
new URL("../src/workspaces/laboratory/VegetationShadowResult.tsx", import.meta.url),
|
||||||
"utf8",
|
"utf8",
|
||||||
@@ -473,6 +473,10 @@ test("vegetation realtime LAB uses one upstream Rerun clock and keeps archival r
|
|||||||
new URL("../src/components/laboratory/CanonicalVegetationRerunReplay.tsx", import.meta.url),
|
new URL("../src/components/laboratory/CanonicalVegetationRerunReplay.tsx", import.meta.url),
|
||||||
"utf8",
|
"utf8",
|
||||||
),
|
),
|
||||||
|
readFile(
|
||||||
|
new URL("../src/styles/m4-replay-threat.css", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
assert.doesNotMatch(resultSource, /M48MaskComparisonVisual/);
|
assert.doesNotMatch(resultSource, /M48MaskComparisonVisual/);
|
||||||
assert.match(resultSource, /M49TgsFullShadowEvidence/);
|
assert.match(resultSource, /M49TgsFullShadowEvidence/);
|
||||||
@@ -493,6 +497,34 @@ test("vegetation realtime LAB uses one upstream Rerun clock and keeps archival r
|
|||||||
assert.match(rerunSource, /resolveCanonicalLabReplay/);
|
assert.match(rerunSource, /resolveCanonicalLabReplay/);
|
||||||
assert.doesNotMatch(rerunSource, /canonical-overlay\.rrd/);
|
assert.doesNotMatch(rerunSource, /canonical-overlay\.rrd/);
|
||||||
assert.match(rerunSource, /unifiedPerception: splitView/);
|
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, /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, /isRecordedPlaybackPresentationReady\(viewerStatus, playback\)/);
|
||||||
|
assert.match(rerunSource, /data-presentation-state=\{presentationState\}/);
|
||||||
|
assert.match(rerunSource, /<ActivityIndicator label="Загружаем синхронизированную запись"/);
|
||||||
|
assert.match(rerunSource, /presentationReady && playback && playbackController/);
|
||||||
|
assert.match(
|
||||||
|
replayStyles,
|
||||||
|
/canonical-vegetation-rerun-replay:not\(\[data-presentation-state="ready"\]\)[\s\S]*laboratory-evidence-viewer__controls/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
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.doesNotMatch(rerunSource, /LaboratoryRecordedClipPlayer|LaboratoryMetricEvidenceScene/);
|
||||||
assert.match(resultSource, /point-aligned 3D semantics пока не запечатаны/);
|
assert.match(resultSource, /point-aligned 3D semantics пока не запечатаны/);
|
||||||
assert.match(rerunSource, /value: "tgs", label: "TGS", disabled: true/);
|
assert.match(rerunSource, /value: "tgs", label: "TGS", disabled: true/);
|
||||||
@@ -500,6 +532,7 @@ test("vegetation realtime LAB uses one upstream Rerun clock and keeps archival r
|
|||||||
assert.match(canonicalSource, /secondary=\{spatialPane/);
|
assert.match(canonicalSource, /secondary=\{spatialPane/);
|
||||||
assert.match(canonicalSource, /missioncore\.canonical-recorded-lab-replay\/v1/);
|
assert.match(canonicalSource, /missioncore\.canonical-recorded-lab-replay\/v1/);
|
||||||
assert.match(canonicalSource, /separatorLabel="Изменить размер видео\/камеры и 3D\/плана"/);
|
assert.match(canonicalSource, /separatorLabel="Изменить размер видео\/камеры и 3D\/плана"/);
|
||||||
|
assert.match(canonicalSource, /resizable=\{splitView && !unifiedContent\}/);
|
||||||
assert.match(resultSource, /linked canonical M4\.9 TGS evidence/);
|
assert.match(resultSource, /linked canonical M4\.9 TGS evidence/);
|
||||||
assert.match(resultSource, /linkedTgsResultId/);
|
assert.match(resultSource, /linkedTgsResultId/);
|
||||||
assert.match(benchmarkSource, /M48MaskComparisonVisual/);
|
assert.match(benchmarkSource, /M48MaskComparisonVisual/);
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ CREATE TABLE IF NOT EXISTS observation_lab_instances (
|
|||||||
published_at_utc TEXT NOT NULL,
|
published_at_utc TEXT NOT NULL,
|
||||||
include_recorded_media INTEGER CHECK (include_recorded_media IN (0, 1)),
|
include_recorded_media INTEGER CHECK (include_recorded_media IN (0, 1)),
|
||||||
replay_capability_json TEXT,
|
replay_capability_json TEXT,
|
||||||
|
operator_display_name TEXT,
|
||||||
provenance_json TEXT NOT NULL
|
provenance_json TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -298,6 +299,13 @@ class SessionStore:
|
|||||||
if row["session_id"] in lab_rows
|
if row["session_id"] in lab_rows
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
|
operator_display_name=(
|
||||||
|
_operator_display_name_from_row(
|
||||||
|
lab_rows[row["session_id"]]["operator_display_name"]
|
||||||
|
)
|
||||||
|
if row["session_id"] in lab_rows
|
||||||
|
else None
|
||||||
|
),
|
||||||
)
|
)
|
||||||
for row in selected
|
for row in selected
|
||||||
)
|
)
|
||||||
@@ -364,6 +372,13 @@ class SessionStore:
|
|||||||
summary=_summary_from_row(
|
summary=_summary_from_row(
|
||||||
row,
|
row,
|
||||||
lab=None if lab_row is None else _lab_binding_from_row(lab_row),
|
lab=None if lab_row is None else _lab_binding_from_row(lab_row),
|
||||||
|
operator_display_name=(
|
||||||
|
None
|
||||||
|
if lab_row is None
|
||||||
|
else _operator_display_name_from_row(
|
||||||
|
lab_row["operator_display_name"]
|
||||||
|
)
|
||||||
|
),
|
||||||
),
|
),
|
||||||
sources=sources,
|
sources=sources,
|
||||||
artifacts=artifacts,
|
artifacts=artifacts,
|
||||||
@@ -627,6 +642,62 @@ class SessionStore:
|
|||||||
raise SessionIntegrityError("LAB session publication was not durable")
|
raise SessionIntegrityError("LAB session publication was not durable")
|
||||||
return binding
|
return binding
|
||||||
|
|
||||||
|
def rename_capability_lab_projection(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
display_name: str,
|
||||||
|
) -> str:
|
||||||
|
"""Set only an operator-facing alias on one typed LAB projection.
|
||||||
|
|
||||||
|
The immutable observation summary keeps the canonical publication name
|
||||||
|
so a strict publisher retry can still validate the exact sealed
|
||||||
|
projection. The alias affects only catalog presentation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_validate_identifier(session_id, "LAB session id")
|
||||||
|
normalized_name = _normalize_operator_display_name(display_name)
|
||||||
|
with self._lock, self._connect() as connection:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
summary, _lab = _require_capability_owned_lab_projection(
|
||||||
|
connection,
|
||||||
|
session_id,
|
||||||
|
)
|
||||||
|
override = (
|
||||||
|
None
|
||||||
|
if normalized_name == summary["display_name"]
|
||||||
|
else normalized_name
|
||||||
|
)
|
||||||
|
updated = connection.execute(
|
||||||
|
"UPDATE observation_lab_instances SET operator_display_name = ? "
|
||||||
|
"WHERE session_id = ?",
|
||||||
|
(override, session_id),
|
||||||
|
).rowcount
|
||||||
|
if updated != 1:
|
||||||
|
connection.rollback()
|
||||||
|
raise SessionNotFoundError("observation session was not found")
|
||||||
|
connection.commit()
|
||||||
|
return normalized_name
|
||||||
|
|
||||||
|
def delete_capability_lab_projection(self, session_id: str) -> None:
|
||||||
|
"""Delete only one typed LAB catalog projection and its copied rows.
|
||||||
|
|
||||||
|
No evidence locator, source session, prepared recording, or viewer
|
||||||
|
cache is inspected or removed by this operation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_validate_identifier(session_id, "LAB session id")
|
||||||
|
with self._lock, self._connect() as connection:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
_require_capability_owned_lab_projection(connection, session_id)
|
||||||
|
deleted = connection.execute(
|
||||||
|
"DELETE FROM observation_sessions WHERE session_id = ?",
|
||||||
|
(session_id,),
|
||||||
|
).rowcount
|
||||||
|
if deleted != 1:
|
||||||
|
connection.rollback()
|
||||||
|
raise SessionNotFoundError("observation session was not found")
|
||||||
|
connection.commit()
|
||||||
|
|
||||||
def delete_session(self, session_id: str) -> None:
|
def delete_session(self, session_id: str) -> None:
|
||||||
"""Permanently delete one exact catalogued evidence directory and row."""
|
"""Permanently delete one exact catalogued evidence directory and row."""
|
||||||
|
|
||||||
@@ -931,6 +1002,11 @@ class SessionStore:
|
|||||||
"ADD COLUMN include_recorded_media INTEGER "
|
"ADD COLUMN include_recorded_media INTEGER "
|
||||||
"CHECK (include_recorded_media IN (0, 1))"
|
"CHECK (include_recorded_media IN (0, 1))"
|
||||||
)
|
)
|
||||||
|
if "operator_display_name" not in lab_columns:
|
||||||
|
connection.execute(
|
||||||
|
"ALTER TABLE observation_lab_instances "
|
||||||
|
"ADD COLUMN operator_display_name TEXT"
|
||||||
|
)
|
||||||
_migrate_canonical_replay_capabilities(connection)
|
_migrate_canonical_replay_capabilities(connection)
|
||||||
connection.commit()
|
connection.commit()
|
||||||
with _ignore_os_error():
|
with _ignore_os_error():
|
||||||
@@ -1583,16 +1659,87 @@ def _canonical_rolling_capability(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_operator_display_name(value: str) -> str:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ValueError("LAB display name must be text")
|
||||||
|
normalized = value.strip()
|
||||||
|
if not 1 <= len(normalized) <= 160 or any(
|
||||||
|
ord(character) < 32 for character in normalized
|
||||||
|
):
|
||||||
|
raise ValueError("LAB display name must contain 1..160 printable characters")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _operator_display_name_from_row(value: object) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise SessionIntegrityError("stored LAB operator display name is invalid")
|
||||||
|
try:
|
||||||
|
return _normalize_operator_display_name(value)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise SessionIntegrityError("stored LAB operator display name is invalid") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _require_capability_owned_lab_projection(
|
||||||
|
connection: sqlite3.Connection,
|
||||||
|
session_id: str,
|
||||||
|
) -> tuple[sqlite3.Row, sqlite3.Row]:
|
||||||
|
summary = connection.execute(
|
||||||
|
"SELECT * FROM observation_sessions WHERE session_id = ?",
|
||||||
|
(session_id,),
|
||||||
|
).fetchone()
|
||||||
|
if summary is None:
|
||||||
|
raise SessionNotFoundError("observation session was not found")
|
||||||
|
lab = connection.execute(
|
||||||
|
"SELECT * FROM observation_lab_instances WHERE session_id = ?",
|
||||||
|
(session_id,),
|
||||||
|
).fetchone()
|
||||||
|
if lab is None:
|
||||||
|
raise SessionIntegrityError(
|
||||||
|
"observation session is not a capability-owned LAB projection"
|
||||||
|
)
|
||||||
|
binding = _lab_binding_from_row(lab)
|
||||||
|
if (
|
||||||
|
summary["archive_id"] != LAB_ARCHIVE_ID
|
||||||
|
or summary["origin"] != LAB_ORIGIN
|
||||||
|
or binding.replay_capability is None
|
||||||
|
or binding.session_id != session_id
|
||||||
|
or binding.source_session_id == session_id
|
||||||
|
or lab["include_recorded_media"] not in {0, 1}
|
||||||
|
or connection.execute(
|
||||||
|
"SELECT 1 FROM observation_lab_instances WHERE session_id = ?",
|
||||||
|
(binding.source_session_id,),
|
||||||
|
).fetchone()
|
||||||
|
is not None
|
||||||
|
):
|
||||||
|
raise SessionIntegrityError(
|
||||||
|
"observation session is not a capability-owned LAB projection"
|
||||||
|
)
|
||||||
|
_operator_display_name_from_row(lab["operator_display_name"])
|
||||||
|
_validate_existing_lab_projection(
|
||||||
|
connection,
|
||||||
|
session_id=session_id,
|
||||||
|
source_session_id=binding.source_session_id,
|
||||||
|
display_name=summary["display_name"],
|
||||||
|
run_created_at_utc=binding.run_created_at_utc,
|
||||||
|
duration_seconds=summary["duration_seconds"],
|
||||||
|
include_recorded_media=bool(lab["include_recorded_media"]),
|
||||||
|
)
|
||||||
|
return summary, lab
|
||||||
|
|
||||||
|
|
||||||
def _summary_from_row(
|
def _summary_from_row(
|
||||||
row: sqlite3.Row,
|
row: sqlite3.Row,
|
||||||
*,
|
*,
|
||||||
lab: LabSessionBinding | None = None,
|
lab: LabSessionBinding | None = None,
|
||||||
|
operator_display_name: str | None = None,
|
||||||
) -> SessionSummary:
|
) -> SessionSummary:
|
||||||
raw_modalities = json.loads(row["modalities_json"])
|
raw_modalities = json.loads(row["modalities_json"])
|
||||||
modalities = tuple(cast(SessionModality, value) for value in raw_modalities)
|
modalities = tuple(cast(SessionModality, value) for value in raw_modalities)
|
||||||
return SessionSummary(
|
return SessionSummary(
|
||||||
session_id=row["session_id"],
|
session_id=row["session_id"],
|
||||||
display_name=row["display_name"],
|
display_name=operator_display_name or row["display_name"],
|
||||||
status=cast(SessionStatus, row["status"]),
|
status=cast(SessionStatus, row["status"]),
|
||||||
started_at_utc=row["started_at_utc"],
|
started_at_utc=row["started_at_utc"],
|
||||||
completed_at_utc=row["completed_at_utc"],
|
completed_at_utc=row["completed_at_utc"],
|
||||||
@@ -1646,6 +1793,8 @@ def _serialize_replay_capability(value: LabReplayCapability | None) -> str | Non
|
|||||||
def _replay_capability_from_row(value: object) -> LabReplayCapability | None:
|
def _replay_capability_from_row(value: object) -> LabReplayCapability | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
|
if not isinstance(value, str | bytes | bytearray):
|
||||||
|
raise SessionIntegrityError("stored LAB replay capability is invalid")
|
||||||
try:
|
try:
|
||||||
document = json.loads(value)
|
document = json.loads(value)
|
||||||
except (TypeError, json.JSONDecodeError) as exc:
|
except (TypeError, json.JSONDecodeError) as exc:
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ from k1link.web.map_api import (
|
|||||||
build_map_router,
|
build_map_router,
|
||||||
)
|
)
|
||||||
from k1link.web.map_view_api import build_map_view_router
|
from k1link.web.map_view_api import build_map_view_router
|
||||||
|
from k1link.web.observatory_api import build_observatory_router
|
||||||
from k1link.web.plugin_catalog import DevicePluginCatalog, PluginCatalogError
|
from k1link.web.plugin_catalog import DevicePluginCatalog, PluginCatalogError
|
||||||
from k1link.web.plugin_runtime import (
|
from k1link.web.plugin_runtime import (
|
||||||
STATE_READ_ACTION_ID,
|
STATE_READ_ACTION_ID,
|
||||||
@@ -691,6 +692,7 @@ app.include_router(
|
|||||||
point_color_renderers=plugin_environment.point_color_renderers,
|
point_color_renderers=plugin_environment.point_color_renderers,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
app.include_router(build_observatory_router(session_store))
|
||||||
app.include_router(
|
app.include_router(
|
||||||
build_environment_router(root_provider=lambda: session_store.data_dir / "ui-environment")
|
build_environment_router(root_provider=lambda: session_store.data_dir / "ui-environment")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Response
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from k1link.sessions import SessionIntegrityError, SessionNotFoundError, SessionStore
|
||||||
|
|
||||||
|
OBSERVATORY_PROJECTION_SCHEMA: Literal[
|
||||||
|
"missioncore.observatory-lab-projection/v1"
|
||||||
|
] = "missioncore.observatory-lab-projection/v1"
|
||||||
|
OBSERVATORY_RENAME_SCHEMA: Literal[
|
||||||
|
"missioncore.observatory-lab-projection-rename/v1"
|
||||||
|
] = "missioncore.observatory-lab-projection-rename/v1"
|
||||||
|
|
||||||
|
|
||||||
|
class _StrictApiModel(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
||||||
|
class ObservatoryProjectionRenameRequest(_StrictApiModel):
|
||||||
|
schema_version: Literal[
|
||||||
|
"missioncore.observatory-lab-projection-rename/v1"
|
||||||
|
]
|
||||||
|
display_name: str = Field(min_length=1, max_length=160)
|
||||||
|
|
||||||
|
|
||||||
|
class ObservatoryProjectionDocument(_StrictApiModel):
|
||||||
|
schema_version: Literal["missioncore.observatory-lab-projection/v1"]
|
||||||
|
session_id: str
|
||||||
|
display_name: str
|
||||||
|
|
||||||
|
|
||||||
|
def build_observatory_router(store: SessionStore) -> APIRouter:
|
||||||
|
"""Build bounded catalog-only mutations for typed Observatory projections."""
|
||||||
|
|
||||||
|
router = APIRouter(tags=["observatory"])
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/api/v1/observatory/lab-projections/{session_id}",
|
||||||
|
response_model=ObservatoryProjectionDocument,
|
||||||
|
)
|
||||||
|
def rename_observatory_lab_projection(
|
||||||
|
session_id: str,
|
||||||
|
request: ObservatoryProjectionRenameRequest,
|
||||||
|
) -> ObservatoryProjectionDocument:
|
||||||
|
try:
|
||||||
|
display_name = store.rename_capability_lab_projection(
|
||||||
|
session_id,
|
||||||
|
request.display_name,
|
||||||
|
)
|
||||||
|
except SessionNotFoundError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="Проекция Обсерватории не найдена.",
|
||||||
|
) from exc
|
||||||
|
except SessionIntegrityError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="Запись не является управляемой проекцией Обсерватории.",
|
||||||
|
) from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail="Некорректные параметры проекции Обсерватории.",
|
||||||
|
) from exc
|
||||||
|
return ObservatoryProjectionDocument(
|
||||||
|
schema_version=OBSERVATORY_PROJECTION_SCHEMA,
|
||||||
|
session_id=session_id,
|
||||||
|
display_name=display_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/api/v1/observatory/lab-projections/{session_id}",
|
||||||
|
status_code=204,
|
||||||
|
)
|
||||||
|
def delete_observatory_lab_projection(session_id: str) -> Response:
|
||||||
|
try:
|
||||||
|
store.delete_capability_lab_projection(session_id)
|
||||||
|
except SessionNotFoundError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="Проекция Обсерватории не найдена.",
|
||||||
|
) from exc
|
||||||
|
except SessionIntegrityError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="Запись не является управляемой проекцией Обсерватории.",
|
||||||
|
) from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail="Некорректный идентификатор проекции Обсерватории.",
|
||||||
|
) from exc
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
return router
|
||||||
@@ -38,6 +38,7 @@ from k1link.sessions.media import _manifest_generation_sha256
|
|||||||
from k1link.viewer.recorded import recorded_blueprint_rrd
|
from k1link.viewer.recorded import recorded_blueprint_rrd
|
||||||
from k1link.viewer.rerun_bridge import RerunSceneSettings
|
from k1link.viewer.rerun_bridge import RerunSceneSettings
|
||||||
from k1link.web.camera_archive import CameraArchiveWriter
|
from k1link.web.camera_archive import CameraArchiveWriter
|
||||||
|
from k1link.web.observatory_api import build_observatory_router
|
||||||
from k1link.web.session_api import (
|
from k1link.web.session_api import (
|
||||||
LayoutPutRequest,
|
LayoutPutRequest,
|
||||||
RecordedBlueprintRequest,
|
RecordedBlueprintRequest,
|
||||||
@@ -422,6 +423,141 @@ def test_session_router_rolls_capability_projections_out_only_in_v2(
|
|||||||
).status_code == 422
|
).status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_observatory_projection_api_renames_alias_and_deletes_only_projection(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
repository = tmp_path / "repo"
|
||||||
|
sessions = repository / "sessions"
|
||||||
|
source = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||||
|
source_payload = source / "captures" / "mqtt_live" / "mqtt.raw.k1mqtt"
|
||||||
|
source_payload_sha256 = hashlib.sha256(source_payload.read_bytes()).hexdigest()
|
||||||
|
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||||
|
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
||||||
|
source_snapshot = store.get_session_with_catalog_snapshot(source.name)[1]
|
||||||
|
capability = LabReplayCapability(
|
||||||
|
schema_version="missioncore.observation-lab-replay-capability/v1",
|
||||||
|
kind="canonical-recorded-rerun",
|
||||||
|
viewer_profile="recorded-session",
|
||||||
|
timeline="session_time",
|
||||||
|
activation="explicit",
|
||||||
|
commands_enabled=False,
|
||||||
|
)
|
||||||
|
projection = store.publish_lab_instance(
|
||||||
|
session_id="lab-observatory-api-projection",
|
||||||
|
source_session_id=source.name,
|
||||||
|
display_name="LAB V1 · canonical API projection",
|
||||||
|
lab_id="LAB V1",
|
||||||
|
result_kind="recorded-perception-qualification",
|
||||||
|
result_id="lab-v1-vegetation-shadow-" + "8" * 64,
|
||||||
|
source_result_id="lab-v1-vegetation-shadow-" + "9" * 64,
|
||||||
|
run_created_at_utc="2026-08-29T18:05:11.329061+00:00",
|
||||||
|
include_recorded_media=False,
|
||||||
|
replay_capability=capability,
|
||||||
|
provenance={
|
||||||
|
"replay_capability": capability.as_dict(),
|
||||||
|
"method": lab_method(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
cache_sentinel = store.data_dir / "recordings" / projection.session_id / "cached.rrd"
|
||||||
|
cache_sentinel.parent.mkdir(parents=True)
|
||||||
|
cache_sentinel.write_bytes(b"must-not-be-touched")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
store,
|
||||||
|
"delete_session",
|
||||||
|
lambda _session_id: pytest.fail(
|
||||||
|
"Observatory router must not enter generic evidence/cache deletion"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
application = FastAPI()
|
||||||
|
application.include_router(build_observatory_router(store))
|
||||||
|
client = TestClient(application)
|
||||||
|
url = f"/api/v1/observatory/lab-projections/{projection.session_id}"
|
||||||
|
|
||||||
|
assert client.patch(
|
||||||
|
url,
|
||||||
|
json={
|
||||||
|
"schema_version": "missioncore.observatory-lab-projection-rename/v0",
|
||||||
|
"display_name": "Неверная версия",
|
||||||
|
},
|
||||||
|
).status_code == 422
|
||||||
|
assert client.patch(
|
||||||
|
url,
|
||||||
|
json={
|
||||||
|
"schema_version": "missioncore.observatory-lab-projection-rename/v1",
|
||||||
|
"display_name": "Новый разбор",
|
||||||
|
"unexpected": True,
|
||||||
|
},
|
||||||
|
).status_code == 422
|
||||||
|
assert client.patch(
|
||||||
|
url,
|
||||||
|
json={
|
||||||
|
"schema_version": "missioncore.observatory-lab-projection-rename/v1",
|
||||||
|
"display_name": " ",
|
||||||
|
},
|
||||||
|
).status_code == 422
|
||||||
|
|
||||||
|
renamed = client.patch(
|
||||||
|
url,
|
||||||
|
json={
|
||||||
|
"schema_version": "missioncore.observatory-lab-projection-rename/v1",
|
||||||
|
"display_name": " Маршрут у школы ",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert renamed.status_code == 200
|
||||||
|
assert renamed.json() == {
|
||||||
|
"schema_version": "missioncore.observatory-lab-projection/v1",
|
||||||
|
"session_id": projection.session_id,
|
||||||
|
"display_name": "Маршрут у школы",
|
||||||
|
}
|
||||||
|
assert store.get_session(projection.session_id).summary.display_name == "Маршрут у школы"
|
||||||
|
|
||||||
|
deleted = client.delete(url)
|
||||||
|
assert deleted.status_code == 204
|
||||||
|
assert deleted.content == b""
|
||||||
|
assert client.delete(url).status_code == 404
|
||||||
|
assert store.get_session_with_catalog_snapshot(source.name)[1] == source_snapshot
|
||||||
|
assert hashlib.sha256(source_payload.read_bytes()).hexdigest() == source_payload_sha256
|
||||||
|
assert source.is_dir()
|
||||||
|
assert cache_sentinel.read_bytes() == b"must-not-be-touched"
|
||||||
|
|
||||||
|
|
||||||
|
def test_observatory_projection_api_rejects_source_and_legacy_lab(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
repository = tmp_path / "repo"
|
||||||
|
sessions = repository / "sessions"
|
||||||
|
source = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||||
|
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||||
|
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
||||||
|
legacy = store.publish_lab_instance(
|
||||||
|
session_id="lab-observatory-api-legacy",
|
||||||
|
source_session_id=source.name,
|
||||||
|
display_name="LAB E21 · legacy API row",
|
||||||
|
lab_id="LAB E21",
|
||||||
|
result_kind="e21-realtime-envelope",
|
||||||
|
result_id="e21-realtime-envelope-" + "a" * 64,
|
||||||
|
run_created_at_utc="2026-07-23T15:55:15.548Z",
|
||||||
|
provenance={"method": lab_method()},
|
||||||
|
)
|
||||||
|
application = FastAPI()
|
||||||
|
application.include_router(build_observatory_router(store))
|
||||||
|
client = TestClient(application)
|
||||||
|
rename_document = {
|
||||||
|
"schema_version": "missioncore.observatory-lab-projection-rename/v1",
|
||||||
|
"display_name": "Нельзя изменить",
|
||||||
|
}
|
||||||
|
|
||||||
|
for session_id in (source.name, legacy.session_id):
|
||||||
|
url = f"/api/v1/observatory/lab-projections/{session_id}"
|
||||||
|
assert client.patch(url, json=rename_document).status_code == 409
|
||||||
|
assert client.delete(url).status_code == 409
|
||||||
|
|
||||||
|
assert source.is_dir()
|
||||||
|
assert store.get_session(source.name).summary.session_id == source.name
|
||||||
|
assert store.get_session(legacy.session_id).summary.display_name == "LAB E21 · legacy API row"
|
||||||
|
|
||||||
|
|
||||||
def test_delete_session_removes_evidence_and_cache_but_refuses_an_open_recording(
|
def test_delete_session_removes_evidence_and_cache_but_refuses_an_open_recording(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -907,6 +907,9 @@ def test_lab_replay_capability_column_migrates_existing_catalog(
|
|||||||
connection.execute(
|
connection.execute(
|
||||||
"ALTER TABLE observation_lab_instances DROP COLUMN include_recorded_media"
|
"ALTER TABLE observation_lab_instances DROP COLUMN include_recorded_media"
|
||||||
)
|
)
|
||||||
|
connection.execute(
|
||||||
|
"ALTER TABLE observation_lab_instances DROP COLUMN operator_display_name"
|
||||||
|
)
|
||||||
connection.commit()
|
connection.commit()
|
||||||
|
|
||||||
migrated = SessionStore(repository, data_dir=data_dir)
|
migrated = SessionStore(repository, data_dir=data_dir)
|
||||||
@@ -919,6 +922,7 @@ def test_lab_replay_capability_column_migrates_existing_catalog(
|
|||||||
}
|
}
|
||||||
assert "replay_capability_json" in columns
|
assert "replay_capability_json" in columns
|
||||||
assert "include_recorded_media" in columns
|
assert "include_recorded_media" in columns
|
||||||
|
assert "operator_display_name" in columns
|
||||||
assert migrated.get_lab_instance(legacy.session_id).replay_capability is None
|
assert migrated.get_lab_instance(legacy.session_id).replay_capability is None
|
||||||
|
|
||||||
|
|
||||||
@@ -1248,6 +1252,265 @@ def test_lab_instance_persists_typed_explicit_recorded_replay_capability(
|
|||||||
store.get_lab_instance(binding.session_id)
|
store.get_lab_instance(binding.session_id)
|
||||||
|
|
||||||
|
|
||||||
|
def test_capability_projection_rename_is_only_an_operator_alias(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
repository = tmp_path / "repo"
|
||||||
|
sessions = repository / "sessions"
|
||||||
|
source = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||||
|
source_payload = source / "captures" / "mqtt_live" / "mqtt.raw.k1mqtt"
|
||||||
|
source_payload_sha256 = hashlib.sha256(source_payload.read_bytes()).hexdigest()
|
||||||
|
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||||
|
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
||||||
|
_source_detail, source_snapshot = store.get_session_with_catalog_snapshot(source.name)
|
||||||
|
capability = LabReplayCapability(
|
||||||
|
schema_version="missioncore.observation-lab-replay-capability/v1",
|
||||||
|
kind="canonical-recorded-rerun",
|
||||||
|
viewer_profile="recorded-session",
|
||||||
|
timeline="session_time",
|
||||||
|
activation="explicit",
|
||||||
|
commands_enabled=False,
|
||||||
|
)
|
||||||
|
parameters = {
|
||||||
|
"session_id": "lab-recorded-operator-alias",
|
||||||
|
"source_session_id": source.name,
|
||||||
|
"display_name": "LAB V1 · canonical recorded replay",
|
||||||
|
"lab_id": "LAB V1",
|
||||||
|
"result_kind": "recorded-perception-qualification",
|
||||||
|
"result_id": "lab-v1-vegetation-shadow-" + "a" * 64,
|
||||||
|
"source_result_id": "lab-v1-vegetation-shadow-" + "b" * 64,
|
||||||
|
"run_created_at_utc": "2026-08-29T18:05:11.329061+00:00",
|
||||||
|
"include_recorded_media": False,
|
||||||
|
"replay_capability": capability,
|
||||||
|
"provenance": {
|
||||||
|
"replay_capability": capability.as_dict(),
|
||||||
|
"method": lab_method(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
binding = store.publish_lab_instance(**parameters)
|
||||||
|
immutable_binding = store.get_lab_instance(binding.session_id)
|
||||||
|
_projection_detail, projection_snapshot = store.get_session_with_catalog_snapshot(
|
||||||
|
binding.session_id
|
||||||
|
)
|
||||||
|
|
||||||
|
assert store.rename_capability_lab_projection(
|
||||||
|
binding.session_id,
|
||||||
|
" Маршрут у школы ",
|
||||||
|
) == "Маршрут у школы"
|
||||||
|
|
||||||
|
assert store.get_session(binding.session_id).summary.display_name == "Маршрут у школы"
|
||||||
|
assert (
|
||||||
|
store.list_recent(scope="laboratory").items[0].display_name
|
||||||
|
== "Маршрут у школы"
|
||||||
|
)
|
||||||
|
assert store.get_lab_instance(binding.session_id) == immutable_binding
|
||||||
|
assert store.get_session_with_catalog_snapshot(source.name)[1] == source_snapshot
|
||||||
|
assert (
|
||||||
|
store.get_session_with_catalog_snapshot(binding.session_id)[1]
|
||||||
|
== projection_snapshot
|
||||||
|
)
|
||||||
|
assert hashlib.sha256(source_payload.read_bytes()).hexdigest() == source_payload_sha256
|
||||||
|
with sqlite3.connect(store.database_path) as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT sessions.display_name, lab.operator_display_name "
|
||||||
|
"FROM observation_sessions AS sessions "
|
||||||
|
"JOIN observation_lab_instances AS lab USING (session_id) "
|
||||||
|
"WHERE sessions.session_id = ?",
|
||||||
|
(binding.session_id,),
|
||||||
|
).fetchone()
|
||||||
|
assert row == ("LAB V1 · canonical recorded replay", "Маршрут у школы")
|
||||||
|
|
||||||
|
# A strict publisher retry still sees the untouched canonical name and
|
||||||
|
# immutable provenance, even while the catalog presents the alias.
|
||||||
|
assert store.publish_lab_instance(**parameters) == binding
|
||||||
|
assert store.get_session(binding.session_id).summary.display_name == "Маршрут у школы"
|
||||||
|
assert store.rename_capability_lab_projection(
|
||||||
|
binding.session_id,
|
||||||
|
parameters["display_name"],
|
||||||
|
) == parameters["display_name"]
|
||||||
|
with sqlite3.connect(store.database_path) as connection:
|
||||||
|
override = connection.execute(
|
||||||
|
"SELECT operator_display_name FROM observation_lab_instances "
|
||||||
|
"WHERE session_id = ?",
|
||||||
|
(binding.session_id,),
|
||||||
|
).fetchone()[0]
|
||||||
|
assert override is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_capability_projection_delete_removes_only_catalog_projection(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
repository = tmp_path / "repo"
|
||||||
|
sessions = repository / "sessions"
|
||||||
|
source = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||||
|
source_payload = source / "captures" / "mqtt_live" / "mqtt.raw.k1mqtt"
|
||||||
|
source_payload_sha256 = hashlib.sha256(source_payload.read_bytes()).hexdigest()
|
||||||
|
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||||
|
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
||||||
|
source_detail, source_snapshot = store.get_session_with_catalog_snapshot(source.name)
|
||||||
|
source_replay = store.prepare_replay(source.name)
|
||||||
|
capability = LabReplayCapability(
|
||||||
|
schema_version="missioncore.observation-lab-replay-capability/v1",
|
||||||
|
kind="canonical-recorded-rerun",
|
||||||
|
viewer_profile="recorded-session",
|
||||||
|
timeline="session_time",
|
||||||
|
activation="explicit",
|
||||||
|
commands_enabled=False,
|
||||||
|
)
|
||||||
|
binding = store.publish_lab_instance(
|
||||||
|
session_id="lab-recorded-delete-only-projection",
|
||||||
|
source_session_id=source.name,
|
||||||
|
display_name="LAB V1 · disposable catalog projection",
|
||||||
|
lab_id="LAB V1",
|
||||||
|
result_kind="recorded-perception-qualification",
|
||||||
|
result_id="lab-v1-vegetation-shadow-" + "c" * 64,
|
||||||
|
source_result_id="lab-v1-vegetation-shadow-" + "d" * 64,
|
||||||
|
run_created_at_utc="2026-08-29T18:05:11.329061+00:00",
|
||||||
|
include_recorded_media=False,
|
||||||
|
replay_capability=capability,
|
||||||
|
provenance={
|
||||||
|
"replay_capability": capability.as_dict(),
|
||||||
|
"method": lab_method(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
projection_replay = store.prepare_replay(binding.session_id)
|
||||||
|
cache_sentinel = store.data_dir / "recordings" / binding.session_id / "sentinel.rrd"
|
||||||
|
cache_sentinel.parent.mkdir(parents=True)
|
||||||
|
cache_sentinel.write_bytes(b"cache-owned-by-eviction-policy")
|
||||||
|
assert projection_replay.primary_artifact.path == source_replay.primary_artifact.path
|
||||||
|
|
||||||
|
store.delete_capability_lab_projection(binding.session_id)
|
||||||
|
|
||||||
|
with pytest.raises(SessionNotFoundError):
|
||||||
|
store.get_session(binding.session_id)
|
||||||
|
retained, retained_snapshot = store.get_session_with_catalog_snapshot(source.name)
|
||||||
|
assert retained == source_detail
|
||||||
|
assert retained_snapshot == source_snapshot
|
||||||
|
assert source.is_dir()
|
||||||
|
assert source_payload.is_file()
|
||||||
|
assert hashlib.sha256(source_payload.read_bytes()).hexdigest() == source_payload_sha256
|
||||||
|
assert cache_sentinel.read_bytes() == b"cache-owned-by-eviction-policy"
|
||||||
|
assert (
|
||||||
|
store.prepare_replay(source.name).primary_artifact.path
|
||||||
|
== source_replay.primary_artifact.path
|
||||||
|
)
|
||||||
|
with sqlite3.connect(store.database_path) as connection:
|
||||||
|
assert connection.execute(
|
||||||
|
"SELECT COUNT(*) FROM observation_lab_instances WHERE session_id = ?",
|
||||||
|
(binding.session_id,),
|
||||||
|
).fetchone()[0] == 0
|
||||||
|
assert connection.execute(
|
||||||
|
"SELECT COUNT(*) FROM observation_session_artifacts WHERE session_id = ?",
|
||||||
|
(binding.session_id,),
|
||||||
|
).fetchone()[0] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_capability_projection_mutations_reject_source_legacy_and_corrupt_rows(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
repository = tmp_path / "repo"
|
||||||
|
sessions = repository / "sessions"
|
||||||
|
source = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||||
|
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||||
|
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
||||||
|
legacy = store.publish_lab_instance(
|
||||||
|
session_id="lab-legacy-not-observatory-owned",
|
||||||
|
source_session_id=source.name,
|
||||||
|
display_name="LAB E21 · legacy",
|
||||||
|
lab_id="LAB E21",
|
||||||
|
result_kind="e21-realtime-envelope",
|
||||||
|
result_id="e21-realtime-envelope-" + "e" * 64,
|
||||||
|
run_created_at_utc="2026-07-23T15:55:15.548Z",
|
||||||
|
provenance={"method": lab_method()},
|
||||||
|
)
|
||||||
|
capability = LabReplayCapability(
|
||||||
|
schema_version="missioncore.observation-lab-replay-capability/v1",
|
||||||
|
kind="canonical-recorded-rerun",
|
||||||
|
viewer_profile="recorded-session",
|
||||||
|
timeline="session_time",
|
||||||
|
activation="explicit",
|
||||||
|
commands_enabled=False,
|
||||||
|
)
|
||||||
|
corrupt = store.publish_lab_instance(
|
||||||
|
session_id="lab-recorded-corrupt-owner",
|
||||||
|
source_session_id=source.name,
|
||||||
|
display_name="LAB V1 · corrupt owner",
|
||||||
|
lab_id="LAB V1",
|
||||||
|
result_kind="recorded-perception-qualification",
|
||||||
|
result_id="lab-v1-vegetation-shadow-" + "f" * 64,
|
||||||
|
run_created_at_utc="2026-08-29T18:05:11.329061+00:00",
|
||||||
|
include_recorded_media=False,
|
||||||
|
replay_capability=capability,
|
||||||
|
provenance={
|
||||||
|
"replay_capability": capability.as_dict(),
|
||||||
|
"method": lab_method(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
mismatch = store.publish_lab_instance(
|
||||||
|
session_id="lab-recorded-provenance-mismatch",
|
||||||
|
source_session_id=source.name,
|
||||||
|
display_name="LAB V1 · provenance mismatch",
|
||||||
|
lab_id="LAB V1",
|
||||||
|
result_kind="recorded-perception-qualification",
|
||||||
|
result_id="lab-v1-vegetation-shadow-" + "1" * 64,
|
||||||
|
run_created_at_utc="2026-08-29T18:05:11.329061+00:00",
|
||||||
|
include_recorded_media=False,
|
||||||
|
replay_capability=capability,
|
||||||
|
provenance={
|
||||||
|
"replay_capability": capability.as_dict(),
|
||||||
|
"method": lab_method(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
with sqlite3.connect(store.database_path) as connection:
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE observation_sessions SET archive_id = ? WHERE session_id = ?",
|
||||||
|
("not-observatory-owned", corrupt.session_id),
|
||||||
|
)
|
||||||
|
mismatched_provenance = {
|
||||||
|
"replay_capability": {
|
||||||
|
**capability.as_dict(),
|
||||||
|
"commands_enabled": True,
|
||||||
|
},
|
||||||
|
"method": lab_method(),
|
||||||
|
}
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE observation_lab_instances SET provenance_json = ? "
|
||||||
|
"WHERE session_id = ?",
|
||||||
|
(
|
||||||
|
json.dumps(mismatched_provenance, separators=(",", ":")),
|
||||||
|
mismatch.session_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
|
||||||
|
for session_id in (source.name, legacy.session_id, corrupt.session_id):
|
||||||
|
with pytest.raises(SessionIntegrityError, match="capability-owned"):
|
||||||
|
store.rename_capability_lab_projection(session_id, "Нельзя изменить")
|
||||||
|
with pytest.raises(SessionIntegrityError, match="capability-owned"):
|
||||||
|
store.delete_capability_lab_projection(session_id)
|
||||||
|
|
||||||
|
with pytest.raises(SessionIntegrityError, match="does not match provenance"):
|
||||||
|
store.rename_capability_lab_projection(mismatch.session_id, "Нельзя изменить")
|
||||||
|
with pytest.raises(SessionIntegrityError, match="does not match provenance"):
|
||||||
|
store.delete_capability_lab_projection(mismatch.session_id)
|
||||||
|
|
||||||
|
assert store.get_session(source.name).summary.session_id == source.name
|
||||||
|
assert store.get_session(legacy.session_id).summary.display_name == "LAB E21 · legacy"
|
||||||
|
with sqlite3.connect(store.database_path) as connection:
|
||||||
|
retained_ids = {
|
||||||
|
row[0]
|
||||||
|
for row in connection.execute(
|
||||||
|
"SELECT session_id FROM observation_sessions"
|
||||||
|
).fetchall()
|
||||||
|
}
|
||||||
|
assert {
|
||||||
|
source.name,
|
||||||
|
legacy.session_id,
|
||||||
|
corrupt.session_id,
|
||||||
|
mismatch.session_id,
|
||||||
|
} <= retained_ids
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("value", [0, 1, None, True])
|
@pytest.mark.parametrize("value", [0, 1, None, True])
|
||||||
def test_lab_replay_capability_rejects_non_literal_false(value: object) -> None:
|
def test_lab_replay_capability_rejects_non_literal_false(value: object) -> None:
|
||||||
with pytest.raises(ValueError, match="replay capability"):
|
with pytest.raises(ValueError, match="replay capability"):
|
||||||
|
|||||||
Reference in New Issue
Block a user