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 * Covers the design invariants that don't depend on per-camera resolution
* differences in the mock fixture: * differences in the mock fixture:
* 1. Dialog opens from the Clone button on a camera row. * 1. Dialog opens from the "Clone settings" button below Add/Delete.
* 2. "Stream URLs and roles" is forced on and disabled for new-camera target. * 2. A source camera must be chosen inside the dialog before cloning.
* 3. Clicking Clone issues a PUT and shows a restart prompt. * 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 * The spatial-mismatch warning path is exercised in unit-level review and via
* via manual QA the shared mock fixture ships every camera at 1280×720, * manual QA the shared mock fixture ships every camera at 1280×720. The
* so an E2E assertion for that path would silently pass without coverage. * 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"; import { test, expect } from "../fixtures/frigate-test";
const CLONE_BUTTON_ARIA_PREFIX = "Clone settings from"; async function openCloneDialog(frigateApp: {
const DIALOG_TITLE_PREFIX = "Clone settings from"; 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.describe("Camera clone dialog @medium @mobile", () => {
test.beforeEach(async ({ frigateApp }) => { test.beforeEach(async ({ frigateApp }) => {
@ -25,31 +48,26 @@ test.describe("Camera clone dialog @medium @mobile", () => {
).toBeVisible(); ).toBeVisible();
}); });
test("opens the dialog from a camera row's Clone button", async ({ test("opens the dialog from the Clone settings button", async ({
frigateApp, frigateApp,
}) => { }) => {
const cloneButton = frigateApp.page await openCloneDialog(frigateApp);
.getByRole("button", { name: new RegExp(CLONE_BUTTON_ARIA_PREFIX, "i") })
.first();
await cloneButton.click();
await expect(frigateApp.page.getByRole("dialog")).toBeVisible();
await expect( await expect(
frigateApp.page frigateApp.page.getByRole("dialog").getByText(/Clone camera settings/i),
.getByRole("dialog")
.getByText(new RegExp(DIALOG_TITLE_PREFIX, "i")),
).toBeVisible(); ).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 ({ test("forces Stream URLs and roles on for new-camera target", async ({
frigateApp, frigateApp,
}) => { }) => {
const cloneButton = frigateApp.page await openCloneDialog(frigateApp);
.getByRole("button", { name: new RegExp(CLONE_BUTTON_ARIA_PREFIX, "i") }) await selectSource(frigateApp, "Front Door");
.first();
await cloneButton.click();
await expect(frigateApp.page.getByRole("dialog")).toBeVisible();
// The "New camera" radio is selected by default; the Streams group renders // The "New camera" radio is selected by default; the Streams group renders
// the ffmpeg_live checkbox as forced-checked and disabled. // the ffmpeg_live checkbox as forced-checked and disabled.
@ -63,7 +81,7 @@ test.describe("Camera clone dialog @medium @mobile", () => {
await expect(streamsCheckbox).toBeDisabled(); 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, frigateApp,
}) => { }) => {
const requests: { body: unknown }[] = []; const requests: { body: unknown }[] = [];
@ -83,20 +101,15 @@ test.describe("Camera clone dialog @medium @mobile", () => {
frigateApp.page.getByRole("heading", { name: /Manage Cameras/i }), frigateApp.page.getByRole("heading", { name: /Manage Cameras/i }),
).toBeVisible(); ).toBeVisible();
const cloneButton = frigateApp.page await openCloneDialog(frigateApp);
.getByRole("button", { name: new RegExp(CLONE_BUTTON_ARIA_PREFIX, "i") }) await selectSource(frigateApp, "Front Door");
.first();
await cloneButton.click();
await expect(frigateApp.page.getByRole("dialog")).toBeVisible();
const nameInput = frigateApp.page.getByPlaceholder( const nameInput = frigateApp.page.getByPlaceholder(
/e\.g\., back_door or Back Door/i, /e\.g\., back_door or Back Door/i,
); );
await nameInput.fill("clone_target_one"); await nameInput.fill("clone_target_one");
// After typing a valid name, the Clone button becomes enabled because // With a source picked and a valid name, changeCount > 0 enables Clone.
// changeCount > 0 (the dialog's previewPayloads memo watches the name).
await expect( await expect(
frigateApp.page.getByRole("button", { name: /^Clone$/i }), frigateApp.page.getByRole("button", { name: /^Clone$/i }),
).toBeEnabled({ timeout: 5_000 }); ).toBeEnabled({ timeout: 5_000 });
@ -123,4 +136,46 @@ test.describe("Camera clone dialog @medium @mobile", () => {
frigateApp.page.getByRole("button", { name: /Restart/i }).first(), frigateApp.page.getByRole("button", { name: /Restart/i }).first(),
).toBeVisible({ timeout: 8_000 }); ).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." "saveSuccess": "Updated camera type for {{cameraName}}. Restart Frigate to apply the changes."
}, },
"clone": { "clone": {
"trigger": "Clone camera or copy settings", "sectionTitle": "Clone settings",
"triggerAriaLabel": "Clone settings from {{cameraName}}", "sectionDescription": "Copy configuration from one camera to another camera or a new one.",
"title": "Clone settings from {{cameraName}}", "button": "Clone settings",
"description": "Copy this camera's configuration to a new or existing camera. Identity (name, friendly name, web UI URL, display order) is never copied.", "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": { "target": {
"legend": "Target", "legend": "Target",
"newRadio": "New camera", "newRadio": "New camera",
@ -558,9 +564,10 @@
"newNameRequired": "Camera name is required", "newNameRequired": "Camera name is required",
"newNameInvalid": "Invalid camera name", "newNameInvalid": "Invalid camera name",
"newNameCollision": "A camera with this name already exists", "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.", "newStreamsForced": "Streams are always copied for a new camera.",
"existingRadio": "Existing camera", "existingCamerasRadio": "Existing cameras",
"existingPlaceholder": "Choose camera…", "allCameras": "All cameras",
"existingPlaceholder": "Select at least one camera",
"existingDisabled": "No other cameras to copy to" "existingDisabled": "No other cameras to copy to"
}, },
"categories": { "categories": {
@ -572,7 +579,7 @@
"spatial": "Spatial settings", "spatial": "Spatial settings",
"streams": "Streams", "streams": "Streams",
"spatialWarningTitle": "Resolution mismatch", "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", "restartHint": "Restart required",
"items": { "items": {
"record": "Recording", "record": "Recording",
@ -611,8 +618,13 @@
}, },
"toast": { "toast": {
"success": "Settings copied to {{cameraName}}", "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}}", "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}}", "newCameraPartialFailure": "Camera {{cameraName}} was created but some settings failed to copy: {{errorMessage}}",
"sourceMissing": "Source camera no longer exists", "sourceMissing": "Source camera no longer exists",
"submitError": "Failed to clone camera: {{errorMessage}}" "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 { useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
@ -39,7 +46,12 @@ import type { FrigateConfig } from "@/types/frigateConfig";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Label } from "@/components/ui/label"; 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 { import {
CLONE_CATEGORIES, CLONE_CATEGORIES,
type CloneCategoryKey, type CloneCategoryKey,
@ -53,38 +65,39 @@ import {
import { buildConfigDataForPath } from "@/utils/configUtil"; import { buildConfigDataForPath } from "@/utils/configUtil";
import { useConfigSchema } from "@/hooks/use-config-schema"; import { useConfigSchema } from "@/hooks/use-config-schema";
import { useRestart } from "@/api/ws"; import { useRestart } from "@/api/ws";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import RestartDialog from "@/components/overlay/dialog/RestartDialog"; import RestartDialog from "@/components/overlay/dialog/RestartDialog";
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
import SaveAllPreviewPopover from "@/components/overlay/detail/SaveAllPreviewPopover"; import SaveAllPreviewPopover from "@/components/overlay/detail/SaveAllPreviewPopover";
import FilterSwitch from "@/components/filter/FilterSwitch";
type CloneCameraDialogProps = { type CloneCameraDialogProps = {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
sourceCamera: string;
}; };
type CloneFormValues = { type CloneFormValues = {
sourceCamera: string;
targetMode: "new" | "existing"; targetMode: "new" | "existing";
newName: string; newName: string;
existingTarget: string; existingTargets: string[];
}; };
export default function CloneCameraDialog({ export default function CloneCameraDialog({
open, open,
onClose, onClose,
sourceCamera,
}: CloneCameraDialogProps) { }: CloneCameraDialogProps) {
const { t } = useTranslation(["views/settings", "common"]); const { t } = useTranslation(["views/settings", "common"]);
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const { data: rawPaths } = useSWR<RawCameraPaths>("config/raw_paths"); const { data: rawPaths } = useSWR<RawCameraPaths>("config/raw_paths");
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const otherCameras = useMemo(() => { const sourceCameras = useMemo(() => {
if (!config) return []; if (!config) return [];
return Object.keys(config.cameras) return Object.keys(config.cameras)
.filter((c) => c !== sourceCamera && !isReplayCamera(c)) .filter((c) => !isReplayCamera(c))
.sort(); .sort();
}, [config, sourceCamera]); }, [config]);
const formSchema = useMemo(() => { const formSchema = useMemo(() => {
const reservedNames = new Set<string>([ const reservedNames = new Set<string>([
@ -93,11 +106,19 @@ export default function CloneCameraDialog({
]); ]);
return z return z
.object({ .object({
sourceCamera: z.string(),
targetMode: z.enum(["new", "existing"]), targetMode: z.enum(["new", "existing"]),
newName: z.string(), newName: z.string(),
existingTarget: z.string(), existingTargets: z.array(z.string()),
}) })
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
if (!data.sourceCamera) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["sourceCamera"],
message: t("cameraManagement.clone.source.required"),
});
}
if (data.targetMode === "new") { if (data.targetMode === "new") {
const trimmed = data.newName.trim(); const trimmed = data.newName.trim();
if (!trimmed) { if (!trimmed) {
@ -124,10 +145,10 @@ export default function CloneCameraDialog({
message: t("cameraManagement.clone.target.newNameCollision"), message: t("cameraManagement.clone.target.newNameCollision"),
}); });
} }
} else if (!data.existingTarget) { } else if (data.existingTargets.length === 0) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
path: ["existingTarget"], path: ["existingTargets"],
message: t("cameraManagement.clone.target.existingPlaceholder"), message: t("cameraManagement.clone.target.existingPlaceholder"),
}); });
} }
@ -137,27 +158,40 @@ export default function CloneCameraDialog({
const form = useForm<CloneFormValues>({ const form = useForm<CloneFormValues>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
sourceCamera: "",
targetMode: "new", targetMode: "new",
newName: "", newName: "",
existingTarget: "", existingTargets: [],
}, },
}); });
const sourceCamera = form.watch("sourceCamera");
const targetMode = form.watch("targetMode"); const targetMode = form.watch("targetMode");
const existingTarget = form.watch("existingTarget"); const existingTargets = form.watch("existingTargets");
const targetIsNew = targetMode === "new"; const targetIsNew = targetMode === "new";
const srcCfg = config?.cameras?.[sourceCamera]; const otherCameras = useMemo(() => {
const dstCfg = if (!config) return [];
!targetIsNew && existingTarget return Object.keys(config.cameras)
? config?.cameras?.[existingTarget] .filter((c) => c !== sourceCamera && !isReplayCamera(c))
: undefined; .sort();
}, [config, sourceCamera]);
const resMatch = useMemo( const srcCfg = config?.cameras?.[sourceCamera];
() => resolutionsMatch(srcCfg?.detect, dstCfg?.detect),
[srcCfg, dstCfg], // 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< const [selectedCategories, setSelectedCategories] = useState<
Set<CloneCategoryKey> Set<CloneCategoryKey>
@ -169,15 +203,28 @@ export default function CloneCameraDialog({
if (open && !wasOpenRef.current) { if (open && !wasOpenRef.current) {
wasOpenRef.current = true; wasOpenRef.current = true;
form.reset({ form.reset({
sourceCamera: "",
targetMode: "new", targetMode: "new",
newName: "", newName: "",
existingTarget: otherCameras[0] ?? "", existingTargets: [],
}); });
setSelectedCategories(getCategoryDefaults(true)); setSelectedCategories(getCategoryDefaults(true));
} else if (!open) { } else if (!open) {
wasOpenRef.current = false; 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. // Reset selection to per-mode defaults when the user switches target mode.
useEffect(() => { useEffect(() => {
@ -196,7 +243,7 @@ export default function CloneCameraDialog({
const selectAllCategories = useCallback(() => { const selectAllCategories = useCallback(() => {
setSelectedCategories((prev) => { setSelectedCategories((prev) => {
const next = new Set(prev); const next = new Set(prev);
const includeSpatial = targetIsNew || resMatch; const includeSpatial = targetIsNew || allResMatch;
for (const cat of CLONE_CATEGORIES) { for (const cat of CLONE_CATEGORIES) {
if (cat.newCameraOnly && !targetIsNew) continue; if (cat.newCameraOnly && !targetIsNew) continue;
if (cat.group === "spatial" && !includeSpatial) continue; if (cat.group === "spatial" && !includeSpatial) continue;
@ -205,7 +252,7 @@ export default function CloneCameraDialog({
} }
return next; return next;
}); });
}, [targetIsNew, resMatch]); }, [targetIsNew, allResMatch]);
const selectNoneCategories = useCallback(() => { const selectNoneCategories = useCallback(() => {
setSelectedCategories((prev) => { setSelectedCategories((prev) => {
@ -241,48 +288,77 @@ export default function CloneCameraDialog({
const fullSchema = useConfigSchema(); const fullSchema = useConfigSchema();
const { send: sendRestart } = useRestart(); const { send: sendRestart } = useRestart();
const statusBar = useContext(StatusBarMessagesContext);
const [restartDialogOpen, setRestartDialogOpen] = useState(false); const [restartDialogOpen, setRestartDialogOpen] = useState(false);
const watchedNewName = const watchedNewName =
useWatch({ control: form.control, name: "newName" }) ?? ""; useWatch({ control: form.control, name: "newName" }) ?? "";
const previewPayloads = useMemo(() => { // Payloads grouped per destination camera. New mode has a single target;
if (!config || !fullSchema || !srcCfg) return []; // existing mode fans out across every selected camera.
const targetInput = targetIsNew ? watchedNewName : existingTarget; const targetPayloads = useMemo<
if (!targetInput) return []; { target: string; payloads: ReturnType<typeof buildClonedCameraPayloads> }[]
if (!targetIsNew && !config.cameras?.[targetInput]) return []; >(() => {
return buildClonedCameraPayloads({ if (!config || !fullSchema || !srcCfg) {
sourceCfg: srcCfg, return [];
sourceName: sourceCamera, }
targetInput, if (targetIsNew) {
targetIsNew, const finalName = processCameraName(watchedNewName || "").finalCameraName;
selectedKeys: selectedCategories, if (!watchedNewName || !finalName) return [];
fullConfig: config, return [
fullSchema, {
rawPaths, 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, config,
fullSchema, fullSchema,
srcCfg, srcCfg,
sourceCamera, sourceCamera,
targetIsNew, targetIsNew,
existingTarget, existingTargets,
watchedNewName, watchedNewName,
selectedCategories, selectedCategories,
rawPaths, rawPaths,
]); ]);
const previewTarget = targetIsNew const previewPayloads = useMemo(
? processCameraName(watchedNewName || "").finalCameraName () => targetPayloads.flatMap((tp) => tp.payloads),
: existingTarget; [targetPayloads],
);
const previewItems = useMemo( const previewItems = useMemo(
() => () =>
previewTarget targetPayloads.flatMap((tp) =>
? buildClonePreviewItems(previewPayloads, previewTarget) buildClonePreviewItems(tp.payloads, tp.target),
: [], ),
[previewPayloads, previewTarget], [targetPayloads],
); );
const anyNeedsRestart = previewPayloads.some((p) => p.needsRestart); const anyNeedsRestart = previewPayloads.some((p) => p.needsRestart);
@ -302,38 +378,126 @@ export default function CloneCameraDialog({
return; return;
} }
const target = targetIsNew const friendlyName = (cam: string) =>
? processCameraName(values.newName.trim()).finalCameraName config.cameras?.[cam]?.friendly_name ?? cam;
: values.existingTarget;
const targetLabel = targetIsNew const extractError = (error: unknown) =>
? values.newName.trim() (axios.isAxiosError(error) &&
: (config.cameras?.[target]?.friendly_name ?? target); (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); setIsSubmitting(true);
let appliedCount = 0;
let failedSection: string | undefined; if (targetIsNew) {
let failureMessage: string | undefined; 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 { try {
for (const payload of previewPayloads) { for (const { target, payloads } of targetPayloads) {
try { let cameraError: string | undefined;
await axios.put("config/set", { for (const payload of payloads) {
requires_restart: payload.needsRestart ? 1 : 0, try {
update_topic: payload.updateTopic, await axios.put("config/set", {
config_data: buildConfigDataForPath( requires_restart: payload.needsRestart ? 1 : 0,
payload.basePath, update_topic: payload.updateTopic,
payload.sanitizedOverrides, config_data: buildConfigDataForPath(
), payload.basePath,
}); payload.sanitizedOverrides,
appliedCount += 1; ),
} catch (error) { });
failedSection = payload.basePath; } catch (error) {
failureMessage = cameraError = extractError(error);
(axios.isAxiosError(error) && break;
(error.response?.data?.message || }
error.response?.data?.detail)) || }
(error instanceof Error ? error.message : "Unknown error"); if (cameraError) {
break; failed.push(friendlyName(target));
lastError = cameraError;
} else {
succeeded.push(friendlyName(target));
} }
} }
} finally { } finally {
@ -341,50 +505,41 @@ export default function CloneCameraDialog({
setIsSubmitting(false); setIsSubmitting(false);
} }
if (failedSection) { if (failed.length > 0) {
if (targetIsNew && appliedCount > 0) { toast.error(
toast.error( t("cameraManagement.clone.toast.partialFailureMulti", {
t("cameraManagement.clone.toast.newCameraPartialFailure", { successCount: succeeded.length,
cameraName: targetLabel, failed: failed.join(", "),
errorMessage: failureMessage, errorMessage: lastError,
}), }),
{ position: "top-center" }, { position: "top-center", duration: 10000 },
); );
} else {
toast.error(
t("cameraManagement.clone.toast.partialFailure", {
successCount: appliedCount,
failedSection,
errorMessage: failureMessage,
}),
{ position: "top-center" },
);
}
return; return;
} }
const singleLabel = succeeded.length === 1 ? succeeded[0] : undefined;
if (anyNeedsRestart) { if (anyNeedsRestart) {
markRestartRequired();
toast.success( toast.success(
t("cameraManagement.clone.toast.successWithRestart", { singleLabel
cameraName: targetLabel, ? t("cameraManagement.clone.toast.successWithRestart", {
}), cameraName: singleLabel,
{ })
position: "top-center", : t("cameraManagement.clone.toast.successMultiWithRestart", {
duration: 10000, count: succeeded.length,
action: ( }),
<a onClick={() => setRestartDialogOpen(true)}> { position: "top-center", duration: 10000, action: restartAction },
<Button>
{t("restart.button", { ns: "components/dialog" })}
</Button>
</a>
),
},
); );
} else { } else {
toast.success( toast.success(
t("cameraManagement.clone.toast.success", { singleLabel
cameraName: targetLabel, ? t("cameraManagement.clone.toast.success", {
}), cameraName: singleLabel,
})
: t("cameraManagement.clone.toast.successMulti", {
count: succeeded.length,
}),
{ position: "top-center" }, { position: "top-center" },
); );
} }
@ -396,9 +551,11 @@ export default function CloneCameraDialog({
srcCfg, srcCfg,
fullSchema, fullSchema,
previewPayloads, previewPayloads,
targetPayloads,
targetIsNew, targetIsNew,
anyNeedsRestart, anyNeedsRestart,
onClose, onClose,
statusBar,
t, t,
], ],
); );
@ -407,16 +564,12 @@ export default function CloneCameraDialog({
<Dialog open={open} onOpenChange={(o) => !o && onClose()}> <Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent <DialogContent
className={cn( 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()} onInteractOutside={(e) => e.preventDefault()}
> >
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>{t("cameraManagement.clone.title")}</DialogTitle>
{t("cameraManagement.clone.title", {
cameraName: sourceFriendlyName,
})}
</DialogTitle>
<DialogDescription> <DialogDescription>
{t("cameraManagement.clone.description")} {t("cameraManagement.clone.description")}
</DialogDescription> </DialogDescription>
@ -424,6 +577,43 @@ export default function CloneCameraDialog({
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> <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"> <div className="space-y-3">
<Label className="text-base"> <Label className="text-base">
{t("cameraManagement.clone.target.legend")} {t("cameraManagement.clone.target.legend")}
@ -467,6 +657,7 @@ export default function CloneCameraDialog({
<FormControl> <FormControl>
<Input <Input
{...form.register("newName")} {...form.register("newName")}
className="max-w-xs"
placeholder={t( placeholder={t(
"cameraManagement.clone.target.newNamePlaceholder", "cameraManagement.clone.target.newNamePlaceholder",
)} )}
@ -509,7 +700,9 @@ export default function CloneCameraDialog({
"text-muted-foreground", "text-muted-foreground",
)} )}
> >
{t("cameraManagement.clone.target.existingRadio")} {t(
"cameraManagement.clone.target.existingCamerasRadio",
)}
{otherCameras.length === 0 && ( {otherCameras.length === 0 && (
<span className="ml-2 text-xs"> <span className="ml-2 text-xs">
( (
@ -525,34 +718,102 @@ export default function CloneCameraDialog({
otherCameras.length > 0 && ( otherCameras.length > 0 && (
<FormField <FormField
control={form.control} control={form.control}
name="existingTarget" name="existingTargets"
render={({ field: tgtField }) => ( render={({ field: tgtField }) => {
<FormItem className="ml-7"> const selected = tgtField.value ?? [];
<FormControl> const allSelected =
<Select otherCameras.length > 0 &&
value={tgtField.value} otherCameras.every((c) =>
onValueChange={tgtField.onChange} selected.includes(c),
> );
<SelectTrigger className="w-full max-w-xs"> const selectedNames = otherCameras
<SelectValue .filter((c) => selected.includes(c))
placeholder={t( .map(
"cameraManagement.clone.target.existingPlaceholder", (c) =>
)} config?.cameras?.[c]?.friendly_name ??
/> c,
</SelectTrigger> );
<SelectContent> const summary = allSelected
{otherCameras.map((cam) => ( ? t(
<SelectItem key={cam} value={cam}> "cameraManagement.clone.target.allCameras",
{config?.cameras?.[cam] )
?.friendly_name ?? cam} : selectedNames.length > 0
</SelectItem> ? selectedNames.join(", ")
))} : t(
</SelectContent> "cameraManagement.clone.target.existingPlaceholder",
</Select> );
</FormControl> return (
<FormMessage /> <FormItem className="ml-7">
</FormItem> <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> </div>
@ -617,9 +878,8 @@ export default function CloneCameraDialog({
{t("cameraManagement.clone.categories.spatial")} {t("cameraManagement.clone.categories.spatial")}
</Label> </Label>
{!targetIsNew && {!targetIsNew &&
!resMatch &&
srcCfg?.detect && srcCfg?.detect &&
dstCfg?.detect && ( mismatchedTargets.length > 0 && (
<Alert variant="warning"> <Alert variant="warning">
<LuTriangleAlert className="size-5" /> <LuTriangleAlert className="size-5" />
<AlertTitle> <AlertTitle>
@ -632,13 +892,14 @@ export default function CloneCameraDialog({
"cameraManagement.clone.categories.spatialWarning", "cameraManagement.clone.categories.spatialWarning",
{ {
srcCamera: sourceFriendlyName, srcCamera: sourceFriendlyName,
dstCamera:
config?.cameras?.[existingTarget]
?.friendly_name ?? existingTarget,
srcWidth: srcCfg.detect.width, srcWidth: srcCfg.detect.width,
srcHeight: srcCfg.detect.height, srcHeight: srcCfg.detect.height,
dstWidth: dstCfg.detect.width, cameras: mismatchedTargets
dstHeight: dstCfg.detect.height, .map(
(c) =>
config?.cameras?.[c]?.friendly_name ?? c,
)
.join(", "),
}, },
)} )}
</AlertDescription> </AlertDescription>

View File

@ -53,7 +53,12 @@ function resolveDef(schema: RJSFSchema, name: string): RJSFSchema | undefined {
return defs ? defs[name] : 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( function stripAutoDefaultFilters(
section: string, section: string,
sourceSection: JsonObject, sourceSection: JsonObject,
@ -80,20 +85,56 @@ function stripAutoDefaultFilters(
) )
: new Set<string>(); : 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 = {}; const cleaned: JsonObject = {};
for (const [label, value] of Object.entries(filters as JsonObject)) { for (const [label, value] of Object.entries(filters as JsonObject)) {
const expected = attributeSet.has(label) ? attributeDefaults : baseDefaults; const expected = attributeSet.has(label) ? attributeDefaults : baseDefaults;
if (isEqual(value, expected)) continue; const valNorm = withoutRuntimeFields(value as JsonValue);
cleaned[label] = 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 }; return { ...sourceSection, filters: cleaned };
} }
/** /**
* Strip named runtime-only fields from each entry in a dict-of-objects * Strip runtime-only fields from each entry of a dict-of-objects (mask
* (mask `enabled_in_config`/`raw_coordinates`, zone `color`). The settings * `enabled_in_config`/`raw_coordinates`, zone `color`) that clone re-injects
* UI doesn't need this because BaseSection's form never exposes these * from the API.
* sub-collections; we do because clone re-injects them from the API.
*/ */
function stripDictEntryFields( function stripDictEntryFields(
dict: unknown, dict: unknown,
@ -135,11 +176,9 @@ function stripResetMarkers(
} }
/** /**
* Collapse per-section payloads into one camera-level payload + `…/add` * Collapse per-section payloads into one camera-level `…/add` payload. The
* topic. New cameras are created atomically by the backend's `add` * backend's `add` handler validates atomically, avoiding the per-section
* handler, so a single PUT avoids the intermediate-validation ordering * ordering problem (e.g. `review.required_zones` referencing unwritten zones).
* problem (e.g. a `review` required_zone referencing zones not yet written)
* that the per-section path is subject to.
*/ */
function bundleNewCameraPayload( function bundleNewCameraPayload(
payloads: SectionSavePayload[], payloads: SectionSavePayload[],
@ -166,10 +205,8 @@ function bundleNewCameraPayload(
} }
/** /**
* Drop empty `*_args` arrays from ffmpeg inputs. Mirrors * Drop empty `*_args` arrays from ffmpeg inputs the establishing payload
* `sanitizeOverridesForSection`'s ffmpeg cleanup, which we don't go * uses `buildOverrides` directly, bypassing `sanitizeOverridesForSection`.
* through here because the establishing payload uses `buildOverrides`
* directly.
*/ */
function cleanupFfmpegInputArgs( function cleanupFfmpegInputArgs(
ffmpeg: JsonValue | undefined, ffmpeg: JsonValue | undefined,
@ -225,10 +262,9 @@ function restoreFfmpegPaths(
} }
/** /**
* Replay the backend's per-camera detect-field formulas (`frigate/config/ * Replay the backend's per-camera detect-field formulas on the synthetic
* config.py`) on the synthetic side so they cancel out of the diff. The * baseline so the source's computed values cancel out of the diff (the global
* global config doesn't get per-camera derivation, so without this the * config has no per-camera derivation).
* source's computed values surface as overrides.
*/ */
function applyDetectComputedDefaults( function applyDetectComputedDefaults(
detect: JsonObject, detect: JsonObject,
@ -473,12 +509,9 @@ export function buildClonedCameraPayloads({
} }
} }
// Section-backed categories — flow through prepareSectionSavePayload // Order matters for the existing-camera multi-PUT path (each PUT re-validates
// for matching restart/update-topic behavior. Order matters for the // the whole config): `detect` then `zones` must precede sections that
// existing-camera multi-PUT path: each PUT re-validates the whole // reference zones via `required_zones` (review, objects, snapshots, mqtt).
// config, so dependency providers must precede consumers — `detect`
// (resolution) then `zones` before sections that reference zones via
// `required_zones` (review, objects, snapshots, mqtt).
const SECTION_KEYS: Array<{ key: CloneCategoryKey; section: string }> = [ const SECTION_KEYS: Array<{ key: CloneCategoryKey; section: string }> = [
{ key: "detect", section: "detect" }, { key: "detect", section: "detect" },
{ key: "zones", section: "zones" }, { key: "zones", section: "zones" },
@ -500,14 +533,11 @@ export function buildClonedCameraPayloads({
{ key: "genai", section: "genai" }, { key: "genai", section: "genai" },
]; ];
// Synthetic target so we can reuse prepareSectionSavePayload unchanged. // Synthetic target reused as the diff baseline. New-camera: seed sections
// For new-camera target, seed sections where camera schema accepts all // whose camera schema accepts all global fields (correct inheritance
// global fields — gives buildOverrides the right inheritance baseline. // baseline), but leave divergent per-camera sections (mqtt, birdseye, lpr,
// Sections with divergent per-camera Pydantic classes (mqtt, birdseye, // face_recognition, semantic_search, audio_transcription, genai) unset —
// lpr, face_recognition, semantic_search, audio_transcription, genai) // seeding from global would surface its extra fields as Reset markers.
// are left unset so prepareSectionSavePayload's schema defaults handle
// filtering instead — seeding from global would emit its extra fields
// as Reset markers.
const GLOBAL_INHERITED_SECTIONS = [ const GLOBAL_INHERITED_SECTIONS = [
"detect", "detect",
"objects", "objects",
@ -531,24 +561,39 @@ export function buildClonedCameraPayloads({
]).filter(([, value]) => value !== undefined && value !== null), ]).filter(([, value]) => value !== undefined && value !== null),
), ),
} as unknown as FrigateConfig["cameras"][string]) } as unknown as FrigateConfig["cameras"][string])
: (fullConfig.cameras?.[target] ?? : ((fullConfig.cameras?.[target]
({ enabled: true } as unknown as FrigateConfig["cameras"][string])); ? cloneDeep(fullConfig.cameras[target])
: { enabled: true }) as unknown as FrigateConfig["cameras"][string]);
// Symmetric filter strip: same treatment as the per-section source // Strip auto-default filters from the baseline (matching the per-section
// strip below, so default-only entries cancel out of the diff. // 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)) { for (const section of Object.keys(FILTER_SECTION_DEFS)) {
const syntheticSection = (syntheticTargetCamera as unknown as JsonObject)[ const syntheticSection = syntheticCameraObj[section];
section
];
if (syntheticSection && typeof syntheticSection === "object") { if (syntheticSection && typeof syntheticSection === "object") {
(syntheticTargetCamera as unknown as JsonObject)[section] = syntheticCameraObj[section] = stripAutoDefaultFilters(
stripAutoDefaultFilters( section,
section, syntheticSection as JsonObject,
syntheticSection as JsonObject, fullSchema,
fullSchema, fullConfig,
fullConfig, syntheticTargetCamera as CameraConfig,
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. // so apply the formulas using source's fps to keep both sides aligned.
// Existing-camera target already has the values from its own parse. // Existing-camera target already has the values from its own parse.
if (targetIsNew && sourceCfg.detect) { if (targetIsNew && sourceCfg.detect) {
const syntheticCameraObj = syntheticTargetCamera as unknown as JsonObject;
const syntheticDetect = syntheticCameraObj.detect; const syntheticDetect = syntheticCameraObj.detect;
if (syntheticDetect && typeof syntheticDetect === "object") { if (syntheticDetect && typeof syntheticDetect === "object") {
syntheticCameraObj.detect = applyDetectComputedDefaults( syntheticCameraObj.detect = applyDetectComputedDefaults(
@ -583,10 +627,9 @@ export function buildClonedCameraPayloads({
)[section]; )[section];
if (sourceSectionValue == null) continue; if (sourceSectionValue == null) continue;
// Sanitize the source the same way BaseSection's form does // Sanitize the source like BaseSection's form does: strip runtime/derived
// implicitly: strips runtime/derived fields and function-resolved // and hidden-path fields (e.g. `hideAttributeFilters` drops untracked
// hidden paths (e.g. `hideAttributeFilters` removing untracked- // attributes based on the source's track list).
// attribute entries based on source's track list).
const sectionConfig = getSectionConfig(section, "camera"); const sectionConfig = getSectionConfig(section, "camera");
const resolvedHiddenFields = resolveHiddenFieldEntries( const resolvedHiddenFields = resolveHiddenFieldEntries(
sectionConfig.hiddenFields, sectionConfig.hiddenFields,

View File

@ -1,12 +1,18 @@
import Heading from "@/components/ui/heading"; import Heading from "@/components/ui/heading";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { import {
CONTROL_COLUMN_CLASS_NAME, CONTROL_COLUMN_CLASS_NAME,
SettingsGroupCard, SettingsGroupCard,
SPLIT_ROW_CLASS_NAME, SPLIT_ROW_CLASS_NAME,
} from "@/components/card/SettingsGroupCard"; } from "@/components/card/SettingsGroupCard";
import { toast } from "sonner"; import { toast } from "sonner";
import { Toaster } from "@/components/ui/sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import useSWR from "swr"; import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
@ -52,6 +58,7 @@ import {
import type { ProfileState } from "@/types/profile"; import type { ProfileState } from "@/types/profile";
import { getProfileColor } from "@/utils/profileColors"; import { getProfileColor } from "@/utils/profileColors";
import { isReplayCamera } from "@/utils/cameraUtil"; import { isReplayCamera } from "@/utils/cameraUtil";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
Select, Select,
@ -90,9 +97,7 @@ export default function CameraManagementView({
const [showWizard, setShowWizard] = useState(false); const [showWizard, setShowWizard] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [cloneSourceCamera, setCloneSourceCamera] = useState<string | null>( const [showCloneDialog, setShowCloneDialog] = useState(false);
null,
);
// State for restart dialog when enabling a disabled camera // State for restart dialog when enabling a disabled camera
const [restartDialogOpen, setRestartDialogOpen] = useState(false); const [restartDialogOpen, setRestartDialogOpen] = useState(false);
@ -222,12 +227,6 @@ export default function CameraManagementView({
return ( return (
<> <>
<Toaster
richColors
className="z-[1000]"
position="top-center"
closeButton
/>
<div className="flex size-full space-y-6"> <div className="flex size-full space-y-6">
<div className="scrollbar-container flex-1 overflow-y-auto pb-2"> <div className="scrollbar-container flex-1 overflow-y-auto pb-2">
<Heading as="h4" className="mb-2"> <Heading as="h4" className="mb-2">
@ -259,6 +258,27 @@ export default function CameraManagementView({
)} )}
</div> </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) && ( {(enabledCameras.length > 0 || disabledCameras.length > 0) && (
<SettingsGroupCard <SettingsGroupCard
title={ title={
@ -293,7 +313,6 @@ export default function CameraManagementView({
onConfigChanged={updateConfig} onConfigChanged={updateConfig}
onDragEnd={handleReorderDragEnd} onDragEnd={handleReorderDragEnd}
setRestartDialogOpen={setRestartDialogOpen} setRestartDialogOpen={setRestartDialogOpen}
onClone={setCloneSourceCamera}
/> />
))} ))}
</Reorder.Group> </Reorder.Group>
@ -313,7 +332,6 @@ export default function CameraManagementView({
camera={camera} camera={camera}
onConfigChanged={updateConfig} onConfigChanged={updateConfig}
setRestartDialogOpen={setRestartDialogOpen} setRestartDialogOpen={setRestartDialogOpen}
onClone={setCloneSourceCamera}
/> />
))} ))}
</div> </div>
@ -372,9 +390,8 @@ export default function CameraManagementView({
onRestart={() => sendRestart("restart")} onRestart={() => sendRestart("restart")}
/> />
<CloneCameraDialog <CloneCameraDialog
open={cloneSourceCamera !== null} open={showCloneDialog}
onClose={() => setCloneSourceCamera(null)} onClose={() => setShowCloneDialog(false)}
sourceCamera={cloneSourceCamera ?? ""}
/> />
</> </>
); );
@ -416,7 +433,6 @@ type ActiveCameraRowProps = {
onConfigChanged: () => Promise<unknown>; onConfigChanged: () => Promise<unknown>;
onDragEnd: () => void; onDragEnd: () => void;
setRestartDialogOpen: React.Dispatch<React.SetStateAction<boolean>>; setRestartDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
onClone: (camera: string) => void;
}; };
function ActiveCameraRow({ function ActiveCameraRow({
@ -424,7 +440,6 @@ function ActiveCameraRow({
onConfigChanged, onConfigChanged,
onDragEnd, onDragEnd,
setRestartDialogOpen, setRestartDialogOpen,
onClone,
}: ActiveCameraRowProps) { }: ActiveCameraRowProps) {
const { t } = useTranslation(["views/settings"]); const { t } = useTranslation(["views/settings"]);
const controls = useDragControls(); const controls = useDragControls();
@ -452,22 +467,6 @@ function ActiveCameraRow({
cameraName={camera} cameraName={camera}
onConfigChanged={onConfigChanged} 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> </div>
<CameraStatusSelect <CameraStatusSelect
cameraName={camera} cameraName={camera}
@ -483,17 +482,13 @@ type DisabledCameraRowProps = {
camera: string; camera: string;
onConfigChanged: () => Promise<unknown>; onConfigChanged: () => Promise<unknown>;
setRestartDialogOpen: React.Dispatch<React.SetStateAction<boolean>>; setRestartDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
onClone: (camera: string) => void;
}; };
function DisabledCameraRow({ function DisabledCameraRow({
camera, camera,
onConfigChanged, onConfigChanged,
setRestartDialogOpen, setRestartDialogOpen,
onClone,
}: DisabledCameraRowProps) { }: DisabledCameraRowProps) {
const { t } = useTranslation(["views/settings"]);
return ( return (
<div className="flex flex-row items-center justify-between"> <div className="flex flex-row items-center justify-between">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@ -502,22 +497,6 @@ function DisabledCameraRow({
cameraName={camera} cameraName={camera}
onConfigChanged={onConfigChanged} 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> </div>
<CameraStatusSelect <CameraStatusSelect
cameraName={camera} cameraName={camera}
@ -551,6 +530,7 @@ function CameraStatusSelect({
]); ]);
const { payload: enabledState, send: sendEnabled } = const { payload: enabledState, send: sendEnabled } =
useEnabledState(cameraName); useEnabledState(cameraName);
const statusBar = useContext(StatusBarMessagesContext);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const currentStatus: CameraStatus = isDisabledInConfig const currentStatus: CameraStatus = isDisabledInConfig
@ -636,6 +616,12 @@ function CameraStatusSelect({
}, },
}); });
await onConfigChanged(); await onConfigChanged();
statusBar?.addMessage(
"config_restart_required",
t("configForm.restartRequiredFooter", { ns: "views/settings" }),
undefined,
"config_restart_required",
);
toast.success( toast.success(
t("cameraManagement.streams.disableSuccess", { t("cameraManagement.streams.disableSuccess", {
ns: "views/settings", ns: "views/settings",
@ -667,6 +653,7 @@ function CameraStatusSelect({
onConfigChanged, onConfigChanged,
sendEnabled, sendEnabled,
setRestartDialogOpen, setRestartDialogOpen,
statusBar,
t, t,
], ],
); );