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>
);
}