feat(archive): complete recorded session lifecycle

This commit is contained in:
DCCONSTRUCTIONS
2026-07-19 01:07:21 +03:00
parent ffffee1879
commit 71c85e9894
22 changed files with 922 additions and 144 deletions
@@ -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,
};
}