frontend tweaks and Job infrastructure

This commit is contained in:
Josh Hawkins 2026-04-11 13:21:08 -05:00
parent 0d9d1d7652
commit 549642e97e
8 changed files with 520 additions and 190 deletions

View File

@ -58,6 +58,7 @@
}, },
"select": "Select", "select": "Select",
"export": "Export", "export": "Export",
"queueing": "Queueing Export...",
"selectOrExport": "Select or Export", "selectOrExport": "Select or Export",
"tabs": { "tabs": {
"export": "Single Camera", "export": "Single Camera",
@ -67,31 +68,38 @@
"timeRange": "Time range", "timeRange": "Time range",
"selectFromTimeline": "Select from Timeline", "selectFromTimeline": "Select from Timeline",
"cameraSelection": "Cameras", "cameraSelection": "Cameras",
"selectedCount_one": "1 selected", "cameraSelectionHelp": "Cameras with tracked objects in this time range are pre-selected",
"selectedCount_other": "{{count}} selected",
"checkingActivity": "Checking camera activity...", "checkingActivity": "Checking camera activity...",
"noCameras": "No cameras available", "noCameras": "No cameras available",
"detectionCount_one": "1 detection", "detectionCount_one": "1 tracked object",
"detectionCount_other": "{{count}} detections", "detectionCount_other": "{{count}} tracked objects",
"nameLabel": "Export name",
"namePlaceholder": "Optional base name for these exports", "namePlaceholder": "Optional base name for these exports",
"queueingButton": "Queueing Exports...",
"exportButton_one": "Export 1 Camera", "exportButton_one": "Export 1 Camera",
"exportButton_other": "Export {{count}} Cameras" "exportButton_other": "Export {{count}} Cameras"
}, },
"toast": { "toast": {
"success": "Successfully started export. View the file in the exports page.", "success": "Successfully started export. View the file in the exports page.",
"queued": "Export queued. View progress in the exports page.",
"view": "View", "view": "View",
"batchSuccess_one": "Started 1 export. Opening the case now.", "batchSuccess_one": "Started 1 export. Opening the case now.",
"batchSuccess_other": "Started {{count}} exports. Opening the case now.", "batchSuccess_other": "Started {{count}} exports. Opening the case now.",
"batchPartial": "Started {{successful}} of {{total}} exports. Failed cameras: {{failedCameras}}", "batchPartial": "Started {{successful}} of {{total}} exports. Failed cameras: {{failedCameras}}",
"batchFailed": "Failed to start {{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": { "error": {
"failed": "Failed to start export: {{error}}", "failed": "Failed to queue export: {{error}}",
"endTimeMustAfterStartTime": "End time must be after start time", "endTimeMustAfterStartTime": "End time must be after start time",
"noVaildTimeSelected": "No valid time range selected" "noVaildTimeSelected": "No valid time range selected"
} }
}, },
"fromTimeline": { "fromTimeline": {
"saveExport": "Save Export", "saveExport": "Save Export",
"queueingExport": "Queueing Export...",
"previewExport": "Preview Export", "previewExport": "Preview Export",
"useThisRange": "Use This Range" "useThisRange": "Use This Range"
} }

View File

@ -55,6 +55,11 @@
"cameraCount_other": "{{count}} cameras", "cameraCount_other": "{{count}} cameras",
"emptyCase": "No exports yet" "emptyCase": "No exports yet"
}, },
"jobCard": {
"defaultName": "{{camera}} export",
"queued": "Queued",
"running": "Running"
},
"caseView": { "caseView": {
"noDescription": "No description", "noDescription": "No description",
"createdAt": "Created {{value}}", "createdAt": "Created {{value}}",

View File

@ -13,7 +13,7 @@ import {
} from "../ui/dialog"; } from "../ui/dialog";
import { Input } from "../ui/input"; import { Input } from "../ui/input";
import useKeyboardListener from "@/hooks/use-keyboard-listener"; 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 { baseUrl } from "@/api/baseUrl";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { shareOrCopy } from "@/utils/browserUtil"; 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<FrigateConfig>("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 (
<div
className={cn(
"relative flex aspect-video items-center justify-center overflow-hidden rounded-lg border border-dashed border-border bg-secondary/40 md:rounded-2xl",
className,
)}
>
<div className="absolute left-3 top-3 z-30 rounded-full bg-black/55 px-2 py-1 text-xs text-white">
{cameraName}
</div>
<div className="absolute right-3 top-3 z-30 rounded-full bg-selected/90 px-2 py-1 text-xs text-selected-foreground">
{statusLabel}
</div>
<div className="flex flex-col items-center gap-3 px-6 text-center">
<ActivityIndicator />
<div className="text-sm font-medium text-primary">{displayName}</div>
<div className="text-xs text-muted-foreground">{formattedDate}</div>
</div>
</div>
);
}

View File

