From bc65713ae4df0ba1ebe438cc5db863eab1c69f97 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 28 May 2026 18:44:06 -0500 Subject: [PATCH] Clone camera settings (#23339) * add clone dialog * i18n * tweaks * add to camera management pane * add e2e test * optional disable portal prop * radio and checkbox tweaks * tweak i18n * add select all/select none * fixes * reset form only on open transition * unselect all targets for existing camera * fix test * reorder sections for save and collapse to single put for new camera * change source and allow cloning to multiple cameras * tweak language * fix overflowing text in save all popover * tweaks * fix per label object masks * use grid for source and target * language tweak --- web/e2e/specs/clone-camera.spec.ts | 181 +++ web/public/locales/en/views/settings.json | 86 ++ .../overlay/detail/SaveAllPreviewPopover.tsx | 13 +- .../components/settings/CloneCameraDialog.tsx | 1046 +++++++++++++++++ web/src/utils/cameraClone.ts | 856 ++++++++++++++ web/src/utils/configUtil.ts | 1 + .../views/settings/CameraManagementView.tsx | 53 +- 7 files changed, 2223 insertions(+), 13 deletions(-) create mode 100644 web/e2e/specs/clone-camera.spec.ts create mode 100644 web/src/components/settings/CloneCameraDialog.tsx create mode 100644 web/src/utils/cameraClone.ts diff --git a/web/e2e/specs/clone-camera.spec.ts b/web/e2e/specs/clone-camera.spec.ts new file mode 100644 index 0000000000..1c75e71a3d --- /dev/null +++ b/web/e2e/specs/clone-camera.spec.ts @@ -0,0 +1,181 @@ +/** + * Camera clone dialog E2E tests. + * + * Covers the design invariants that don't depend on per-camera resolution + * differences in the mock fixture: + * 1. Dialog opens from the "Clone settings" button below Add/Delete. + * 2. A source camera must be chosen inside the dialog before cloning. + * 3. "Stream URLs and roles" is forced on and disabled for new-camera target. + * 4. Cloning to a new camera issues a single add PUT and shows a restart prompt. + * 5. The existing-camera target selects multiple destinations via a switch + * popover (with an "All cameras" toggle and source exclusion); the closed + * trigger summarizes the selection by name or as "All cameras". + * + * The spatial-mismatch warning path is exercised in unit-level review and via + * manual QA — the shared mock fixture ships every camera at 1280×720. The + * existing-camera PUT fan-out is likewise not asserted here: the mock cameras + * are identical apart from stream URLs (which existing-camera clones never + * copy) and the schema mock is empty, so a clone onto them produces no diff + * and no PUT. That path is covered by unit-level review and manual QA. + */ + +import { test, expect } from "../fixtures/frigate-test"; + +async function openCloneDialog(frigateApp: { + page: import("@playwright/test").Page; +}) { + await frigateApp.page + .getByRole("button", { name: /^Clone settings$/i }) + .click(); + await expect(frigateApp.page.getByRole("dialog")).toBeVisible(); +} + +async function selectSource( + frigateApp: { page: import("@playwright/test").Page }, + source: string, +) { + await frigateApp.page.getByRole("dialog").getByRole("combobox").click(); + await frigateApp.page + .getByRole("option", { name: source, exact: true }) + .click(); +} + +test.describe("Camera clone dialog @medium @mobile", () => { + test.beforeEach(async ({ frigateApp }) => { + await frigateApp.goto("/settings?page=cameraManagement"); + await expect( + frigateApp.page.getByRole("heading", { name: /Manage Cameras/i }), + ).toBeVisible(); + }); + + test("opens the dialog from the Clone settings button", async ({ + frigateApp, + }) => { + await openCloneDialog(frigateApp); + + await expect( + frigateApp.page.getByRole("dialog").getByText(/Clone camera settings/i), + ).toBeVisible(); + + // The Clone button is disabled until a source (and target) is chosen. + await expect( + frigateApp.page.getByRole("button", { name: /^Clone$/i }), + ).toBeDisabled(); + }); + + test("forces Stream URLs and roles on for new-camera target", async ({ + frigateApp, + }) => { + await openCloneDialog(frigateApp); + await selectSource(frigateApp, "Front Door"); + + // The "New camera" radio is selected by default; the Streams group renders + // the ffmpeg_live checkbox as forced-checked and disabled. + const streamsLabel = frigateApp.page + .locator("label") + .filter({ hasText: /Stream URLs and roles/i }); + await expect(streamsLabel).toBeVisible(); + + const streamsCheckbox = streamsLabel.getByRole("checkbox"); + await expect(streamsCheckbox).toBeChecked(); + await expect(streamsCheckbox).toBeDisabled(); + }); + + test("issues a single add PUT and shows restart toast for new-camera target", async ({ + frigateApp, + }) => { + const requests: { body: unknown }[] = []; + + await frigateApp.page.route("**/api/config/set", async (route) => { + const body = route.request().postDataJSON(); + requests.push({ body }); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ success: true, require_restart: false }), + }); + }); + + await frigateApp.goto("/settings?page=cameraManagement"); + await expect( + frigateApp.page.getByRole("heading", { name: /Manage Cameras/i }), + ).toBeVisible(); + + await openCloneDialog(frigateApp); + await selectSource(frigateApp, "Front Door"); + + const nameInput = frigateApp.page.getByPlaceholder( + /e\.g\., back_door or Back Door/i, + ); + await nameInput.fill("clone_target_one"); + + // With a source picked and a valid name, changeCount > 0 enables Clone. + await expect( + frigateApp.page.getByRole("button", { name: /^Clone$/i }), + ).toBeEnabled({ timeout: 5_000 }); + + await frigateApp.page.getByRole("button", { name: /^Clone$/i }).click(); + + // New-camera clones bundle into a single atomic add PUT (avoids + // per-section validation ordering issues). + await expect.poll(() => requests.length, { timeout: 10_000 }).toBe(1); + + const firstBody = requests[0].body as { + requires_restart?: number; + update_topic?: string; + }; + expect(firstBody.update_topic).toMatch( + /config\/cameras\/clone_target_one\/add/, + ); + expect(firstBody.requires_restart).toBe(1); + + // The toast offers a Restart action because new-camera always needs restart. + // .first() avoids strict-mode rejection when both the toast action and the + // RestartDialog trigger render concurrently. + await expect( + frigateApp.page.getByRole("button", { name: /Restart/i }).first(), + ).toBeVisible({ timeout: 8_000 }); + }); + + test("selects multiple existing destination cameras via a switch popover", async ({ + frigateApp, + }) => { + await openCloneDialog(frigateApp); + await selectSource(frigateApp, "Front Door"); + + await frigateApp.page + .getByRole("radio", { name: /Existing cameras/i }) + .click(); + + const dialog = frigateApp.page.getByRole("dialog"); + + // The destination trigger starts with the empty-selection placeholder. + await dialog + .getByRole("button", { name: /Select at least one camera/i }) + .click(); + + // The chosen source is excluded from the destination switch list. + await expect( + dialog.getByRole("switch", { name: /Backyard/i }), + ).toBeVisible(); + await expect(dialog.getByRole("switch", { name: /Garage/i })).toBeVisible(); + await expect( + dialog.getByRole("switch", { name: /^Front Door$/i }), + ).toHaveCount(0); + + // Selecting a single camera summarizes by name once the popover closes. + await dialog.getByRole("switch", { name: /Backyard/i }).click(); + await frigateApp.page.keyboard.press("Escape"); + await expect( + dialog.getByRole("button", { name: /^Backyard$/i }), + ).toBeVisible(); + + // Reopen and select everything; the trigger collapses to "All cameras". + await dialog.getByRole("button", { name: /^Backyard$/i }).click(); + await dialog.getByRole("switch", { name: /^All cameras$/i }).click(); + await frigateApp.page.keyboard.press("Escape"); + await expect( + dialog.getByRole("button", { name: /^All cameras$/i }), + ).toBeVisible(); + }); +}); diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 966f83d725..2374c506e7 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -544,6 +544,92 @@ "normal": "Normal", "dedicatedLpr": "Dedicated LPR", "saveSuccess": "Updated camera type for {{cameraName}}. Restart Frigate to apply the changes." + }, + "clone": { + "sectionTitle": "Clone settings", + "sectionDescription": "Copy configuration from one camera to another camera or a new one.", + "button": "Clone settings", + "title": "Clone camera settings", + "description": "Copy a camera's configuration to one or more other cameras or a new camera. Identity (name, friendly name, web UI URL, display order) is never copied.", + "source": { + "label": "Source camera", + "placeholder": "Select a source camera", + "required": "Select a source camera" + }, + "target": { + "legend": "Target", + "newRadio": "New camera", + "newNameLabel": "Camera name", + "newNamePlaceholder": "e.g., back_door or Back Door", + "newNameRequired": "Camera name is required", + "newNameInvalid": "Invalid camera name", + "newNameCollision": "A camera with this name already exists", + "newStreamsForced": "Streams are always copied for a new camera.", + "existingCamerasRadio": "Existing cameras", + "allCameras": "All cameras", + "existingPlaceholder": "Select at least one camera", + "existingDisabled": "No other cameras to copy to" + }, + "categories": { + "legend": "Settings to clone", + "description": "Choose which settings to copy from the source camera.", + "selectAll": "Select all", + "selectNone": "Select none", + "resetDefaults": "Reset to defaults", + "general": "General", + "spatial": "Spatial settings", + "streams": "Streams", + "spatialWarningTitle": "Resolution mismatch", + "spatialWarning": "Source camera {{srcCamera}} detect resolution ({{srcWidth}}×{{srcHeight}}) differs from: {{cameras}}. Polygons may not align on those cameras. These defaults are off; enable to copy as-is.", + "restartHint": "Restart required", + "items": { + "record": "Recording", + "snapshots": "Snapshots", + "review": "Review", + "motion": "Motion detection", + "objects": "Objects", + "audio": "Audio detection", + "audio_transcription": "Audio transcription", + "notifications": "Notifications", + "birdseye": "Birdseye", + "mqtt": "MQTT", + "timestamp_style": "Timestamp style", + "onvif": "ONVIF", + "lpr": "License plate recognition", + "face_recognition": "Face recognition", + "semantic_search": "Semantic search", + "genai": "Generative AI", + "type": "Camera type (normal / dedicated LPR)", + "profiles": "Profiles", + "detect": "Detect dimensions", + "zones": "Zones", + "motion_mask": "Motion masks", + "object_masks": "Object masks", + "ffmpeg_live": "Stream URLs and roles" + } + }, + "footer": { + "changeCount_zero": "No changes selected", + "changeCount_one": "{{count}} change will be applied", + "changeCount_other": "{{count}} changes will be applied", + "restartNeeded": "Restart will be required for some changes.", + "liveOnly": "All changes will apply live without a restart.", + "submit": "Clone", + "submitting": "Cloning…" + }, + "toast": { + "success": "Settings copied to {{cameraName}}", + "successWithRestart": "Settings copied to {{cameraName}}. Restart Frigate to apply all changes.", + "successMulti_one": "Settings copied to {{count}} camera", + "successMulti_other": "Settings copied to {{count}} cameras", + "successMultiWithRestart_one": "Settings copied to {{count}} camera. Restart Frigate to apply all changes.", + "successMultiWithRestart_other": "Settings copied to {{count}} cameras. Restart Frigate to apply all changes.", + "partialFailure": "{{successCount}} sections applied; '{{failedSection}}' failed: {{errorMessage}}", + "partialFailureMulti": "Copied to {{successCount}} camera(s); failed for {{failed}}: {{errorMessage}}", + "newCameraPartialFailure": "Camera {{cameraName}} was created but some settings failed to copy: {{errorMessage}}", + "sourceMissing": "Source camera no longer exists", + "submitError": "Failed to clone camera: {{errorMessage}}" + } } }, "cameraReview": { diff --git a/web/src/components/overlay/detail/SaveAllPreviewPopover.tsx b/web/src/components/overlay/detail/SaveAllPreviewPopover.tsx index a775935315..e65b347de8 100644 --- a/web/src/components/overlay/detail/SaveAllPreviewPopover.tsx +++ b/web/src/components/overlay/detail/SaveAllPreviewPopover.tsx @@ -22,6 +22,7 @@ type SaveAllPreviewPopoverProps = { className?: string; align?: "start" | "center" | "end"; side?: "top" | "bottom" | "left" | "right"; + disablePortal?: boolean; }; export default function SaveAllPreviewPopover({ @@ -29,6 +30,7 @@ export default function SaveAllPreviewPopover({ className, align = "end", side = "bottom", + disablePortal = false, }: SaveAllPreviewPopoverProps) { const { t } = useTranslation(["views/settings", "common"]); const [open, setOpen] = useState(false); @@ -67,6 +69,7 @@ export default function SaveAllPreviewPopover({ event.preventDefault()} > @@ -108,13 +111,13 @@ export default function SaveAllPreviewPopover({ }`} className="rounded-md border border-secondary bg-background_alt p-2" > -
+
{t("saveAllPreview.scope.label", { ns: "views/settings", })} - {scopeLabel} + {scopeLabel} {item.profileName && ( <> @@ -122,7 +125,7 @@ export default function SaveAllPreviewPopover({ ns: "views/settings", })} - + {item.profileName} @@ -132,7 +135,7 @@ export default function SaveAllPreviewPopover({ ns: "views/settings", })} - + {item.fieldPath} @@ -140,7 +143,7 @@ export default function SaveAllPreviewPopover({ ns: "views/settings", })} - + {formatValue(item.value)}
diff --git a/web/src/components/settings/CloneCameraDialog.tsx b/web/src/components/settings/CloneCameraDialog.tsx new file mode 100644 index 0000000000..61458211b8 --- /dev/null +++ b/web/src/components/settings/CloneCameraDialog.tsx @@ -0,0 +1,1046 @@ +import { + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useForm, useWatch } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { useTranslation } from "react-i18next"; +import useSWR, { mutate as swrMutate } from "swr"; +import axios from "axios"; +import { toast } from "sonner"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { cn } from "@/lib/utils"; +import { isReplayCamera, processCameraName } from "@/utils/cameraUtil"; +import type { FrigateConfig } from "@/types/frigateConfig"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Label } from "@/components/ui/label"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { LuArrowRight, LuChevronDown, LuTriangleAlert } from "react-icons/lu"; +import { + CLONE_CATEGORIES, + type CloneCategoryKey, + type CloneCategoryGroup, + type RawCameraPaths, + getCategoryDefaults, + resolutionsMatch, + buildClonedCameraPayloads, + buildClonePreviewItems, +} from "@/utils/cameraClone"; +import { buildConfigDataForPath } from "@/utils/configUtil"; +import { useConfigSchema } from "@/hooks/use-config-schema"; +import { useRestart } from "@/api/ws"; +import { StatusBarMessagesContext } from "@/context/statusbar-provider"; +import RestartDialog from "@/components/overlay/dialog/RestartDialog"; +import ActivityIndicator from "@/components/indicators/activity-indicator"; +import SaveAllPreviewPopover from "@/components/overlay/detail/SaveAllPreviewPopover"; +import FilterSwitch from "@/components/filter/FilterSwitch"; + +type CloneCameraDialogProps = { + open: boolean; + onClose: () => void; +}; + +type CloneFormValues = { + sourceCamera: string; + targetMode: "new" | "existing"; + newName: string; + existingTargets: string[]; +}; + +export default function CloneCameraDialog({ + open, + onClose, +}: CloneCameraDialogProps) { + const { t } = useTranslation(["views/settings", "common"]); + const { data: config } = useSWR("config"); + const { data: rawPaths } = useSWR("config/raw_paths"); + const [isSubmitting, setIsSubmitting] = useState(false); + + const sourceCameras = useMemo(() => { + if (!config) return []; + return Object.keys(config.cameras) + .filter((c) => !isReplayCamera(c)) + .sort(); + }, [config]); + + const formSchema = useMemo(() => { + const reservedNames = new Set([ + ...(config ? Object.keys(config.cameras) : []), + ...(config?.go2rtc?.streams ? Object.keys(config.go2rtc.streams) : []), + ]); + return z + .object({ + sourceCamera: z.string(), + targetMode: z.enum(["new", "existing"]), + newName: z.string(), + existingTargets: z.array(z.string()), + }) + .superRefine((data, ctx) => { + if (!data.sourceCamera) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["sourceCamera"], + message: t("cameraManagement.clone.source.required"), + }); + } + if (data.targetMode === "new") { + const trimmed = data.newName.trim(); + if (!trimmed) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["newName"], + message: t("cameraManagement.clone.target.newNameRequired"), + }); + return; + } + const { finalCameraName } = processCameraName(trimmed); + if (!finalCameraName) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["newName"], + message: t("cameraManagement.clone.target.newNameInvalid"), + }); + return; + } + if (reservedNames.has(finalCameraName)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["newName"], + message: t("cameraManagement.clone.target.newNameCollision"), + }); + } + } else if (data.existingTargets.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["existingTargets"], + message: t("cameraManagement.clone.target.existingPlaceholder"), + }); + } + }); + }, [config, t]); + + const form = useForm({ + resolver: zodResolver(formSchema), + mode: "onChange", + defaultValues: { + sourceCamera: "", + targetMode: "new", + newName: "", + existingTargets: [], + }, + }); + + const sourceCamera = form.watch("sourceCamera"); + const targetMode = form.watch("targetMode"); + const existingTargets = form.watch("existingTargets"); + + const targetIsNew = targetMode === "new"; + + const otherCameras = useMemo(() => { + if (!config) return []; + return Object.keys(config.cameras) + .filter((c) => c !== sourceCamera && !isReplayCamera(c)) + .sort(); + }, [config, sourceCamera]); + + const srcCfg = config?.cameras?.[sourceCamera]; + + // Existing targets whose detect resolution differs from the source. Spatial + // settings use detect-resolution coordinates, so cloning them to a camera + // with a different resolution is flagged (but still allowed). + const mismatchedTargets = useMemo(() => { + if (targetIsNew || !srcCfg?.detect) return []; + return existingTargets.filter((cam) => { + const dst = config?.cameras?.[cam]; + return dst?.detect && !resolutionsMatch(srcCfg.detect, dst.detect); + }); + }, [targetIsNew, srcCfg, existingTargets, config]); + + const allResMatch = mismatchedTargets.length === 0; + + const [selectedCategories, setSelectedCategories] = useState< + Set + >(() => getCategoryDefaults(true)); + + // Reset form + selection only on the open transition + const wasOpenRef = useRef(false); + useEffect(() => { + if (open && !wasOpenRef.current) { + wasOpenRef.current = true; + form.reset({ + sourceCamera: "", + targetMode: "new", + newName: "", + existingTargets: [], + }); + setSelectedCategories(getCategoryDefaults(true)); + } else if (!open) { + wasOpenRef.current = false; + } + }, [open, form]); + + // Drop the source camera from the target selection if it gets picked. + useEffect(() => { + if (!sourceCamera) return; + const current = form.getValues("existingTargets"); + if (current.includes(sourceCamera)) { + form.setValue( + "existingTargets", + current.filter((c) => c !== sourceCamera), + ); + } + }, [sourceCamera, form]); + + // Reset selection to per-mode defaults when the user switches target mode. + useEffect(() => { + setSelectedCategories(getCategoryDefaults(targetIsNew)); + }, [targetIsNew]); + + const toggleCategory = useCallback((key: CloneCategoryKey) => { + setSelectedCategories((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }, []); + + const selectAllCategories = useCallback(() => { + setSelectedCategories((prev) => { + const next = new Set(prev); + const includeSpatial = targetIsNew || allResMatch; + for (const cat of CLONE_CATEGORIES) { + if (cat.newCameraOnly && !targetIsNew) continue; + if (cat.group === "spatial" && !includeSpatial) continue; + if (cat.group === "streams") continue; + next.add(cat.key); + } + return next; + }); + }, [targetIsNew, allResMatch]); + + const selectNoneCategories = useCallback(() => { + setSelectedCategories((prev) => { + const next = new Set(); + for (const cat of CLONE_CATEGORIES) { + if (cat.group === "streams" && prev.has(cat.key)) { + next.add(cat.key); + } + } + return next; + }); + }, []); + + const visibleCategories = useMemo( + () => CLONE_CATEGORIES.filter((c) => targetIsNew || !c.newCameraOnly), + [targetIsNew], + ); + + const groupedCategories = useMemo(() => { + const groups: Record = { + general: [], + spatial: [], + streams: [], + }; + for (const c of visibleCategories) { + groups[c.group].push(c); + } + return groups; + }, [visibleCategories]); + + const sourceFriendlyName = + config?.cameras?.[sourceCamera]?.friendly_name ?? sourceCamera; + + const fullSchema = useConfigSchema(); + const { send: sendRestart } = useRestart(); + const statusBar = useContext(StatusBarMessagesContext); + const [restartDialogOpen, setRestartDialogOpen] = useState(false); + + const watchedNewName = + useWatch({ control: form.control, name: "newName" }) ?? ""; + + // Payloads grouped per destination camera. New mode has a single target; + // existing mode fans out across every selected camera. + const targetPayloads = useMemo< + { target: string; payloads: ReturnType }[] + >(() => { + if (!config || !fullSchema || !srcCfg) { + return []; + } + if (targetIsNew) { + const finalName = processCameraName(watchedNewName || "").finalCameraName; + if (!watchedNewName || !finalName) return []; + return [ + { + target: finalName, + payloads: buildClonedCameraPayloads({ + sourceCfg: srcCfg, + sourceName: sourceCamera, + targetInput: watchedNewName, + targetIsNew: true, + selectedKeys: selectedCategories, + fullConfig: config, + fullSchema, + rawPaths, + }), + }, + ]; + } + return existingTargets + .filter((cam) => config.cameras?.[cam]) + .map((cam) => ({ + target: cam, + payloads: buildClonedCameraPayloads({ + sourceCfg: srcCfg, + sourceName: sourceCamera, + targetInput: cam, + targetIsNew: false, + selectedKeys: selectedCategories, + fullConfig: config, + fullSchema, + rawPaths, + }), + })); + }, [ + config, + fullSchema, + srcCfg, + sourceCamera, + targetIsNew, + existingTargets, + watchedNewName, + selectedCategories, + rawPaths, + ]); + + const previewPayloads = useMemo( + () => targetPayloads.flatMap((tp) => tp.payloads), + [targetPayloads], + ); + + const previewItems = useMemo( + () => + targetPayloads.flatMap((tp) => + buildClonePreviewItems(tp.payloads, tp.target), + ), + [targetPayloads], + ); + + const anyNeedsRestart = previewPayloads.some((p) => p.needsRestart); + const changeCount = previewItems.length; + + const onSubmit = useCallback( + async (values: CloneFormValues) => { + if (!config || !srcCfg || !fullSchema) return; + if (previewPayloads.length === 0) { + toast.error( + t("cameraManagement.clone.toast.submitError", { + errorMessage: t("cameraManagement.clone.footer.changeCount", { + count: 0, + }), + }), + ); + return; + } + + const friendlyName = (cam: string) => + config.cameras?.[cam]?.friendly_name ?? cam; + + const extractError = (error: unknown) => + (axios.isAxiosError(error) && + (error.response?.data?.message || error.response?.data?.detail)) || + (error instanceof Error ? error.message : "Unknown error"); + + const restartAction = ( + setRestartDialogOpen(true)}> + + + ); + + const markRestartRequired = () => + statusBar?.addMessage( + "config_restart_required", + t("configForm.restartRequiredFooter"), + undefined, + "config_restart_required", + ); + + setIsSubmitting(true); + + if (targetIsNew) { + const targetLabel = values.newName.trim(); + const payloads = targetPayloads[0]?.payloads ?? []; + let appliedCount = 0; + let failedSection: string | undefined; + let failureMessage: string | undefined; + + try { + for (const payload of payloads) { + try { + await axios.put("config/set", { + requires_restart: payload.needsRestart ? 1 : 0, + update_topic: payload.updateTopic, + config_data: buildConfigDataForPath( + payload.basePath, + payload.sanitizedOverrides, + ), + }); + appliedCount += 1; + } catch (error) { + failedSection = payload.basePath; + failureMessage = extractError(error); + break; + } + } + } finally { + await swrMutate("config"); + setIsSubmitting(false); + } + + if (failedSection) { + toast.error( + appliedCount > 0 + ? t("cameraManagement.clone.toast.newCameraPartialFailure", { + cameraName: targetLabel, + errorMessage: failureMessage, + }) + : t("cameraManagement.clone.toast.partialFailure", { + successCount: appliedCount, + failedSection, + errorMessage: failureMessage, + }), + { position: "top-center" }, + ); + return; + } + + if (anyNeedsRestart) { + markRestartRequired(); + toast.success( + t("cameraManagement.clone.toast.successWithRestart", { + cameraName: targetLabel, + }), + { position: "top-center", duration: 10000, action: restartAction }, + ); + } else { + toast.success( + t("cameraManagement.clone.toast.success", { + cameraName: targetLabel, + }), + { position: "top-center" }, + ); + } + + onClose(); + return; + } + + // One or more existing cameras: keep going if a camera fails, summarize. + const succeeded: string[] = []; + const failed: string[] = []; + let lastError: string | undefined; + + try { + for (const { target, payloads } of targetPayloads) { + let cameraError: string | undefined; + for (const payload of payloads) { + try { + await axios.put("config/set", { + requires_restart: payload.needsRestart ? 1 : 0, + update_topic: payload.updateTopic, + config_data: buildConfigDataForPath( + payload.basePath, + payload.sanitizedOverrides, + ), + }); + } catch (error) { + cameraError = extractError(error); + break; + } + } + if (cameraError) { + failed.push(friendlyName(target)); + lastError = cameraError; + } else { + succeeded.push(friendlyName(target)); + } + } + } finally { + await swrMutate("config"); + setIsSubmitting(false); + } + + if (failed.length > 0) { + toast.error( + t("cameraManagement.clone.toast.partialFailureMulti", { + successCount: succeeded.length, + failed: failed.join(", "), + errorMessage: lastError, + }), + { position: "top-center", duration: 10000 }, + ); + return; + } + + const singleLabel = succeeded.length === 1 ? succeeded[0] : undefined; + + if (anyNeedsRestart) { + markRestartRequired(); + toast.success( + singleLabel + ? t("cameraManagement.clone.toast.successWithRestart", { + cameraName: singleLabel, + }) + : t("cameraManagement.clone.toast.successMultiWithRestart", { + count: succeeded.length, + }), + { position: "top-center", duration: 10000, action: restartAction }, + ); + } else { + toast.success( + singleLabel + ? t("cameraManagement.clone.toast.success", { + cameraName: singleLabel, + }) + : t("cameraManagement.clone.toast.successMulti", { + count: succeeded.length, + }), + { position: "top-center" }, + ); + } + + onClose(); + }, + [ + config, + srcCfg, + fullSchema, + previewPayloads, + targetPayloads, + targetIsNew, + anyNeedsRestart, + onClose, + statusBar, + t, + ], + ); + + return ( + !o && onClose()}> + e.preventDefault()} + > + + {t("cameraManagement.clone.title")} + + {t("cameraManagement.clone.description")} + + + +
+ +
+
+ + ( + + + + + + + )} + /> +
+ +
+ + ( + + + +
+
+ + +
+ {targetMode === "new" && ( + + + {t( + "cameraManagement.clone.target.newNameLabel", + )} + + + + + {form.formState.errors.newName?.message && ( +

+ {String( + form.formState.errors.newName.message, + )} +

+ )} +

+ {t( + "cameraManagement.clone.target.newStreamsForced", + )} +

+
+ )} +
+
+
+ + +
+ {targetMode === "existing" && + otherCameras.length > 0 && ( + { + const selected = tgtField.value ?? []; + const allSelected = + otherCameras.length > 0 && + otherCameras.every((c) => + selected.includes(c), + ); + const selectedNames = otherCameras + .filter((c) => selected.includes(c)) + .map( + (c) => + config?.cameras?.[c]?.friendly_name ?? + c, + ); + const summary = allSelected + ? t( + "cameraManagement.clone.target.allCameras", + ) + : selectedNames.length > 0 + ? selectedNames.join(", ") + : t( + "cameraManagement.clone.target.existingPlaceholder", + ); + return ( + + + + + + + + +
+ + tgtField.onChange( + checked + ? [...otherCameras] + : [], + ) + } + /> +
+ {otherCameras.map((cam) => ( + + tgtField.onChange( + checked + ? [...selected, cam] + : selected.filter( + (c) => c !== cam, + ), + ) + } + /> + ))} +
+
+
+
+ +
+ ); + }} + /> + )} +
+
+
+
+ )} + /> +
+ +
+ +
+ +
+
+
+ +
+
+
+ +

