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",
"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"
}

View File

@ -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}}",

View File

@ -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<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 { 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<string | undefined>();
const [activeTab, setActiveTab] = useState<ExportTab>("export");
const [isStartingExport, setIsStartingExport] = useState(false);
const previousModeRef = useRef<ExportMode>(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<StartExportResponse>(
`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: (
<a href="/export" target="_blank" rel="noopener noreferrer">
<Button>{t("export.toast.view")}</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", {
error: errorMessage,
}),
{ position: "top-center" },
);
);
toast.success(t("export.toast.queued"), {
position: "top-center",
action: (
<a href="/export" target="_blank" rel="noopener noreferrer">
<Button>{t("export.toast.view")}</Button>
</a>
),
});
}, [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<boolean>;
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<string[]>([]);
const [batchCaseSelection, setBatchCaseSelection] = useState<string>(
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<CameraActivity[]>(() => {
const allCameraIds = Object.keys(config?.cameras ?? {});
const counts = new Map<string, number>();
const byCamera = new Map<string, 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) => {
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<BatchExportResponse>(
"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 (
<div className="w-full">
<div
className={cn(
"flex w-full flex-col",
!isDesktop && "max-h-[80dvh] min-h-0",
)}
>
{isDesktop && (
<>
<DialogHeader>
<DialogTitle>{t("menu.export", { ns: "common" })}</DialogTitle>
</DialogHeader>
<SelectSeparator className="my-4 bg-secondary" />
</>
<DialogHeader className="mb-4">
<DialogTitle>{t("menu.export", { ns: "common" })}</DialogTitle>
</DialogHeader>
)}
<Tabs
value={activeTab}
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">
<TabsTrigger value="export">{t("export.tabs.export")}</TabsTrigger>
@ -628,7 +695,13 @@ export function ExportContent({
</TabsTrigger>
</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
className={`flex flex-col gap-4 ${isDesktop ? "" : "mt-1"}`}
onValueChange={(value) => onSelectTime(value as ExportOption)}
@ -708,49 +781,69 @@ export function ExportContent({
</div>
</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">
<Label className="text-sm text-secondary-foreground">
{t("export.multiCamera.timeRange")}
</Label>
<CustomTimeSelector
latestTime={latestTime}
range={range}
setRange={setRange}
startLabel={t("export.time.start.title")}
endLabel={t("export.time.end.title")}
/>
<Button
size="sm"
className="mt-2"
onClick={() => {
if (!range) {
setRange({
before: currentTime + 30,
after: currentTime - 30,
});
}
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1 [&>div]:!mx-0 [&>div]:!mt-0">
<CustomTimeSelector
latestTime={latestTime}
range={range}
setRange={setRange}
startLabel={t("export.time.start.title")}
endLabel={t("export.time.end.title")}
/>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
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");
setMode("timeline_multi");
}}
>
{t("export.multiCamera.selectFromTimeline")}
</Button>
setActiveTab("multi");
setMode("timeline_multi");
}}
>
<LuAudioLines className="size-4 -rotate-90" />
</Button>
</TooltipTrigger>
<TooltipContent>
{t("export.multiCamera.selectFromTimeline")}
</TooltipContent>
</Tooltip>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm text-secondary-foreground">
{t("export.multiCamera.cameraSelection")}
</Label>
<div className="text-xs text-muted-foreground">
{t("export.multiCamera.selectedCount", {
count: selectedCameraCount,
})}
</div>
<Label className="text-sm text-secondary-foreground">
{t("export.multiCamera.cameraSelection")}
</Label>
<div className="text-xs text-muted-foreground">
{t("export.multiCamera.cameraSelectionHelp")}
</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 && (
<div className="px-2 py-4 text-sm text-muted-foreground">
{t("export.multiCamera.checkingActivity")}
@ -768,15 +861,18 @@ export function ExportContent({
<button
key={activity.camera}
type="button"
aria-pressed={isSelected}
className={cn(
"flex w-full items-center gap-3 rounded-md border border-border px-3 py-2 text-left transition-colors",
activity.hasDetections
? "bg-secondary/40 hover:bg-secondary/70"
: "bg-background text-muted-foreground",
"flex w-full items-center gap-3 rounded-md border px-3 py-2 text-left transition-colors",
isSelected
? "border-selected bg-selected/10 ring-1 ring-selected"
: "border-transparent bg-secondary/40 hover:bg-secondary/70",
!activity.hasDetections &&
!isSelected &&
"text-muted-foreground",
)}
onClick={() => toggleCameraSelection(activity.camera)}
>
<Checkbox checked={isSelected} />
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-medium text-primary">
@ -788,16 +884,24 @@ export function ExportContent({
})}
</span>
</div>
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-secondary">
<div
className={cn(
"h-full rounded-full",
activity.hasDetections ? "bg-selected" : "bg-muted",
)}
style={{
width: `${Math.max(activity.intensity * 100, activity.hasDetections ? 8 : 0)}%`,
}}
/>
<div className="relative mt-2 h-1.5 w-full overflow-hidden rounded-full bg-secondary">
{activity.segments.map((segment, index) => {
const leftPct = segment.start * 100;
const widthPct = Math.max(
(segment.end - segment.start) * 100,
1,
);
return (
<div
key={index}
className="absolute top-0 h-full rounded-full bg-selected"
style={{
left: `${leftPct}%`,
width: `${widthPct}%`,
}}
/>
);
})}
</div>
</div>
</button>
@ -806,13 +910,18 @@ export function ExportContent({
</div>
</div>
<Input
className="text-md"
type="search"
placeholder={t("export.multiCamera.namePlaceholder")}
value={name}
onChange={(e) => setName(e.target.value)}
/>
<div className="space-y-2">
<Label className="text-sm text-secondary-foreground">
{t("export.multiCamera.nameLabel")}
</Label>
<Input
className="text-md"
type="search"
placeholder={t("export.multiCamera.namePlaceholder")}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label className="text-sm text-secondary-foreground">
@ -826,9 +935,6 @@ export function ExportContent({
<SelectValue placeholder={t("export.case.placeholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
{t("label.none", { ns: "common" })}
</SelectItem>
{cases
?.sort((a, b) => a.name.localeCompare(b.name))
.map((caseItem) => (
@ -843,13 +949,15 @@ export function ExportContent({
</SelectContent>
</Select>
{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
className="text-md"
placeholder={t("export.case.newCaseNamePlaceholder")}
value={newCaseName}
onChange={(event) => setNewCaseName(event.target.value)}
/>
<Textarea
className="text-md"
placeholder={t("export.case.newCaseDescriptionPlaceholder")}
value={newCaseDescription}
onChange={(event) =>
@ -878,20 +986,24 @@ export function ExportContent({
aria-label={t("export.selectOrExport")}
variant="select"
size="sm"
onClick={() => {
disabled={isStartingExport}
onClick={async () => {
if (selectedOption == "timeline") {
setRange({ before: currentTime + 30, after: currentTime - 30 });
setMode("timeline");
} else {
onStartExport();
setSelectedOption("1");
setMode("none");
const didQueue = await onStartExport();
if (didQueue) {
setSelectedOption("1");
}
}
}}
>
{selectedOption == "timeline"
? t("export.select")
: t("export.export")}
{isStartingExport
? t("export.queueing")
: selectedOption == "timeline"
? t("export.select")
: t("export.export")}
</Button>
) : (
<Button
@ -904,9 +1016,11 @@ export function ExportContent({
disabled={!canStartBatchExport}
onClick={() => void startBatchExport()}
>
{t("export.multiCamera.exportButton", {
count: selectedCameraCount,
})}
{isStartingBatchExport
? t("export.multiCamera.queueingButton")
: t("export.multiCamera.exportButton", {
count: selectedCameraCount,
})}
</Button>
)}
</DialogFooter>

View File

@ -26,6 +26,7 @@ import SaveExportOverlay from "./SaveExportOverlay";
import { isMobile } from "react-device-detect";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { StartExportResponse } from "@/types/export";
type DrawerMode =
| "none"
@ -114,7 +115,12 @@ export default function MobileReviewSettingsDrawer({
const [selectedCaseId, setSelectedCaseId] = useState<string | undefined>(
undefined,
);
const onStartExport = useCallback(() => {
const [isStartingExport, setIsStartingExport] = useState(false);
const onStartExport = useCallback(async () => {
if (isStartingExport) {
return false;
}
if (!range) {
toast.error(
t("export.toast.error.noVaildTimeSelected", {
@ -124,7 +130,7 @@ export default function MobileReviewSettingsDrawer({
position: "top-center",
},
);
return;
return false;
}
if (range.before < range.after) {
@ -136,55 +142,67 @@ export default function MobileReviewSettingsDrawer({
position: "top-center",
},
);
return;
return false;
}
axios
.post(
setIsStartingExport(true);
try {
await axios.post<StartExportResponse>(
`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", { ns: "components/dialog" }),
{
position: "top-center",
action: (
<a href="/export" target="_blank" rel="noopener noreferrer">
<Button>
{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",
},
);
);
toast.success(t("export.toast.queued", { ns: "components/dialog" }), {
position: "top-center",
action: (
<a href="/export" target="_blank" rel="noopener noreferrer">
<Button>
{t("export.toast.view", { ns: "components/dialog" })}
</Button>
</a>
),
});
}, [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 () => {
if (
@ -344,6 +362,7 @@ export default function MobileReviewSettingsDrawer({
name={name}
selectedCaseId={selectedCaseId}
activeTab={exportTab}
isStartingExport={isStartingExport}
onStartExport={onStartExport}
setActiveTab={setExportTab}
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"
show={mode == "timeline" || mode == "timeline_multi"}
hidePreview={mode == "timeline_multi"}
isSaving={isStartingExport}
saveLabel={
mode == "timeline_multi"
? t("export.fromTimeline.useThisRange", { ns: "components/dialog" })
@ -513,7 +533,7 @@ export default function MobileReviewSettingsDrawer({
return;
}
onStartExport();
void onStartExport();
}}
onCancel={() => {
setExportTab("export");

View File

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

View File

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

View File

@ -32,20 +32,55 @@ export type BatchExportResult = {
camera: string;
export_id?: string | null;
success: boolean;
status?: string | null;
error?: string | null;
};
export type BatchExportResponse = {
export_case_id: string;
export_case_id?: string | null;
export_ids: string[];
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 = {
camera: string;
count: number;
intensity: number;
hasDetections: boolean;
segments: CameraActivitySegment[];
};
export type DeleteClipType = {