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