diff --git a/web/src/api/ws.ts b/web/src/api/ws.ts index 845ad1fa63..909a1bb5d9 100644 --- a/web/src/api/ws.ts +++ b/web/src/api/ws.ts @@ -811,10 +811,10 @@ export function useTriggers(): { payload: TriggerStatus } { return { payload: parsed }; } -export function useJobStatus( +export function useJobStatus( jobType: string, revalidateOnFocus: boolean = true, -): { payload: Job | null } { +): { payload: Job | null } { const { value: { payload }, send: sendCommand, @@ -846,7 +846,7 @@ export function useJobStatus( // eslint-disable-next-line react-hooks/exhaustive-deps }, [revalidateOnFocus]); - return { payload: currentJob as Job | null }; + return { payload: currentJob as Job | null }; } export function useWsMessageSubscribe(callback: (msg: WsFeedMessage) => void) { diff --git a/web/src/components/card/ExportCard.tsx b/web/src/components/card/ExportCard.tsx index 724179128c..966aab4dcc 100644 --- a/web/src/components/card/ExportCard.tsx +++ b/web/src/components/card/ExportCard.tsx @@ -1,6 +1,7 @@ import ActivityIndicator from "../indicators/activity-indicator"; import { Button } from "../ui/button"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { Progress } from "../ui/progress"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { isMobile } from "react-device-detect"; import { FiMoreVertical } from "react-icons/fi"; import { Skeleton } from "../ui/skeleton"; @@ -128,6 +129,14 @@ export function ExportCard({ exportedRecording.thumb_path.length > 0, ); + // Resync the skeleton state whenever the backing export changes. The + // list keys by id now, so in practice the component remounts instead + // of receiving new props — but this keeps the card honest if a parent + // ever reuses the instance across different exports. + useEffect(() => { + setLoading(exportedRecording.thumb_path.length > 0); + }, [exportedRecording.thumb_path]); + // selection const cardRef = useRef(null); @@ -392,8 +401,35 @@ export function ActiveExportJobCard({ camera: cameraName, }); }, [cameraName, job.name, t]); - const statusLabel = - job.status === "queued" ? t("jobCard.queued") : t("jobCard.running"); + + const step = job.current_step + ? job.current_step + : job.status === "queued" + ? "queued" + : "preparing"; + const percent = Math.round(job.progress_percent ?? 0); + + const stepLabel = useMemo(() => { + switch (step) { + case "queued": + return t("jobCard.queued"); + case "preparing": + return t("jobCard.preparing"); + case "copying": + return t("jobCard.copying"); + case "encoding": + return t("jobCard.encoding"); + case "encoding_retry": + return t("jobCard.encodingRetry"); + case "finalizing": + return t("jobCard.finalizing"); + default: + return t("jobCard.running"); + } + }, [step, t]); + + const hasDeterminateProgress = + step === "copying" || step === "encoding" || step === "encoding_retry"; return (
-
- {statusLabel} -
-
- +
+
+ {stepLabel} + {hasDeterminateProgress && ` · ${percent}%`} +
+ {step === "queued" ? ( + + ) : hasDeterminateProgress ? ( + + ) : ( +
+
+
+ )}
{displayName}
diff --git a/web/src/components/filter/ExportActionGroup.tsx b/web/src/components/filter/ExportActionGroup.tsx index 92e5f251bc..d2dd789fec 100644 --- a/web/src/components/filter/ExportActionGroup.tsx +++ b/web/src/components/filter/ExportActionGroup.tsx @@ -30,6 +30,7 @@ type ExportActionGroupProps = { cases?: ExportCase[]; currentCaseId?: string; mutate: () => void; + deleteExports: (ids: string[]) => Promise; }; export default function ExportActionGroup({ selectedExports, @@ -38,6 +39,7 @@ export default function ExportActionGroup({ cases, currentCaseId, mutate, + deleteExports, }: ExportActionGroupProps) { const { t } = useTranslation(["views/exports", "common"]); const isAdmin = useIsAdmin(); @@ -50,27 +52,24 @@ export default function ExportActionGroup({ const onDelete = useCallback(() => { const ids = selectedExports.map((e) => e.id); - axios - .post("exports/delete", { ids }) - .then((resp) => { - if (resp.status === 200) { - toast.success(t("bulkToast.success.delete"), { - position: "top-center", - }); - setSelectedExports([]); - mutate(); - } + deleteExports(ids) + .then(() => { + toast.success(t("bulkToast.success.delete"), { + position: "top-center", + }); + setSelectedExports([]); }) .catch((error) => { const errorMessage = - error.response?.data?.message || - error.response?.data?.detail || + error?.response?.data?.message || + error?.response?.data?.detail || "Unknown error"; - toast.error(t("bulkToast.error.deleteFailed", { errorMessage }), { - position: "top-center", - }); + toast.error( + t("bulkToast.error.deleteFailed", { errorMessage: errorMessage }), + { position: "top-center" }, + ); }); - }, [selectedExports, setSelectedExports, mutate, t]); + }, [selectedExports, setSelectedExports, deleteExports, t]); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [bypassDialog, setBypassDialog] = useState(false); @@ -92,36 +91,54 @@ export default function ExportActionGroup({ const [removeDialogOpen, setRemoveDialogOpen] = useState(false); const [deleteExportsOnRemove, setDeleteExportsOnRemove] = useState(false); + const [isRemovingFromCase, setIsRemovingFromCase] = useState(false); const handleRemoveFromCase = useCallback(() => { const ids = selectedExports.map((e) => e.id); + const deleting = deleteExportsOnRemove; + setIsRemovingFromCase(true); - const request = deleteExportsOnRemove - ? axios.post("exports/delete", { ids }) - : axios.post("exports/reassign", { ids, export_case_id: null }); + const request = deleting + ? deleteExports(ids) + : axios + .post("exports/reassign", { ids, export_case_id: null }) + .then(() => { + mutate(); + }); request - .then((resp) => { - if (resp.status === 200) { - toast.success(t("bulkToast.success.remove"), { - position: "top-center", - }); - setSelectedExports([]); - mutate(); - setRemoveDialogOpen(false); - setDeleteExportsOnRemove(false); - } + .then(() => { + const successKey = deleting + ? "bulkToast.success.delete" + : "bulkToast.success.remove"; + toast.success(t(successKey), { position: "top-center" }); + setSelectedExports([]); + setRemoveDialogOpen(false); + setDeleteExportsOnRemove(false); }) .catch((error) => { const errorMessage = - error.response?.data?.message || - error.response?.data?.detail || + error?.response?.data?.message || + error?.response?.data?.detail || "Unknown error"; - toast.error(t("bulkToast.error.reassignFailed", { errorMessage }), { + const errorKey = deleting + ? "bulkToast.error.deleteFailed" + : "bulkToast.error.reassignFailed"; + toast.error(t(errorKey, { errorMessage: errorMessage }), { position: "top-center", }); + }) + .finally(() => { + setIsRemovingFromCase(false); }); - }, [selectedExports, deleteExportsOnRemove, setSelectedExports, mutate, t]); + }, [ + selectedExports, + deleteExportsOnRemove, + setSelectedExports, + mutate, + deleteExports, + t, + ]); // ── Case picker ───────────────────────────────────────────────── @@ -243,6 +260,7 @@ export default function ExportActionGroup({ { + if (isRemovingFromCase) return; if (!open) { setRemoveDialogOpen(false); setDeleteExportsOnRemove(false); @@ -274,15 +292,17 @@ export default function ExportActionGroup({ id="bulk-delete-exports-switch" checked={deleteExportsOnRemove} onCheckedChange={setDeleteExportsOnRemove} + disabled={isRemovingFromCase} />
- + {t("button.cancel", { ns: "common" })} {t("button.delete", { ns: "common" })} diff --git a/web/src/pages/Exports.tsx b/web/src/pages/Exports.tsx index 8d9466e3d3..4f9b78c6d5 100644 --- a/web/src/pages/Exports.tsx +++ b/web/src/pages/Exports.tsx @@ -1,4 +1,5 @@ import { baseUrl } from "@/api/baseUrl"; +import { useJobStatus } from "@/api/ws"; import { ActiveExportJobCard, CaseCard, @@ -87,23 +88,45 @@ function Exports() { // Data const { data: cases, mutate: updateCases } = useSWR("cases"); - const { data: activeExportJobs } = useSWR("jobs/export", { - refreshInterval: (latestJobs) => ((latestJobs ?? []).length > 0 ? 2000 : 0), - }); - // Keep polling exports while there are queued/running jobs OR while any - // existing export is still marked in_progress. Without the second clause, - // a stale in_progress=true snapshot can stick if the activeExportJobs poll - // clears before the rawExports poll fires — SWR cancels the pending - // rawExports refresh and the UI freezes on spinners until a manual reload. + + // The HTTP fetch hydrates the page on first paint and on focus. Once the + // WebSocket is connected, the `job_state` topic delivers progress updates + // in real time, so periodic polling here would only add noise. + const { data: pollExportJobs, mutate: updateActiveJobs } = useSWR< + ExportJob[] + >("jobs/export", { refreshInterval: 0 }); + + const { payload: exportJobState } = useJobStatus<{ jobs: ExportJob[] }>( + "export", + ); + const wsExportJobs = useMemo( + () => exportJobState?.results?.jobs ?? [], + [exportJobState], + ); + + // Merge: a job present in the WS payload is authoritative (it has the + // freshest progress); the SWR snapshot fills in jobs that haven't yet + // arrived over the socket (e.g. before the first WS message after a + // page load). Once we've seen at least one WS message, we trust the WS + // payload as the complete active set. + const hasWsState = exportJobState !== null; + const activeExportJobs = useMemo(() => { + if (hasWsState) { + return wsExportJobs; + } + return pollExportJobs ?? []; + }, [hasWsState, wsExportJobs, pollExportJobs]); + + // Keep polling exports while any existing export is still marked + // in_progress so the UI flips from spinner to playable card without a + // manual reload. Once active jobs disappear from the WS feed we also + // mutate() below to fetch newly-completed exports immediately. const { data: rawExports, mutate: updateExports } = useSWR( exportSearchParams && Object.keys(exportSearchParams).length > 0 ? ["exports", exportSearchParams] : "exports", { refreshInterval: (latestExports) => { - if ((activeExportJobs?.length ?? 0) > 0) { - return 2000; - } if ((latestExports ?? []).some((exp) => exp.in_progress)) { return 2000; } @@ -112,22 +135,40 @@ function Exports() { }, ); + // When one or more active jobs disappear from the WS feed, refresh the + // exports list so newly-finished items appear without waiting for focus- + // based SWR revalidation. Clear the HTTP jobs snapshot once the live set is + // empty so a stale poll result does not resurrect completed jobs. + const previousActiveJobIdsRef = useRef>(new Set()); + useEffect(() => { + const previousIds = previousActiveJobIdsRef.current; + const currentIds = new Set(activeExportJobs.map((job) => job.id)); + const removedJob = Array.from(previousIds).some( + (id) => !currentIds.has(id), + ); + + if (removedJob) { + updateExports(); + updateCases(); + } + + if (previousIds.size > 0 && currentIds.size === 0) { + updateActiveJobs([], false); + } + previousActiveJobIdsRef.current = currentIds; + }, [activeExportJobs, updateExports, updateCases, updateActiveJobs]); + const visibleActiveJobs = useMemo(() => { - const existingExportIds = new Set((rawExports ?? []).map((exp) => exp.id)); const filteredCameras = exportFilter?.cameras; return (activeExportJobs ?? []).filter((job) => { - if (existingExportIds.has(job.id)) { - return false; - } - if (filteredCameras && filteredCameras.length > 0) { return filteredCameras.includes(job.camera); } return true; }); - }, [activeExportJobs, exportFilter?.cameras, rawExports]); + }, [activeExportJobs, exportFilter?.cameras]); const activeJobsByCase = useMemo<{ [caseId: string]: ExportJob[] }>(() => { const grouped: { [caseId: string]: ExportJob[] } = {}; @@ -144,9 +185,26 @@ function Exports() { return grouped; }, [visibleActiveJobs]); + // The backend inserts the Export row with in_progress=True before the + // FFmpeg encode kicks off, so the same id is briefly present in BOTH + // rawExports and the active job list. The ActiveExportJobCard renders + // step + percent; the ExportCard would render a binary spinner. To + // avoid that downgrade, hide the rawExport entry while there's a + // matching active job — once the job leaves the active list the + // exports SWR refresh kicks in and the regular card takes over. + const activeJobIds = useMemo>( + () => new Set(visibleActiveJobs.map((job) => job.id)), + [visibleActiveJobs], + ); + + const visibleExports = useMemo( + () => (rawExports ?? []).filter((exp) => !activeJobIds.has(exp.id)), + [activeJobIds, rawExports], + ); + const exportsByCase = useMemo<{ [caseId: string]: Export[] }>(() => { const grouped: { [caseId: string]: Export[] } = {}; - (rawExports ?? []).forEach((exp) => { + visibleExports.forEach((exp) => { const caseId = exp.export_case ?? exp.export_case_id ?? "none"; if (!grouped[caseId]) { grouped[caseId] = []; @@ -155,7 +213,7 @@ function Exports() { grouped[caseId].push(exp); }); return grouped; - }, [rawExports]); + }, [visibleExports]); const filteredCases = useMemo(() => { if (!cases) return []; @@ -184,6 +242,34 @@ function Exports() { updateCases(); }, [updateExports, updateCases]); + // Deletes one or more exports and keeps the UI in sync. SWR's default + // mutate() keeps the stale list visible until the revalidation GET + // returns, which can be seconds for large batches — long enough for + // users to click on a card whose underlying file is already gone. + // Strip the deleted ids from the cache up front, then fire the POST, + // then revalidate to reconcile with server truth. + const deleteExports = useCallback( + async (ids: string[]): Promise => { + const idSet = new Set(ids); + const removeDeleted = (current: Export[] | undefined) => + current ? current.filter((exp) => !idSet.has(exp.id)) : current; + + await updateExports(removeDeleted, { revalidate: false }); + + try { + await axios.post("exports/delete", { ids }); + await updateExports(); + await updateCases(); + } catch (err) { + // On failure, pull fresh state from the server so any items that + // weren't actually deleted reappear in the UI. + await updateExports(); + throw err; + } + }, + [updateExports, updateCases], + ); + // Search const [search, setSearch] = useState(""); @@ -208,7 +294,9 @@ function Exports() { return false; } - setSelected(rawExports.find((exp) => exp.id == id)); + // Use visibleExports so deep links to a still-encoding id don't try + // to open a player against a half-written video file. + setSelected(visibleExports.find((exp) => exp.id == id)); return true; }); @@ -260,7 +348,7 @@ function Exports() { const currentExports = selectedCaseId ? exportsByCase[selectedCaseId] || [] : exports; - const visibleExports = currentExports.filter((e) => { + const selectable = currentExports.filter((e) => { if (e.in_progress) return false; if (!search) return true; return e.name @@ -268,8 +356,8 @@ function Exports() { .replaceAll("_", " ") .includes(search.toLowerCase()); }); - if (selectedExports.length < visibleExports.length) { - setSelectedExports(visibleExports); + if (selectedExports.length < selectable.length) { + setSelectedExports(selectable); } else { setSelectedExports([]); } @@ -293,15 +381,19 @@ function Exports() { return; } - axios - .post("exports/delete", { ids: [deleteClip.file] }) - .then((response) => { - if (response.status == 200) { - setDeleteClip(undefined); - mutate(); - } + deleteExports([deleteClip.file]) + .then(() => setDeleteClip(undefined)) + .catch((error) => { + const errorMessage = + error?.response?.data?.message || + error?.response?.data?.detail || + "Unknown error"; + toast.error( + t("bulkToast.error.deleteFailed", { errorMessage: errorMessage }), + { position: "top-center" }, + ); }); - }, [deleteClip, mutate]); + }, [deleteClip, deleteExports, t]); const onHandleRename = useCallback( (id: string, update: string) => { @@ -629,6 +721,7 @@ function Exports() { cases={cases} currentCaseId={selectedCaseId} mutate={mutate} + deleteExports={deleteExports} /> ) : ( <> @@ -893,7 +986,7 @@ function AllExportsView({ ))} {filteredExports.map((item) => ( e.id === item.id)} diff --git a/web/src/types/export.ts b/web/src/types/export.ts index 1926b533b8..113f0f103c 100644 --- a/web/src/types/export.ts +++ b/web/src/types/export.ts @@ -59,6 +59,14 @@ export type StartExportResponse = { status?: string | null; }; +export type ExportJobStep = + | "queued" + | "preparing" + | "copying" + | "encoding" + | "encoding_retry" + | "finalizing"; + export type ExportJob = { id: string; job_type: string; @@ -77,6 +85,8 @@ export type ExportJob = { video_path?: string; thumb_path?: string; } | null; + current_step?: ExportJobStep; + progress_percent?: number; }; export type CameraActivitySegment = {