Compare commits

...

5 Commits

Author SHA1 Message Date
Josh Hawkins
e593ac9599 fix frontend tests 2026-04-13 14:06:43 -05:00
Josh Hawkins
0b0041734c remove unused 2026-04-13 14:03:51 -05:00
Josh Hawkins
8bc81b849f fix i18n 2026-04-13 14:01:17 -05:00
Josh Hawkins
29d575cc18 fix codeql 2026-04-13 13:59:58 -05:00
Josh Hawkins
aa5934c745 tweaks
- add None to case selection list
- allow new case creation from single cam export dialog
2026-04-13 10:59:29 -05:00
6 changed files with 108 additions and 18 deletions

View File

@ -596,7 +596,7 @@ def export_recordings_batch(
) )
try: try:
start_export_job(request.app.frigate_config, export_job) start_export_job(request.app.frigate_config, export_job)
except Exception as err: except Exception:
logger.exception("Failed to queue export job %s", export_job.id) logger.exception("Failed to queue export job %s", export_job.id)
results.append( results.append(
{ {
@ -604,7 +604,7 @@ def export_recordings_batch(
"export_id": None, "export_id": None,
"success": False, "success": False,
"status": None, "status": None,
"error": str(err), "error": "Failed to queue export job",
"item_index": index, "item_index": index,
"client_item_id": item.client_item_id, "client_item_id": item.client_item_id,
} }

View File

@ -460,10 +460,9 @@ test.describe("Multi-Review Export @high", () => {
.filter({ hasText: /Export 2 reviews/i }); .filter({ hasText: /Export 2 reviews/i });
await expect(dialog).toBeVisible({ timeout: 5_000 }); await expect(dialog).toBeVisible({ timeout: 5_000 });
// The dialog uses a Select trigger for case selection (admins). The // The dialog uses a Select trigger for case selection (admins). The
// default "Create new case" value is shown on the trigger and the // default "None" value is shown on the trigger.
// New-case inputs render directly below.
await expect(dialog.locator("button[role='combobox']")).toBeVisible(); await expect(dialog.locator("button[role='combobox']")).toBeVisible();
await expect(dialog.getByText(/Create new case/i)).toBeVisible(); await expect(dialog.getByText(/None/)).toBeVisible();
}); });
test("starting an export posts the expected payload and navigates to the case", async ({ test("starting an export posts the expected payload and navigates to the case", async ({
@ -513,6 +512,12 @@ test.describe("Multi-Review Export @high", () => {
.filter({ hasText: /Export 2 reviews/i }); .filter({ hasText: /Export 2 reviews/i });
await expect(dialog).toBeVisible({ timeout: 5_000 }); await expect(dialog).toBeVisible({ timeout: 5_000 });
// Select "Create new case" from the case dropdown (default is "None")
await dialog.locator("button[role='combobox']").click();
await frigateApp.page
.getByRole("option", { name: /Create new case/i })
.click();
const nameInput = dialog.locator("input").first(); const nameInput = dialog.locator("input").first();
await nameInput.fill("E2E Incident"); await nameInput.fill("E2E Incident");

View File

@ -81,11 +81,13 @@
"exportButton_other": "Export {{count}} Cameras" "exportButton_other": "Export {{count}} Cameras"
}, },
"multi": { "multi": {
"title": "Export {{count}} reviews",
"title_one": "Export 1 review", "title_one": "Export 1 review",
"title_other": "Export {{count}} reviews", "title_other": "Export {{count}} reviews",
"description": "Export each selected review. All exports will be grouped under a single case.", "description": "Export each selected review. All exports will be grouped under a single case.",
"descriptionNoCase": "Export each selected review.", "descriptionNoCase": "Export each selected review.",
"caseNamePlaceholder": "Review export - {{date}}", "caseNamePlaceholder": "Review export - {{date}}",
"exportButton": "Export {{count}} reviews",
"exportButton_one": "Export 1 review", "exportButton_one": "Export 1 review",
"exportButton_other": "Export {{count}} reviews", "exportButton_other": "Export {{count}} reviews",
"exportingButton": "Exporting...", "exportingButton": "Exporting...",

View File

@ -93,6 +93,8 @@ export default function ExportDialog({
const { t } = useTranslation(["components/dialog"]); const { t } = useTranslation(["components/dialog"]);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [selectedCaseId, setSelectedCaseId] = useState<string | undefined>(); const [selectedCaseId, setSelectedCaseId] = useState<string | undefined>();
const [singleNewCaseName, setSingleNewCaseName] = useState("");
const [singleNewCaseDescription, setSingleNewCaseDescription] = useState("");
const [activeTab, setActiveTab] = useState<ExportTab>("export"); const [activeTab, setActiveTab] = useState<ExportTab>("export");
const [isStartingExport, setIsStartingExport] = useState(false); const [isStartingExport, setIsStartingExport] = useState(false);
const previousModeRef = useRef<ExportMode>(mode); const previousModeRef = useRef<ExportMode>(mode);
@ -137,12 +139,24 @@ export default function ExportDialog({
setIsStartingExport(true); setIsStartingExport(true);
try { 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>( 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)}`,
{ {
source: "recordings", source: "recordings",
name, name,
export_case_id: selectedCaseId || undefined, export_case_id: exportCaseId,
}, },
); );
@ -156,6 +170,8 @@ export default function ExportDialog({
}); });
setName(""); setName("");
setSelectedCaseId(undefined); setSelectedCaseId(undefined);
setSingleNewCaseName("");
setSingleNewCaseDescription("");
setRange(undefined); setRange(undefined);
setMode("none"); setMode("none");
return true; return true;
@ -183,6 +199,8 @@ export default function ExportDialog({
name, name,
range, range,
selectedCaseId, selectedCaseId,
singleNewCaseDescription,
singleNewCaseName,
setMode, setMode,
setRange, setRange,
t, t,
@ -191,6 +209,8 @@ export default function ExportDialog({
const handleCancel = useCallback(() => { const handleCancel = useCallback(() => {
setName(""); setName("");
setSelectedCaseId(undefined); setSelectedCaseId(undefined);
setSingleNewCaseName("");
setSingleNewCaseDescription("");
setMode("none"); setMode("none");
setRange(undefined); setRange(undefined);
setActiveTab("export"); setActiveTab("export");
@ -272,12 +292,16 @@ export default function ExportDialog({
range={range} range={range}
name={name} name={name}
selectedCaseId={selectedCaseId} selectedCaseId={selectedCaseId}
singleNewCaseName={singleNewCaseName}
singleNewCaseDescription={singleNewCaseDescription}
activeTab={activeTab} activeTab={activeTab}
isStartingExport={isStartingExport} isStartingExport={isStartingExport}
onStartExport={onStartExport} onStartExport={onStartExport}
setActiveTab={setActiveTab} setActiveTab={setActiveTab}
setName={setName} setName={setName}
setSelectedCaseId={setSelectedCaseId} setSelectedCaseId={setSelectedCaseId}
setSingleNewCaseName={setSingleNewCaseName}
setSingleNewCaseDescription={setSingleNewCaseDescription}
setRange={setRange} setRange={setRange}
setMode={setMode} setMode={setMode}
onCancel={handleCancel} onCancel={handleCancel}
@ -294,12 +318,16 @@ type ExportContentProps = {
range?: TimeRange; range?: TimeRange;
name: string; name: string;
selectedCaseId?: string; selectedCaseId?: string;
singleNewCaseName: string;
singleNewCaseDescription: string;
activeTab: ExportTab; activeTab: ExportTab;
isStartingExport: boolean; isStartingExport: boolean;
onStartExport: () => Promise<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;
setSingleNewCaseName: (name: string) => void;
setSingleNewCaseDescription: (description: string) => void;
setRange: (range: TimeRange | undefined) => void; setRange: (range: TimeRange | undefined) => void;
setMode: (mode: ExportMode) => void; setMode: (mode: ExportMode) => void;
onCancel: () => void; onCancel: () => void;
@ -311,12 +339,16 @@ export function ExportContent({
range, range,
name, name,
selectedCaseId, selectedCaseId,
singleNewCaseName,
singleNewCaseDescription,
activeTab, activeTab,
isStartingExport, isStartingExport,
onStartExport, onStartExport,
setActiveTab, setActiveTab,
setName, setName,
setSelectedCaseId, setSelectedCaseId,
setSingleNewCaseName,
setSingleNewCaseDescription,
setRange, setRange,
setMode, setMode,
onCancel, onCancel,
@ -332,7 +364,7 @@ export function ExportContent({
); );
const [selectedCameraIds, setSelectedCameraIds] = useState<string[]>([]); const [selectedCameraIds, setSelectedCameraIds] = useState<string[]>([]);
const [batchCaseSelection, setBatchCaseSelection] = useState<string>( const [batchCaseSelection, setBatchCaseSelection] = useState<string>(
selectedCaseId || "new", selectedCaseId || "none",
); );
const [hasManualCameraSelection, setHasManualCameraSelection] = const [hasManualCameraSelection, setHasManualCameraSelection] =
useState(false); useState(false);
@ -483,7 +515,8 @@ export function ExportContent({
Boolean(range && range.before > range.after) && Boolean(range && range.before > range.after) &&
selectedCameraCount > 0 && selectedCameraCount > 0 &&
!isStartingBatchExport && !isStartingBatchExport &&
(batchCaseSelection !== "new" || newCaseName.trim().length > 0); (batchCaseSelection !== "new" || newCaseName.trim().length > 0) &&
batchCaseSelection.length > 0;
const onSelectTime = useCallback( const onSelectTime = useCallback(
(option: ExportOption) => { (option: ExportOption) => {
@ -567,7 +600,7 @@ export function ExportContent({
})), })),
}; };
if (isAdmin) { if (isAdmin && batchCaseSelection !== "none") {
if (batchCaseSelection === "new") { if (batchCaseSelection === "new") {
payload.new_case_name = newCaseName.trim(); payload.new_case_name = newCaseName.trim();
payload.new_case_description = newCaseDescription.trim() || undefined; payload.new_case_description = newCaseDescription.trim() || undefined;
@ -786,8 +819,30 @@ export function ExportContent({
{caseItem.name} {caseItem.name}
</SelectItem> </SelectItem>
))} ))}
<SelectSeparator />
<SelectItem value="new">
{t("export.case.newCaseOption")}
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
{selectedCaseId === "new" && (
<div className="space-y-2 pt-1">
<Input
className="text-md"
placeholder={t("export.case.newCaseNamePlaceholder")}
value={singleNewCaseName}
onChange={(e) => setSingleNewCaseName(e.target.value)}
/>
<Textarea
className="text-md"
placeholder={t("export.case.newCaseDescriptionPlaceholder")}
value={singleNewCaseDescription}
onChange={(e) =>
setSingleNewCaseDescription(e.target.value)
}
/>
</div>
)}
</div> </div>
)} )}
</TabsContent> </TabsContent>
@ -947,6 +1002,9 @@ 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) => (

View File

@ -115,6 +115,8 @@ export default function MobileReviewSettingsDrawer({
const [selectedCaseId, setSelectedCaseId] = useState<string | undefined>( const [selectedCaseId, setSelectedCaseId] = useState<string | undefined>(
undefined, undefined,
); );
const [singleNewCaseName, setSingleNewCaseName] = useState("");
const [singleNewCaseDescription, setSingleNewCaseDescription] = useState("");
const [isStartingExport, setIsStartingExport] = useState(false); const [isStartingExport, setIsStartingExport] = useState(false);
const onStartExport = useCallback(async () => { const onStartExport = useCallback(async () => {
if (isStartingExport) { if (isStartingExport) {
@ -148,12 +150,24 @@ export default function MobileReviewSettingsDrawer({
setIsStartingExport(true); setIsStartingExport(true);
try { 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>( 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)}`,
{ {
source: "recordings", source: "recordings",
name, name,
export_case_id: selectedCaseId || undefined, export_case_id: exportCaseId,
}, },
); );
@ -169,6 +183,8 @@ export default function MobileReviewSettingsDrawer({
}); });
setName(""); setName("");
setSelectedCaseId(undefined); setSelectedCaseId(undefined);
setSingleNewCaseName("");
setSingleNewCaseDescription("");
setRange(undefined); setRange(undefined);
setMode("none"); setMode("none");
return true; return true;
@ -199,6 +215,8 @@ export default function MobileReviewSettingsDrawer({
name, name,
range, range,
selectedCaseId, selectedCaseId,
singleNewCaseDescription,
singleNewCaseName,
setRange, setRange,
setMode, setMode,
t, t,
@ -361,12 +379,16 @@ export default function MobileReviewSettingsDrawer({
range={range} range={range}
name={name} name={name}
selectedCaseId={selectedCaseId} selectedCaseId={selectedCaseId}
singleNewCaseName={singleNewCaseName}
singleNewCaseDescription={singleNewCaseDescription}
activeTab={exportTab} activeTab={exportTab}
isStartingExport={isStartingExport} isStartingExport={isStartingExport}
onStartExport={onStartExport} onStartExport={onStartExport}
setActiveTab={setExportTab} setActiveTab={setExportTab}
setName={setName} setName={setName}
setSelectedCaseId={setSelectedCaseId} setSelectedCaseId={setSelectedCaseId}
setSingleNewCaseName={setSingleNewCaseName}
setSingleNewCaseDescription={setSingleNewCaseDescription}
setRange={setRange} setRange={setRange}
setMode={(mode) => { setMode={(mode) => {
setMode(mode); setMode(mode);
@ -379,6 +401,8 @@ export default function MobileReviewSettingsDrawer({
setMode("none"); setMode("none");
setRange(undefined); setRange(undefined);
setSelectedCaseId(undefined); setSelectedCaseId(undefined);
setSingleNewCaseName("");
setSingleNewCaseDescription("");
setExportTab("export"); setExportTab("export");
setDrawerMode("select"); setDrawerMode("select");
}} }}

View File

@ -55,6 +55,7 @@ type MultiExportDialogProps = {
children: React.ReactNode; children: React.ReactNode;
}; };
const NONE_CASE_OPTION = "none";
const NEW_CASE_OPTION = "new"; const NEW_CASE_OPTION = "new";
export default function MultiExportDialog({ export default function MultiExportDialog({
@ -74,10 +75,7 @@ export default function MultiExportDialog({
const { data: cases } = useSWR<ExportCase[]>(isAdmin ? "cases" : null); const { data: cases } = useSWR<ExportCase[]>(isAdmin ? "cases" : null);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
// Single unified state: either NEW_CASE_OPTION or an existing case id. const [caseSelection, setCaseSelection] = useState<string>(NONE_CASE_OPTION);
// Defaults to NEW_CASE_OPTION, which is also the only valid value for
// non-admins since they can't attach to existing cases.
const [caseSelection, setCaseSelection] = useState<string>(NEW_CASE_OPTION);
const [newCaseName, setNewCaseName] = useState(""); const [newCaseName, setNewCaseName] = useState("");
const [newCaseDescription, setNewCaseDescription] = useState(""); const [newCaseDescription, setNewCaseDescription] = useState("");
const [isExporting, setIsExporting] = useState(false); const [isExporting, setIsExporting] = useState(false);
@ -134,7 +132,7 @@ export default function MultiExportDialog({
}, [t, locale]); }, [t, locale]);
const resetState = useCallback(() => { const resetState = useCallback(() => {
setCaseSelection(NEW_CASE_OPTION); setCaseSelection(NONE_CASE_OPTION);
setNewCaseName(""); setNewCaseName("");
setNewCaseDescription(""); setNewCaseDescription("");
setIsExporting(false); setIsExporting(false);
@ -146,7 +144,7 @@ export default function MultiExportDialog({
resetState(); resetState();
} else { } else {
// Freshly reset each time so the default name reflects "now" // Freshly reset each time so the default name reflects "now"
setCaseSelection(NEW_CASE_OPTION); setCaseSelection(NONE_CASE_OPTION);
setNewCaseName(defaultCaseName); setNewCaseName(defaultCaseName);
setNewCaseDescription(""); setNewCaseDescription("");
setIsExporting(false); setIsExporting(false);
@ -185,7 +183,7 @@ export default function MultiExportDialog({
const payload: BatchExportBody = { items }; const payload: BatchExportBody = { items };
if (isAdmin) { if (isAdmin && caseSelection !== NONE_CASE_OPTION) {
if (isNewCase) { if (isNewCase) {
payload.new_case_name = newCaseName.trim(); payload.new_case_name = newCaseName.trim();
payload.new_case_description = newCaseDescription.trim() || undefined; payload.new_case_description = newCaseDescription.trim() || undefined;
@ -323,12 +321,15 @@ export default function MultiExportDialog({
<SelectValue placeholder={t("export.case.placeholder")} /> <SelectValue placeholder={t("export.case.placeholder")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value={NONE_CASE_OPTION}>
{t("label.none", { ns: "common" })}
</SelectItem>
{existingCases.map((caseItem) => ( {existingCases.map((caseItem) => (
<SelectItem key={caseItem.id} value={caseItem.id}> <SelectItem key={caseItem.id} value={caseItem.id}>
{caseItem.name} {caseItem.name}
</SelectItem> </SelectItem>
))} ))}
{existingCases.length > 0 && <SelectSeparator />} <SelectSeparator />
<SelectItem value={NEW_CASE_OPTION}> <SelectItem value={NEW_CASE_OPTION}>
{t("export.case.newCaseOption")} {t("export.case.newCaseOption")}
</SelectItem> </SelectItem>