diff --git a/web/public/locales/en/components/dialog.json b/web/public/locales/en/components/dialog.json index 3a9196063..f23e18e9a 100644 --- a/web/public/locales/en/components/dialog.json +++ b/web/public/locales/en/components/dialog.json @@ -58,6 +58,7 @@ }, "select": "Select", "export": "Export", + "queueing": "Queueing Export...", "selectOrExport": "Select or Export", "tabs": { "export": "Single Camera", @@ -67,31 +68,38 @@ "timeRange": "Time range", "selectFromTimeline": "Select from Timeline", "cameraSelection": "Cameras", - "selectedCount_one": "1 selected", - "selectedCount_other": "{{count}} selected", + "cameraSelectionHelp": "Cameras with tracked objects in this time range are pre-selected", "checkingActivity": "Checking camera activity...", "noCameras": "No cameras available", - "detectionCount_one": "1 detection", - "detectionCount_other": "{{count}} detections", + "detectionCount_one": "1 tracked object", + "detectionCount_other": "{{count}} tracked objects", + "nameLabel": "Export name", "namePlaceholder": "Optional base name for these exports", + "queueingButton": "Queueing Exports...", "exportButton_one": "Export 1 Camera", "exportButton_other": "Export {{count}} Cameras" }, "toast": { "success": "Successfully started export. View the file in the exports page.", + "queued": "Export queued. View progress in the exports page.", "view": "View", "batchSuccess_one": "Started 1 export. Opening the case now.", "batchSuccess_other": "Started {{count}} exports. Opening the case now.", "batchPartial": "Started {{successful}} of {{total}} exports. Failed cameras: {{failedCameras}}", "batchFailed": "Failed to start {{total}} exports. Failed cameras: {{failedCameras}}", + "batchQueuedSuccess_one": "Queued 1 export. Opening the case now.", + "batchQueuedSuccess_other": "Queued {{count}} exports. Opening the case now.", + "batchQueuedPartial": "Queued {{successful}} of {{total}} exports. Failed cameras: {{failedCameras}}", + "batchQueueFailed": "Failed to queue {{total}} exports. Failed cameras: {{failedCameras}}", "error": { - "failed": "Failed to start export: {{error}}", + "failed": "Failed to queue export: {{error}}", "endTimeMustAfterStartTime": "End time must be after start time", "noVaildTimeSelected": "No valid time range selected" } }, "fromTimeline": { "saveExport": "Save Export", + "queueingExport": "Queueing Export...", "previewExport": "Preview Export", "useThisRange": "Use This Range" } diff --git a/web/public/locales/en/views/exports.json b/web/public/locales/en/views/exports.json index b8b0eb6c5..03ae38fae 100644 --- a/web/public/locales/en/views/exports.json +++ b/web/public/locales/en/views/exports.json @@ -55,6 +55,11 @@ "cameraCount_other": "{{count}} cameras", "emptyCase": "No exports yet" }, + "jobCard": { + "defaultName": "{{camera}} export", + "queued": "Queued", + "running": "Running" + }, "caseView": { "noDescription": "No description", "createdAt": "Created {{value}}", diff --git a/web/src/components/card/ExportCard.tsx b/web/src/components/card/ExportCard.tsx index cc412801a..d7925de4c 100644 --- a/web/src/components/card/ExportCard.tsx +++ b/web/src/components/card/ExportCard.tsx @@ -13,7 +13,7 @@ import { } from "../ui/dialog"; import { Input } from "../ui/input"; import useKeyboardListener from "@/hooks/use-keyboard-listener"; -import { DeleteClipType, Export, ExportCase } from "@/types/export"; +import { DeleteClipType, Export, ExportCase, ExportJob } from "@/types/export"; import { baseUrl } from "@/api/baseUrl"; import { cn } from "@/lib/utils"; import { shareOrCopy } from "@/utils/browserUtil"; @@ -342,3 +342,57 @@ export function ExportCard({ ); } + +type ActiveExportJobCardProps = { + className?: string; + job: ExportJob; +}; + +export function ActiveExportJobCard({ + className = "", + job, +}: ActiveExportJobCardProps) { + const { t } = useTranslation(["views/exports", "common"]); + const cameraName = useCameraFriendlyName(job.camera); + const { data: config } = useSWR("config"); + const timeFormat = useTimeFormat(config); + const formattedDate = useFormattedTimestamp( + job.request_start_time, + t(`time.formattedTimestampMonthDayYearHourMinute.${timeFormat}`, { + ns: "common", + }), + config?.ui.timezone, + ); + const displayName = useMemo(() => { + if (job.name && job.name.length > 0) { + return job.name.replaceAll("_", " "); + } + + return t("jobCard.defaultName", { + camera: cameraName, + }); + }, [cameraName, job.name, t]); + const statusLabel = + job.status === "queued" ? t("jobCard.queued") : t("jobCard.running"); + + return ( +
+
+ {cameraName} +
+
+ {statusLabel} +
+
+ +
{displayName}
+
{formattedDate}
+
+
+ ); +} diff --git a/web/src/components/overlay/ExportDialog.tsx b/web/src/components/overlay/ExportDialog.tsx index c42ecbd74..dc7ab86ff 100644 --- a/web/src/components/overlay/ExportDialog.tsx +++ b/web/src/components/overlay/ExportDialog.tsx @@ -13,6 +13,7 @@ import { RadioGroup, RadioGroupItem } from "../ui/radio-group"; import { Button } from "../ui/button"; import { ExportMode } from "@/types/filter"; import { FaArrowDown } from "react-icons/fa"; +import { LuAudioLines } from "react-icons/lu"; import axios from "axios"; import { toast } from "sonner"; import { Input } from "../ui/input"; @@ -23,6 +24,7 @@ import { BatchExportResponse, CameraActivity, ExportCase, + StartExportResponse, } from "@/types/export"; import { Select, @@ -32,6 +34,11 @@ import { SelectTrigger, SelectValue, } from "../ui/select"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { isDesktop, isMobile } from "react-device-detect"; import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import SaveExportOverlay from "./SaveExportOverlay"; @@ -43,7 +50,6 @@ import { CustomTimeSelector } from "./CustomTimeSelector"; import { Event } from "@/types/event"; import { FrigateConfig } from "@/types/frigateConfig"; import { resolveCameraName } from "@/hooks/use-camera-friendly-name"; -import { Checkbox } from "../ui/checkbox"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; import { Textarea } from "../ui/textarea"; import { useNavigate } from "react-router-dom"; @@ -87,6 +93,7 @@ export default function ExportDialog({ const [name, setName] = useState(""); const [selectedCaseId, setSelectedCaseId] = useState(); const [activeTab, setActiveTab] = useState("export"); + const [isStartingExport, setIsStartingExport] = useState(false); const previousModeRef = useRef(mode); useEffect(() => { @@ -107,59 +114,78 @@ export default function ExportDialog({ previousModeRef.current = mode; }, [mode]); - const onStartExport = useCallback(() => { + const onStartExport = useCallback(async () => { + if (isStartingExport) { + return false; + } + if (!range) { toast.error(t("export.toast.error.noVaildTimeSelected"), { position: "top-center", }); - return; + return false; } if (range.before < range.after) { toast.error(t("export.toast.error.endTimeMustAfterStartTime"), { position: "top-center", }); - return; + return false; } - axios - .post( + setIsStartingExport(true); + + try { + await axios.post( `export/${camera}/start/${Math.round(range.after)}/end/${Math.round(range.before)}`, { - playback: "realtime", + source: "recordings", name, export_case_id: selectedCaseId || undefined, }, - ) - .then((response) => { - if (response.status == 200) { - toast.success(t("export.toast.success"), { - position: "top-center", - action: ( - - - - ), - }); - setName(""); - setSelectedCaseId(undefined); - setRange(undefined); - setMode("none"); - } - }) - .catch((error) => { - const errorMessage = - error.response?.data?.message || - error.response?.data?.detail || - "Unknown error"; - toast.error( - t("export.toast.error.failed", { - error: errorMessage, - }), - { position: "top-center" }, - ); + ); + + toast.success(t("export.toast.queued"), { + position: "top-center", + action: ( + + + + ), }); - }, [camera, name, range, selectedCaseId, setMode, setRange, t]); + setName(""); + setSelectedCaseId(undefined); + setRange(undefined); + setMode("none"); + return true; + } catch (error) { + const apiError = error as { + response?: { data?: { message?: string; detail?: string } }; + }; + const errorMessage = + apiError.response?.data?.message || + apiError.response?.data?.detail || + "Unknown error"; + toast.error( + t("export.toast.error.failed", { + error: errorMessage, + }), + { position: "top-center" }, + ); + return false; + } finally { + setIsStartingExport(false); + } + }, [ + camera, + isStartingExport, + name, + range, + selectedCaseId, + setMode, + setRange, + t, + ]); const handleCancel = useCallback(() => { setName(""); @@ -185,6 +211,7 @@ export default function ExportDialog({ className="pointer-events-none absolute left-1/2 top-8 z-50 -translate-x-1/2" show={mode == "timeline" || mode == "timeline_multi"} hidePreview={mode == "timeline_multi"} + isSaving={isStartingExport} saveLabel={ mode == "timeline_multi" ? t("export.fromTimeline.useThisRange") @@ -198,7 +225,7 @@ export default function ExportDialog({ return; } - onStartExport(); + void onStartExport(); }} onCancel={handleCancel} /> @@ -245,6 +272,7 @@ export default function ExportDialog({ name={name} selectedCaseId={selectedCaseId} activeTab={activeTab} + isStartingExport={isStartingExport} onStartExport={onStartExport} setActiveTab={setActiveTab} setName={setName} @@ -266,7 +294,8 @@ type ExportContentProps = { name: string; selectedCaseId?: string; activeTab: ExportTab; - onStartExport: () => void; + isStartingExport: boolean; + onStartExport: () => Promise; setActiveTab: (tab: ExportTab) => void; setName: (name: string) => void; setSelectedCaseId: (caseId: string | undefined) => void; @@ -282,6 +311,7 @@ export function ExportContent({ name, selectedCaseId, activeTab, + isStartingExport, onStartExport, setActiveTab, setName, @@ -300,12 +330,13 @@ export function ExportContent({ ); const [selectedCameraIds, setSelectedCameraIds] = useState([]); const [batchCaseSelection, setBatchCaseSelection] = useState( - selectedCaseId || "none", + selectedCaseId || "new", ); const [hasManualCameraSelection, setHasManualCameraSelection] = useState(false); const [newCaseName, setNewCaseName] = useState(""); const [newCaseDescription, setNewCaseDescription] = useState(""); + const [isStartingBatchExport, setIsStartingBatchExport] = useState(false); const multiRangeKey = useMemo(() => { if (activeTab !== "multi" || !range) { return undefined; @@ -380,24 +411,47 @@ export function ExportContent({ const cameraActivities = useMemo(() => { const allCameraIds = Object.keys(config?.cameras ?? {}); - const counts = new Map(); + const byCamera = new Map(); events?.forEach((event) => { - counts.set(event.camera, (counts.get(event.camera) ?? 0) + 1); + const bucket = byCamera.get(event.camera); + if (bucket) { + bucket.push(event); + } else { + byCamera.set(event.camera, [event]); + } }); - const maxCount = Math.max(1, ...Array.from(counts.values()), 1); + const rangeStart = debouncedRange?.after ?? 0; + const rangeEnd = debouncedRange?.before ?? 0; + const rangeDuration = Math.max(1, rangeEnd - rangeStart); return allCameraIds.map((cameraId) => { - const count = counts.get(cameraId) ?? 0; + const cameraEvents = byCamera.get(cameraId) ?? []; + const segments = cameraEvents + .map((event) => { + // Event end_time is null for in-progress events; fall back to start. + const eventEnd = event.end_time ?? event.start_time; + const start = Math.max( + 0, + Math.min(1, (event.start_time - rangeStart) / rangeDuration), + ); + const end = Math.max( + 0, + Math.min(1, (eventEnd - rangeStart) / rangeDuration), + ); + return { start, end: Math.max(end, start) }; + }) + .sort((a, b) => a.start - b.start); + return { camera: cameraId, - count, - intensity: count / maxCount, - hasDetections: count > 0, + count: cameraEvents.length, + hasDetections: cameraEvents.length > 0, + segments, }; }); - }, [config?.cameras, events]); + }, [config?.cameras, debouncedRange, events]); useEffect(() => { if ( @@ -426,8 +480,8 @@ export function ExportContent({ const canStartBatchExport = Boolean(range && range.before > range.after) && selectedCameraCount > 0 && - ((batchCaseSelection !== "none" && batchCaseSelection !== "new") || - (batchCaseSelection === "new" && newCaseName.trim().length > 0)); + !isStartingBatchExport && + (batchCaseSelection !== "new" || newCaseName.trim().length > 0); const onSelectTime = useCallback( (option: ExportOption) => { @@ -482,6 +536,10 @@ export function ExportContent({ }, []); const startBatchExport = useCallback(async () => { + if (isStartingBatchExport) { + return; + } + if (!range) { toast.error(t("export.toast.error.noVaildTimeSelected"), { position: "top-center", @@ -510,6 +568,8 @@ export function ExportContent({ payload.export_case_id = batchCaseSelection; } + setIsStartingBatchExport(true); + try { const response = await axios.post( "exports/batch", @@ -527,7 +587,7 @@ export function ExportContent({ if (failedResults.length > 0 && successfulResults.length > 0) { toast.success( - t("export.toast.batchPartial", { + t("export.toast.batchQueuedPartial", { successful: successfulResults.length, total: results.length, failedCameras: failedResults @@ -541,7 +601,7 @@ export function ExportContent({ ); } else if (failedResults.length > 0) { toast.error( - t("export.toast.batchFailed", { + t("export.toast.batchQueueFailed", { total: results.length, failedCameras: failedResults .map((result) => resolveCameraName(config, result.camera)) @@ -554,7 +614,7 @@ export function ExportContent({ ); } else { toast.success( - t("export.toast.batchSuccess", { + t("export.toast.batchQueuedSuccess", { count: successfulResults.length, }), { position: "top-center" }, @@ -564,13 +624,15 @@ export function ExportContent({ if (successfulResults.length > 0) { setName(""); setSelectedCaseId(undefined); - setBatchCaseSelection("none"); + setBatchCaseSelection("new"); setNewCaseName(""); setNewCaseDescription(""); setRange(undefined); setMode("none"); setActiveTab("export"); - navigate(`/export?caseId=${response.data.export_case_id}`); + if (response.data.export_case_id) { + navigate(`/export?caseId=${response.data.export_case_id}`); + } } } catch (error) { const apiError = error as { @@ -587,10 +649,13 @@ export function ExportContent({ }), { position: "top-center" }, ); + } finally { + setIsStartingBatchExport(false); } }, [ batchCaseSelection, config, + isStartingBatchExport, name, newCaseDescription, newCaseName, @@ -606,20 +671,22 @@ export function ExportContent({ ]); return ( -
+
{isDesktop && ( - <> - - {t("menu.export", { ns: "common" })} - - - + + {t("menu.export", { ns: "common" })} + )} setActiveTab(value as ExportTab)} - className="w-full" + className={cn("w-full", !isDesktop && "flex min-h-0 flex-1 flex-col")} > {t("export.tabs.export")} @@ -628,7 +695,13 @@ export function ExportContent({ - + onSelectTime(value as ExportOption)} @@ -708,49 +781,69 @@ export function ExportContent({
- +
- - + setActiveTab("multi"); + setMode("timeline_multi"); + }} + > + + + + + {t("export.multiCamera.selectFromTimeline")} + + +
-
- -
- {t("export.multiCamera.selectedCount", { - count: selectedCameraCount, - })} -
+ +
+ {t("export.multiCamera.cameraSelectionHelp")}
-
+
{isEventsLoading && (
{t("export.multiCamera.checkingActivity")} @@ -768,15 +861,18 @@ export function ExportContent({ @@ -806,13 +910,18 @@ export function ExportContent({
- setName(e.target.value)} - /> +
+ + setName(e.target.value)} + /> +