@ -13,6 +13,7 @@ import { RadioGroup, RadioGroupItem } from "../ui/radio-group";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { ExportMode } from "@/types/filter"; import { ExportMode } from "@/types/filter";
import { FaArrowDown } from "react-icons/fa"; import { FaArrowDown } from "react-icons/fa";
import { LuAudioLines } from "react-icons/lu";
import axios from "axios"; import axios from "axios";
import { toast } from "sonner"; import { toast } from "sonner";
import { Input } from "../ui/input"; import { Input } from "../ui/input";
@ -23,6 +24,7 @@ import {
BatchExportResponse, BatchExportResponse,
CameraActivity, CameraActivity,
ExportCase, ExportCase,
StartExportResponse,
} from "@/types/export"; } from "@/types/export";
import { import {
Select, Select,
@ -32,6 +34,11 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "../ui/select"; } from "../ui/select";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { isDesktop, isMobile } from "react-device-detect"; import { isDesktop, isMobile } from "react-device-detect";
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import SaveExportOverlay from "./SaveExportOverlay"; import SaveExportOverlay from "./SaveExportOverlay";
@ -43,7 +50,6 @@ import { CustomTimeSelector } from "./CustomTimeSelector";
import { Event } from "@/types/event"; import { Event } from "@/types/event";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { resolveCameraName } from "@/hooks/use-camera-friendly-name"; import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
import { Checkbox } from "../ui/checkbox";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import { Textarea } from "../ui/textarea"; import { Textarea } from "../ui/textarea";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@ -87,6 +93,7 @@ export default function ExportDialog({
const [name, setName] = useState(""); const [name, setName] = useState("");
const [selectedCaseId, setSelectedCaseId] = useState<string | undefined>(); const [selectedCaseId, setSelectedCaseId] = useState<string | undefined>();
const [activeTab, setActiveTab] = useState<ExportTab>("export"); const [activeTab, setActiveTab] = useState<ExportTab>("export");
const [isStartingExport, setIsStartingExport] = useState(false);
const previousModeRef = useRef<ExportMode>(mode); const previousModeRef = useRef<ExportMode>(mode);
useEffect(() => { useEffect(() => {
@ -107,59 +114,78 @@ export default function ExportDialog({
previousModeRef.current = mode; previousModeRef.current = mode;
}, [mode]); }, [mode]);
const onStartExport = useCallback(() => { const onStartExport = useCallback(async () => {
if (isStartingExport) {
return false;
}
if (!range) { if (!range) {
toast.error(t("export.toast.error.noVaildTimeSelected"), { toast.error(t("export.toast.error.noVaildTimeSelected"), {
position: "top-center", position: "top-center",
}); });
return; return false;
} }
if (range.before < range.after) { if (range.before < range.after) {
toast.error(t("export.toast.error.endTimeMustAfterStartTime"), { toast.error(t("export.toast.error.endTimeMustAfterStartTime"), {
position: "top-center", position: "top-center",
}); });
return; return false;
} }
axios setIsStartingExport(true);
.post(
try {
await axios.post<StartExportResponse>(
`export/${camera}/start/${Math.round(range.after)}/end/${Math.round(range.before)}`, `export/${camera}/start/${Math.round(range.after)}/end/${Math.round(range.before)}`,
{ {
playback: "realtime", source: "recordings",
name, name,
export_case_id: selectedCaseId || undefined, export_case_id: selectedCaseId || undefined,
}, },
) );
.then((response) => {
if (response.status == 200) { toast.success(t("export.toast.queued"), {
toast.success(t("export.toast.success"), { position: "top-center",
position: "top-center", action: (
action: ( <a href="/export" target="_blank" rel="noopener noreferrer">
<a href="/export" target="_blank" rel="noopener noreferrer"> <Button>{t("export.toast.view")}</Button>
<Button>{t("export.toast.view")}</Button> </a>
</a> ),
),
});
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" },
);
}); });
}, [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(() => { const handleCancel = useCallback(() => {
setName(""); 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" className="pointer-events-none absolute left-1/2 top-8 z-50 -translate-x-1/2"
show={mode == "timeline" || mode == "timeline_multi"} show={mode == "timeline" || mode == "timeline_multi"}
hidePreview={mode == "timeline_multi"} hidePreview={mode == "timeline_multi"}
isSaving={isStartingExport}
saveLabel={ saveLabel={
mode == "timeline_multi" mode == "timeline_multi"
? t("export.fromTimeline.useThisRange") ? t("export.fromTimeline.useThisRange")
@ -198,7 +225,7 @@ export default function ExportDialog({
return; return;
} }
onStartExport(); void onStartExport();
}} }}
onCancel={handleCancel} onCancel={handleCancel}
/> />
@ -245,6 +272,7 @@ export default function ExportDialog({
name={name} name={name}
selectedCaseId={selectedCaseId} selectedCaseId={selectedCaseId}
activeTab={activeTab} activeTab={activeTab}
isStartingExport={isStartingExport}
onStartExport={onStartExport} onStartExport={onStartExport}
setActiveTab={setActiveTab} setActiveTab={setActiveTab}
setName={setName} setName={setName}
@ -266,7 +294,8 @@ type ExportContentProps = {
name: string; name: string;
selectedCaseId?: string; selectedCaseId?: string;
activeTab: ExportTab; activeTab: ExportTab;
onStartExport: () => void; isStartingExport: boolean;
onStartExport: () => Promise<boolean>;
setActiveTab: (tab: ExportTab) => void; setActiveTab: (tab: ExportTab) => void;
setName: (name: string) => void; setName: (name: string) => void;
setSelectedCaseId: (caseId: string | undefined) => void; setSelectedCaseId: (caseId: string | undefined) => void;
@ -282,6 +311,7 @@ export function ExportContent({
name, name,
selectedCaseId, selectedCaseId,
activeTab, activeTab,
isStartingExport,
onStartExport, onStartExport,
setActiveTab, setActiveTab,
setName, setName,
@ -300,12 +330,13 @@ export function ExportContent({
); );
const [selectedCameraIds, setSelectedCameraIds] = useState<string[]>([]); const [selectedCameraIds, setSelectedCameraIds] = useState<string[]>([]);
const [batchCaseSelection, setBatchCaseSelection] = useState<string>( const [batchCaseSelection, setBatchCaseSelection] = useState<string>(
selectedCaseId || "none", selectedCaseId || "new",
); );
const [hasManualCameraSelection, setHasManualCameraSelection] = const [hasManualCameraSelection, setHasManualCameraSelection] =
useState(false); useState(false);
const [newCaseName, setNewCaseName] = useState(""); const [newCaseName, setNewCaseName] = useState("");
const [newCaseDescription, setNewCaseDescription] = useState(""); const [newCaseDescription, setNewCaseDescription] = useState("");
const [isStartingBatchExport, setIsStartingBatchExport] = useState(false);
const multiRangeKey = useMemo(() => { const multiRangeKey = useMemo(() => {
if (activeTab !== "multi" || !range) { if (activeTab !== "multi" || !range) {
return undefined; return undefined;
@ -380,24 +411,47 @@ export function ExportContent({
const cameraActivities = useMemo<CameraActivity[]>(() => { const cameraActivities = useMemo<CameraActivity[]>(() => {
const allCameraIds = Object.keys(config?.cameras ?? {}); const allCameraIds = Object.keys(config?.cameras ?? {});
const counts = new Map<string, number>(); const byCamera = new Map<string, Event[]>();
events?.forEach((event) => { 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) => { 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 { return {
camera: cameraId, camera: cameraId,
count, count: cameraEvents.length,
intensity: count / maxCount, hasDetections: cameraEvents.length > 0,
hasDetections: count > 0, segments,
}; };
}); });
}, [config?.cameras, events]); }, [config?.cameras, debouncedRange, events]);
useEffect(() => { useEffect(() => {
if ( if (
@ -426,8 +480,8 @@ export function ExportContent({
const canStartBatchExport = const canStartBatchExport =
Boolean(range && range.before > range.after) && Boolean(range && range.before > range.after) &&
selectedCameraCount > 0 && selectedCameraCount > 0 &&
((batchCaseSelection !== "none" && batchCaseSelection !== "new") || !isStartingBatchExport &&
(batchCaseSelection === "new" && newCaseName.trim().length > 0)); (batchCaseSelection !== "new" || newCaseName.trim().length > 0);
const onSelectTime = useCallback( const onSelectTime = useCallback(
(option: ExportOption) => { (option: ExportOption) => {
@ -482,6 +536,10 @@ export function ExportContent({
}, []); }, []);
const startBatchExport = useCallback(async () => { const startBatchExport = useCallback(async () => {
if (isStartingBatchExport) {
return;
}
if (!range) { if (!range) {
toast.error(t("export.toast.error.noVaildTimeSelected"), { toast.error(t("export.toast.error.noVaildTimeSelected"), {
position: "top-center", position: "top-center",
@ -510,6 +568,8 @@ export function ExportContent({
payload.export_case_id = batchCaseSelection; payload.export_case_id = batchCaseSelection;
} }
setIsStartingBatchExport(true);
try { try {
const response = await axios.post<BatchExportResponse>( const response = await axios.post<BatchExportResponse>(
"exports/batch", "exports/batch",
@ -527,7 +587,7 @@ export function ExportContent({
if (failedResults.length > 0 && successfulResults.length > 0) { if (failedResults.length > 0 && successfulResults.length > 0) {
toast.success( toast.success(
t("export.toast.batchPartial", { t("export.toast.batchQueuedPartial", {
successful: successfulResults.length, successful: successfulResults.length,
total: results.length, total: results.length,
failedCameras: failedResults failedCameras: failedResults
@ -541,7 +601,7 @@ export function ExportContent({
); );
} else if (failedResults.length > 0) { } else if (failedResults.length > 0) {
toast.error( toast.error(
t("export.toast.batchFailed", { t("export.toast.batchQueueFailed", {
total: results.length, total: results.length,
failedCameras: failedResults failedCameras: failedResults
.map((result) => resolveCameraName(config, result.camera)) .map((result) => resolveCameraName(config, result.camera))
@ -554,7 +614,7 @@ export function ExportContent({
); );
} else { } else {
toast.success( toast.success(
t("export.toast.batchSuccess", { t("export.toast.batchQueuedSuccess", {
count: successfulResults.length, count: successfulResults.length,
}), }),
{ position: "top-center" }, { position: "top-center" },
@ -564,13 +624,15 @@ export function ExportContent({
if (successfulResults.length > 0) { if (successfulResults.length > 0) {
setName(""); setName("");
setSelectedCaseId(undefined); setSelectedCaseId(undefined);
setBatchCaseSelection("none"); setBatchCaseSelection("new");
setNewCaseName(""); setNewCaseName("");
setNewCaseDescription(""); setNewCaseDescription("");
setRange(undefined); setRange(undefined);
setMode("none"); setMode("none");
setActiveTab("export"); 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) { } catch (error) {
const apiError = error as { const apiError = error as {
@ -587,10 +649,13 @@ export function ExportContent({
}), }),
{ position: "top-center" }, { position: "top-center" },
); );
} finally {
setIsStartingBatchExport(false);
} }
}, [ }, [
batchCaseSelection, batchCaseSelection,
config, config,
isStartingBatchExport,
name, name,
newCaseDescription, newCaseDescription,
newCaseName, newCaseName,
@ -606,20 +671,22 @@ export function ExportContent({
]); ]);
return ( return (
<div className="w-full"> <div
className={cn(
"flex w-full flex-col",
!isDesktop && "max-h-[80dvh] min-h-0",
)}
>
{isDesktop && ( {isDesktop && (
<> <DialogHeader className="mb-4">
<DialogHeader> <DialogTitle>{t("menu.export", { ns: "common" })}</DialogTitle>
<DialogTitle>{t("menu.export", { ns: "common" })}</DialogTitle> </DialogHeader>
</DialogHeader>
<SelectSeparator className="my-4 bg-secondary" />
</>
)} )}
<Tabs <Tabs
value={activeTab} value={activeTab}
onValueChange={(value) => setActiveTab(value as ExportTab)} onValueChange={(value) => setActiveTab(value as ExportTab)}
className="w-full" className={cn("w-full", !isDesktop && "flex min-h-0 flex-1 flex-col")}
> >
<TabsList className="grid w-full grid-cols-2"> <TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="export">{t("export.tabs.export")}</TabsTrigger> <TabsTrigger value="export">{t("export.tabs.export")}</TabsTrigger>
@ -628,7 +695,13 @@ export function ExportContent({
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="export" className="mt-4 space-y-4"> <TabsContent
value="export"
className={cn(
"mt-4 space-y-4",
!isDesktop && "min-h-0 flex-1 overflow-y-auto pr-1",
)}
>
<RadioGroup <RadioGroup
className={`flex flex-col gap-4 ${isDesktop ? "" : "mt-1"}`} className={`flex flex-col gap-4 ${isDesktop ? "" : "mt-1"}`}
onValueChange={(value) => onSelectTime(value as ExportOption)} onValueChange={(value) => onSelectTime(value as ExportOption)}
@ -708,49 +781,69 @@ export function ExportContent({
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="multi" className="mt-4 space-y-4"> <TabsContent
value="multi"
className={cn(
"mt-4 space-y-4",
!isDesktop && "min-h-0 flex-1 overflow-y-auto pr-1",
)}
>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm text-secondary-foreground"> <Label className="text-sm text-secondary-foreground">
{t("export.multiCamera.timeRange")} {t("export.multiCamera.timeRange")}
</Label> </Label>
<CustomTimeSelector <div className="flex items-center gap-2">
latestTime={latestTime} <div className="min-w-0 flex-1 [&>div]:!mx-0 [&>div]:!mt-0">
range={range} <CustomTimeSelector
setRange={setRange} latestTime={latestTime}
startLabel={t("export.time.start.title")} range={range}
endLabel={t("export.time.end.title")} setRange={setRange}
/> startLabel={t("export.time.start.title")}
<Button endLabel={t("export.time.end.title")}
size="sm" />
className="mt-2" </div>
onClick={() => { <Tooltip>
if (!range) { <TooltipTrigger asChild>
setRange({ <Button
before: currentTime + 30, size="sm"
after: currentTime - 30, variant="outline"
}); className="size-9 shrink-0 p-0"
} aria-label={t("export.multiCamera.selectFromTimeline")}
onClick={() => {
if (!range) {
setRange({
before: currentTime + 30,
after: currentTime - 30,
});
}
setActiveTab("multi"); setActiveTab("multi");
setMode("timeline_multi"); setMode("timeline_multi");
}} }}
> >
{t("export.multiCamera.selectFromTimeline")} <LuAudioLines className="size-4 -rotate-90" />
</Button> </Button>
</TooltipTrigger>
<TooltipContent>
{t("export.multiCamera.selectFromTimeline")}
</TooltipContent>
</Tooltip>
</div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <Label className="text-sm text-secondary-foreground">
<Label className="text-sm text-secondary-foreground"> {t("export.multiCamera.cameraSelection")}
{t("export.multiCamera.cameraSelection")} </Label>
</Label> <div className="text-xs text-muted-foreground">
<div className="text-xs text-muted-foreground"> {t("export.multiCamera.cameraSelectionHelp")}
{t("export.multiCamera.selectedCount", {
count: selectedCameraCount,
})}
</div>
</div> </div>
<div className="max-h-64 space-y-2 overflow-y-auto rounded-lg border border-border bg-background/50 p-2"> <div
className={cn(
"scrollbar-container space-y-2",
isDesktop && "max-h-64 overflow-y-auto pr-1",
)}
>
{isEventsLoading && ( {isEventsLoading && (
<div className="px-2 py-4 text-sm text-muted-foreground"> <div className="px-2 py-4 text-sm text-muted-foreground">
{t("export.multiCamera.checkingActivity")} {t("export.multiCamera.checkingActivity")}
@ -768,15 +861,18 @@ export function ExportContent({
<button <button
key={activity.camera} key={activity.camera}
type="button" type="button"
aria-pressed={isSelected}
className={cn( className={cn(
"flex w-full items-center gap-3 rounded-md border border-border px-3 py-2 text-left transition-colors", "flex w-full items-center gap-3 rounded-md border px-3 py-2 text-left transition-colors",
activity.hasDetections isSelected
? "bg-secondary/40 hover:bg-secondary/70" ? "border-selected bg-selected/10 ring-1 ring-selected"
: "bg-background text-muted-foreground", : "border-transparent bg-secondary/40 hover:bg-secondary/70",
!activity.hasDetections &&
!isSelected &&
"text-muted-foreground",
)} )}
onClick={() => toggleCameraSelection(activity.camera)} onClick={() => toggleCameraSelection(activity.camera)}
> >
<Checkbox checked={isSelected} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-medium text-primary"> <span className="truncate text-sm font-medium text-primary">
@ -788,16 +884,24 @@ export function ExportContent({
})} })}
</span> </span>
</div> </div>
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-secondary"> <div className="relative mt-2 h-1.5 w-full overflow-hidden rounded-full bg-secondary">
<div {activity.segments.map((segment, index) => {
className={cn( const leftPct = segment.start * 100;
"h-full rounded-full", const widthPct = Math.max(
activity.hasDetections ? "bg-selected" : "bg-muted", (segment.end - segment.start) * 100,
)} 1,
style={{ );
width: `${Math.max(activity.intensity * 100, activity.hasDetections ? 8 : 0)}%`, return (
}} <div
/> key={index}
className="absolute top-0 h-full rounded-full bg-selected"
style={{
left: `${leftPct}%`,
width: `${widthPct}%`,
}}
/>
);
})}
</div> </div>
</div> </div>
</button> </button>
@ -806,13 +910,18 @@ export function ExportContent({
</div> </div>
</div> </div>
<Input <div className="space-y-2">
className="text-md" <Label className="text-sm text-secondary-foreground">
type="search" {t("export.multiCamera.nameLabel")}
placeholder={t("export.multiCamera.namePlaceholder")} </Label>
value={name} <Input
onChange={(e) => setName(e.target.value)} className="text-md"
/> type="search"
placeholder={t("export.multiCamera.namePlaceholder")}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm text-secondary-foreground"> <Label className="text-sm text-secondary-foreground">
@ -826,9 +935,6 @@ export function ExportContent({
<SelectValue placeholder={t("export.case.placeholder")} /> <SelectValue placeholder={t("export.case.placeholder")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="none">
{t("label.none", { ns: "common" })}
</SelectItem>
{cases {cases
?.sort((a, b) => a.name.localeCompare(b.name)) ?.sort((a, b) => a.name.localeCompare(b.name))
.map((caseItem) => ( .map((caseItem) => (
@ -843,13 +949,15 @@ export function ExportContent({
</SelectContent> </SelectContent>
</Select> </Select>
{batchCaseSelection === "new" && ( {batchCaseSelection === "new" && (
<div className="space-y-2 rounded-lg border border-border bg-background/50 p-3"> <div className="space-y-2 pt-1">
<Input <Input
className="text-md"
placeholder={t("export.case.newCaseNamePlaceholder")} placeholder={t("export.case.newCaseNamePlaceholder")}
value={newCaseName} value={newCaseName}
onChange={(event) => setNewCaseName(event.target.value)} onChange={(event) => setNewCaseName(event.target.value)}
/> />
<Textarea <Textarea
className="text-md"
placeholder={t("export.case.newCaseDescriptionPlaceholder")} placeholder={t("export.case.newCaseDescriptionPlaceholder")}
value={newCaseDescription} value={newCaseDescription}
onChange={(event) => onChange={(event) =>
@ -878,20 +986,24 @@ export function ExportContent({
aria-label={t("export.selectOrExport")} aria-label={t("export.selectOrExport")}
variant="select" variant="select"
size="sm" size="sm"
onClick={() => { disabled={isStartingExport}
onClick={async () => {
if (selectedOption == "timeline") { if (selectedOption == "timeline") {
setRange({ before: currentTime + 30, after: currentTime - 30 }); setRange({ before: currentTime + 30, after: currentTime - 30 });
setMode("timeline"); setMode("timeline");
} else { } else {
onStartExport(); const didQueue = await onStartExport();
setSelectedOption("1"); if (didQueue) {
setMode("none"); setSelectedOption("1");
}
} }
}} }}
> >
{selectedOption == "timeline" {isStartingExport
? t("export.select") ? t("export.queueing")
: t("export.export")} : selectedOption == "timeline"
? t("export.select")
: t("export.export")}
</Button> </Button>
) : ( ) : (
<Button <Button
@ -904,9 +1016,11 @@ export function ExportContent({
disabled={!canStartBatchExport} disabled={!canStartBatchExport}
onClick={() => void startBatchExport()} onClick={() => void startBatchExport()}
> >
{t("export.multiCamera.exportButton", { {isStartingBatchExport
count: selectedCameraCount, ? t("export.multiCamera.queueingButton")
})} : t("export.multiCamera.exportButton", {
count: selectedCameraCount,
})}
</Button> </Button>
)} )}
</DialogFooter> </DialogFooter>

View File

@ -26,6 +26,7 @@ import SaveExportOverlay from "./SaveExportOverlay";
import { isMobile } from "react-device-detect"; import { isMobile } from "react-device-detect";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { StartExportResponse } from "@/types/export";
type DrawerMode = type DrawerMode =
| "none" | "none"
@ -114,7 +115,12 @@ export default function MobileReviewSettingsDrawer({
const [selectedCaseId, setSelectedCaseId] = useState<string | undefined>( const [selectedCaseId, setSelectedCaseId] = useState<string | undefined>(
undefined, undefined,
); );
const onStartExport = useCallback(() => { const [isStartingExport, setIsStartingExport] = useState(false);
const onStartExport = useCallback(async () => {
if (isStartingExport) {
return false;
}
if (!range) { if (!range) {
toast.error( toast.error(
t("export.toast.error.noVaildTimeSelected", { t("export.toast.error.noVaildTimeSelected", {
@ -124,7 +130,7 @@ export default function MobileReviewSettingsDrawer({
position: "top-center", position: "top-center",
}, },
); );
return; return false;
} }
if (range.before < range.after) { if (range.before < range.after) {
@ -136,55 +142,67 @@ export default function MobileReviewSettingsDrawer({
position: "top-center", position: "top-center",
}, },
); );
return; return false;
} }
axios setIsStartingExport(true);
.post(
try {
await axios.post<StartExportResponse>(
`export/${camera}/start/${Math.round(range.after)}/end/${Math.round(range.before)}`, `export/${camera}/start/${Math.round(range.after)}/end/${Math.round(range.before)}`,
{ {
playback: "realtime", source: "recordings",
name, name,
export_case_id: selectedCaseId || undefined, export_case_id: selectedCaseId || undefined,
}, },
) );
.then((response) => {
if (response.status == 200) { toast.success(t("export.toast.queued", { ns: "components/dialog" }), {
toast.success( position: "top-center",
t("export.toast.success", { ns: "components/dialog" }), action: (
{ <a href="/export" target="_blank" rel="noopener noreferrer">
position: "top-center", <Button>
action: ( {t("export.toast.view", { ns: "components/dialog" })}
<a href="/export" target="_blank" rel="noopener noreferrer"> </Button>
<Button> </a>
{t("export.toast.view", { ns: "components/dialog" })} ),
</Button>
</a>
),
},
);
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", {
ns: "components/dialog",
error: errorMessage,
}),
{
position: "top-center",
},
);
}); });
}, [camera, name, range, selectedCaseId, setRange, setName, setMode, 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", {
ns: "components/dialog",
error: errorMessage,
}),
{
position: "top-center",
},
);
return false;
} finally {
setIsStartingExport(false);
}
}, [
camera,
isStartingExport,
name,
range,
selectedCaseId,
setRange,
setMode,
t,
]);
const onStartDebugReplay = useCallback(async () => { const onStartDebugReplay = useCallback(async () => {
if ( if (
@ -344,6 +362,7 @@ export default function MobileReviewSettingsDrawer({
name={name} name={name}
selectedCaseId={selectedCaseId} selectedCaseId={selectedCaseId}
activeTab={exportTab} activeTab={exportTab}
isStartingExport={isStartingExport}
onStartExport={onStartExport} onStartExport={onStartExport}
setActiveTab={setExportTab} setActiveTab={setExportTab}
setName={setName} setName={setName}
@ -500,6 +519,7 @@ export default function MobileReviewSettingsDrawer({
className="pointer-events-none absolute left-1/2 top-8 z-50 -translate-x-1/2" className="pointer-events-none absolute left-1/2 top-8 z-50 -translate-x-1/2"
show={mode == "timeline" || mode == "timeline_multi"} show={mode == "timeline" || mode == "timeline_multi"}
hidePreview={mode == "timeline_multi"} hidePreview={mode == "timeline_multi"}
isSaving={isStartingExport}
saveLabel={ saveLabel={
mode == "timeline_multi" mode == "timeline_multi"
? t("export.fromTimeline.useThisRange", { ns: "components/dialog" }) ? t("export.fromTimeline.useThisRange", { ns: "components/dialog" })
@ -513,7 +533,7 @@ export default function MobileReviewSettingsDrawer({
return; return;
} }
onStartExport(); void onStartExport();
}} }}
onCancel={() => { onCancel={() => {
setExportTab("export"); setExportTab("export");

View File

@ -9,6 +9,7 @@ type SaveExportOverlayProps = {
show: boolean; show: boolean;
hidePreview?: boolean; hidePreview?: boolean;
saveLabel?: string; saveLabel?: string;
isSaving?: boolean;
onPreview: () => void; onPreview: () => void;
onSave: () => void; onSave: () => void;
onCancel: () => void; onCancel: () => void;
@ -18,6 +19,7 @@ export default function SaveExportOverlay({
show, show,
hidePreview = false, hidePreview = false,
saveLabel, saveLabel,
isSaving = false,
onPreview, onPreview,
onSave, onSave,
onCancel, onCancel,
@ -36,6 +38,7 @@ export default function SaveExportOverlay({
className="flex items-center gap-1 text-primary" className="flex items-center gap-1 text-primary"
aria-label={t("button.cancel", { ns: "common" })} aria-label={t("button.cancel", { ns: "common" })}
size="sm" size="sm"
disabled={isSaving}
onClick={onCancel} onClick={onCancel}
> >
<LuX /> <LuX />
@ -46,6 +49,7 @@ export default function SaveExportOverlay({
className="flex items-center gap-1" className="flex items-center gap-1"
aria-label={t("export.fromTimeline.previewExport")} aria-label={t("export.fromTimeline.previewExport")}
size="sm" size="sm"
disabled={isSaving}
onClick={onPreview} onClick={onPreview}
> >
<LuVideo /> <LuVideo />
@ -57,10 +61,13 @@ export default function SaveExportOverlay({
aria-label={saveLabel || t("export.fromTimeline.saveExport")} aria-label={saveLabel || t("export.fromTimeline.saveExport")}
variant="select" variant="select"
size="sm" size="sm"
disabled={isSaving}
onClick={onSave} onClick={onSave}
> >
<FaCompactDisc /> <FaCompactDisc />
{saveLabel || t("export.fromTimeline.saveExport")} {isSaving
? t("export.fromTimeline.queueingExport")
: saveLabel || t("export.fromTimeline.saveExport")}
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -1,5 +1,9 @@
import { baseUrl } from "@/api/baseUrl"; import { baseUrl } from "@/api/baseUrl";
import { CaseCard, ExportCard } from "@/components/card/ExportCard"; import {
ActiveExportJobCard,
CaseCard,
ExportCard,
} from "@/components/card/ExportCard";
import { import {
AlertDialog, AlertDialog,
AlertDialogCancel, AlertDialogCancel,
@ -26,6 +30,7 @@ import {
Export, Export,
ExportCase, ExportCase,
ExportFilter, ExportFilter,
ExportJob,
} from "@/types/export"; } from "@/types/export";
import OptionAndInputDialog from "@/components/overlay/dialog/OptionAndInputDialog"; import OptionAndInputDialog from "@/components/overlay/dialog/OptionAndInputDialog";
import axios from "axios"; import axios from "axios";
@ -66,12 +71,50 @@ function Exports() {
// Data // Data
const { data: cases, mutate: updateCases } = useSWR<ExportCase[]>("cases"); const { data: cases, mutate: updateCases } = useSWR<ExportCase[]>("cases");
const { data: activeExportJobs } = useSWR<ExportJob[]>("jobs/export", {
refreshInterval: 2000,
});
const { data: rawExports, mutate: updateExports } = useSWR<Export[]>( const { data: rawExports, mutate: updateExports } = useSWR<Export[]>(
exportSearchParams && Object.keys(exportSearchParams).length > 0 exportSearchParams && Object.keys(exportSearchParams).length > 0
? ["exports", exportSearchParams] ? ["exports", exportSearchParams]
: "exports", : "exports",
{
refreshInterval: (activeExportJobs?.length ?? 0) > 0 ? 2000 : 0,
},
); );
const visibleActiveJobs = useMemo<ExportJob[]>(() => {
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]);
const activeJobsByCase = useMemo<{ [caseId: string]: ExportJob[] }>(() => {
const grouped: { [caseId: string]: ExportJob[] } = {};
visibleActiveJobs.forEach((job) => {
const caseId = job.export_case_id ?? "none";
if (!grouped[caseId]) {
grouped[caseId] = [];
}
grouped[caseId].push(job);
});
return grouped;
}, [visibleActiveJobs]);
const exportsByCase = useMemo<{ [caseId: string]: Export[] }>(() => { const exportsByCase = useMemo<{ [caseId: string]: Export[] }>(() => {
const grouped: { [caseId: string]: Export[] } = {}; const grouped: { [caseId: string]: Export[] } = {};
(rawExports ?? []).forEach((exp) => { (rawExports ?? []).forEach((exp) => {
@ -515,6 +558,7 @@ function Exports() {
selectedCase={selectedCase} selectedCase={selectedCase}
exports={exportsByCase[selectedCase.id] || []} exports={exportsByCase[selectedCase.id] || []}
availableExports={uncategorizedExports} availableExports={uncategorizedExports}
activeJobs={activeJobsByCase[selectedCase.id] || []}
search={search} search={search}
setSelected={setSelected} setSelected={setSelected}
renameClip={onHandleRename} renameClip={onHandleRename}
@ -529,6 +573,7 @@ function Exports() {
cases={filteredCases} cases={filteredCases}
exports={exports} exports={exports}
exportsByCase={exportsByCase} exportsByCase={exportsByCase}
activeJobs={activeJobsByCase["none"] || []}
setSelectedCaseId={setSelectedCaseId} setSelectedCaseId={setSelectedCaseId}
setSelected={setSelected} setSelected={setSelected}
renameClip={onHandleRename} renameClip={onHandleRename}
@ -546,6 +591,7 @@ type AllExportsViewProps = {
cases?: ExportCase[]; cases?: ExportCase[];
exports: Export[]; exports: Export[];
exportsByCase: { [caseId: string]: Export[] }; exportsByCase: { [caseId: string]: Export[] };
activeJobs: ExportJob[];
setSelectedCaseId: (id: string) => void; setSelectedCaseId: (id: string) => void;
setSelected: (e: Export) => void; setSelected: (e: Export) => void;
renameClip: (id: string, update: string) => void; renameClip: (id: string, update: string) => void;
@ -558,6 +604,7 @@ function AllExportsView({
cases, cases,
exports, exports,
exportsByCase, exportsByCase,
activeJobs,
setSelectedCaseId, setSelectedCaseId,
setSelected, setSelected,
renameClip, renameClip,
@ -594,9 +641,24 @@ function AllExportsView({
); );
}, [exports, search]); }, [exports, search]);
const filteredActiveJobs = useMemo<ExportJob[]>(() => {
if (!search) {
return activeJobs;
}
return activeJobs.filter((job) =>
(job.name || job.camera)
.toLowerCase()
.replaceAll("_", " ")
.includes(search.toLowerCase()),
);
}, [activeJobs, search]);
return ( return (
<div className="w-full overflow-hidden"> <div className="w-full overflow-hidden">
{filteredCases?.length || filteredExports.length ? ( {filteredCases?.length ||
filteredActiveJobs.length ||
filteredExports.length ? (
<div <div
ref={contentRef} ref={contentRef}
className="scrollbar-container flex size-full flex-col gap-4 overflow-y-auto" className="scrollbar-container flex size-full flex-col gap-4 overflow-y-auto"
@ -620,13 +682,16 @@ function AllExportsView({
</div> </div>
)} )}
{filteredExports.length > 0 && ( {(filteredActiveJobs.length > 0 || filteredExports.length > 0) && (
<div className="space-y-4"> <div className="space-y-4">
<Heading as="h4">{t("headings.uncategorizedExports")}</Heading> <Heading as="h4">{t("headings.uncategorizedExports")}</Heading>
<div <div
ref={contentRef} ref={contentRef}
className="scrollbar-container grid gap-2 overflow-y-auto sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4" className="scrollbar-container grid gap-2 overflow-y-auto sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
> >
{filteredActiveJobs.map((job) => (
<ActiveExportJobCard key={job.id} job={job} />
))}
{filteredExports.map((item) => ( {filteredExports.map((item) => (
<ExportCard <ExportCard
key={item.name} key={item.name}
@ -659,6 +724,7 @@ type CaseViewProps = {
selectedCase: ExportCase; selectedCase: ExportCase;
exports?: Export[]; exports?: Export[];
availableExports: Export[]; availableExports: Export[];
activeJobs: ExportJob[];
search: string; search: string;
setSelected: (e: Export) => void; setSelected: (e: Export) => void;
renameClip: (id: string, update: string) => void; renameClip: (id: string, update: string) => void;
@ -671,6 +737,7 @@ function CaseView({
selectedCase, selectedCase,
exports, exports,
availableExports, availableExports,
activeJobs,
search, search,
setSelected, setSelected,
renameClip, renameClip,
@ -704,6 +771,23 @@ function CaseView({
); );
}, [selectedCase, exports, search]); }, [selectedCase, exports, search]);
const filteredActiveJobs = useMemo<ExportJob[]>(() => {
const caseJobs = activeJobs.filter(
(job) => job.export_case_id === selectedCase.id,
);
if (!search) {
return caseJobs;
}
return caseJobs.filter((job) =>
(job.name || job.camera)
.toLowerCase()
.replaceAll("_", " ")
.includes(search.toLowerCase()),
);
}, [activeJobs, search, selectedCase.id]);
const cameraCount = useMemo( const cameraCount = useMemo(
() => new Set(filteredExports.map((exp) => exp.camera)).size, () => new Set(filteredExports.map((exp) => exp.camera)).size,
[filteredExports], [filteredExports],
@ -765,11 +849,14 @@ function CaseView({
</div> </div>
)} )}
</div> </div>
{filteredExports.length > 0 ? ( {filteredExports.length > 0 || filteredActiveJobs.length > 0 ? (
<div <div
ref={contentRef} ref={contentRef}
className="scrollbar-container grid min-h-0 flex-1 content-start gap-2 overflow-y-auto sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4" className="scrollbar-container grid min-h-0 flex-1 content-start gap-2 overflow-y-auto sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
> >
{filteredActiveJobs.map((job) => (
<ActiveExportJobCard key={job.id} job={job} />
))}
{filteredExports.map((item) => ( {filteredExports.map((item) => (
<ExportCard <ExportCard
key={item.id} key={item.id}

View File

@ -32,20 +32,55 @@ export type BatchExportResult = {
camera: string; camera: string;
export_id?: string | null; export_id?: string | null;
success: boolean; success: boolean;
status?: string | null;
error?: string | null; error?: string | null;
}; };
export type BatchExportResponse = { export type BatchExportResponse = {
export_case_id: string; export_case_id?: string | null;
export_ids: string[]; export_ids: string[];
results: BatchExportResult[]; results: BatchExportResult[];
}; };
export type StartExportResponse = {
success: boolean;
message: string;
export_id?: string | null;
status?: string | null;
};
export type ExportJob = {
id: string;
job_type: string;
status: string;
camera: string;
name?: string | null;
export_case_id?: string | null;
request_start_time: number;
request_end_time: number;
start_time?: number | null;
end_time?: number | null;
error_message?: string | null;
results?: {
export_id?: string;
export_case_id?: string | null;
video_path?: string;
thumb_path?: string;
} | null;
};
export type CameraActivitySegment = {
/** Fractional start position within the time range, 0-1 inclusive. */
start: number;
/** Fractional end position within the time range, 0-1 inclusive. */
end: number;
};
export type CameraActivity = { export type CameraActivity = {
camera: string; camera: string;
count: number; count: number;
intensity: number;
hasDetections: boolean; hasDetections: boolean;
segments: CameraActivitySegment[];
}; };
export type DeleteClipType = { export type DeleteClipType = {