Export improvements (#22867)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions

* backend

* frontend + i18n

* tests + api spec

* tweak backend to use Job infrastructure for exports

* frontend tweaks and Job infrastructure

* tests

* tweaks

- add ability to remove from case
- change location of counts in case card

* add stale export reaper on startup

* fix toaster close button color

* improve add dialog

* formatting

* hide max_concurrent from camera config export settings

* remove border

* refactor batch endpoint for multiple review items

* frontend

* tests and fastapi spec

* fix deletion of in-progress exports in a case

* tweaks

- hide cases when filtering cameras that have no exports from those cameras
- remove description from case card
- use textarea instead of input for case description in add new case dialog

* add auth exceptions for exports

* add e2e test for deleting cases with exports

* refactor delete and case endpoints

allow bulk deleting and reassigning

* frontend

- bulk selection like Review
- gate admin-only actions
- consolidate dialogs
- spacing/padding tweaks

* i18n and tests

* update openapi spec

* tweaks

- add None to case selection list
- allow new case creation from single cam export dialog

* fix codeql

* fix i18n

* remove unused

* fix frontend tests
This commit is contained in:
Josh Hawkins
2026-04-14 08:19:50 -06:00
committed by GitHub
parent 18c068a3f9
commit e7e6f87682
31 changed files with 6789 additions and 733 deletions
@@ -4,7 +4,7 @@ import { Button } from "../ui/button";
import { FaArrowDown, FaCalendarAlt, FaCog, FaFilter } from "react-icons/fa";
import { LuBug } from "react-icons/lu";
import { TimeRange } from "@/types/timeline";
import { ExportContent, ExportPreviewDialog } from "./ExportDialog";
import { ExportContent, ExportPreviewDialog, ExportTab } from "./ExportDialog";
import {
DebugReplayContent,
SaveDebugReplayOverlay,
@@ -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"
@@ -102,6 +103,7 @@ export default function MobileReviewSettingsDrawer({
]);
const navigate = useNavigate();
const [drawerMode, setDrawerMode] = useState<DrawerMode>("none");
const [exportTab, setExportTab] = useState<ExportTab>("export");
const [selectedReplayOption, setSelectedReplayOption] = useState<
"1" | "5" | "custom" | "timeline"
>("1");
@@ -113,67 +115,112 @@ export default function MobileReviewSettingsDrawer({
const [selectedCaseId, setSelectedCaseId] = useState<string | undefined>(
undefined,
);
const onStartExport = useCallback(() => {
const [singleNewCaseName, setSingleNewCaseName] = useState("");
const [singleNewCaseDescription, setSingleNewCaseDescription] = useState("");
const [isStartingExport, setIsStartingExport] = useState(false);
const onStartExport = useCallback(async () => {
if (isStartingExport) {
return false;
}
if (!range) {
toast.error(t("toast.error.noValidTimeSelected"), {
position: "top-center",
});
return;
toast.error(
t("export.toast.error.noVaildTimeSelected", {
ns: "components/dialog",
}),
{
position: "top-center",
},
);
return false;
}
if (range.before < range.after) {
toast.error(t("toast.error.endTimeMustAfterStartTime"), {
position: "top-center",
});
return;
toast.error(
t("export.toast.error.endTimeMustAfterStartTime", {
ns: "components/dialog",
}),
{
position: "top-center",
},
);
return false;
}
axios
.post(
setIsStartingExport(true);
try {
let exportCaseId: string | undefined = selectedCaseId;
if (selectedCaseId === "new" && singleNewCaseName.trim().length > 0) {
const caseResp = await axios.post("cases", {
name: singleNewCaseName.trim(),
description: singleNewCaseDescription.trim() || undefined,
});
exportCaseId = caseResp.data?.id;
} else if (selectedCaseId === "new" || selectedCaseId === "none") {
exportCaseId = undefined;
}
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,
export_case_id: exportCaseId,
},
)
.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",
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);
setSingleNewCaseName("");
setSingleNewCaseDescription("");
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,
singleNewCaseDescription,
singleNewCaseName,
setRange,
setMode,
t,
]);
const onStartDebugReplay = useCallback(async () => {
if (
@@ -267,6 +314,7 @@ export default function MobileReviewSettingsDrawer({
className="flex w-full items-center justify-center gap-2"
aria-label={t("export")}
onClick={() => {
setExportTab("export");
setDrawerMode("export");
setMode("select");
}}
@@ -331,14 +379,21 @@ export default function MobileReviewSettingsDrawer({
range={range}
name={name}
selectedCaseId={selectedCaseId}
singleNewCaseName={singleNewCaseName}
singleNewCaseDescription={singleNewCaseDescription}
activeTab={exportTab}
isStartingExport={isStartingExport}
onStartExport={onStartExport}
setActiveTab={setExportTab}
setName={setName}
setSelectedCaseId={setSelectedCaseId}
setSingleNewCaseName={setSingleNewCaseName}
setSingleNewCaseDescription={setSingleNewCaseDescription}
setRange={setRange}
setMode={(mode) => {
setMode(mode);
if (mode == "timeline") {
if (mode == "timeline" || mode == "timeline_multi") {
setDrawerMode("none");
}
}}
@@ -346,6 +401,9 @@ export default function MobileReviewSettingsDrawer({
setMode("none");
setRange(undefined);
setSelectedCaseId(undefined);
setSingleNewCaseName("");
setSingleNewCaseDescription("");
setExportTab("export");
setDrawerMode("select");
}}
/>
@@ -483,9 +541,29 @@ export default function MobileReviewSettingsDrawer({
<>
<SaveExportOverlay
className="pointer-events-none absolute left-1/2 top-8 z-50 -translate-x-1/2"
show={mode == "timeline"}
onSave={() => onStartExport()}
onCancel={() => setMode("none")}
show={mode == "timeline" || mode == "timeline_multi"}
hidePreview={mode == "timeline_multi"}
isSaving={isStartingExport}
saveLabel={
mode == "timeline_multi"
? t("export.fromTimeline.useThisRange", { ns: "components/dialog" })
: undefined
}
onSave={() => {
if (mode == "timeline_multi") {
setExportTab("multi");
setDrawerMode("export");
setMode("select");
return;
}
void onStartExport();
}}
onCancel={() => {
setExportTab("export");
setRange(undefined);
setMode("none");
}}
onPreview={() => setShowExportPreview(true)}
/>
<SaveDebugReplayOverlay