mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-09 15:05:26 +03:00
frontend tweaks and Job infrastructure
This commit is contained in:
parent
0d9d1d7652
commit
549642e97e
@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}}",
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -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,33 +114,38 @@ 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">
|
||||||
@ -145,12 +157,14 @@ export default function ExportDialog({
|
|||||||
setSelectedCaseId(undefined);
|
setSelectedCaseId(undefined);
|
||||||
setRange(undefined);
|
setRange(undefined);
|
||||||
setMode("none");
|
setMode("none");
|
||||||
}
|
return true;
|
||||||
})
|
} catch (error) {
|
||||||
.catch((error) => {
|
const apiError = error as {
|
||||||
|
response?: { data?: { message?: string; detail?: string } };
|
||||||
|
};
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
error.response?.data?.message ||
|
apiError.response?.data?.message ||
|
||||||
error.response?.data?.detail ||
|
apiError.response?.data?.detail ||
|
||||||
"Unknown error";
|
"Unknown error";
|
||||||
toast.error(
|
toast.error(
|
||||||
t("export.toast.error.failed", {
|
t("export.toast.error.failed", {
|
||||||
@ -158,8 +172,20 @@ export default function ExportDialog({
|
|||||||
}),
|
}),
|
||||||
{ position: "top-center" },
|
{ position: "top-center" },
|
||||||
);
|
);
|
||||||
});
|
return false;
|
||||||
}, [camera, name, range, selectedCaseId, setMode, setRange, t]);
|
} 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,14 +624,16 @@ 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");
|
||||||
|
if (response.data.export_case_id) {
|
||||||
navigate(`/export?caseId=${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 {
|
||||||
response?: { data?: { message?: string; detail?: string } };
|
response?: { data?: { message?: string; detail?: string } };
|
||||||
@ -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,11 +781,19 @@ 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>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="min-w-0 flex-1 [&>div]:!mx-0 [&>div]:!mt-0">
|
||||||
<CustomTimeSelector
|
<CustomTimeSelector
|
||||||
latestTime={latestTime}
|
latestTime={latestTime}
|
||||||
range={range}
|
range={range}
|
||||||
@ -720,9 +801,14 @@ export function ExportContent({
|
|||||||
startLabel={t("export.time.start.title")}
|
startLabel={t("export.time.start.title")}
|
||||||
endLabel={t("export.time.end.title")}
|
endLabel={t("export.time.end.title")}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="mt-2"
|
variant="outline"
|
||||||
|
className="size-9 shrink-0 p-0"
|
||||||
|
aria-label={t("export.multiCamera.selectFromTimeline")}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!range) {
|
if (!range) {
|
||||||
setRange({
|
setRange({
|
||||||
@ -735,22 +821,29 @@ export function ExportContent({
|
|||||||
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.selectedCount", {
|
{t("export.multiCamera.cameraSelectionHelp")}
|
||||||
count: selectedCameraCount,
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div
|
||||||
<div className="max-h-64 space-y-2 overflow-y-auto rounded-lg border border-border bg-background/50 p-2">
|
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">
|
||||||
|
{activity.segments.map((segment, index) => {
|
||||||
|
const leftPct = segment.start * 100;
|
||||||
|
const widthPct = Math.max(
|
||||||
|
(segment.end - segment.start) * 100,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
key={index}
|
||||||
"h-full rounded-full",
|
className="absolute top-0 h-full rounded-full bg-selected"
|
||||||
activity.hasDetections ? "bg-selected" : "bg-muted",
|
|
||||||
)}
|
|
||||||
style={{
|
style={{
|
||||||
width: `${Math.max(activity.intensity * 100, activity.hasDetections ? 8 : 0)}%`,
|
left: `${leftPct}%`,
|
||||||
|
width: `${widthPct}%`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@ -806,6 +910,10 @@ export function ExportContent({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm text-secondary-foreground">
|
||||||
|
{t("export.multiCamera.nameLabel")}
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
className="text-md"
|
className="text-md"
|
||||||
type="search"
|
type="search"
|
||||||
@ -813,6 +921,7 @@ export function ExportContent({
|
|||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
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,18 +986,22 @@ 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();
|
||||||
|
if (didQueue) {
|
||||||
setSelectedOption("1");
|
setSelectedOption("1");
|
||||||
setMode("none");
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{selectedOption == "timeline"
|
{isStartingExport
|
||||||
|
? t("export.queueing")
|
||||||
|
: selectedOption == "timeline"
|
||||||
? t("export.select")
|
? t("export.select")
|
||||||
: t("export.export")}
|
: t("export.export")}
|
||||||
</Button>
|
</Button>
|
||||||
@ -904,7 +1016,9 @@ export function ExportContent({
|
|||||||
disabled={!canStartBatchExport}
|
disabled={!canStartBatchExport}
|
||||||
onClick={() => void startBatchExport()}
|
onClick={() => void startBatchExport()}
|
||||||
>
|
>
|
||||||
{t("export.multiCamera.exportButton", {
|
{isStartingBatchExport
|
||||||
|
? t("export.multiCamera.queueingButton")
|
||||||
|
: t("export.multiCamera.exportButton", {
|
||||||
count: selectedCameraCount,
|
count: selectedCameraCount,
|
||||||
})}
|
})}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -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,23 +142,22 @@ 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(
|
|
||||||
t("export.toast.success", { ns: "components/dialog" }),
|
|
||||||
{
|
|
||||||
position: "top-center",
|
position: "top-center",
|
||||||
action: (
|
action: (
|
||||||
<a href="/export" target="_blank" rel="noopener noreferrer">
|
<a href="/export" target="_blank" rel="noopener noreferrer">
|
||||||
@ -161,18 +166,19 @@ export default function MobileReviewSettingsDrawer({
|
|||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
),
|
),
|
||||||
},
|
});
|
||||||
);
|
|
||||||
setName("");
|
setName("");
|
||||||
setSelectedCaseId(undefined);
|
setSelectedCaseId(undefined);
|
||||||
setRange(undefined);
|
setRange(undefined);
|
||||||
setMode("none");
|
setMode("none");
|
||||||
}
|
return true;
|
||||||
})
|
} catch (error) {
|
||||||
.catch((error) => {
|
const apiError = error as {
|
||||||
|
response?: { data?: { message?: string; detail?: string } };
|
||||||
|
};
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
error.response?.data?.message ||
|
apiError.response?.data?.message ||
|
||||||
error.response?.data?.detail ||
|
apiError.response?.data?.detail ||
|
||||||
"Unknown error";
|
"Unknown error";
|
||||||
toast.error(
|
toast.error(
|
||||||
t("export.toast.error.failed", {
|
t("export.toast.error.failed", {
|
||||||
@ -183,8 +189,20 @@ export default function MobileReviewSettingsDrawer({
|
|||||||
position: "top-center",
|
position: "top-center",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
return false;
|
||||||
}, [camera, name, range, selectedCaseId, setRange, setName, setMode, t]);
|
} 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");
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user