feat(archive): complete recorded session lifecycle
This commit is contained in:
@@ -40,7 +40,10 @@ import {
|
||||
} from "./core/runtime/latestAsyncCommitter";
|
||||
import { useObservationLayout } from "./core/observation/useObservationLayout";
|
||||
import { recordedObservationSources } from "./core/observation/recordedObservationSources";
|
||||
import type { ObservationSessionReplayLaunch } from "./core/observation/sessionArchive";
|
||||
import type {
|
||||
ObservationSessionReplayLaunch,
|
||||
ObservationSessionSummary,
|
||||
} from "./core/observation/sessionArchive";
|
||||
import { useRecordedSessionAdmission } from "./core/observation/useRecordedSessionAdmission";
|
||||
import { useWorkspaceLayoutProfile } from "./core/observation/useWorkspaceLayoutProfile";
|
||||
import {
|
||||
@@ -136,6 +139,7 @@ export default function App() {
|
||||
const [activeRoot, setActiveRoot] = useState<RootId | null>(null);
|
||||
const [sourceUrl, setSourceUrl] = useState("");
|
||||
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
|
||||
const [recordedReplayLabel, setRecordedReplayLabel] = useState<string | null>(null);
|
||||
const [replayTransitioning, setReplayTransitioning] = useState(false);
|
||||
const [sourceDraft, setSourceDraft] = useState("");
|
||||
const [sourceWindowOpen, setSourceWindowOpen] = useState(false);
|
||||
@@ -447,6 +451,7 @@ export default function App() {
|
||||
// accepting the already-ready archive.
|
||||
setReplayTransitioning(true);
|
||||
setRecordedReplay(null);
|
||||
setRecordedReplayLabel(null);
|
||||
setSourceUrl("");
|
||||
setSourceDraft("");
|
||||
await new Promise<void>((resolve) => {
|
||||
@@ -454,11 +459,15 @@ export default function App() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const acceptRecordedReplay = useCallback((launch: ObservationSessionReplayLaunch) => {
|
||||
const acceptRecordedReplay = useCallback((
|
||||
session: ObservationSessionSummary,
|
||||
launch: ObservationSessionReplayLaunch,
|
||||
) => {
|
||||
if (sourceSwitchBlockedRef.current) {
|
||||
throw new Error(SPATIAL_SOURCE_SWITCH_BLOCKED_REASON);
|
||||
}
|
||||
setRecordedReplay(launch);
|
||||
setRecordedReplayLabel(session.label);
|
||||
setSourceUrl(launch.sourceUrl);
|
||||
setSourceDraft(launch.sourceUrl);
|
||||
setReplayTransitioning(false);
|
||||
@@ -475,6 +484,7 @@ export default function App() {
|
||||
// switch, so it intentionally does not consult the acquisition guard.
|
||||
setReplayTransitioning(false);
|
||||
setRecordedReplay(null);
|
||||
setRecordedReplayLabel(null);
|
||||
setSourceUrl("");
|
||||
setSourceDraft("");
|
||||
}, []);
|
||||
@@ -634,7 +644,11 @@ export default function App() {
|
||||
<ApplicationPanel
|
||||
key={activeDefinition.id}
|
||||
eyebrow={activeDefinition.eyebrow}
|
||||
title={activeDefinition.title}
|
||||
title={
|
||||
activeDefinition.kind === "spatial" && replayActive && recordedReplayLabel
|
||||
? `${activeDefinition.title}: ${recordedReplayLabel}`
|
||||
: activeDefinition.title
|
||||
}
|
||||
description={activeDefinition.description}
|
||||
expanded={workspace.contentExpanded}
|
||||
onExpandedChange={workspace.setContentExpanded}
|
||||
@@ -646,11 +660,10 @@ export default function App() {
|
||||
) : activeDefinition.kind === "spatial" ? (
|
||||
<div className="observation-header-tools">
|
||||
<ObservationSessionSelect
|
||||
limit={3}
|
||||
disabled={runtime.pendingAction !== null || sourceSwitchBlocked}
|
||||
blockedReason={sourceSwitchBlockedReason}
|
||||
onReplayBegin={beginRecordedReplaySwitch}
|
||||
onReplayAccepted={(_session, launch) => acceptRecordedReplay(launch)}
|
||||
onReplayAccepted={acceptRecordedReplay}
|
||||
onReplaySettled={(_session, outcome) => settleRecordedReplaySwitch(outcome)}
|
||||
/>
|
||||
{layoutSaveNotice || workspaceLayoutProfile.error ? (
|
||||
@@ -739,6 +752,7 @@ export default function App() {
|
||||
setSourceDraft("");
|
||||
setSourceUrl("");
|
||||
setRecordedReplay(null);
|
||||
setRecordedReplayLabel(null);
|
||||
}}
|
||||
>
|
||||
Сбросить адрес
|
||||
@@ -752,6 +766,7 @@ export default function App() {
|
||||
if (sourceSwitchBlockedRef.current) return;
|
||||
setSourceUrl(sourceDraft.trim());
|
||||
setRecordedReplay(null);
|
||||
setRecordedReplayLabel(null);
|
||||
}}
|
||||
>
|
||||
Применить адрес
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Dropdown, Icon } from "@nodedc/ui-react";
|
||||
import { useState } from "react";
|
||||
import { ConfirmationModal, Dropdown, Icon } from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
type ObservationSessionReplayLaunch,
|
||||
@@ -93,7 +94,7 @@ function sessionDescription(session: ObservationSessionSummary): string {
|
||||
}
|
||||
|
||||
export function ObservationSessionSelect({
|
||||
limit = 3,
|
||||
limit = 100,
|
||||
disabled = false,
|
||||
blockedReason = null,
|
||||
onReplayBegin,
|
||||
@@ -116,6 +117,7 @@ export function ObservationSessionSelect({
|
||||
outcome: ObservationReplayOutcome,
|
||||
) => void | Promise<void>;
|
||||
}) {
|
||||
const [deleteTarget, setDeleteTarget] = useState<ObservationSessionSummary | null>(null);
|
||||
const sessions = useObservationSessions({
|
||||
limit,
|
||||
replayEnabled: blockedReason === null,
|
||||
@@ -130,7 +132,7 @@ export function ObservationSessionSelect({
|
||||
: "Сохранённые сессии";
|
||||
const presentedTriggerCopy = blockedReason ?? triggerCopy;
|
||||
|
||||
return (
|
||||
return <>
|
||||
<Dropdown
|
||||
className="observation-session-select"
|
||||
placement="bottom-end"
|
||||
@@ -184,33 +186,48 @@ export function ObservationSessionSelect({
|
||||
<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 (
|
||||
<button
|
||||
<div
|
||||
key={session.id}
|
||||
type="button"
|
||||
className="nodedc-dropdown-option observation-session-option"
|
||||
disabled={pending || !session.replayable}
|
||||
onClick={() => {
|
||||
void sessions.replay(session.id).then((accepted) => {
|
||||
if (accepted) close();
|
||||
});
|
||||
}}
|
||||
className="observation-session-option"
|
||||
data-deleting={deleting ? "true" : undefined}
|
||||
>
|
||||
<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)}
|
||||
<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>
|
||||
<span className="observation-session-option__state">
|
||||
{observationSessionVisualLabel(visualState)}
|
||||
</span>
|
||||
</button>
|
||||
<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>
|
||||
@@ -242,5 +259,29 @@ export function ObservationSessionSelect({
|
||||
</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);
|
||||
}}
|
||||
/>
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -135,13 +135,17 @@ export function ObservationMedia({
|
||||
return (
|
||||
<div className="observation-media__empty">
|
||||
<Icon name={sourceIcon[source.modality]} size={20} />
|
||||
<strong>{observationSourceStatusLabel(source)}</strong>
|
||||
<strong>
|
||||
{source.modality === "video" && source.activation && !source.activation.selected
|
||||
? "Источник отключён"
|
||||
: observationSourceStatusLabel(source)}
|
||||
</strong>
|
||||
<span>
|
||||
{source.modality === "video"
|
||||
? source.activation?.controllable
|
||||
? source.activation.selected
|
||||
? "Повторите подключение — локальный адаптер перезапустит выбранную камеру"
|
||||
: "Откройте канал — локальный адаптер подключит выбранную камеру"
|
||||
: "Канал остановлен оператором; при необходимости его можно открыть снова"
|
||||
: "Канал известен, browser-preview сейчас недоступен"
|
||||
: source.description}
|
||||
</span>
|
||||
|
||||
@@ -393,15 +393,27 @@ async function mountVerifiedRecordedEpoch(
|
||||
signal: AbortSignal,
|
||||
): Promise<() => void> {
|
||||
const descriptor = epoch.descriptor;
|
||||
if (
|
||||
descriptor.mediaType === "video/mp4" ||
|
||||
!globalThis.MediaSource ||
|
||||
!MediaSource.isTypeSupported(descriptor.mediaType)
|
||||
) {
|
||||
if (!descriptor.mediaType.startsWith("video/mp4;") || !video.canPlayType(descriptor.mediaType)) {
|
||||
throw new Error("Archived camera codec is not supported");
|
||||
}
|
||||
const mediaSource = new MediaSource();
|
||||
const objectUrl = URL.createObjectURL(mediaSource);
|
||||
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
|
||||
// A sealed fragmented-MP4 generation is already complete and immutable.
|
||||
// Present it as one Blob so Chromium can index and seek the whole archive
|
||||
// without retaining the same 166+ MiB epoch in a quota-limited MSE
|
||||
// SourceBuffer. Every constituent byte was fetched and SHA-256 verified
|
||||
// before this boundary, and Blob preserves their canonical order.
|
||||
const payload = new Blob([epoch.init, ...epoch.segments], {
|
||||
type: descriptor.mediaType,
|
||||
});
|
||||
const expectedByteLength = epoch.init.byteLength + epoch.segments.reduce(
|
||||
(total, segment) => total + segment.byteLength,
|
||||
0,
|
||||
);
|
||||
if (payload.size !== expectedByteLength) {
|
||||
throw new Error("Archived camera Blob is incomplete");
|
||||
}
|
||||
const objectUrl = URL.createObjectURL(payload);
|
||||
const cleanup = () => {
|
||||
video.pause();
|
||||
video.removeAttribute("src");
|
||||
@@ -409,39 +421,8 @@ async function mountVerifiedRecordedEpoch(
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
video.src = objectUrl;
|
||||
video.load();
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onOpen = () => {
|
||||
cleanupListeners();
|
||||
resolve();
|
||||
};
|
||||
const onAbort = () => {
|
||||
cleanupListeners();
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
};
|
||||
const cleanupListeners = () => {
|
||||
mediaSource.removeEventListener("sourceopen", onOpen);
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
};
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
mediaSource.addEventListener("sourceopen", onOpen, { once: true });
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
if (signal.aborted || mediaSource.readyState !== "open") {
|
||||
throw new DOMException("Aborted", "AbortError");
|
||||
}
|
||||
const sourceBuffer = mediaSource.addSourceBuffer(descriptor.mediaType);
|
||||
await appendRecordedMediaBuffer(sourceBuffer, epoch.init, signal);
|
||||
for (const segment of epoch.segments) {
|
||||
await appendRecordedMediaBuffer(sourceBuffer, segment, signal);
|
||||
}
|
||||
if (signal.aborted || mediaSource.readyState !== "open" || sourceBuffer.updating) {
|
||||
throw new Error("Archived camera MediaSource closed before full append");
|
||||
}
|
||||
mediaSource.endOfStream();
|
||||
await waitForSeekableArchive(
|
||||
video,
|
||||
descriptor.timelineEndSeconds - descriptor.timelineStartSeconds,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// A device-agnostic frontend safety policy. Sixteen channels covers multi-rig
|
||||
// vehicles while the independent byte/concurrency limits keep admission
|
||||
// bounded. OPFS-backed sealed generations are the planned scaling path beyond
|
||||
// this in-memory laboratory policy.
|
||||
// bounded. One real accepted archive is ~163 MiB for a single camera, so the
|
||||
// old 128 MiB laboratory ceiling rejected a valid sealed generation before the
|
||||
// first manifest request. OPFS-backed sealed generations remain the scaling
|
||||
// path beyond this in-memory policy.
|
||||
export const MAX_RECORDED_CAMERA_SOURCES = 16;
|
||||
export const MAX_RECORDED_MEDIA_SOURCE_BYTES = 128 * 1024 * 1024;
|
||||
export const MAX_RECORDED_MEDIA_SOURCE_BYTES = 256 * 1024 * 1024;
|
||||
export const MAX_RECORDED_SESSION_CAMERA_BYTES = 512 * 1024 * 1024;
|
||||
export const MAX_CONCURRENT_RECORDED_CAMERA_PREPARATIONS = 1;
|
||||
|
||||
|
||||
@@ -1110,6 +1110,46 @@ export async function fetchObservationSessionCatalog({
|
||||
return decodeObservationSessionCatalog(body);
|
||||
}
|
||||
|
||||
export async function deleteObservationSession(
|
||||
sessionId: string,
|
||||
{
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: { signal?: AbortSignal; fetcher?: ObservationSessionFetch } = {},
|
||||
): Promise<void> {
|
||||
if (!SAFE_ID.test(sessionId)) {
|
||||
throw new ObservationSessionContractError(
|
||||
"Идентификатор удаляемой сессии имеет недопустимый формат.",
|
||||
);
|
||||
}
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetcher(
|
||||
`/api/v1/observation-sessions/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") throw error;
|
||||
throw new ObservationSessionApiError("Не удалось удалить сохранённую сессию.");
|
||||
}
|
||||
const body = await responseBody(response);
|
||||
if (!response.ok || response.status !== 204) {
|
||||
throw new ObservationSessionApiError(
|
||||
apiErrorMessage(body, `Удаление сессии вернуло HTTP ${response.status}.`),
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
if (body !== undefined) {
|
||||
throw new ObservationSessionContractError(
|
||||
"Сервер вернул данные после подтверждённого удаления сессии.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function replayObservationSession(
|
||||
sessionId: string,
|
||||
{
|
||||
|
||||
@@ -27,6 +27,18 @@ export function observationPresentationSourceAfterLayoutApply(
|
||||
return mode === "preserve" ? currentSourceId : null;
|
||||
}
|
||||
|
||||
export function visibleSourceIdsAfterRecordedCatalogActivation(
|
||||
currentIds: readonly string[],
|
||||
sources: readonly ObservationSourceDescriptor[],
|
||||
): string[] {
|
||||
let visibleIds = [...currentIds];
|
||||
for (const source of sources) {
|
||||
if (source.transport !== "recording" || !canOpenByDefault(source)) continue;
|
||||
visibleIds = openObservationSource(visibleIds, source.id, sources).visibleIds;
|
||||
}
|
||||
return visibleIds;
|
||||
}
|
||||
|
||||
export interface ObservationLayoutController {
|
||||
visibleSourceIds: ReadonlySet<string>;
|
||||
focusedSourceId: string | null;
|
||||
@@ -230,7 +242,25 @@ export function useObservationLayout(
|
||||
}
|
||||
const desired = desiredSnapshotRef.current;
|
||||
if (desired) {
|
||||
const previousIdentity = initializedCatalog.current;
|
||||
initializedCatalog.current = identity;
|
||||
if (
|
||||
previousIdentity !== identity &&
|
||||
sources.some((source) => source.transport === "recording")
|
||||
) {
|
||||
const nextVisible = visibleSourceIdsAfterRecordedCatalogActivation(
|
||||
visibleIdsRef.current,
|
||||
sources,
|
||||
);
|
||||
commitVisibleIds(nextVisible);
|
||||
const firstRecordedOverlay = sources.find((source) => (
|
||||
source.transport === "recording" &&
|
||||
source.capabilities.overlay &&
|
||||
nextVisible.includes(source.id)
|
||||
));
|
||||
commitActiveFloatingSourceId(firstRecordedOverlay?.id ?? null);
|
||||
persistLiveLayout();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (initializedCatalog.current === identity) return;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
decodeObservationSessionPreparation,
|
||||
deleteObservationSession,
|
||||
fetchObservationSessionCatalog,
|
||||
fetchObservationSessionPreparation,
|
||||
replayObservationSession,
|
||||
@@ -32,9 +33,11 @@ export interface ObservationSessionsController {
|
||||
preparation: ObservationSessionPreparation | null;
|
||||
replayProgress: ObservationReplayProgress | null;
|
||||
failedSessionId: string | null;
|
||||
deletingSessionId: string | null;
|
||||
refresh: () => Promise<boolean>;
|
||||
replay: (sessionId: string) => Promise<boolean>;
|
||||
retry: () => Promise<boolean>;
|
||||
remove: (sessionId: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface ObservationReplayAttempt {
|
||||
@@ -360,7 +363,7 @@ export function clearObservationReplayPreparation(
|
||||
}
|
||||
|
||||
export function useObservationSessions({
|
||||
limit = 3,
|
||||
limit = 100,
|
||||
replayEnabled = true,
|
||||
onReplayBegin,
|
||||
onReplayAccepted,
|
||||
@@ -389,12 +392,12 @@ export function useObservationSessions({
|
||||
const [preparation, setPreparation] = useState<ObservationSessionPreparation | null>(null);
|
||||
const [replayProgress, setReplayProgress] = useState<ObservationReplayProgress | null>(null);
|
||||
const [failedSessionId, setFailedSessionId] = useState<string | null>(null);
|
||||
const [deletingSessionId, setDeletingSessionId] = useState<string | null>(null);
|
||||
const mounted = useRef(true);
|
||||
const catalogSequence = useRef(0);
|
||||
const reattachStarted = useRef(false);
|
||||
const replayEnabledRef = useRef(replayEnabled);
|
||||
replayEnabledRef.current = replayEnabled;
|
||||
const preparationPollSequence = useRef(0);
|
||||
const replayCoordinator = useRef<ObservationReplayCoordinator | null>(null);
|
||||
if (replayCoordinator.current === null) {
|
||||
replayCoordinator.current = createObservationReplayCoordinator();
|
||||
@@ -409,26 +412,30 @@ export function useObservationSessions({
|
||||
setReplayingSessionId(null);
|
||||
setReplayProgress(null);
|
||||
}, [replayEnabled]);
|
||||
const safeLimit = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : 3;
|
||||
const safeLimit = Number.isFinite(limit)
|
||||
? Math.min(100, Math.max(1, Math.floor(limit)))
|
||||
: 100;
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const loadCatalog = useCallback(async (foreground: boolean) => {
|
||||
const sequence = ++catalogSequence.current;
|
||||
setState("loading");
|
||||
setError(null);
|
||||
if (foreground) setState("loading");
|
||||
try {
|
||||
const catalog = await fetchObservationSessionCatalog({ limit: safeLimit });
|
||||
if (!mounted.current || sequence !== catalogSequence.current) return false;
|
||||
setItems(catalog.items.slice(0, safeLimit));
|
||||
setState("ready");
|
||||
setError(null);
|
||||
return true;
|
||||
} catch (loadError) {
|
||||
if (!mounted.current || sequence !== catalogSequence.current) return false;
|
||||
setState("error");
|
||||
if (foreground) setState("error");
|
||||
setError(errorMessage(loadError));
|
||||
return false;
|
||||
}
|
||||
}, [safeLimit]);
|
||||
|
||||
const refresh = useCallback(() => loadCatalog(true), [loadCatalog]);
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
void refresh();
|
||||
@@ -439,44 +446,21 @@ export function useObservationSessions({
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
const catalogHasActivePreparation = items.some((item) => (
|
||||
item.preparation !== null &&
|
||||
["queued", "validating", "exporting", "finalizing"].includes(item.preparation.state)
|
||||
));
|
||||
|
||||
useEffect(() => {
|
||||
if (state !== "ready" || !catalogHasActivePreparation) return;
|
||||
const sequence = ++preparationPollSequence.current;
|
||||
const controller = new AbortController();
|
||||
const timer = window.setTimeout(async () => {
|
||||
try {
|
||||
const catalog = await fetchObservationSessionCatalog({
|
||||
limit: safeLimit,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (
|
||||
mounted.current &&
|
||||
!controller.signal.aborted &&
|
||||
sequence === preparationPollSequence.current
|
||||
) {
|
||||
setItems(catalog.items.slice(0, safeLimit));
|
||||
}
|
||||
} catch (pollError) {
|
||||
if (
|
||||
mounted.current &&
|
||||
!controller.signal.aborted &&
|
||||
sequence === preparationPollSequence.current
|
||||
) {
|
||||
setError(errorMessage(pollError));
|
||||
}
|
||||
}
|
||||
}, 1_500);
|
||||
return () => {
|
||||
preparationPollSequence.current += 1;
|
||||
window.clearTimeout(timer);
|
||||
controller.abort();
|
||||
if (state !== "ready") return;
|
||||
let disposed = false;
|
||||
let timer = 0;
|
||||
const poll = async () => {
|
||||
if (disposed) return;
|
||||
await loadCatalog(false);
|
||||
if (!disposed) timer = window.setTimeout(() => void poll(), 2_000);
|
||||
};
|
||||
}, [catalogHasActivePreparation, items, safeLimit, state]);
|
||||
timer = window.setTimeout(() => void poll(), 2_000);
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [loadCatalog, state]);
|
||||
|
||||
const executeReplay = useCallback(async (
|
||||
session: ObservationSessionSummary,
|
||||
@@ -606,6 +590,24 @@ export function useObservationSessions({
|
||||
return replay(failedSessionId);
|
||||
}, [failedSessionId, replay]);
|
||||
|
||||
const remove = useCallback(async (sessionId: string) => {
|
||||
if (deletingSessionId !== null || replayingSessionId === sessionId) return false;
|
||||
setDeletingSessionId(sessionId);
|
||||
setError(null);
|
||||
try {
|
||||
await deleteObservationSession(sessionId);
|
||||
if (!mounted.current) return false;
|
||||
setItems((current) => current.filter((item) => item.id !== sessionId));
|
||||
setFailedSessionId((current) => current === sessionId ? null : current);
|
||||
return true;
|
||||
} catch (deleteError) {
|
||||
if (mounted.current) setError(errorMessage(deleteError));
|
||||
return false;
|
||||
} finally {
|
||||
if (mounted.current) setDeletingSessionId(null);
|
||||
}
|
||||
}, [deletingSessionId, replayingSessionId]);
|
||||
|
||||
return {
|
||||
items,
|
||||
state,
|
||||
@@ -614,8 +616,10 @@ export function useObservationSessions({
|
||||
preparation,
|
||||
replayProgress,
|
||||
failedSessionId,
|
||||
deletingSessionId,
|
||||
refresh,
|
||||
replay,
|
||||
retry,
|
||||
remove,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -117,9 +117,42 @@
|
||||
}
|
||||
|
||||
.observation-session-option {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 2.35rem;
|
||||
align-items: stretch;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.observation-session-option__open {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.observation-session-option__delete {
|
||||
display: grid;
|
||||
min-width: 2.35rem;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--nodedc-text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.observation-session-option__delete:hover:not(:disabled),
|
||||
.observation-session-option__delete:focus-visible {
|
||||
background: transparent;
|
||||
color: rgb(var(--nodedc-danger-rgb));
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.observation-session-option__delete:disabled,
|
||||
.observation-session-option[data-deleting="true"] {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.observation-session-option i {
|
||||
display: block;
|
||||
width: 0.45rem;
|
||||
@@ -217,6 +250,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.observation-session-delete-error {
|
||||
color: rgb(var(--nodedc-danger-rgb));
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.workspace-layout-feedback {
|
||||
display: none;
|
||||
|
||||
@@ -323,11 +323,19 @@ function SpatialWorkspace({
|
||||
const floatingSourceMaximized = Boolean(observationLayout.maximizedFloatingSourceId);
|
||||
const timeline = state?.observationTimeline;
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const presentedViewerStatus = rerunPresentationStatus(
|
||||
viewerStatus,
|
||||
recordedSessionGate,
|
||||
recordedSource,
|
||||
);
|
||||
const intentionalSourceEnd = !recordedSource && [
|
||||
"awaiting_external_stop",
|
||||
"stopping",
|
||||
"finalizing",
|
||||
"completed",
|
||||
].includes(state?.acquisition?.state ?? "");
|
||||
const presentedViewerStatus = intentionalSourceEnd
|
||||
? "idle"
|
||||
: rerunPresentationStatus(
|
||||
viewerStatus,
|
||||
recordedSessionGate,
|
||||
recordedSource,
|
||||
);
|
||||
|
||||
const onStatusChange = useCallback((status: RerunViewportStatus, message?: string) => {
|
||||
setViewerStatus(status);
|
||||
@@ -389,7 +397,7 @@ function SpatialWorkspace({
|
||||
}, [observationLayout.setViewportSize]);
|
||||
|
||||
const viewerStatusLabel = {
|
||||
idle: "Источник не назначен",
|
||||
idle: intentionalSourceEnd ? "Источник отключён" : "Источник не назначен",
|
||||
loading: "Подключение",
|
||||
ready: "Визуализатор готов",
|
||||
error: "Ошибка источника",
|
||||
@@ -500,7 +508,7 @@ function SpatialWorkspace({
|
||||
>
|
||||
<span className="section-eyebrow">ВИЗУАЛЬНЫЙ ДВИЖОК</span>
|
||||
<StatusBadge tone={viewerStatusTone}>{viewerStatusLabel}</StatusBadge>
|
||||
{viewerMessage ? <small>{viewerMessage}</small> : null}
|
||||
{!intentionalSourceEnd && viewerMessage ? <small>{viewerMessage}</small> : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -679,6 +687,7 @@ function CameraSourceCard({
|
||||
: selectedPeer
|
||||
? "Переключить"
|
||||
: "Открыть канал";
|
||||
const sourceDisconnected = Boolean(source.activation && !source.activation.selected && visible);
|
||||
|
||||
return (
|
||||
<article className="camera-slot" data-focused={focused ? "true" : undefined}>
|
||||
@@ -686,8 +695,11 @@ function CameraSourceCard({
|
||||
<span>{source.label}</span>
|
||||
<div className="camera-slot__head-actions">
|
||||
<span className="camera-slot__status">
|
||||
<i data-availability={source.availability} aria-hidden="true" />
|
||||
{observationSourceStatusLabel(source)}
|
||||
<i
|
||||
data-availability={sourceDisconnected ? "unverified" : source.availability}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{sourceDisconnected ? "Источник отключён" : observationSourceStatusLabel(source)}
|
||||
</span>
|
||||
{source.activation && !focused ? (
|
||||
<button
|
||||
|
||||
@@ -9,6 +9,7 @@ let decodeObservationSessionCatalog;
|
||||
let decodeObservationSessionReplay;
|
||||
let decodeObservationSessionPreparation;
|
||||
let fetchObservationSessionCatalog;
|
||||
let deleteObservationSession;
|
||||
let replayObservationSession;
|
||||
let fetchObservationSessionPreparation;
|
||||
let cancelObservationSessionPreparation;
|
||||
@@ -34,6 +35,7 @@ before(async () => {
|
||||
decodeObservationSessionReplay,
|
||||
decodeObservationSessionPreparation,
|
||||
fetchObservationSessionCatalog,
|
||||
deleteObservationSession,
|
||||
replayObservationSession,
|
||||
fetchObservationSessionPreparation,
|
||||
cancelObservationSessionPreparation,
|
||||
@@ -136,6 +138,39 @@ test("session catalog decodes canonical snake_case into a path-free camelCase mo
|
||||
assert.equal("path" in catalog.items[0], false);
|
||||
});
|
||||
|
||||
test("opened archive is named in the scene header and trash hover has no pill", async () => {
|
||||
const appSource = await readFile(new URL("../src/App.tsx", import.meta.url), "utf8");
|
||||
const styles = await readFile(
|
||||
new URL("../src/styles/observation-sessions.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const spatialControls = await readFile(
|
||||
new URL(
|
||||
"../../../plugins/xgrids-k1/frontend/src/components/K1SpatialControls.tsx",
|
||||
import.meta.url,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
const acquisitionPipeline = await readFile(
|
||||
new URL(
|
||||
"../../../plugins/xgrids-k1/frontend/src/components/K1AcquisitionPipeline.tsx",
|
||||
import.meta.url,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(appSource, /setRecordedReplayLabel\(session\.label\)/);
|
||||
assert.match(appSource, /`\$\{activeDefinition\.title\}: \$\{recordedReplayLabel\}`/);
|
||||
const deleteStyle = styles.slice(
|
||||
styles.indexOf(".observation-session-option__delete"),
|
||||
styles.indexOf(".observation-session-option__delete:disabled"),
|
||||
);
|
||||
assert.match(deleteStyle, /background:\s*transparent/);
|
||||
assert.doesNotMatch(deleteStyle, /danger-rgb\) \/ 0\.12/);
|
||||
assert.doesNotMatch(spatialControls, /Индикатор постоянно зелёный/);
|
||||
assert.doesNotMatch(acquisitionPipeline, /Индикатор постоянно зелёный/);
|
||||
});
|
||||
|
||||
test("session catalog exposes authoritative background preparation state", () => {
|
||||
const catalog = decodeObservationSessionCatalog({
|
||||
items: [session({
|
||||
@@ -302,6 +337,37 @@ test("catalog API preserves HTTP detail without accepting a malformed success bo
|
||||
);
|
||||
});
|
||||
|
||||
test("delete API uses one opaque same-origin target and requires an empty 204", async () => {
|
||||
const calls = [];
|
||||
await deleteObservationSession("session-20260716T205632Z", {
|
||||
fetcher: async (input, init) => {
|
||||
calls.push({ input: String(input), init });
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(
|
||||
calls[0].input,
|
||||
"/api/v1/observation-sessions/session-20260716T205632Z",
|
||||
);
|
||||
assert.equal(calls[0].init.method, "DELETE");
|
||||
await assert.rejects(
|
||||
deleteObservationSession("../private", { fetcher: async () => new Response(null) }),
|
||||
ObservationSessionContractError,
|
||||
);
|
||||
await assert.rejects(
|
||||
deleteObservationSession("session-20260716T205632Z", {
|
||||
fetcher: async () => new Response(JSON.stringify({ detail: "recording is open" }), {
|
||||
status: 409,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
}),
|
||||
(error) => error instanceof ObservationSessionApiError &&
|
||||
error.status === 409 && error.message === "recording is open",
|
||||
);
|
||||
});
|
||||
|
||||
test("replay API accepts only a same-origin seekable recording descriptor", async () => {
|
||||
const calls = [];
|
||||
const launch = await replayObservationSession("session-20260716T205632Z", {
|
||||
|
||||
@@ -78,7 +78,7 @@ test("any RRD or camera failure closes the complete recorded session", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("camera admission enforces independent 16/128/512 MiB limits before fetching", () => {
|
||||
test("camera admission enforces independent 16/256/512 MiB limits before fetching", () => {
|
||||
const {
|
||||
MAX_RECORDED_CAMERA_SOURCES,
|
||||
MAX_RECORDED_MEDIA_SOURCE_BYTES,
|
||||
@@ -86,10 +86,10 @@ test("camera admission enforces independent 16/128/512 MiB limits before fetchin
|
||||
recordedCameraDescriptorPreflight,
|
||||
} = admission;
|
||||
assert.equal(MAX_RECORDED_CAMERA_SOURCES, 16);
|
||||
assert.equal(MAX_RECORDED_MEDIA_SOURCE_BYTES, 128 * 1024 * 1024);
|
||||
assert.equal(MAX_RECORDED_MEDIA_SOURCE_BYTES, 256 * 1024 * 1024);
|
||||
assert.equal(MAX_RECORDED_SESSION_CAMERA_BYTES, 512 * 1024 * 1024);
|
||||
assert.equal(recordedCameraDescriptorPreflight(
|
||||
Array.from({ length: 4 }, (_, index) => ({
|
||||
Array.from({ length: 2 }, (_, index) => ({
|
||||
id: `camera.${index}`,
|
||||
byteLength: MAX_RECORDED_MEDIA_SOURCE_BYTES,
|
||||
})),
|
||||
|
||||
@@ -14,6 +14,7 @@ let saveObservationWorkspaceLayoutProfile;
|
||||
let WorkspaceLayoutApiError;
|
||||
let WorkspaceLayoutContractError;
|
||||
let observationPresentationSourceAfterLayoutApply;
|
||||
let visibleSourceIdsAfterRecordedCatalogActivation;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
@@ -31,9 +32,10 @@ before(async () => {
|
||||
WorkspaceLayoutApiError,
|
||||
WorkspaceLayoutContractError,
|
||||
} = await server.ssrLoadModule("/src/core/observation/workspaceLayout.ts"));
|
||||
({ observationPresentationSourceAfterLayoutApply } = await server.ssrLoadModule(
|
||||
"/src/core/observation/useObservationLayout.ts",
|
||||
));
|
||||
({
|
||||
observationPresentationSourceAfterLayoutApply,
|
||||
visibleSourceIdsAfterRecordedCatalogActivation,
|
||||
} = await server.ssrLoadModule("/src/core/observation/useObservationLayout.ts"));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
@@ -181,6 +183,36 @@ test("fullscreen presentation survives the viewport resize it causes", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("opening a recorded catalog reveals its sealed cameras beside the point cloud", () => {
|
||||
const source = (id, modality, transport = "recording") => ({
|
||||
id,
|
||||
modality,
|
||||
transport,
|
||||
availability: "available",
|
||||
previewUrl: null,
|
||||
delivery: modality === "video" ? { kind: "recorded-fmp4-manifest" } : null,
|
||||
activation: null,
|
||||
capabilities: {
|
||||
defaultVisible: true,
|
||||
overlay: modality === "video",
|
||||
},
|
||||
});
|
||||
const sources = [
|
||||
source("recorded.spatial.primary", "point-cloud"),
|
||||
source("recorded.camera.left", "video"),
|
||||
source("recorded.camera.right", "video"),
|
||||
source("live.camera", "video", "websocket"),
|
||||
];
|
||||
|
||||
assert.deepEqual(
|
||||
visibleSourceIdsAfterRecordedCatalogActivation(
|
||||
["recorded.spatial.primary"],
|
||||
sources,
|
||||
),
|
||||
["recorded.spatial.primary", "recorded.camera.left", "recorded.camera.right"],
|
||||
);
|
||||
});
|
||||
|
||||
test("workspace layout API uses the canonical endpoint and optimistic revision", async () => {
|
||||
const calls = [];
|
||||
const current = decodeObservationWorkspaceLayoutProfile(wireProfile());
|
||||
|
||||
Reference in New Issue
Block a user