fix(observatory): refine catalog and replay UX

This commit is contained in:
DCCONSTRUCTIONS
2026-08-30 20:36:12 +03:00
parent 023151c186
commit be83283ee0
18 changed files with 2137 additions and 201 deletions
@@ -241,7 +241,7 @@ export function CanonicalRecordedLabReplay<
orientation={splitOrientation}
minPrimarySize={splitView ? 24 : 0}
minSecondarySize={splitView ? 24 : 0}
resizable={splitView}
resizable={splitView && !unifiedContent}
separatorLabel="Изменить размер видео/камеры и 3D/плана"
/>
{mediaMode === "none" && spatialMode === "none" ? (
@@ -1,11 +1,26 @@
import { useEffect, useMemo, useState } from "react";
import { Button, Icon, SegmentedControl } from "@nodedc/ui-react";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type PointerEvent as ReactPointerEvent,
} from "react";
import {
ActivityIndicator,
Button,
Icon,
SegmentedControl,
} from "@nodedc/ui-react";
import { ObservationTimeline } from "../ObservationTimeline";
import {
RerunViewport,
isRecordedPlaybackPresentationReady,
type RerunPlaybackController,
type RerunPlaybackState,
type RerunViewportStatus,
} from "../RerunViewport";
import {
CanonicalRecordedLabReplay,
@@ -31,6 +46,9 @@ interface CanonicalReplayLaunch {
replay: CanonicalLabReplayDescriptor;
}
const RERUN_UNIFIED_CAMERA_SHARE_PERCENT = 46;
const RERUN_NATIVE_DIVIDER_HIT_SLOP_PX = 10;
export function CanonicalVegetationRerunReplay({
resultId,
review,
@@ -52,6 +70,7 @@ export function CanonicalVegetationRerunReplay({
initialMediaMode: "video",
initialSpatialMode: "3d",
});
const splitView = mediaMode !== null && spatialMode !== null;
const [semanticLayer, setSemanticLayer] = useState<SemanticLayer>("vegetation");
const [showSemantics, setShowSemantics] = useState(true);
const [spatialLayer, setSpatialLayer] = useState<SpatialLayer>("source");
@@ -59,13 +78,92 @@ export function CanonicalVegetationRerunReplay({
const [playback, setPlayback] = useState<RerunPlaybackState | null>(null);
const [playbackController, setPlaybackController] =
useState<RerunPlaybackController | null>(null);
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>("idle");
const [launch, setLaunch] = useState<CanonicalReplayLaunch | null>(null);
const [launchError, setLaunchError] = useState<string | null>(null);
const viewerFrameRef = useRef<HTMLDivElement>(null);
const nativeSplitPercentRef = useRef(RERUN_UNIFIED_CAMERA_SHARE_PERCENT);
const nativeSplitTrackingCleanupRef = useRef<(() => void) | null>(null);
const previousSplitViewRef = useRef(splitView);
const trackedLaunchSha256Ref = useRef<string | null>(null);
const stopNativeSplitTracking = useCallback(() => {
nativeSplitTrackingCleanupRef.current?.();
nativeSplitTrackingCleanupRef.current = null;
}, []);
const trackNativeSplit = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if (
!splitView
|| event.button !== 0
|| !(event.target instanceof HTMLCanvasElement)
) return;
const frame = viewerFrameRef.current;
if (!frame) return;
const bounds = frame.getBoundingClientRect();
if (bounds.width <= 0) return;
const dividerX = bounds.left
+ bounds.width * nativeSplitPercentRef.current / 100;
if (Math.abs(event.clientX - dividerX) > RERUN_NATIVE_DIVIDER_HIT_SLOP_PX) return;
stopNativeSplitTracking();
const pointerId = event.pointerId;
const update = (pointerEvent: PointerEvent) => {
if (pointerEvent.pointerId !== pointerId) return;
const currentBounds = frame.getBoundingClientRect();
if (currentBounds.width <= 0) return;
const next = Math.min(90, Math.max(
10,
(pointerEvent.clientX - currentBounds.left) / currentBounds.width * 100,
));
nativeSplitPercentRef.current = next;
frame.style.setProperty("--canonical-rerun-camera-pane", `${next}%`);
};
const stop = (pointerEvent: PointerEvent) => {
if (pointerEvent.pointerId !== pointerId) return;
stopNativeSplitTracking();
};
window.addEventListener("pointermove", update, true);
window.addEventListener("pointerup", stop, true);
window.addEventListener("pointercancel", stop, true);
nativeSplitTrackingCleanupRef.current = () => {
window.removeEventListener("pointermove", update, true);
window.removeEventListener("pointerup", stop, true);
window.removeEventListener("pointercancel", stop, true);
};
}, [splitView, stopNativeSplitTracking]);
useEffect(() => stopNativeSplitTracking, [stopNativeSplitTracking]);
useEffect(() => {
if (!splitView) stopNativeSplitTracking();
const launchSha256 = launch?.replay.sha256 ?? null;
if (
trackedLaunchSha256Ref.current !== launchSha256
|| (splitView && !previousSplitViewRef.current)
) {
nativeSplitPercentRef.current = RERUN_UNIFIED_CAMERA_SHARE_PERCENT;
}
trackedLaunchSha256Ref.current = launchSha256;
previousSplitViewRef.current = splitView;
const cameraPanePercent = mediaMode === null
? 0
: splitView
? nativeSplitPercentRef.current
: 100;
viewerFrameRef.current?.style.setProperty(
"--canonical-rerun-camera-pane",
`${cameraPanePercent}%`,
);
}, [launch?.replay.sha256, mediaMode, splitView, stopNativeSplitTracking]);
useEffect(() => {
const controller = new AbortController();
setLaunch(null);
setLaunchError(null);
setPlayback(null);
setPlaybackController(null);
setViewerStatus("idle");
void resolveObservationSessionReplay(review.sessionId, {
signal: controller.signal,
maximumWaitMs: 30 * 60 * 1000,
@@ -87,7 +185,13 @@ export function CanonicalVegetationRerunReplay({
return () => controller.abort();
}, [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(() => ({
...defaultSceneSettings,
accumulationSeconds: spatialLayer === "local" ? 5 : 0,
@@ -123,7 +227,7 @@ export function CanonicalVegetationRerunReplay({
cuboids3d: false,
},
perceptionRetryGeneration: 0,
lockPerceptionCameraInteraction: false,
lockPerceptionCameraInteraction: mediaMode !== null,
}) : null;
const mediaLayerControls = (
@@ -188,9 +292,9 @@ export function CanonicalVegetationRerunReplay({
</Button>
);
const transport = playback && playbackController ? (
const transport = presentationReady && playback && playbackController ? (
<ObservationTimeline
className="m4-replay-threat-visual__timeline"
className="m4-replay-threat-visual__timeline canonical-vegetation-rerun-replay__timeline"
active
sourceCount={3}
mode="recorded"
@@ -205,45 +309,75 @@ export function CanonicalVegetationRerunReplay({
/>
) : undefined;
return (
<CanonicalRecordedLabReplay
label="RAVNOVES004TREE · канонический повтор Rerun"
mediaMode={mediaMode ?? "none"}
mediaModes={[
{ value: "video", label: "ВИДЕО" },
{ value: "camera", label: "КАМЕРА" },
]}
spatialMode={spatialMode ?? "none"}
spatialModes={[
{ value: "3d", label: "3D" },
{ value: "plan", label: "ПЛАН" },
]}
expanded={expanded}
splitPrimarySize={splitPrimarySize}
splitOrientation={splitOrientation}
mediaAriaLabel={mediaMode === "camera" ? "Камера" : "Видео и семантика"}
spatialAriaLabel={spatialMode === "plan" ? "Вид сверху" : "Трёхмерная сцена"}
mediaLayerControls={mediaLayerControls}
spatialLayerControls={spatialLayerControls}
spatialLeadingControl={resetSpatialView}
mediaMultiLayer
unifiedContent={profile ? (
<RerunViewport
profile={profile}
sceneSettings={sceneSettings}
onPlaybackChange={setPlayback}
onPlaybackControllerChange={setPlaybackController}
/>
) : (
<div className="l3-visual-audit__state" role={launchError ? "alert" : "status"}>
{launchError ?? "Готовим единый кэш канонического повтора RAV004…"}
</div>
)}
emptyMessage="Выберите ВИДЕО/КАМЕРА или 3D/ПЛАН. Общие часы Rerun останутся на месте."
transport={transport}
onMediaModeChange={onMediaModeChange}
onSpatialModeChange={onSpatialModeChange}
onExpandedChange={onExpandedChange}
onSplitPrimarySizeChange={onSplitPrimarySizeChange}
/>
<div
className="canonical-vegetation-rerun-replay"
data-presentation-state={presentationState}
aria-busy={presentationState === "loading"}
>
<CanonicalRecordedLabReplay
label="RAVNOVES004TREE · канонический повтор Rerun"
mediaMode={mediaMode ?? "none"}
mediaModes={[
{ value: "video", label: "ВИДЕО" },
{ value: "camera", label: "КАМЕРА" },
]}
spatialMode={spatialMode ?? "none"}
spatialModes={[
{ value: "3d", label: "3D" },
{ value: "plan", label: "ПЛАН" },
]}
expanded={expanded}
splitPrimarySize={splitPrimarySize}
splitOrientation={splitOrientation}
mediaAriaLabel={mediaMode === "camera" ? "Камера" : "Видео и семантика"}
spatialAriaLabel={spatialMode === "plan" ? "Вид сверху" : "Трёхмерная сцена"}
mediaLayerControls={mediaLayerControls}
spatialLayerControls={spatialLayerControls}
spatialLeadingControl={resetSpatialView}
mediaMultiLayer
unifiedContent={profile ? (
<div
ref={viewerFrameRef}
className="canonical-vegetation-rerun-replay__viewport-lock"
data-split-view={splitView ? "true" : undefined}
style={{
"--canonical-rerun-camera-pane": `${
mediaMode === null
? 0
: splitView
? nativeSplitPercentRef.current
: 100
}%`,
} as CSSProperties}
onPointerDownCapture={splitView ? trackNativeSplit : undefined}
>
<RerunViewport
profile={profile}
sceneSettings={sceneSettings}
onStatusChange={setViewerStatus}
onPlaybackChange={setPlayback}
onPlaybackControllerChange={setPlaybackController}
/>
</div>
) : launchError ? (
<div className="l3-visual-audit__state" role="alert">
{launchError}
</div>
) : (
<div aria-hidden="true" />
)}
emptyMessage="Выберите ВИДЕО/КАМЕРА или 3D/ПЛАН. Общие часы Rerun останутся на месте."
deckOverlays={presentationState === "loading" ? (
<div className="canonical-vegetation-rerun-replay__loading">
<ActivityIndicator label="Загружаем синхронизированную запись" />
</div>
) : undefined}
transport={transport}
onMediaModeChange={onMediaModeChange}
onSpatialModeChange={onSpatialModeChange}
onExpandedChange={onExpandedChange}
onSplitPrimarySizeChange={onSplitPrimarySizeChange}
/>
</div>
);
}
@@ -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 {
constructor(message: string) {
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 {
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 {
applyObservatoryCatalogMutationOverlay,
fetchObservatoryCatalog,
reconcileObservatoryCatalogMutationOverlay,
type ObservatoryCatalog,
type ObservatoryCatalogMutationOverlay,
} from "./catalog";
export type ObservatoryCatalogState =
@@ -16,9 +19,23 @@ export interface ObservatoryCatalogController {
readonly catalog: ObservatoryCatalog | null;
readonly state: ObservatoryCatalogState;
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 {
return error instanceof Error && error.message.trim()
? error.message
@@ -33,31 +50,99 @@ export function useObservatoryCatalog(): ObservatoryCatalogController {
const [catalog, setCatalog] = useState<ObservatoryCatalog | null>(null);
const [state, setState] = useState<ObservatoryCatalogState>("idle");
const [error, setError] = useState<string | null>(null);
const [generation, setGeneration] = useState(0);
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();
activeRequestRef.current = { id, controller };
const requestMutationRevision = mutationRevisionRef.current;
setState(catalogRef.current ? "refreshing" : "loading");
setError(null);
void fetchObservatoryCatalog({ signal: controller.signal })
.then((next) => {
if (controller.signal.aborted) return;
catalogRef.current = next;
setCatalog(next);
setState("ready");
})
.catch((loadError: unknown) => {
if (controller.signal.aborted || isAbortError(loadError)) return;
setError(errorMessage(loadError));
setState("error");
try {
const serverCatalog = await fetchObservatoryCatalog({
signal: controller.signal,
});
return () => controller.abort();
}, [generation]);
const refresh = useCallback(() => {
setGeneration((current) => current + 1);
if (controller.signal.aborted || requestSequenceRef.current !== id) {
return null;
}
const reconciled = reconcileObservatoryCatalogMutationOverlay(
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;
}
.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 {
position: absolute;
z-index: 1;
@@ -282,6 +329,10 @@
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 {
box-sizing: border-box;
min-width: 0;
+144 -55
View File
@@ -10,7 +10,6 @@
.observatory-catalog-bar,
.observatory-catalog-bar__controls,
.observatory-notice,
.observatory-session-summary > header,
.observatory-evidence > header {
display: flex;
align-items: center;
@@ -27,7 +26,6 @@
.observatory-lead > div,
.observatory-catalog-bar__copy,
.observatory-session-summary header > div,
.observatory-evidence header > div {
min-width: 0;
}
@@ -77,64 +75,91 @@
margin: 0;
}
.observatory-session-grid {
.observatory-session-stack {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(24rem, 100%), 1fr));
gap: 1rem;
align-items: start;
gap: 0.9rem;
container-name: observatory-session;
container-type: inline-size;
}
.observatory-session-summary,
.observatory-evidence {
.observatory-session-summary {
display: grid;
grid-template-columns: minmax(14rem, 1.1fr) minmax(22rem, 1.6fr) minmax(14rem, auto);
align-items: center;
gap: 1rem;
}
.observatory-session-summary dl,
.observatory-evidence-list dl {
.observatory-session-summary__identity {
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;
}
.observatory-session-summary dl > div,
.observatory-evidence-list dl > div {
.observatory-session-summary__facts > div {
display: grid;
grid-template-columns: minmax(7.5rem, 0.42fr) minmax(0, 1fr);
gap: 0.85rem;
align-items: baseline;
min-width: 0;
gap: 0.24rem;
}
.observatory-session-summary dt,
.observatory-evidence-list dt {
.observatory-session-summary__facts dt {
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
}
.observatory-session-summary dd,
.observatory-evidence-list dd {
.observatory-session-summary__facts dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
color: var(--nodedc-text-secondary);
}
.observatory-session-summary code,
.observatory-evidence-list code {
font-size: var(--nodedc-font-size-xs);
.observatory-session-summary__end {
display: grid;
min-width: 0;
justify-items: end;
gap: 0.6rem;
}
.observatory-modalities {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.45rem;
align-items: center;
color: var(--nodedc-text-muted);
}
.observatory-evidence {
display: grid;
gap: 0.65rem;
}
.observatory-evidence > header {
padding: 0 0.2rem;
}
.observatory-evidence-list {
display: grid;
gap: 0.75rem;
gap: 0.4rem;
margin: 0;
padding: 0;
list-style: none;
@@ -142,11 +167,51 @@
.observatory-evidence-card {
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__action,
.observatory-evidence-card__icon {
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-state,
.observatory-replay-state__actions {
@@ -156,28 +221,17 @@
gap: 0.85rem;
}
.observatory-evidence-card__heading,
.observatory-evidence-card__action,
.observatory-replay__header,
.observatory-replay-state {
flex-wrap: wrap;
}
.observatory-evidence-card__heading > div,
.observatory-evidence-card__action > span,
.observatory-replay__header > div,
.observatory-replay-state > div {
min-width: 0;
}
.observatory-evidence-card__action {
padding-top: 0.85rem;
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-evidence-card__actions:empty {
display: none;
}
.observatory-replay,
@@ -205,17 +259,6 @@
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 {
display: grid;
min-height: 13rem;
@@ -242,6 +285,56 @@
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) {
.observatory-lead,
.observatory-catalog-bar,
@@ -251,7 +344,6 @@
flex-direction: column;
}
.observatory-evidence-card__action,
.observatory-replay__header,
.observatory-replay-state {
align-items: stretch;
@@ -268,7 +360,4 @@
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 { flushSync } from "react-dom";
import {
ActivityIndicator,
Button,
ConfirmationModal,
GlassSurface,
Icon,
IconButton,
Select,
StatusBadge,
TextField,
Window,
WindowFooterActions,
} from "@nodedc/ui-react";
import { CanonicalVegetationRerunReplay } from "../../components/laboratory/CanonicalVegetationRerunReplay";
@@ -15,6 +21,15 @@ import {
fetchObservatoryRecordedRunReview,
type ObservatoryRecordedRunBinding,
} 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 type { WorkspaceDefinition } from "../../productModel";
@@ -24,6 +39,17 @@ type ObservatoryRecordedRunReview = Awaited<
ReturnType<typeof fetchObservatoryRecordedRunReview>
>;
type ObservatoryMutationReconciliation =
| {
readonly kind: "rename";
readonly sessionId: string;
readonly displayName: string;
}
| {
readonly kind: "delete";
readonly sessionId: string;
};
type ObservatoryReplayState =
| { readonly kind: "closed" }
| {
@@ -94,6 +120,12 @@ function catalogStateLabel(
return "Читаем каталог";
}
function mutationErrorMessage(error: unknown): string {
return error instanceof Error && error.message.trim()
? error.message
: "Не удалось изменить лабораторный результат в Обсерватории.";
}
export function ObservatoryWorkspace({
definition,
}: {
@@ -102,6 +134,14 @@ export function ObservatoryWorkspace({
const controller = useObservatoryCatalog();
const [selectedSessionId, setSelectedSessionId] = useState("");
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 items = controller.catalog?.items ?? EMPTY_OBSERVATORY_ITEMS;
@@ -136,16 +176,42 @@ export function ObservatoryWorkspace({
const replayEvidenceId = replay.kind === "closed"
? null
: replay.binding.evidenceSessionId;
const replayEvidence = replayEvidenceId === null
? null
: selectedSession?.evidence.find(
(evidence) => evidence.sessionId === replayEvidenceId,
) ?? null;
useEffect(() => {
if (
replayEvidenceId === null
|| selectedSession?.evidence.some(
(evidence) => evidence.sessionId === replayEvidenceId,
)
|| replayEvidence
) return;
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 attempt = replayCoordinatorRef.current.begin();
@@ -173,6 +239,101 @@ export function ObservatoryWorkspace({
setSelectedSessionId(sessionId);
}, [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 (
<div
className="observatory-workspace"
@@ -268,22 +429,14 @@ export function ObservatoryWorkspace({
<p>После завершения записи источник появится здесь без создания демонстрационных данных.</p>
</GlassSurface>
) : selectedSession ? (
<section className="observatory-session-grid" aria-label="Выбранная сессия и связанные результаты">
<GlassSurface className="observatory-session-summary" padding="lg">
<header>
<div>
<span className="section-eyebrow">ИСХОДНАЯ СЕССИЯ</span>
<h3>{selectedSession.source.label}</h3>
</div>
<StatusBadge tone={statusTone(selectedSession.source.status)}>
{statusLabel[selectedSession.source.status]}
</StatusBadge>
</header>
<dl>
<div>
<dt>Идентификатор</dt>
<dd><code>{selectedSession.source.id}</code></dd>
</div>
<section className="observatory-session-stack" aria-label="Выбранная сессия и связанные результаты">
<GlassSurface className="observatory-session-summary" padding="md">
<div className="observatory-session-summary__identity">
<span className="section-eyebrow">ИСХОДНАЯ СЕССИЯ</span>
<h3>{selectedSession.source.label}</h3>
<code>{selectedSession.source.id}</code>
</div>
<dl className="observatory-session-summary__facts">
<div><dt>Начало</dt><dd>{formatTimestamp(selectedSession.source.startedAtUtc)}</dd></div>
<div><dt>Длительность</dt><dd>{formatDuration(selectedSession.source.durationSeconds)}</dd></div>
<div>
@@ -291,18 +444,23 @@ export function ObservatoryWorkspace({
<dd>{selectedSession.source.replayable ? "Доступна" : "Не подготовлена"}</dd>
</div>
</dl>
<div className="observatory-modalities" aria-label="Каналы сессии">
{selectedSession.source.modalities.length > 0
? selectedSession.source.modalities.map((modality) => (
<StatusBadge key={modality} tone="neutral">
{modalityLabel[modality] ?? modality}
</StatusBadge>
))
: <span>Каналы не зафиксированы</span>}
<div className="observatory-session-summary__end">
<StatusBadge tone={statusTone(selectedSession.source.status)}>
{statusLabel[selectedSession.source.status]}
</StatusBadge>
<div className="observatory-modalities" aria-label="Каналы сессии">
{selectedSession.source.modalities.length > 0
? selectedSession.source.modalities.map((modality) => (
<StatusBadge key={modality} tone="neutral">
{modalityLabel[modality] ?? modality}
</StatusBadge>
))
: <span>Каналы не зафиксированы</span>}
</div>
</div>
</GlassSurface>
<GlassSurface className="observatory-evidence" padding="lg">
<section className="observatory-evidence">
<header>
<div>
<span className="section-eyebrow">СВЯЗАННЫЕ РЕЗУЛЬТАТЫ</span>
@@ -316,53 +474,52 @@ export function ObservatoryWorkspace({
<ol className="observatory-evidence-list">
{presentedEvidence.map((evidence) => (
<li key={evidence.sessionId}>
<GlassSurface className="observatory-evidence-card" padding="md" tone="soft">
<div className="observatory-evidence-card__heading">
<div>
<strong>{evidence.lab.labId}</strong>
<span>{evidence.label}</span>
</div>
<div className="observatory-evidence-card">
<span className="observatory-evidence-card__icon" aria-hidden="true">
<Icon name="clipboard" size={18} />
</span>
<div className="observatory-evidence-card__copy">
<strong>{evidence.lab.labId}</strong>
<span>{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 ? (
<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}
</div>
<dl>
<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>
</div>
</li>
))}
</ol>
@@ -382,7 +539,7 @@ export function ObservatoryWorkspace({
{" "}связанных результатов. Полный архив остаётся в legacy LAB.
</p>
) : null}
</GlassSurface>
</section>
</section>
) : null}
@@ -418,7 +575,7 @@ export function ObservatoryWorkspace({
<header className="observatory-replay__header">
<div>
<span className="section-eyebrow">ВИЗУАЛЬНЫЙ РАЗБОР / ЗАПИСАННАЯ СЕССИЯ</span>
<h3>RAVNOVES004TREE · полный маршрут восприятия</h3>
<h3>{replayEvidence?.label ?? replay.binding.resultId}</h3>
<p>Записанный маршрут синхронизирован по общей временной шкале.</p>
</div>
<Button
@@ -446,6 +603,95 @@ export function ObservatoryWorkspace({
</span>
</GlassSurface>
) : 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>
);
}
@@ -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, /secondary=\{spatialPane/);
assert.match(canonical, /primarySize=\{splitView \? splitPrimarySize : mediaMode !== "none" \? 100 : 0\}/);
assert.match(canonical, /resizable=\{splitView\}/);
assert.match(canonical, /separatorLabel="Изменить размер VIDEO\/CAMERA и 3D\/PLAN"/);
assert.match(canonical, /resizable=\{splitView && !unifiedContent\}/);
assert.match(canonical, /separatorLabel="Изменить размер видео\/камеры и 3D\/плана"/);
assert.match(canonical, /secondaryMode=\{\{/);
assert.match(visual, /playback=\{playbackController\.playback\}/);
assert.match(visual, /clock: "external"/);
@@ -4,8 +4,12 @@ import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let applyObservatoryCatalogMutationOverlay;
let buildObservatoryCatalog;
let fetchObservatoryCatalog;
let findObservatoryEvidence;
let observatoryCatalogConfirmsEvidenceDeletion;
let reconcileObservatoryCatalogMutationOverlay;
let ObservatoryCatalogContractError;
before(async () => {
@@ -15,8 +19,12 @@ before(async () => {
server: { middlewareMode: true },
});
({
applyObservatoryCatalogMutationOverlay,
buildObservatoryCatalog,
fetchObservatoryCatalog,
findObservatoryEvidence,
observatoryCatalogConfirmsEvidenceDeletion,
reconcileObservatoryCatalogMutationOverlay,
ObservatoryCatalogContractError,
} = 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",
});
});
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, /observatory-notice__copy/);
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, /replay\.kind === "ready"[\s\S]*<CanonicalVegetationRerunReplay/);
assert.match(workspace, /Проверяем точную связь результата/);
assert.match(workspace, /role="alert"/);
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, /data-observatory-authority="observation-only"/);
assert.doesNotMatch(workspace, /Нарушена связь|compactIdentity/);
@@ -112,5 +122,70 @@ test("Observatory mounts the one shared canonical replay only after explicit adm
styles,
/@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/);
});
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 () => {
const [resultSource, benchmarkSource, m49Source, canonicalSource, rerunSource] = await Promise.all([
const [resultSource, benchmarkSource, m49Source, canonicalSource, rerunSource, replayStyles] = await Promise.all([
readFile(
new URL("../src/workspaces/laboratory/VegetationShadowResult.tsx", import.meta.url),
"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),
"utf8",
),
readFile(
new URL("../src/styles/m4-replay-threat.css", import.meta.url),
"utf8",
),
]);
assert.doesNotMatch(resultSource, /M48MaskComparisonVisual/);
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.doesNotMatch(rerunSource, /canonical-overlay\.rrd/);
assert.match(rerunSource, /unifiedPerception: splitView/);
assert.match(rerunSource, /lockPerceptionCameraInteraction: mediaMode !== null/);
assert.match(rerunSource, /!splitView[\s\S]*event\.button !== 0/);
assert.match(rerunSource, /if \(!splitView\) stopNativeSplitTracking\(\)/);
assert.match(rerunSource, /event\.target instanceof HTMLCanvasElement/);
assert.match(rerunSource, /--canonical-rerun-camera-pane/);
assert.match(rerunSource, /onPointerDownCapture=\{splitView \? trackNativeSplit : undefined\}/);
assert.match(rerunSource, /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.match(resultSource, /point-aligned 3D semantics пока не запечатаны/);
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, /missioncore\.canonical-recorded-lab-replay\/v1/);
assert.match(canonicalSource, /separatorLabel="Изменить размер видео\/камеры и 3D\/плана"/);
assert.match(canonicalSource, /resizable=\{splitView && !unifiedContent\}/);
assert.match(resultSource, /linked canonical M4\.9 TGS evidence/);
assert.match(resultSource, /linkedTgsResultId/);
assert.match(benchmarkSource, /M48MaskComparisonVisual/);
+150 -1
View File
@@ -118,6 +118,7 @@ CREATE TABLE IF NOT EXISTS observation_lab_instances (
published_at_utc TEXT NOT NULL,
include_recorded_media INTEGER CHECK (include_recorded_media IN (0, 1)),
replay_capability_json TEXT,
operator_display_name TEXT,
provenance_json TEXT NOT NULL
);
@@ -298,6 +299,13 @@ class SessionStore:
if row["session_id"] in lab_rows
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
)
@@ -364,6 +372,13 @@ class SessionStore:
summary=_summary_from_row(
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,
artifacts=artifacts,
@@ -627,6 +642,62 @@ class SessionStore:
raise SessionIntegrityError("LAB session publication was not durable")
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:
"""Permanently delete one exact catalogued evidence directory and row."""
@@ -931,6 +1002,11 @@ class SessionStore:
"ADD COLUMN include_recorded_media INTEGER "
"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)
connection.commit()
with _ignore_os_error():
@@ -1583,16 +1659,87 @@ def _canonical_rolling_capability(
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(
row: sqlite3.Row,
*,
lab: LabSessionBinding | None = None,
operator_display_name: str | None = None,
) -> SessionSummary:
raw_modalities = json.loads(row["modalities_json"])
modalities = tuple(cast(SessionModality, value) for value in raw_modalities)
return SessionSummary(
session_id=row["session_id"],
display_name=row["display_name"],
display_name=operator_display_name or row["display_name"],
status=cast(SessionStatus, row["status"]),
started_at_utc=row["started_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:
if value is None:
return None
if not isinstance(value, str | bytes | bytearray):
raise SessionIntegrityError("stored LAB replay capability is invalid")
try:
document = json.loads(value)
except (TypeError, json.JSONDecodeError) as exc:
+2
View File
@@ -144,6 +144,7 @@ from k1link.web.map_api import (
build_map_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_runtime import (
STATE_READ_ACTION_ID,
@@ -691,6 +692,7 @@ app.include_router(
point_color_renderers=plugin_environment.point_color_renderers,
)
)
app.include_router(build_observatory_router(session_store))
app.include_router(
build_environment_router(root_provider=lambda: session_store.data_dir / "ui-environment")
)
+98
View File
@@ -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
+136
View File
@@ -38,6 +38,7 @@ from k1link.sessions.media import _manifest_generation_sha256
from k1link.viewer.recorded import recorded_blueprint_rrd
from k1link.viewer.rerun_bridge import RerunSceneSettings
from k1link.web.camera_archive import CameraArchiveWriter
from k1link.web.observatory_api import build_observatory_router
from k1link.web.session_api import (
LayoutPutRequest,
RecordedBlueprintRequest,
@@ -422,6 +423,141 @@ def test_session_router_rolls_capability_projections_out_only_in_v2(
).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(
tmp_path: Path,
) -> None:
+263
View File
@@ -907,6 +907,9 @@ def test_lab_replay_capability_column_migrates_existing_catalog(
connection.execute(
"ALTER TABLE observation_lab_instances DROP COLUMN include_recorded_media"
)
connection.execute(
"ALTER TABLE observation_lab_instances DROP COLUMN operator_display_name"
)
connection.commit()
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 "include_recorded_media" in columns
assert "operator_display_name" in columns
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)
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])
def test_lab_replay_capability_rejects_non_literal_false(value: object) -> None:
with pytest.raises(ValueError, match="replay capability"):