+ {t("cameraManagement.clone.categories.description")} +

+
+
+ + {t("cameraManagement.clone.categories.selectAll")} + + + + {t("cameraManagement.clone.categories.selectNone")} + +
+
+ +
+ +
+ {groupedCategories.general.map((cat) => ( + + ))} +
+
+ + {groupedCategories.spatial.length > 0 && ( +
+ + {!targetIsNew && + srcCfg?.detect && + mismatchedTargets.length > 0 && ( + + + + {t( + "cameraManagement.clone.categories.spatialWarningTitle", + )} + + + {t( + "cameraManagement.clone.categories.spatialWarning", + { + srcCamera: sourceFriendlyName, + srcWidth: srcCfg.detect.width, + srcHeight: srcCfg.detect.height, + cameras: mismatchedTargets + .map( + (c) => + config?.cameras?.[c]?.friendly_name ?? c, + ) + .join(", "), + }, + )} + + + )} +
+ {groupedCategories.spatial.map((cat) => ( + + ))} +
+
+ )} + + {targetIsNew && groupedCategories.streams.length > 0 && ( +
+ +
+ {groupedCategories.streams.map((cat) => ( + + ))} +
+
+ )} +
+ + +
+ {changeCount > 0 && ( + <> +
+ + {t("cameraManagement.clone.footer.changeCount", { + count: changeCount, + })} + + {changeCount > 0 && ( + + )} +
+ + {anyNeedsRestart + ? t("cameraManagement.clone.footer.restartNeeded") + : t("cameraManagement.clone.footer.liveOnly")} + + + )} +
+
+ + +
+
+
+ + setRestartDialogOpen(false)} + onRestart={() => sendRestart("restart")} + /> +
+
+ ); +} diff --git a/web/src/utils/cameraClone.ts b/web/src/utils/cameraClone.ts new file mode 100644 index 0000000000..c985c917f0 --- /dev/null +++ b/web/src/utils/cameraClone.ts @@ -0,0 +1,856 @@ +import cloneDeep from "lodash/cloneDeep"; +import isEqual from "lodash/isEqual"; +import merge from "lodash/merge"; +import type { RJSFSchema } from "@rjsf/utils"; + +import { + buildOverrides, + cameraUpdateTopicMap, + flattenOverrides, + getEffectiveAttributeLabels, + getSectionConfig, + prepareSectionSavePayload, + resolveHiddenFieldEntries, + sanitizeSectionData, + type SectionSavePayload, +} from "@/utils/configUtil"; +import { applySchemaDefaults } from "@/lib/config-schema"; +import type { SaveAllPreviewItem } from "@/components/overlay/detail/SaveAllPreviewPopover"; +import type { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; +import type { + ConfigSectionData, + JsonObject, + JsonValue, +} from "@/types/configForm"; +import { processCameraName } from "@/utils/cameraUtil"; + +/** + * Sections whose `filters` dict is auto-populated by the backend at parse + * time. `attributeBump` reflects the global-level `min_score=0.7` override + * the backend applies to attribute labels (face, license_plate, Frigate+ + * couriers) — see `frigate/config/config.py`. + */ +const FILTER_SECTION_DEFS: Record< + string, + { + listField: string; + filterDef: string; + attributeBump?: { min_score: number }; + } +> = { + objects: { + listField: "track", + filterDef: "FilterConfig", + attributeBump: { min_score: 0.7 }, + }, + audio: { listField: "listen", filterDef: "AudioFilterConfig" }, +}; + +function resolveDef(schema: RJSFSchema, name: string): RJSFSchema | undefined { + const defs = + (schema as { $defs?: Record }).$defs ?? + (schema as { definitions?: Record }).definitions; + return defs ? defs[name] : undefined; +} + +/** + * Reduce each filter entry to the fields that differ from the backend's + * auto-default. An entry that is entirely auto-populated drops out; a + * partially-customized entry keeps only its customized fields, so cloning + * doesn't copy the auto-populated default for every other field. + */ +function stripAutoDefaultFilters( + section: string, + sourceSection: JsonObject, + fullSchema: RJSFSchema, + fullConfig: FrigateConfig, + fullCameraConfig: CameraConfig, +): JsonObject { + const def = FILTER_SECTION_DEFS[section]; + if (!def) return sourceSection; + const filters = sourceSection.filters; + if (!filters || typeof filters !== "object" || Array.isArray(filters)) { + return sourceSection; + } + const filterDef = resolveDef(fullSchema, def.filterDef); + if (!filterDef) return sourceSection; + const baseDefaults = applySchemaDefaults(filterDef, {}) as JsonObject; + const attributeDefaults = def.attributeBump + ? ({ ...baseDefaults, ...def.attributeBump } as JsonObject) + : baseDefaults; + const attributeSet = + section === "objects" + ? new Set( + getEffectiveAttributeLabels(fullConfig, fullCameraConfig, "camera"), + ) + : new Set(); + + // Ignore runtime-only `mask`/`raw_mask`: the API ships them as `{}` while the + // schema default omits them, which would otherwise break the equality check. + const withoutRuntimeFields = (entry: JsonValue): JsonValue => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + return entry; + } + const copy = { ...(entry as JsonObject) }; + delete copy.mask; + delete copy.raw_mask; + return copy; + }; + + const cleaned: JsonObject = {}; + for (const [label, value] of Object.entries(filters as JsonObject)) { + const expected = attributeSet.has(label) ? attributeDefaults : baseDefaults; + const valNorm = withoutRuntimeFields(value as JsonValue); + const expNorm = withoutRuntimeFields(expected as JsonValue); + + // Non-object filter value: keep only if it differs from the default. + if ( + !valNorm || + typeof valNorm !== "object" || + Array.isArray(valNorm) || + !expNorm || + typeof expNorm !== "object" || + Array.isArray(expNorm) + ) { + if (!isEqual(valNorm, expNorm)) { + cleaned[label] = value as JsonValue; + } + continue; + } + + const diff: JsonObject = {}; + for (const [field, fieldValue] of Object.entries(valNorm as JsonObject)) { + if (!isEqual(fieldValue, (expNorm as JsonObject)[field])) { + diff[field] = fieldValue as JsonValue; + } + } + if (Object.keys(diff).length > 0) { + cleaned[label] = diff; + } + } + return { ...sourceSection, filters: cleaned }; +} + +/** + * Strip runtime-only fields from each entry of a dict-of-objects (mask + * `enabled_in_config`/`raw_coordinates`, zone `color`) that clone re-injects + * from the API. + */ +function stripDictEntryFields( + dict: unknown, + fieldsToStrip: readonly string[], +): unknown { + if (!dict || typeof dict !== "object" || Array.isArray(dict)) return dict; + const result: JsonObject = {}; + for (const [key, value] of Object.entries(dict as JsonObject)) { + if (value && typeof value === "object" && !Array.isArray(value)) { + const cleaned = { ...(value as JsonObject) }; + for (const field of fieldsToStrip) { + delete cleaned[field]; + } + result[key] = cleaned as JsonValue; + } else { + result[key] = value as JsonValue; + } + } + return result; +} + +/** + * Per-object masks (`objects.filters.