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

287 lines
11 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 function ObservationSessionSelect({
limit = 100,
disabled = false,
blockedReason = null,
onReplayBegin,
onReplayAccepted,
onReplaySettled,
}: {
limit?: number;
disabled?: boolean;
blockedReason?: string | null;
onReplayBegin?: (
session: ObservationSessionSummary,
launch: ObservationSessionReplayLaunch,
) => void | Promise<void>;
onReplayAccepted?: (
session: ObservationSessionSummary,
launch: ObservationSessionReplayLaunch,
) => void | Promise<void>;
onReplaySettled?: (
session: ObservationSessionSummary,
outcome: ObservationReplayOutcome,
) => void | Promise<void>;
}) {
const [deleteTarget, setDeleteTarget] = useState<ObservationSessionSummary | null>(null);
const sessions = useObservationSessions({
limit,
replayEnabled: blockedReason === null,
onReplayBegin,
onReplayAccepted,
onReplaySettled,
});
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.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>
<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);
}}
/>
</>;
}