diff --git a/web/src/components/settings/CloneCameraDialog.tsx b/web/src/components/settings/CloneCameraDialog.tsx new file mode 100644 index 0000000000..a7d4f2e656 --- /dev/null +++ b/web/src/components/settings/CloneCameraDialog.tsx @@ -0,0 +1,665 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { useTranslation } from "react-i18next"; +import useSWR, { mutate as swrMutate } from "swr"; +import axios from "axios"; +import { toast } from "sonner"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { cn } from "@/lib/utils"; +import { isReplayCamera, processCameraName } from "@/utils/cameraUtil"; +import type { FrigateConfig } from "@/types/frigateConfig"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { LuTriangleAlert } from "react-icons/lu"; +import { + CLONE_CATEGORIES, + type CloneCategoryKey, + type CloneCategoryGroup, + getCategoryDefaults, + resolutionsMatch, + buildClonedCameraPayloads, + buildClonePreviewItems, +} from "@/utils/cameraClone"; +import { buildConfigDataForPath } from "@/utils/configUtil"; +import { useConfigSchema } from "@/hooks/use-config-schema"; +import { useRestart } from "@/api/ws"; +import RestartDialog from "@/components/overlay/dialog/RestartDialog"; +import ActivityIndicator from "@/components/indicators/activity-indicator"; +import SaveAllPreviewPopover from "@/components/overlay/detail/SaveAllPreviewPopover"; + +type CloneCameraDialogProps = { + open: boolean; + onClose: () => void; + sourceCamera: string; +}; + +type CloneFormValues = { + targetMode: "new" | "existing"; + newName: string; + existingTarget: string; +}; + +export default function CloneCameraDialog({ + open, + onClose, + sourceCamera, +}: CloneCameraDialogProps) { + const { t } = useTranslation(["views/settings", "common"]); + const { data: config } = useSWR("config"); + const [isSubmitting, setIsSubmitting] = useState(false); + + const otherCameras = useMemo(() => { + if (!config) return []; + return Object.keys(config.cameras) + .filter((c) => c !== sourceCamera && !isReplayCamera(c)) + .sort(); + }, [config, sourceCamera]); + + const formSchema = useMemo(() => { + const reservedNames = new Set([ + ...(config ? Object.keys(config.cameras) : []), + ...(config?.go2rtc?.streams ? Object.keys(config.go2rtc.streams) : []), + ]); + return z + .object({ + targetMode: z.enum(["new", "existing"]), + newName: z.string(), + existingTarget: z.string(), + }) + .superRefine((data, ctx) => { + if (data.targetMode === "new") { + const trimmed = data.newName.trim(); + if (!trimmed) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["newName"], + message: t("cameraManagement.clone.target.newNameRequired"), + }); + return; + } + const { finalCameraName } = processCameraName(trimmed); + if (!finalCameraName) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["newName"], + message: t("cameraManagement.clone.target.newNameInvalid"), + }); + return; + } + if (reservedNames.has(finalCameraName)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["newName"], + message: t("cameraManagement.clone.target.newNameCollision"), + }); + } + } else if (!data.existingTarget) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["existingTarget"], + message: t("cameraManagement.clone.target.existingPlaceholder"), + }); + } + }); + }, [config, t]); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + targetMode: "new", + newName: "", + existingTarget: "", + }, + }); + + // Reset whenever the dialog opens. + useEffect(() => { + if (open) { + form.reset({ + targetMode: "new", + newName: "", + existingTarget: otherCameras[0] ?? "", + }); + } + }, [open, form, otherCameras]); + + const targetMode = form.watch("targetMode"); + const existingTarget = form.watch("existingTarget"); + + const targetIsNew = targetMode === "new"; + + const srcCfg = config?.cameras?.[sourceCamera]; + const dstCfg = + !targetIsNew && existingTarget + ? config?.cameras?.[existingTarget] + : undefined; + + const resMatch = useMemo( + () => resolutionsMatch(srcCfg?.detect, dstCfg?.detect), + [srcCfg, dstCfg], + ); + + const [selectedCategories, setSelectedCategories] = useState< + Set + >(() => getCategoryDefaults(true, true)); + + // Reset defaults when target mode or target identity changes. + useEffect(() => { + setSelectedCategories(getCategoryDefaults(targetIsNew, resMatch)); + }, [targetIsNew, existingTarget, resMatch]); + + const toggleCategory = useCallback((key: CloneCategoryKey) => { + setSelectedCategories((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }, []); + + const visibleCategories = useMemo( + () => CLONE_CATEGORIES.filter((c) => targetIsNew || !c.newCameraOnly), + [targetIsNew], + ); + + const groupedCategories = useMemo(() => { + const groups: Record = { + general: [], + spatial: [], + streams: [], + }; + for (const c of visibleCategories) { + groups[c.group].push(c); + } + return groups; + }, [visibleCategories]); + + const sourceFriendlyName = + config?.cameras?.[sourceCamera]?.friendly_name ?? sourceCamera; + + const fullSchema = useConfigSchema(); + const { send: sendRestart } = useRestart(); + const [restartDialogOpen, setRestartDialogOpen] = useState(false); + + const previewPayloads = useMemo(() => { + if (!config || !fullSchema || !srcCfg) return []; + const targetInput = targetIsNew + ? form.getValues("newName") + : existingTarget; + if (!targetInput) return []; + if (!targetIsNew && !config.cameras?.[targetInput]) return []; + return buildClonedCameraPayloads({ + sourceCfg: srcCfg, + sourceName: sourceCamera, + targetInput, + targetIsNew, + selectedKeys: selectedCategories, + fullConfig: config, + fullSchema, + }); + }, [ + config, + fullSchema, + srcCfg, + targetIsNew, + existingTarget, + selectedCategories, + sourceCamera, + form, + ]); + + const previewTarget = targetIsNew + ? processCameraName(form.watch("newName") || "").finalCameraName + : existingTarget; + + const previewItems = useMemo( + () => + previewTarget + ? buildClonePreviewItems(previewPayloads, previewTarget) + : [], + [previewPayloads, previewTarget], + ); + + const anyNeedsRestart = previewPayloads.some((p) => p.needsRestart); + const changeCount = previewItems.length; + + const onSubmit = useCallback( + async (values: CloneFormValues) => { + if (!config || !srcCfg || !fullSchema) return; + if (previewPayloads.length === 0) { + toast.error( + t("cameraManagement.clone.toast.submitError", { + errorMessage: t("cameraManagement.clone.footer.changeCount", { + count: 0, + }), + }), + ); + return; + } + + const target = targetIsNew + ? processCameraName(values.newName.trim()).finalCameraName + : values.existingTarget; + const targetLabel = targetIsNew + ? values.newName.trim() + : (config.cameras?.[target]?.friendly_name ?? target); + + setIsSubmitting(true); + let appliedCount = 0; + let failedSection: string | undefined; + let failureMessage: 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; + } + } + } finally { + await swrMutate("config"); + 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" }, + ); + } + return; + } + + if (anyNeedsRestart) { + toast.success( + t("cameraManagement.clone.toast.successWithRestart", { + cameraName: targetLabel, + }), + { + position: "top-center", + duration: 10000, + action: ( + setRestartDialogOpen(true)}> + + + ), + }, + ); + } else { + toast.success( + t("cameraManagement.clone.toast.success", { + cameraName: targetLabel, + }), + { position: "top-center" }, + ); + } + + onClose(); + }, + [ + config, + srcCfg, + fullSchema, + previewPayloads, + targetIsNew, + anyNeedsRestart, + onClose, + t, + ], + ); + + return ( + !o && onClose()}> + e.preventDefault()} + > + + + {t("cameraManagement.clone.title", { + cameraName: sourceFriendlyName, + })} + + + {t("cameraManagement.clone.description")} + + + +
+ +
+ + {t("cameraManagement.clone.target.legend")} + + ( + + + +
+
+ + +
+ {targetMode === "new" && ( + ( + + + {t( + "cameraManagement.clone.target.newNameLabel", + )} + + + + + +

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

+
+ )} + /> + )} +
+
+
+ + +
+ {targetMode === "existing" && + otherCameras.length > 0 && ( + ( + + + + + + + )} + /> + )} +
+
+
+
+ )} + /> +
+ +
+ + {t("cameraManagement.clone.categories.legend")} + + +
+

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

