feat(ui): separate live data and lab surfaces

This commit is contained in:
DCCONSTRUCTIONS
2026-07-26 12:50:10 +03:00
parent 677dbdb776
commit 05413e3f5c
19 changed files with 1430 additions and 402 deletions
@@ -92,17 +92,7 @@ function sessionDescription(session: ObservationSessionSummary): string {
return `${formatStartedAt(session.startedAtUtc)} · ${formatDuration(session.durationSeconds)} · ${modalities}`;
}
export function ObservationSessionSelect({
limit = 100,
disabled = false,
blockedReason = null,
onReplayBegin,
onReplayAccepted,
onReplaySettled,
}: {
limit?: number;
disabled?: boolean;
blockedReason?: string | null;
export interface ObservationSessionReplayCallbacks {
onReplayBegin?: (
session: ObservationSessionSummary,
launch: ObservationSessionReplayLaunch,
@@ -115,6 +105,19 @@ export function ObservationSessionSelect({
session: ObservationSessionSummary,
outcome: ObservationReplayOutcome,
) => void | Promise<void>;
}
export function ObservationSessionSelect({
limit = 100,
disabled = false,
blockedReason = null,
onReplayBegin,
onReplayAccepted,
onReplaySettled,
}: ObservationSessionReplayCallbacks & {
limit?: number;
disabled?: boolean;
blockedReason?: string | null;
}) {
const [deleteTarget, setDeleteTarget] = useState<ObservationSessionSummary | null>(null);
const sessions = useObservationSessions({
@@ -300,3 +303,177 @@ export function ObservationSessionSelect({
/>
</>;
}
export function ObservationSessionArchive({
limit = 100,
labsOnly = false,
disabled = false,
blockedReason = null,
onReplayBegin,
onReplayAccepted,
onReplaySettled,
}: ObservationSessionReplayCallbacks & {
limit?: number;
labsOnly?: boolean;
disabled?: boolean;
blockedReason?: string | null;
}) {
const [deleteTarget, setDeleteTarget] = useState<ObservationSessionSummary | null>(null);
const sessions = useObservationSessions({
limit,
replayEnabled: blockedReason === null,
onReplayBegin,
onReplayAccepted,
onReplaySettled,
});
const items = labsOnly
? sessions.items.filter((session) => session.lab !== null)
: sessions.items;
return <>
<section
className="observation-session-archive"
aria-label={labsOnly ? "Лабораторные записи" : "Сохранённые сессии"}
>
<header className="observation-session-archive__head">
<div>
<span className="section-eyebrow">
{labsOnly ? "ВОСПРОИЗВОДИМЫЕ ЛАБОРАТОРНЫЕ ЗАПИСИ" : "АРХИВ НАБЛЮДЕНИЯ"}
</span>
<h2>{labsOnly ? "Записанные лабораторные работы" : "Сохранённые сессии"}</h2>
<p>
{labsOnly
? "Каждая запись связана с LAB-конфигурацией и открывается как самостоятельный визуальный результат."
: "Выберите запись: сцена и синхронные каналы откроются ниже, не меняя эфир наблюдения."}
</p>
</div>
<button
type="button"
className="observation-session-archive__refresh"
aria-label="Обновить каталог сессий"
disabled={sessions.state === "loading"}
onClick={() => void sessions.refresh()}
>
<Icon name="refresh" size={14} />
<span>Обновить</span>
</button>
</header>
{items.length > 0 ? (
<div className="observation-session-archive__list">
{items.map((session) => {
const pending = sessions.replayingSessionId === session.id;
const deleting = sessions.deletingSessionId === session.id;
const failed = sessions.failedSessionId === session.id;
const visualState = observationSessionVisualState(session, { pending, failed });
return (
<article
key={session.id}
className="observation-session-archive__item"
data-deleting={deleting ? "true" : undefined}
>
<button
type="button"
className="observation-session-archive__open"
disabled={disabled || pending || deleting || !session.replayable}
title={blockedReason ?? undefined}
onClick={() => void sessions.replay(session.id)}
>
<i data-session-visual-state={visualState} aria-hidden="true" />
<span>
<strong>
{session.lab &&
!session.label.startsWith(`${session.lab.labId} ·`) ? (
<small>{session.lab.labId}</small>
) : null}
{session.label}
</strong>
<small>{sessionDescription(session)}</small>
</span>
<em>
{pending && sessions.replayProgress
? progressCopy(sessions.replayProgress.phase)
: observationSessionVisualLabel(visualState)}
</em>
</button>
<button
type="button"
className="observation-session-archive__delete"
aria-label={`Удалить сохранённую сессию ${session.label}`}
disabled={pending || deleting}
onClick={() => setDeleteTarget(session)}
>
<Icon name="trash" size={15} />
</button>
</article>
);
})}
</div>
) : sessions.state === "loading" ? (
<div className="observation-session-archive__empty" role="status">
<span className="busy-indicator" aria-hidden="true" />
<strong>Читаем каталог</strong>
</div>
) : (
<div className="observation-session-archive__empty">
<Icon name={sessions.error ? "alert" : "database"} size={18} />
<strong>
{sessions.error
? "Каталог недоступен"
: labsOnly
? "LAB-записей пока нет"
: "Сессий пока нет"}
</strong>
<span>
{sessions.error
?? (labsOnly
? "Зафиксированные лабораторные записи появятся здесь автоматически."
: "Завершённые записи появятся здесь автоматически.")}
</span>
</div>
)}
{sessions.error && items.length > 0 ? (
<footer className="observation-session-menu__error" role="alert">
<Icon name="alert" size={13} />
<span>{sessions.error}</span>
{sessions.failedSessionId ? (
<button type="button" onClick={() => void sessions.retry()}>
Повторить
</button>
) : null}
</footer>
) : null}
</section>
<ConfirmationModal
open={deleteTarget !== null}
title="Удалить сохранённую сессию?"
description={deleteTarget ? <>
<strong>{deleteTarget.label}</strong>
{deleteTarget.lab ? (
<p>
Будут удалены только LAB-запись из каталога и её подготовленный кэш.
Исходная запись {deleteTarget.lab.sourceSessionId} и зафиксированный
лабораторный результат останутся неизменными.
</p>
) : (
<p>
Сессия, исходные данные наблюдения, подготовленная Rerun-запись и
видеоматериалы будут удалены с этого сервера без возможности восстановления.
</p>
)}
</> : null}
confirmLabel="Удалить сессию"
pendingLabel="Удаление…"
danger
onClose={() => {
if (sessions.deletingSessionId === null) setDeleteTarget(null);
}}
onConfirm={async () => {
if (!deleteTarget) return;
if (await sessions.remove(deleteTarget.id)) setDeleteTarget(null);
}}
/>
</>;
}