feat(archive): complete recorded session lifecycle
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user