fix(observatory): refine catalog and replay UX
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user