diff --git a/web/e2e/specs/clone-camera.spec.ts b/web/e2e/specs/clone-camera.spec.ts index da8d7d61ea..1c75e71a3d 100644 --- a/web/e2e/specs/clone-camera.spec.ts +++ b/web/e2e/specs/clone-camera.spec.ts @@ -3,19 +3,42 @@ * * Covers the design invariants that don't depend on per-camera resolution * differences in the mock fixture: - * 1. Dialog opens from the Clone button on a camera row. - * 2. "Stream URLs and roles" is forced on and disabled for new-camera target. - * 3. Clicking Clone issues a PUT and shows a restart prompt. + * 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, - * so an E2E assertion for that path would silently pass without coverage. + * 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"; -const CLONE_BUTTON_ARIA_PREFIX = "Clone settings from"; -const DIALOG_TITLE_PREFIX = "Clone settings from"; +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 }) => { @@ -25,31 +48,26 @@ test.describe("Camera clone dialog @medium @mobile", () => { ).toBeVisible(); }); - test("opens the dialog from a camera row's Clone button", async ({ + test("opens the dialog from the Clone settings button", async ({ frigateApp, }) => { - const cloneButton = frigateApp.page - .getByRole("button", { name: new RegExp(CLONE_BUTTON_ARIA_PREFIX, "i") }) - .first(); - await cloneButton.click(); + await openCloneDialog(frigateApp); - await expect(frigateApp.page.getByRole("dialog")).toBeVisible(); await expect( - frigateApp.page - .getByRole("dialog") - .getByText(new RegExp(DIALOG_TITLE_PREFIX, "i")), + 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, }) => { - const cloneButton = frigateApp.page - .getByRole("button", { name: new RegExp(CLONE_BUTTON_ARIA_PREFIX, "i") }) - .first(); - await cloneButton.click(); - - await expect(frigateApp.page.getByRole("dialog")).toBeVisible(); + 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. @@ -63,7 +81,7 @@ test.describe("Camera clone dialog @medium @mobile", () => { await expect(streamsCheckbox).toBeDisabled(); }); - test("issues a PUT and shows restart toast for new-camera target", async ({ + test("issues a single add PUT and shows restart toast for new-camera target", async ({ frigateApp, }) => { const requests: { body: unknown }[] = []; @@ -83,20 +101,15 @@ test.describe("Camera clone dialog @medium @mobile", () => { frigateApp.page.getByRole("heading", { name: /Manage Cameras/i }), ).toBeVisible(); - const cloneButton = frigateApp.page - .getByRole("button", { name: new RegExp(CLONE_BUTTON_ARIA_PREFIX, "i") }) - .first(); - await cloneButton.click(); - - await expect(frigateApp.page.getByRole("dialog")).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"); - // After typing a valid name, the Clone button becomes enabled because - // changeCount > 0 (the dialog's previewPayloads memo watches the name). + // 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 }); @@ -123,4 +136,46 @@ test.describe("Camera clone dialog @medium @mobile", () => { 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 812b3432f7..55fff5a9ae 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -546,10 +546,16 @@ "saveSuccess": "Updated camera type for {{cameraName}}. Restart Frigate to apply the changes." }, "clone": { - "trigger": "Clone camera or copy settings", - "triggerAriaLabel": "Clone settings from {{cameraName}}", - "title": "Clone settings from {{cameraName}}", - "description": "Copy this camera's configuration to a new or existing camera. Identity (name, friendly name, web UI URL, display order) is never copied.", + "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", @@ -558,9 +564,10 @@ "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. You'll edit the RTSP URLs after cloning.", - "existingRadio": "Existing camera", - "existingPlaceholder": "Choose camera…", + "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": { @@ -572,7 +579,7 @@ "spatial": "Spatial settings", "streams": "Streams", "spatialWarningTitle": "Resolution mismatch", - "spatialWarning": "Source camera {{srcCamera}} detect resolution ({{srcWidth}}×{{srcHeight}}) differs from target camera {{dstCamera}} detect resolution ({{dstWidth}}×{{dstHeight}}). Polygons may not align. These defaults are off; enable to copy as-is.", + "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", @@ -611,8 +618,13 @@ }, "toast": { "success": "Settings copied to {{cameraName}}", - "successWithRestart": "Settings copied to {{cameraName}}. Restart Frigate to fully apply.", + "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}}" diff --git a/web/src/components/settings/CloneCameraDialog.tsx b/web/src/components/settings/CloneCameraDialog.tsx index b4ccb01813..76b8548b15 100644 --- a/web/src/components/settings/CloneCameraDialog.tsx +++ b/web/src/components/settings/CloneCameraDialog.tsx @@ -1,4 +1,11 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +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"; @@ -39,7 +46,12 @@ 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 { LuTriangleAlert } from "react-icons/lu"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { LuChevronDown, LuTriangleAlert } from "react-icons/lu"; import { CLONE_CATEGORIES, type CloneCategoryKey, @@ -53,38 +65,39 @@ import { 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; - sourceCamera: string; }; type CloneFormValues = { + sourceCamera: string; targetMode: "new" | "existing"; newName: string; - existingTarget: string; + existingTargets: string[]; }; export default function CloneCameraDialog({ open, onClose, - sourceCamera, }: 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 otherCameras = useMemo(() => { + const sourceCameras = useMemo(() => { if (!config) return []; return Object.keys(config.cameras) - .filter((c) => c !== sourceCamera && !isReplayCamera(c)) + .filter((c) => !isReplayCamera(c)) .sort(); - }, [config, sourceCamera]); + }, [config]); const formSchema = useMemo(() => { const reservedNames = new Set([ @@ -93,11 +106,19 @@ export default function CloneCameraDialog({ ]); return z .object({ + sourceCamera: z.string(), targetMode: z.enum(["new", "existing"]), newName: z.string(), - existingTarget: 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) { @@ -124,10 +145,10 @@ export default function CloneCameraDialog({ message: t("cameraManagement.clone.target.newNameCollision"), }); } - } else if (!data.existingTarget) { + } else if (data.existingTargets.length === 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, - path: ["existingTarget"], + path: ["existingTargets"], message: t("cameraManagement.clone.target.existingPlaceholder"), }); } @@ -137,27 +158,40 @@ export default function CloneCameraDialog({ const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { + sourceCamera: "", targetMode: "new", newName: "", - existingTarget: "", + existingTargets: [], }, }); + const sourceCamera = form.watch("sourceCamera"); const targetMode = form.watch("targetMode"); - const existingTarget = form.watch("existingTarget"); + const existingTargets = form.watch("existingTargets"); const targetIsNew = targetMode === "new"; - const srcCfg = config?.cameras?.[sourceCamera]; - const dstCfg = - !targetIsNew && existingTarget - ? config?.cameras?.[existingTarget] - : undefined; + const otherCameras = useMemo(() => { + if (!config) return []; + return Object.keys(config.cameras) + .filter((c) => c !== sourceCamera && !isReplayCamera(c)) + .sort(); + }, [config, sourceCamera]); - const resMatch = useMemo( - () => resolutionsMatch(srcCfg?.detect, dstCfg?.detect), - [srcCfg, dstCfg], - ); + 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 @@ -169,15 +203,28 @@ export default function CloneCameraDialog({ if (open && !wasOpenRef.current) { wasOpenRef.current = true; form.reset({ + sourceCamera: "", targetMode: "new", newName: "", - existingTarget: otherCameras[0] ?? "", + existingTargets: [], }); setSelectedCategories(getCategoryDefaults(true)); } else if (!open) { wasOpenRef.current = false; } - }, [open, form, otherCameras]); + }, [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(() => { @@ -196,7 +243,7 @@ export default function CloneCameraDialog({ const selectAllCategories = useCallback(() => { setSelectedCategories((prev) => { const next = new Set(prev); - const includeSpatial = targetIsNew || resMatch; + const includeSpatial = targetIsNew || allResMatch; for (const cat of CLONE_CATEGORIES) { if (cat.newCameraOnly && !targetIsNew) continue; if (cat.group === "spatial" && !includeSpatial) continue; @@ -205,7 +252,7 @@ export default function CloneCameraDialog({ } return next; }); - }, [targetIsNew, resMatch]); + }, [targetIsNew, allResMatch]); const selectNoneCategories = useCallback(() => { setSelectedCategories((prev) => { @@ -241,48 +288,77 @@ export default function CloneCameraDialog({ const fullSchema = useConfigSchema(); const { send: sendRestart } = useRestart(); + const statusBar = useContext(StatusBarMessagesContext); const [restartDialogOpen, setRestartDialogOpen] = useState(false); const watchedNewName = useWatch({ control: form.control, name: "newName" }) ?? ""; - const previewPayloads = useMemo(() => { - if (!config || !fullSchema || !srcCfg) return []; - const targetInput = targetIsNew ? watchedNewName : existingTarget; - if (!targetInput) return []; - if (!targetIsNew && !config.cameras?.[targetInput]) return []; - return buildClonedCameraPayloads({ - sourceCfg: srcCfg, - sourceName: sourceCamera, - targetInput, - targetIsNew, - selectedKeys: selectedCategories, - fullConfig: config, - fullSchema, - rawPaths, - }); + // 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, - existingTarget, + existingTargets, watchedNewName, selectedCategories, rawPaths, ]); - const previewTarget = targetIsNew - ? processCameraName(watchedNewName || "").finalCameraName - : existingTarget; + const previewPayloads = useMemo( + () => targetPayloads.flatMap((tp) => tp.payloads), + [targetPayloads], + ); const previewItems = useMemo( () => - previewTarget - ? buildClonePreviewItems(previewPayloads, previewTarget) - : [], - [previewPayloads, previewTarget], + targetPayloads.flatMap((tp) => + buildClonePreviewItems(tp.payloads, tp.target), + ), + [targetPayloads], ); const anyNeedsRestart = previewPayloads.some((p) => p.needsRestart); @@ -302,38 +378,126 @@ export default function CloneCameraDialog({ return; } - const target = targetIsNew - ? processCameraName(values.newName.trim()).finalCameraName - : values.existingTarget; - const targetLabel = targetIsNew - ? values.newName.trim() - : (config.cameras?.[target]?.friendly_name ?? target); + 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); - let appliedCount = 0; - let failedSection: string | undefined; - let failureMessage: string | undefined; + + 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 payload of previewPayloads) { - 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 = - (axios.isAxiosError(error) && - (error.response?.data?.message || - error.response?.data?.detail)) || - (error instanceof Error ? error.message : "Unknown error"); - break; + 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 { @@ -341,50 +505,41 @@ export default function CloneCameraDialog({ setIsSubmitting(false); } - if (failedSection) { - if (targetIsNew && appliedCount > 0) { - toast.error( - t("cameraManagement.clone.toast.newCameraPartialFailure", { - cameraName: targetLabel, - errorMessage: failureMessage, - }), - { position: "top-center" }, - ); - } else { - toast.error( - t("cameraManagement.clone.toast.partialFailure", { - successCount: appliedCount, - failedSection, - errorMessage: failureMessage, - }), - { position: "top-center" }, - ); - } + 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( - t("cameraManagement.clone.toast.successWithRestart", { - cameraName: targetLabel, - }), - { - position: "top-center", - duration: 10000, - action: ( - setRestartDialogOpen(true)}> - - - ), - }, + 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( - t("cameraManagement.clone.toast.success", { - cameraName: targetLabel, - }), + singleLabel + ? t("cameraManagement.clone.toast.success", { + cameraName: singleLabel, + }) + : t("cameraManagement.clone.toast.successMulti", { + count: succeeded.length, + }), { position: "top-center" }, ); } @@ -396,9 +551,11 @@ export default function CloneCameraDialog({ srcCfg, fullSchema, previewPayloads, + targetPayloads, targetIsNew, anyNeedsRestart, onClose, + statusBar, t, ], ); @@ -407,16 +564,12 @@ export default function CloneCameraDialog({ !o && onClose()}> e.preventDefault()} > - - {t("cameraManagement.clone.title", { - cameraName: sourceFriendlyName, - })} - + {t("cameraManagement.clone.title")} {t("cameraManagement.clone.description")} @@ -424,6 +577,43 @@ export default function CloneCameraDialog({
+
+ + ( + + + + + + + )} + /> +
+
{!targetIsNew && - !resMatch && srcCfg?.detect && - dstCfg?.detect && ( + mismatchedTargets.length > 0 && ( @@ -632,13 +892,14 @@ export default function CloneCameraDialog({ "cameraManagement.clone.categories.spatialWarning", { srcCamera: sourceFriendlyName, - dstCamera: - config?.cameras?.[existingTarget] - ?.friendly_name ?? existingTarget, srcWidth: srcCfg.detect.width, srcHeight: srcCfg.detect.height, - dstWidth: dstCfg.detect.width, - dstHeight: dstCfg.detect.height, + cameras: mismatchedTargets + .map( + (c) => + config?.cameras?.[c]?.friendly_name ?? c, + ) + .join(", "), }, )} diff --git a/web/src/utils/cameraClone.ts b/web/src/utils/cameraClone.ts index 25da7f4d98..f10d7dc606 100644 --- a/web/src/utils/cameraClone.ts +++ b/web/src/utils/cameraClone.ts @@ -53,7 +53,12 @@ function resolveDef(schema: RJSFSchema, name: string): RJSFSchema | undefined { return defs ? defs[name] : undefined; } -/** Remove filter entries that exactly match the backend's auto-default. */ +/** + * 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, @@ -80,20 +85,56 @@ function stripAutoDefaultFilters( ) : 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; - if (isEqual(value, expected)) continue; - cleaned[label] = value as JsonValue; + 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 named runtime-only fields from each entry in a dict-of-objects - * (mask `enabled_in_config`/`raw_coordinates`, zone `color`). The settings - * UI doesn't need this because BaseSection's form never exposes these - * sub-collections; we do because clone re-injects them from the API. + * 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, @@ -135,11 +176,9 @@ function stripResetMarkers( } /** - * Collapse per-section payloads into one camera-level payload + `…/add` - * topic. New cameras are created atomically by the backend's `add` - * handler, so a single PUT avoids the intermediate-validation ordering - * problem (e.g. a `review` required_zone referencing zones not yet written) - * that the per-section path is subject to. + * Collapse per-section payloads into one camera-level `…/add` payload. The + * backend's `add` handler validates atomically, avoiding the per-section + * ordering problem (e.g. `review.required_zones` referencing unwritten zones). */ function bundleNewCameraPayload( payloads: SectionSavePayload[], @@ -166,10 +205,8 @@ function bundleNewCameraPayload( } /** - * Drop empty `*_args` arrays from ffmpeg inputs. Mirrors - * `sanitizeOverridesForSection`'s ffmpeg cleanup, which we don't go - * through here because the establishing payload uses `buildOverrides` - * directly. + * Drop empty `*_args` arrays from ffmpeg inputs — the establishing payload + * uses `buildOverrides` directly, bypassing `sanitizeOverridesForSection`. */ function cleanupFfmpegInputArgs( ffmpeg: JsonValue | undefined, @@ -225,10 +262,9 @@ function restoreFfmpegPaths( } /** - * Replay the backend's per-camera detect-field formulas (`frigate/config/ - * config.py`) on the synthetic side so they cancel out of the diff. The - * global config doesn't get per-camera derivation, so without this the - * source's computed values surface as overrides. + * Replay the backend's per-camera detect-field formulas on the synthetic + * baseline so the source's computed values cancel out of the diff (the global + * config has no per-camera derivation). */ function applyDetectComputedDefaults( detect: JsonObject, @@ -473,12 +509,9 @@ export function buildClonedCameraPayloads({ } } - // Section-backed categories — flow through prepareSectionSavePayload - // for matching restart/update-topic behavior. Order matters for the - // existing-camera multi-PUT path: each PUT re-validates the whole - // config, so dependency providers must precede consumers — `detect` - // (resolution) then `zones` before sections that reference zones via - // `required_zones` (review, objects, snapshots, mqtt). + // Order matters for the existing-camera multi-PUT path (each PUT re-validates + // the whole config): `detect` then `zones` must precede sections that + // reference zones via `required_zones` (review, objects, snapshots, mqtt). const SECTION_KEYS: Array<{ key: CloneCategoryKey; section: string }> = [ { key: "detect", section: "detect" }, { key: "zones", section: "zones" }, @@ -500,14 +533,11 @@ export function buildClonedCameraPayloads({ { key: "genai", section: "genai" }, ]; - // Synthetic target so we can reuse prepareSectionSavePayload unchanged. - // For new-camera target, seed sections where camera schema accepts all - // global fields — gives buildOverrides the right inheritance baseline. - // Sections with divergent per-camera Pydantic classes (mqtt, birdseye, - // lpr, face_recognition, semantic_search, audio_transcription, genai) - // are left unset so prepareSectionSavePayload's schema defaults handle - // filtering instead — seeding from global would emit its extra fields - // as Reset markers. + // Synthetic target reused as the diff baseline. New-camera: seed sections + // whose camera schema accepts all global fields (correct inheritance + // baseline), but leave divergent per-camera sections (mqtt, birdseye, lpr, + // face_recognition, semantic_search, audio_transcription, genai) unset — + // seeding from global would surface its extra fields as Reset markers. const GLOBAL_INHERITED_SECTIONS = [ "detect", "objects", @@ -531,24 +561,39 @@ export function buildClonedCameraPayloads({ ]).filter(([, value]) => value !== undefined && value !== null), ), } as unknown as FrigateConfig["cameras"][string]) - : (fullConfig.cameras?.[target] ?? - ({ enabled: true } as unknown as FrigateConfig["cameras"][string])); + : ((fullConfig.cameras?.[target] + ? cloneDeep(fullConfig.cameras[target]) + : { enabled: true }) as unknown as FrigateConfig["cameras"][string]); - // Symmetric filter strip: same treatment as the per-section source - // strip below, so default-only entries cancel out of the diff. + // Strip auto-default filters from the baseline (matching the per-section + // source strip) so default-only entries cancel. Includes `base_config` (the + // pre-profile parse getBaseCameraSectionValue reads) — otherwise its + // auto-populated entries become `""` resets and the backend KeyErrors + // deleting a key not in the YAML. Cloned above so this won't mutate the cache. + const syntheticCameraObj = syntheticTargetCamera as unknown as JsonObject; + const baseConfigObj = syntheticCameraObj.base_config as + | Record + | undefined; for (const section of Object.keys(FILTER_SECTION_DEFS)) { - const syntheticSection = (syntheticTargetCamera as unknown as JsonObject)[ - section - ]; + const syntheticSection = syntheticCameraObj[section]; if (syntheticSection && typeof syntheticSection === "object") { - (syntheticTargetCamera as unknown as JsonObject)[section] = - stripAutoDefaultFilters( - section, - syntheticSection as JsonObject, - fullSchema, - fullConfig, - syntheticTargetCamera as CameraConfig, - ); + syntheticCameraObj[section] = stripAutoDefaultFilters( + section, + syntheticSection as JsonObject, + fullSchema, + fullConfig, + syntheticTargetCamera as CameraConfig, + ); + } + const baseSection = baseConfigObj?.[section]; + if (baseConfigObj && baseSection && typeof baseSection === "object") { + baseConfigObj[section] = stripAutoDefaultFilters( + section, + baseSection, + fullSchema, + fullConfig, + syntheticTargetCamera as CameraConfig, + ); } } @@ -556,7 +601,6 @@ export function buildClonedCameraPayloads({ // so apply the formulas using source's fps to keep both sides aligned. // Existing-camera target already has the values from its own parse. if (targetIsNew && sourceCfg.detect) { - const syntheticCameraObj = syntheticTargetCamera as unknown as JsonObject; const syntheticDetect = syntheticCameraObj.detect; if (syntheticDetect && typeof syntheticDetect === "object") { syntheticCameraObj.detect = applyDetectComputedDefaults( @@ -583,10 +627,9 @@ export function buildClonedCameraPayloads({ )[section]; if (sourceSectionValue == null) continue; - // Sanitize the source the same way BaseSection's form does - // implicitly: strips runtime/derived fields and function-resolved - // hidden paths (e.g. `hideAttributeFilters` removing untracked- - // attribute entries based on source's track list). + // Sanitize the source like BaseSection's form does: strip runtime/derived + // and hidden-path fields (e.g. `hideAttributeFilters` drops untracked + // attributes based on the source's track list). const sectionConfig = getSectionConfig(section, "camera"); const resolvedHiddenFields = resolveHiddenFieldEntries( sectionConfig.hiddenFields, diff --git a/web/src/views/settings/CameraManagementView.tsx b/web/src/views/settings/CameraManagementView.tsx index d28b621dc8..96ac321362 100644 --- a/web/src/views/settings/CameraManagementView.tsx +++ b/web/src/views/settings/CameraManagementView.tsx @@ -1,12 +1,18 @@ import Heading from "@/components/ui/heading"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { CONTROL_COLUMN_CLASS_NAME, SettingsGroupCard, SPLIT_ROW_CLASS_NAME, } from "@/components/card/SettingsGroupCard"; import { toast } from "sonner"; -import { Toaster } from "@/components/ui/sonner"; import { Button } from "@/components/ui/button"; import useSWR from "swr"; import { FrigateConfig } from "@/types/frigateConfig"; @@ -52,6 +58,7 @@ import { import type { ProfileState } from "@/types/profile"; import { getProfileColor } from "@/utils/profileColors"; import { isReplayCamera } from "@/utils/cameraUtil"; +import { StatusBarMessagesContext } from "@/context/statusbar-provider"; import { cn } from "@/lib/utils"; import { Select, @@ -90,9 +97,7 @@ export default function CameraManagementView({ const [showWizard, setShowWizard] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); - const [cloneSourceCamera, setCloneSourceCamera] = useState( - null, - ); + const [showCloneDialog, setShowCloneDialog] = useState(false); // State for restart dialog when enabling a disabled camera const [restartDialogOpen, setRestartDialogOpen] = useState(false); @@ -222,12 +227,6 @@ export default function CameraManagementView({ return ( <> -
@@ -259,6 +258,27 @@ export default function CameraManagementView({ )}
+ {enabledCameras.length + disabledCameras.length > 0 && ( +
+
+
+ {t("cameraManagement.clone.sectionTitle")} +
+

+ {t("cameraManagement.clone.sectionDescription")} +

+
+ +
+ )} + {(enabledCameras.length > 0 || disabledCameras.length > 0) && ( ))} @@ -313,7 +332,6 @@ export default function CameraManagementView({ camera={camera} onConfigChanged={updateConfig} setRestartDialogOpen={setRestartDialogOpen} - onClone={setCloneSourceCamera} /> ))}
@@ -372,9 +390,8 @@ export default function CameraManagementView({ onRestart={() => sendRestart("restart")} /> setCloneSourceCamera(null)} - sourceCamera={cloneSourceCamera ?? ""} + open={showCloneDialog} + onClose={() => setShowCloneDialog(false)} /> ); @@ -416,7 +433,6 @@ type ActiveCameraRowProps = { onConfigChanged: () => Promise; onDragEnd: () => void; setRestartDialogOpen: React.Dispatch>; - onClone: (camera: string) => void; }; function ActiveCameraRow({ @@ -424,7 +440,6 @@ function ActiveCameraRow({ onConfigChanged, onDragEnd, setRestartDialogOpen, - onClone, }: ActiveCameraRowProps) { const { t } = useTranslation(["views/settings"]); const controls = useDragControls(); @@ -452,22 +467,6 @@ function ActiveCameraRow({ cameraName={camera} onConfigChanged={onConfigChanged} /> - - - - - {t("cameraManagement.clone.trigger")} -
Promise; setRestartDialogOpen: React.Dispatch>; - onClone: (camera: string) => void; }; function DisabledCameraRow({ camera, onConfigChanged, setRestartDialogOpen, - onClone, }: DisabledCameraRowProps) { - const { t } = useTranslation(["views/settings"]); - return (
@@ -502,22 +497,6 @@ function DisabledCameraRow({ cameraName={camera} onConfigChanged={onConfigChanged} /> - - - - - {t("cameraManagement.clone.trigger")} -