+
+ {groupedCategories.general.map((cat) => ( + + ))} +
+
+ + {groupedCategories.spatial.length > 0 && ( +
+

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

+ {!targetIsNew && + !resMatch && + srcCfg?.detect && + dstCfg?.detect && ( + + + + {t( + "cameraManagement.clone.categories.spatialWarning", + { + srcWidth: srcCfg.detect.width, + srcHeight: srcCfg.detect.height, + dstWidth: dstCfg.detect.width, + dstHeight: dstCfg.detect.height, + }, + )} + + + )} +
+ {groupedCategories.spatial.map((cat) => ( + + ))} +
+
+ )} + + {targetIsNew && groupedCategories.streams.length > 0 && ( +
+

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

+
+ {groupedCategories.streams.map((cat) => ( + + ))} +
+
+ )} +
+ + +
+ + {t("cameraManagement.clone.footer.changeCount", { + count: changeCount, + })} + + {changeCount > 0 && ( + + )} + + {anyNeedsRestart + ? t("cameraManagement.clone.footer.restartNeeded") + : t("cameraManagement.clone.footer.liveOnly")} + +
+
+ + +
+
+
+ + setRestartDialogOpen(false)} + onRestart={() => sendRestart("restart")} + /> +
+
+ ); +} diff --git a/web/src/utils/cameraClone.ts b/web/src/utils/cameraClone.ts new file mode 100644 index 0000000000..35fe39489b --- /dev/null +++ b/web/src/utils/cameraClone.ts @@ -0,0 +1,425 @@ +import cloneDeep from "lodash/cloneDeep"; +import type { RJSFSchema } from "@rjsf/utils"; + +import { + cameraUpdateTopicMap, + flattenOverrides, + prepareSectionSavePayload, + type SectionSavePayload, +} from "@/utils/configUtil"; +import type { SaveAllPreviewItem } from "@/components/overlay/detail/SaveAllPreviewPopover"; +import type { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; +import type { JsonObject, JsonValue } from "@/types/configForm"; +import { processCameraName } from "@/utils/cameraUtil"; + +/** + * Static catalog of categories the clone dialog exposes. Each entry maps to + * one or more keys under `cameras.` and tells the dialog (and + * `buildClonedCameraPayloads`) how to render and treat the category. + * + * Most categories pair 1:1 with an existing section config (`record`, + * `motion`, `objects`, etc.) and flow through `prepareSectionSavePayload`. + * A handful are special cases handled directly in `cameraClone.ts`: + * + * - `motion_mask` / `object_masks`: carve-outs that merge into their parent + * section's payload when also selected, or emit a standalone mask-only + * payload otherwise. + * - `ffmpeg_live`: bundles `ffmpeg` + `live` for new-camera targets only. + * - `type` / `profiles`: bypass `prepareSectionSavePayload` because they + * are not edited through schema-driven form sections. + */ +export type CloneCategoryKey = + | "record" + | "snapshots" + | "review" + | "motion" + | "objects" + | "audio" + | "audio_transcription" + | "notifications" + | "birdseye" + | "mqtt" + | "timestamp_style" + | "onvif" + | "lpr" + | "face_recognition" + | "semantic_search" + | "genai" + | "type" + | "profiles" + | "detect" + | "zones" + | "motion_mask" + | "object_masks" + | "ffmpeg_live"; + +export type CloneCategoryGroup = "general" | "spatial" | "streams"; + +export type CloneCategory = { + key: CloneCategoryKey; + group: CloneCategoryGroup; + /** True when this category is only valid for "new camera" targets. */ + newCameraOnly?: boolean; + /** True when this category is forced selected for new-camera targets. */ + forcedForNewCamera?: boolean; + /** Default selection state for "existing camera" targets when resolutions match. */ + defaultOnExisting: boolean; +}; + +export const CLONE_CATEGORIES: readonly CloneCategory[] = [ + // General + { key: "record", group: "general", defaultOnExisting: true }, + { key: "snapshots", group: "general", defaultOnExisting: true }, + { key: "review", group: "general", defaultOnExisting: true }, + { key: "motion", group: "general", defaultOnExisting: true }, + { key: "objects", group: "general", defaultOnExisting: true }, + { key: "audio", group: "general", defaultOnExisting: true }, + { key: "audio_transcription", group: "general", defaultOnExisting: true }, + { key: "notifications", group: "general", defaultOnExisting: true }, + { key: "birdseye", group: "general", defaultOnExisting: true }, + { key: "mqtt", group: "general", defaultOnExisting: true }, + { key: "timestamp_style", group: "general", defaultOnExisting: true }, + { key: "onvif", group: "general", defaultOnExisting: false }, + { key: "lpr", group: "general", defaultOnExisting: true }, + { key: "face_recognition", group: "general", defaultOnExisting: true }, + { key: "semantic_search", group: "general", defaultOnExisting: true }, + { key: "genai", group: "general", defaultOnExisting: true }, + { key: "type", group: "general", defaultOnExisting: false }, + { key: "profiles", group: "general", defaultOnExisting: true }, + // Spatial — defaults computed via resolutionsMatch() + { key: "detect", group: "spatial", defaultOnExisting: true }, + { key: "zones", group: "spatial", defaultOnExisting: true }, + { key: "motion_mask", group: "spatial", defaultOnExisting: true }, + { key: "object_masks", group: "spatial", defaultOnExisting: true }, + // Streams — only for new-camera target, forced on + { + key: "ffmpeg_live", + group: "streams", + newCameraOnly: true, + forcedForNewCamera: true, + defaultOnExisting: false, + }, +] as const; + +/** + * Compare detect dimensions exactly. Used to decide whether spatial-category + * defaults start on or off when targeting an existing camera. Exact match is + * the only always-safe default — zone/mask coordinates can be stored as + * relative (0-1) OR explicit pixels, and aspect-ratio-only tolerance still + * distorts explicit polygons. + */ +export function resolutionsMatch( + srcDetect: CameraConfig["detect"] | undefined, + dstDetect: CameraConfig["detect"] | undefined, +): boolean { + if (!srcDetect || !dstDetect) return false; + if ( + typeof srcDetect.width !== "number" || + typeof srcDetect.height !== "number" + ) { + return false; + } + if ( + typeof dstDetect.width !== "number" || + typeof dstDetect.height !== "number" + ) { + return false; + } + return ( + srcDetect.width === dstDetect.width && srcDetect.height === dstDetect.height + ); +} + +/** + * Compute the initial selection set for the dialog based on target type and + * resolution match. Pure function — the dialog re-runs this whenever the + * target or source changes to reset defaults sanely. + * + * - new-camera target: every non-`newCameraOnly` category that has + * `defaultOnExisting: true` is on, plus every `forcedForNewCamera`. + * Spatial categories are all on (the cloned detect dims become the new + * camera's, so the source's polygons are internally consistent). + * + * - existing-camera target with matching resolution: each category uses + * its `defaultOnExisting`. + * + * - existing-camera target with mismatched resolution: every spatial + * category defaults off; non-spatial uses `defaultOnExisting`. + */ +export function getCategoryDefaults( + targetIsNew: boolean, + resolutionMatches: boolean, +): Set { + const selected = new Set(); + for (const cat of CLONE_CATEGORIES) { + if (cat.newCameraOnly && !targetIsNew) continue; + if (targetIsNew && cat.forcedForNewCamera) { + selected.add(cat.key); + continue; + } + if (cat.group === "spatial") { + if (targetIsNew || resolutionMatches) { + if (cat.defaultOnExisting) selected.add(cat.key); + } + continue; + } + if (cat.defaultOnExisting) selected.add(cat.key); + } + return selected; +} + +type BuildClonedPayloadsArgs = { + sourceCfg: CameraConfig; + sourceName: string; + /** Raw user input for new camera, or the existing-camera key. */ + targetInput: string; + targetIsNew: boolean; + selectedKeys: Set; + fullConfig: FrigateConfig; + fullSchema: RJSFSchema; +}; + +/** + * Produce the ordered list of section-save payloads needed to clone the + * selected categories from `sourceCfg` to the target camera. The dialog + * iterates the returned array and issues one `PUT /api/config/set` per + * payload, in array order. + * + * Order rules: + * 1. For new-camera targets, the first payload establishes the camera + * (enabled + friendly_name + ffmpeg + live) and uses + * `update_topic: config/cameras//add`. + * 2. The `type` payload (camera type lpr/normal) goes before all other + * per-section updates because it influences how the backend resolves + * camera-level overrides. + * 3. All `prepareSectionSavePayload`-derived sections come next. + * 4. The `profiles` payload (wholesale dict replacement) comes last — + * it doesn't dispatch a hot-reload topic, so ordering relative to + * other sections doesn't matter, but keeping it last keeps the + * restart-prompt logic readable. + */ +export function buildClonedCameraPayloads({ + sourceCfg, + sourceName, + targetInput, + targetIsNew, + selectedKeys, + fullConfig, + fullSchema, +}: BuildClonedPayloadsArgs): SectionSavePayload[] { + const payloads: SectionSavePayload[] = []; + + const { finalCameraName: target, friendlyName } = targetIsNew + ? processCameraName(targetInput) + : { finalCameraName: targetInput, friendlyName: undefined }; + + // 1. New-camera establishing payload + if (targetIsNew) { + const addOverrides: Record = { + enabled: true, + }; + if (friendlyName) { + addOverrides.friendly_name = friendlyName; + } + if (selectedKeys.has("ffmpeg_live") && sourceCfg.ffmpeg) { + addOverrides.ffmpeg = cloneDeep(sourceCfg.ffmpeg); + } + if (selectedKeys.has("ffmpeg_live") && sourceCfg.live) { + addOverrides.live = cloneDeep(sourceCfg.live); + } + payloads.push({ + basePath: `cameras.${target}`, + sanitizedOverrides: addOverrides as JsonObject, + updateTopic: `config/cameras/${target}/add`, + needsRestart: true, + pendingDataKey: `${target}::__add__`, + }); + } + + // 2. Camera type (top-level scalar — bypasses prepareSectionSavePayload) + if (selectedKeys.has("type")) { + const srcType = (sourceCfg as { type?: string | null }).type; + if (srcType !== undefined && srcType !== null) { + payloads.push({ + basePath: `cameras.${target}`, + sanitizedOverrides: { type: srcType }, + updateTopic: undefined, + needsRestart: true, + pendingDataKey: `${target}::type`, + }); + } + } + + // 3. Section-backed categories — flow through the existing per-section + // save infrastructure so restart and update-topic behavior matches the + // rest of the settings UI exactly. + const SECTION_KEYS: Array<{ key: CloneCategoryKey; section: string }> = [ + { key: "record", section: "record" }, + { key: "snapshots", section: "snapshots" }, + { key: "review", section: "review" }, + { key: "motion", section: "motion" }, + { key: "objects", section: "objects" }, + { key: "audio", section: "audio" }, + { key: "audio_transcription", section: "audio_transcription" }, + { key: "notifications", section: "notifications" }, + { key: "birdseye", section: "birdseye" }, + { key: "mqtt", section: "mqtt" }, + { key: "timestamp_style", section: "timestamp_style" }, + { key: "onvif", section: "onvif" }, + { key: "lpr", section: "lpr" }, + { key: "face_recognition", section: "face_recognition" }, + { key: "semantic_search", section: "semantic_search" }, + { key: "genai", section: "genai" }, + { key: "detect", section: "detect" }, + { key: "zones", section: "zones" }, + ]; + + // Build a synthetic config that pretends the target camera exists with + // the source's section value as its current pending data. This lets us + // reuse prepareSectionSavePayload unchanged. + const syntheticConfig: FrigateConfig = { + ...fullConfig, + cameras: { + ...fullConfig.cameras, + // If target is new, seed an empty camera entry so the helper can + // compute a diff against defaults instead of crashing. + [target]: + fullConfig.cameras?.[target] ?? + ({ enabled: true } as unknown as FrigateConfig["cameras"][string]), + }, + }; + + for (const { key, section } of SECTION_KEYS) { + if (!selectedKeys.has(key)) continue; + const sourceSectionValue = ( + sourceCfg as unknown as Record + )[section]; + if (sourceSectionValue == null) continue; + + let pendingSectionValue = cloneDeep(sourceSectionValue); + + // Carve-out: when both Motion sensitivity and Motion mask are selected, + // merge the mask fields into the motion payload (the motion section's + // hiddenFields would otherwise strip them before write). + if (key === "motion" && selectedKeys.has("motion_mask")) { + const srcMotion = sourceSectionValue as { + mask?: unknown; + raw_mask?: unknown; + }; + pendingSectionValue = { + ...(pendingSectionValue as object), + ...(srcMotion.mask !== undefined ? { mask: srcMotion.mask } : {}), + ...(srcMotion.raw_mask !== undefined + ? { raw_mask: srcMotion.raw_mask } + : {}), + }; + } + if (key === "objects" && selectedKeys.has("object_masks")) { + const srcObjects = sourceSectionValue as { mask?: unknown }; + pendingSectionValue = { + ...(pendingSectionValue as object), + ...(srcObjects.mask !== undefined ? { mask: srcObjects.mask } : {}), + }; + } + + const payload = prepareSectionSavePayload({ + pendingDataKey: `${target}::${section}`, + pendingData: pendingSectionValue, + config: syntheticConfig, + fullSchema, + }); + if (payload) { + payloads.push(payload); + } + } + + // 3a. Standalone mask payloads — emitted ONLY when the parent section's + // category is unselected. (When both are selected the masks were + // merged into the parent payload above.) + if (selectedKeys.has("motion_mask") && !selectedKeys.has("motion")) { + const srcMotion = sourceCfg.motion as + | { mask?: unknown; raw_mask?: unknown } + | undefined; + if ( + srcMotion && + (srcMotion.mask !== undefined || srcMotion.raw_mask !== undefined) + ) { + payloads.push({ + basePath: `cameras.${target}.motion`, + sanitizedOverrides: { + ...(srcMotion.mask !== undefined + ? { mask: srcMotion.mask as JsonValue } + : {}), + ...(srcMotion.raw_mask !== undefined + ? { raw_mask: srcMotion.raw_mask as JsonValue } + : {}), + } as JsonObject, + updateTopic: `config/cameras/${target}/${cameraUpdateTopicMap.motion}`, + needsRestart: false, + pendingDataKey: `${target}::motion.masks`, + }); + } + } + if (selectedKeys.has("object_masks") && !selectedKeys.has("objects")) { + const srcObjects = sourceCfg.objects as { mask?: unknown } | undefined; + if (srcObjects && srcObjects.mask !== undefined) { + payloads.push({ + basePath: `cameras.${target}.objects`, + sanitizedOverrides: { mask: srcObjects.mask as JsonValue }, + updateTopic: `config/cameras/${target}/${cameraUpdateTopicMap.objects}`, + needsRestart: false, + pendingDataKey: `${target}::objects.masks`, + }); + } + } + + // 4. Profiles — wholesale replacement of cameras..profiles. + if (selectedKeys.has("profiles")) { + const srcProfiles = (sourceCfg as { profiles?: unknown }).profiles; + if (srcProfiles && typeof srcProfiles === "object") { + payloads.push({ + basePath: `cameras.${target}.profiles`, + sanitizedOverrides: cloneDeep(srcProfiles) as JsonObject, + updateTopic: undefined, + needsRestart: true, + pendingDataKey: `${target}::profiles`, + }); + } + } + + // sourceName is currently unused but kept in the signature so the dialog + // can pass it explicitly (matches the helper's logical boundary — "clone + // FROM x TO y" — and lets future logging hook in without a signature change). + void sourceName; + + return payloads; +} + +/** + * Flatten clone payloads into the preview-popover item shape. Each item's + * `fieldPath` is rendered camera-relative (e.g., `record.retain.days`) + * rather than absolute (`cameras.front_door.record.retain.days`), matching + * the section-level Save All preview pattern in BaseSection. + */ +export function buildClonePreviewItems( + payloads: SectionSavePayload[], + targetCamera: string, +): SaveAllPreviewItem[] { + const cameraPrefix = `cameras.${targetCamera}.`; + return payloads.flatMap((p) => { + const flattened = flattenOverrides(p.sanitizedOverrides as JsonValue); + const sectionRelativeBase = p.basePath.startsWith(cameraPrefix) + ? p.basePath.slice(cameraPrefix.length) + : p.basePath; + return flattened.map(({ path, value }) => ({ + scope: "camera" as const, + cameraName: targetCamera, + fieldPath: path + ? sectionRelativeBase + ? `${sectionRelativeBase}.${path}` + : path + : sectionRelativeBase, + value, + })); + }); +} diff --git a/web/src/utils/configUtil.ts b/web/src/utils/configUtil.ts index 3ebe96a63b..6593c95a3a 100644 --- a/web/src/utils/configUtil.ts +++ b/web/src/utils/configUtil.ts @@ -81,6 +81,7 @@ export const cameraUpdateTopicMap: Record = { mqtt: "mqtt", onvif: "onvif", ui: "ui", + zones: "zones", }; // Sections where global config serves as the default for per-camera config.