Files
NODEDC_MISSION_CORE/apps/control-station/src/components/ObservationSessionSelect.tsx
T

485 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from "react";
import { ConfirmationModal, Dropdown, Icon } from "@nodedc/ui-react";
import {
type ObservationSessionReplayLaunch,
type ObservationSessionSummary,
} from "../core/observation/sessionArchive";
import { useObservationSessions } from "../core/observation/useObservationSessions";
import type {
ObservationPreparationPhase,
ObservationReplayOutcome,
} from "../core/observation/useObservationSessions";
import "../styles/observation-sessions.css";
type SessionVisualState = "ready" | "processing" | "cold" | "error";
export function observationSessionVisualState(
session: ObservationSessionSummary,
{ pending = false, failed = false }: { pending?: boolean; failed?: boolean } = {},
): SessionVisualState {
if (failed) return "error";
if (pending) return "processing";
if (session.preparation !== null) {
if (["queued", "validating", "exporting", "finalizing"].includes(
session.preparation.state,
)) return "processing";
if (session.preparation.state === "ready" && session.replayable) return "ready";
return "error";
}
if (session.status === "recording") return "processing";
return session.replayable ? "cold" : "error";
}
export function observationSessionVisualLabel(state: SessionVisualState): string {
if (state === "ready") return "Готово";
if (state === "processing") return "Обработка";
if (state === "cold") return "Подготовить";
return "Ошибка";
}
const modalityLabel: Record<string, string> = {
"point-cloud": "облако точек",
pose: "траектория",
trajectory: "траектория",
video: "видео",
image: "изображения",
depth: "глубина",
telemetry: "телеметрия",
};
const preparationLabel: Record<ObservationPreparationPhase, string> = {
requesting: "Запрашиваем подготовку",
queued: "В очереди",
validating: "Проверяем запись",
exporting: "Готовим операторскую сцену",
finalizing: "Завершаем подготовку",
failed: "Подготовка не выполнена",
cancelled: "Подготовка отменена",
};
function progressCopy(phase: ObservationPreparationPhase): string {
// Backend progress values are phase markers and heartbeat revisions, not a
// measured fraction of bytes or frames. Presenting 0.5 as "50%" made a
// healthy long export look stalled, especially after a browser reload.
return preparationLabel[phase];
}
function formatStartedAt(value: string): string {
return new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(value));
}
function formatDuration(seconds: number): string {
const totalSeconds = Math.max(0, Math.round(seconds));
const hours = Math.floor(totalSeconds / 3_600);
const minutes = Math.floor((totalSeconds % 3_600) / 60);
const remainingSeconds = totalSeconds % 60;
return hours > 0
? `${hours}:${String(minutes).padStart(2, "0")}:${String(remainingSeconds).padStart(2, "0")}`
: `${minutes}:${String(remainingSeconds).padStart(2, "0")}`;
}
function sessionDescription(session: ObservationSessionSummary): string {
const modalities = session.modalities.length
? session.modalities.map((value) => modalityLabel[value] ?? value).join(" · ")
: "каналы не зафиксированы";
return `${formatStartedAt(session.startedAtUtc)} · ${formatDuration(session.durationSeconds)} · ${modalities}`;
}
export interface ObservationSessionReplayCallbacks {
onReplayBegin?: (
session: ObservationSessionSummary,
launch: ObservationSessionReplayLaunch,
) => void | Promise<void>;
onReplayAccepted?: (
session: ObservationSessionSummary,
launch: ObservationSessionReplayLaunch,
) => void | Promise<void>;
onReplaySettled?: (
session: ObservationSessionSummary,
outcome: ObservationReplayOutcome,
) => void | Promise<void>;
onDeleteBegin?: (sessionId: string) => void | Promise<void>;
}
export function ObservationSessionSelect({
limit = 100,
disabled = false,
blockedReason = null,
onReplayBegin,
onReplayAccepted,
onReplaySettled,
onDeleteBegin,
}: ObservationSessionReplayCallbacks & {
limit?: number;
disabled?: boolean;
blockedReason?: string | null;
}) {
const [deleteTarget, setDeleteTarget] = useState<ObservationSessionSummary | null>(null);
const sessions = useObservationSessions({
limit,
scope: "source",
replayEnabled: blockedReason === null,
onReplayBegin,
onReplayAccepted,
onReplaySettled,
onDeleteBegin,
});
const triggerCopy = sessions.replayProgress
? progressCopy(sessions.replayProgress.phase)
: sessions.state === "loading"
? "Загружаем сессии…"
: "Сохранённые сессии";
const presentedTriggerCopy = blockedReason ?? triggerCopy;
return <>
<Dropdown
className="observation-session-select"
placement="bottom-end"
width={390}
offset={10}
disabled={disabled}
surfaceRole="dialog"
surfaceClassName="observation-session-menu"
trigger={({ open, toggle, setAnchorRef, setTriggerRef, surfaceId }) => (
<button
ref={(node) => {
setAnchorRef(node);
setTriggerRef(node);
}}
type="button"
className="observation-session-select__trigger"
data-active={open ? "true" : undefined}
aria-label="Открыть сохранённые сессии наблюдения"
aria-haspopup="dialog"
aria-expanded={open}
aria-controls={surfaceId}
disabled={disabled}
title={blockedReason ?? undefined}
onClick={toggle}
>
<Icon name="database" size={15} />
<span>{presentedTriggerCopy}</span>
{sessions.state === "ready" ? <small>{sessions.items.length}</small> : null}
<Icon name="chevron-down" size={14} />
</button>
)}
>
{({ close }) => (
<div className="observation-session-menu__content">
<header className="observation-session-menu__head">
<div>
<span className="section-eyebrow">ИСТОРИЯ НАБЛЮДЕНИЯ</span>
<strong>Сохранённые сессии</strong>
</div>
<button
type="button"
aria-label="Обновить каталог сессий"
disabled={sessions.state === "loading"}
onClick={() => void sessions.refresh()}
>
<Icon name="refresh" size={14} />
</button>
</header>
{sessions.items.length > 0 ? (
<div className="observation-session-menu__list">
{sessions.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 (
<div
key={session.id}
className="observation-session-option"
data-deleting={deleting ? "true" : undefined}
>
<button
type="button"
className="nodedc-dropdown-option observation-session-option__open"
disabled={pending || deleting || !session.replayable}
onClick={() => {
void sessions.replay(session.id).then((accepted) => {
if (accepted) close();
});
}}
>
<span className="nodedc-dropdown-option__icon">
<i data-session-visual-state={visualState} aria-hidden="true" />
</span>
<span className="nodedc-dropdown-option__body">
<span className="nodedc-dropdown-option__label">
{session.lab &&
!session.label.startsWith(`${session.lab.labId} ·`) ? (
<span className="observation-session-option__lab">
{session.lab.labId}
</span>
) : null}
{session.label}
</span>
<span className="nodedc-dropdown-option__description">
{sessionDescription(session)}
</span>
</span>
<span className="observation-session-option__state">
{deleting ? "Удаление…" : observationSessionVisualLabel(visualState)}
</span>
</button>
<button
type="button"
className="observation-session-option__delete"
aria-label={`Удалить сохранённую сессию ${session.label}`}
disabled={pending || deleting}
onClick={() => setDeleteTarget(session)}
>
<Icon name="trash" size={15} />
</button>
</div>
);
})}
</div>
) : sessions.state === "loading" ? (
<div className="observation-session-menu__empty" role="status">
<span className="busy-indicator" aria-hidden="true" />
<strong>Читаем каталог</strong>
</div>
) : (
<div className="observation-session-menu__empty">
<Icon name={sessions.error ? "alert" : "database"} size={18} />
<strong>{sessions.error ? "Каталог недоступен" : "Сессий пока нет"}</strong>
<span>{sessions.error ?? "Завершённые записи появятся здесь автоматически."}</span>
</div>
)}
{sessions.error && sessions.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}
</div>
)}
</Dropdown>
<ConfirmationModal
open={deleteTarget !== null}
title="Удалить сохранённую сессию?"
description={deleteTarget ? <>
<strong>{deleteTarget.label}</strong>
{deleteTarget.lab ? (
<p>
Будут удалены только LAB-запись из каталога и её подготовленный кэш.
Исходная запись {deleteTarget.lab.sourceSessionId} и зафиксированный
лабораторный результат останутся неизменными.
</p>
) : (
<p>
Сессия, исходные данные наблюдения, подготовленная Rerun-запись и
видеоматериалы будут удалены с этого сервера без возможности восстановления.
</p>
)}
{sessions.error && sessions.deletingSessionId === null ? (
<p className="observation-session-delete-error" role="alert">{sessions.error}</p>
) : null}
</> : null}
confirmLabel="Удалить сессию"
pendingLabel="Удаление…"
danger
onClose={() => {
if (sessions.deletingSessionId === null) setDeleteTarget(null);
}}
onConfirm={async () => {
if (!deleteTarget) return;
if (await sessions.remove(deleteTarget.id)) setDeleteTarget(null);
}}
/>
</>;
}
export function ObservationSessionArchive({
limit = 100,
labsOnly = false,
disabled = false,
blockedReason = null,
onReplayBegin,
onReplayAccepted,
onReplaySettled,
onDeleteBegin,
}: ObservationSessionReplayCallbacks & {
limit?: number;
labsOnly?: boolean;
disabled?: boolean;
blockedReason?: string | null;
}) {
const [deleteTarget, setDeleteTarget] = useState<ObservationSessionSummary | null>(null);
const sessions = useObservationSessions({
limit,
scope: labsOnly ? "laboratory" : "source",
replayEnabled: blockedReason === null,
onReplayBegin,
onReplayAccepted,
onReplaySettled,
onDeleteBegin,
});
const items = 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);
}}
/>
</>;
}