change source and allow cloning to multiple cameras

This commit is contained in:
Josh Hawkins 2026-05-28 12:49:46 -05:00
parent b852b65024
commit fbde1e7549
5 changed files with 662 additions and 304 deletions

View File

@ -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();
});
});

View File

@ -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}}"

View File

@ -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<FrigateConfig>("config");
const { data: rawPaths } = useSWR<RawCameraPaths>("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<string>([
@ -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<CloneFormValues>({
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<CloneCategoryKey>
@ -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<typeof buildClonedCameraPayloads> }[]
>(() => {
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 = (
<a onClick={() => setRestartDialogOpen(true)}>
<Button>{t("restart.button", { ns: "components/dialog" })}</Button>
</a>
);
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: (
<a onClick={() => setRestartDialogOpen(true)}>
<Button>
{t("restart.button", { ns: "components/dialog" })}
</Button>
</a>
),
},
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({
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent
className={cn(
"scrollbar-container max-h-[90dvh] max-w-3xl overflow-y-auto",
"scrollbar-container max-h-[90dvh] max-w-4xl overflow-y-auto",
)}
onInteractOutside={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>
{t("cameraManagement.clone.title", {
cameraName: sourceFriendlyName,
})}
</DialogTitle>
<DialogTitle>{t("cameraManagement.clone.title")}</DialogTitle>
<DialogDescription>
{t("cameraManagement.clone.description")}
</DialogDescription>
@ -424,6 +577,43 @@ export default function CloneCameraDialog({
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="space-y-3">
<Label className="text-base">
{t("cameraManagement.clone.source.label")}
</Label>
<FormField
control={form.control}
name="sourceCamera"
render={({ field }) => (
<FormItem>
<FormControl>
<Select
value={field.value}
onValueChange={field.onChange}
disabled={isSubmitting}
>
<SelectTrigger className="w-full max-w-xs">
<SelectValue
placeholder={t(
"cameraManagement.clone.source.placeholder",
)}
/>
</SelectTrigger>
<SelectContent>
{sourceCameras.map((cam) => (
<SelectItem key={cam} value={cam}>
{config?.cameras?.[cam]?.friendly_name ?? cam}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="space-y-3">
<Label className="text-base">
{t("cameraManagement.clone.target.legend")}
@ -467,6 +657,7 @@ export default function CloneCameraDialog({
<FormControl>
<Input
{...form.register("newName")}
className="max-w-xs"
placeholder={t(
"cameraManagement.clone.target.newNamePlaceholder",
)}
@ -509,7 +700,9 @@ export default function CloneCameraDialog({
"text-muted-foreground",
)}
>
{t("cameraManagement.clone.target.existingRadio")}
{t(
"cameraManagement.clone.target.existingCamerasRadio",
)}
{otherCameras.length === 0 && (
<span className="ml-2 text-xs">
(
@ -525,34 +718,102 @@ export default function CloneCameraDialog({
otherCameras.length > 0 && (
<FormField
control={form.control}
name="existingTarget"
render={({ field: tgtField }) => (
<FormItem className="ml-7">
<FormControl>
<Select
value={tgtField.value}
onValueChange={tgtField.onChange}
>
<SelectTrigger className="w-full max-w-xs">
<SelectValue
placeholder={t(
"cameraManagement.clone.target.existingPlaceholder",
)}
/>
</SelectTrigger>
<SelectContent>
{otherCameras.map((cam) => (
<SelectItem key={cam} value={cam}>
{config?.cameras?.[cam]
?.friendly_name ?? cam}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
name="existingTargets"
render={({ field: tgtField }) => {
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 (
<FormItem className="ml-7">
<Popover>
<FormControl>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
disabled={isSubmitting}
className="w-full max-w-xs justify-between font-normal"
>
<span
className={cn(
"truncate",
selectedNames.length === 0 &&
"text-muted-foreground",
)}
>
{summary}
</span>
<LuChevronDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
</FormControl>
<PopoverContent
align="start"
disablePortal
className="w-[--radix-popover-trigger-width] p-0"
>
<div className="scrollbar-container max-h-60 space-y-2.5 overflow-y-auto p-4">
<FilterSwitch
label={t(
"cameraManagement.clone.target.allCameras",
)}
isChecked={allSelected}
disabled={isSubmitting}
onCheckedChange={(checked) =>
tgtField.onChange(
checked
? [...otherCameras]
: [],
)
}
/>
<div className="border-t border-border/40" />
{otherCameras.map((cam) => (
<FilterSwitch
key={cam}
label={cam}
type="camera"
isChecked={selected.includes(
cam,
)}
disabled={isSubmitting}
onCheckedChange={(checked) =>
tgtField.onChange(
checked
? [...selected, cam]
: selected.filter(
(c) => c !== cam,
),
)
}
/>
))}
</div>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
);
}}
/>
)}
</div>
@ -617,9 +878,8 @@ export default function CloneCameraDialog({
{t("cameraManagement.clone.categories.spatial")}
</Label>
{!targetIsNew &&
!resMatch &&
srcCfg?.detect &&
dstCfg?.detect && (
mismatchedTargets.length > 0 && (
<Alert variant="warning">
<LuTriangleAlert className="size-5" />
<AlertTitle>
@ -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(", "),
},
)}
</AlertDescription>

View File

@ -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<string>();
// 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<string, JsonObject>
| 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,

View File

@ -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<string | null>(
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 (
<>
<Toaster
richColors
className="z-[1000]"
position="top-center"
closeButton
/>
<div className="flex size-full space-y-6">
<div className="scrollbar-container flex-1 overflow-y-auto pb-2">
<Heading as="h4" className="mb-2">
@ -259,6 +258,27 @@ export default function CameraManagementView({
)}
</div>
{enabledCameras.length + disabledCameras.length > 0 && (
<div className="mb-5 space-y-3">
<div className="space-y-0.5">
<div className="text-md font-medium">
{t("cameraManagement.clone.sectionTitle")}
</div>
<p className="text-sm text-muted-foreground">
{t("cameraManagement.clone.sectionDescription")}
</p>
</div>
<Button
variant="select"
onClick={() => setShowCloneDialog(true)}
className="flex max-w-48 items-center gap-2"
>
<LuCopy className="h-4 w-4" />
{t("cameraManagement.clone.button")}
</Button>
</div>
)}
{(enabledCameras.length > 0 || disabledCameras.length > 0) && (
<SettingsGroupCard
title={
@ -293,7 +313,6 @@ export default function CameraManagementView({
onConfigChanged={updateConfig}
onDragEnd={handleReorderDragEnd}
setRestartDialogOpen={setRestartDialogOpen}
onClone={setCloneSourceCamera}
/>
))}
</Reorder.Group>
@ -313,7 +332,6 @@ export default function CameraManagementView({
camera={camera}
onConfigChanged={updateConfig}
setRestartDialogOpen={setRestartDialogOpen}
onClone={setCloneSourceCamera}
/>
))}
</div>
@ -372,9 +390,8 @@ export default function CameraManagementView({
onRestart={() => sendRestart("restart")}
/>
<CloneCameraDialog
open={cloneSourceCamera !== null}
onClose={() => setCloneSourceCamera(null)}
sourceCamera={cloneSourceCamera ?? ""}
open={showCloneDialog}
onClose={() => setShowCloneDialog(false)}
/>
</>
);
@ -416,7 +433,6 @@ type ActiveCameraRowProps = {
onConfigChanged: () => Promise<unknown>;
onDragEnd: () => void;
setRestartDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
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}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7"
aria-label={t("cameraManagement.clone.triggerAriaLabel", {
cameraName: camera,
})}
onClick={() => onClone(camera)}
>
<LuCopy className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("cameraManagement.clone.trigger")}</TooltipContent>
</Tooltip>
</div>
<CameraStatusSelect
cameraName={camera}
@ -483,17 +482,13 @@ type DisabledCameraRowProps = {
camera: string;
onConfigChanged: () => Promise<unknown>;
setRestartDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
onClone: (camera: string) => void;
};
function DisabledCameraRow({
camera,
onConfigChanged,
setRestartDialogOpen,
onClone,
}: DisabledCameraRowProps) {
const { t } = useTranslation(["views/settings"]);
return (
<div className="flex flex-row items-center justify-between">
<div className="flex items-center gap-1">
@ -502,22 +497,6 @@ function DisabledCameraRow({
cameraName={camera}
onConfigChanged={onConfigChanged}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7"
aria-label={t("cameraManagement.clone.triggerAriaLabel", {
cameraName: camera,
})}
onClick={() => onClone(camera)}
>
<LuCopy className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("cameraManagement.clone.trigger")}</TooltipContent>
</Tooltip>
</div>
<CameraStatusSelect
cameraName={camera}
@ -551,6 +530,7 @@ function CameraStatusSelect({
]);
const { payload: enabledState, send: sendEnabled } =
useEnabledState(cameraName);
const statusBar = useContext(StatusBarMessagesContext);
const [isSaving, setIsSaving] = useState(false);
const currentStatus: CameraStatus = isDisabledInConfig
@ -636,6 +616,12 @@ function CameraStatusSelect({
},
});
await onConfigChanged();
statusBar?.addMessage(
"config_restart_required",
t("configForm.restartRequiredFooter", { ns: "views/settings" }),
undefined,
"config_restart_required",
);
toast.success(
t("cameraManagement.streams.disableSuccess", {
ns: "views/settings",
@ -667,6 +653,7 @@ function CameraStatusSelect({
onConfigChanged,
sendEnabled,
setRestartDialogOpen,
statusBar,
t,
],
);