import { useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react"; import { useForm, useWatch } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { useTranslation } from "react-i18next"; import useSWR, { mutate as swrMutate } from "swr"; import axios from "axios"; import { toast } from "sonner"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { cn } from "@/lib/utils"; import { isReplayCamera, processCameraName } from "@/utils/cameraUtil"; import type { FrigateConfig } from "@/types/frigateConfig"; import { Checkbox } from "@/components/ui/checkbox"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Label } from "@/components/ui/label"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { LuChevronDown, LuTriangleAlert } from "react-icons/lu"; import { CLONE_CATEGORIES, type CloneCategoryKey, type CloneCategoryGroup, type RawCameraPaths, getCategoryDefaults, resolutionsMatch, buildClonedCameraPayloads, buildClonePreviewItems, } from "@/utils/cameraClone"; import { buildConfigDataForPath } from "@/utils/configUtil"; import { useConfigSchema } from "@/hooks/use-config-schema"; import { useRestart } from "@/api/ws"; import { StatusBarMessagesContext } from "@/context/statusbar-provider"; import RestartDialog from "@/components/overlay/dialog/RestartDialog"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import SaveAllPreviewPopover from "@/components/overlay/detail/SaveAllPreviewPopover"; import FilterSwitch from "@/components/filter/FilterSwitch"; type CloneCameraDialogProps = { open: boolean; onClose: () => void; }; type CloneFormValues = { sourceCamera: string; targetMode: "new" | "existing"; newName: string; existingTargets: string[]; }; export default function CloneCameraDialog({ open, onClose, }: CloneCameraDialogProps) { const { t } = useTranslation(["views/settings", "common"]); const { data: config } = useSWR("config"); const { data: rawPaths } = useSWR("config/raw_paths"); const [isSubmitting, setIsSubmitting] = useState(false); const sourceCameras = useMemo(() => { if (!config) return []; return Object.keys(config.cameras) .filter((c) => !isReplayCamera(c)) .sort(); }, [config]); const formSchema = useMemo(() => { const reservedNames = new Set([ ...(config ? Object.keys(config.cameras) : []), ...(config?.go2rtc?.streams ? Object.keys(config.go2rtc.streams) : []), ]); return z .object({ sourceCamera: z.string(), targetMode: z.enum(["new", "existing"]), newName: z.string(), existingTargets: z.array(z.string()), }) .superRefine((data, ctx) => { if (!data.sourceCamera) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["sourceCamera"], message: t("cameraManagement.clone.source.required"), }); } if (data.targetMode === "new") { const trimmed = data.newName.trim(); if (!trimmed) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["newName"], message: t("cameraManagement.clone.target.newNameRequired"), }); return; } const { finalCameraName } = processCameraName(trimmed); if (!finalCameraName) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["newName"], message: t("cameraManagement.clone.target.newNameInvalid"), }); return; } if (reservedNames.has(finalCameraName)) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["newName"], message: t("cameraManagement.clone.target.newNameCollision"), }); } } else if (data.existingTargets.length === 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["existingTargets"], message: t("cameraManagement.clone.target.existingPlaceholder"), }); } }); }, [config, t]); const form = useForm({ resolver: zodResolver(formSchema), mode: "onChange", defaultValues: { sourceCamera: "", targetMode: "new", newName: "", existingTargets: [], }, }); const sourceCamera = form.watch("sourceCamera"); const targetMode = form.watch("targetMode"); const existingTargets = form.watch("existingTargets"); const targetIsNew = targetMode === "new"; const otherCameras = useMemo(() => { if (!config) return []; return Object.keys(config.cameras) .filter((c) => c !== sourceCamera && !isReplayCamera(c)) .sort(); }, [config, sourceCamera]); const srcCfg = config?.cameras?.[sourceCamera]; // Existing targets whose detect resolution differs from the source. Spatial // settings use detect-resolution coordinates, so cloning them to a camera // with a different resolution is flagged (but still allowed). const mismatchedTargets = useMemo(() => { if (targetIsNew || !srcCfg?.detect) return []; return existingTargets.filter((cam) => { const dst = config?.cameras?.[cam]; return dst?.detect && !resolutionsMatch(srcCfg.detect, dst.detect); }); }, [targetIsNew, srcCfg, existingTargets, config]); const allResMatch = mismatchedTargets.length === 0; const [selectedCategories, setSelectedCategories] = useState< Set >(() => getCategoryDefaults(true)); // Reset form + selection only on the open transition const wasOpenRef = useRef(false); useEffect(() => { if (open && !wasOpenRef.current) { wasOpenRef.current = true; form.reset({ sourceCamera: "", targetMode: "new", newName: "", existingTargets: [], }); setSelectedCategories(getCategoryDefaults(true)); } else if (!open) { wasOpenRef.current = false; } }, [open, form]); // Drop the source camera from the target selection if it gets picked. useEffect(() => { if (!sourceCamera) return; const current = form.getValues("existingTargets"); if (current.includes(sourceCamera)) { form.setValue( "existingTargets", current.filter((c) => c !== sourceCamera), ); } }, [sourceCamera, form]); // Reset selection to per-mode defaults when the user switches target mode. useEffect(() => { setSelectedCategories(getCategoryDefaults(targetIsNew)); }, [targetIsNew]); const toggleCategory = useCallback((key: CloneCategoryKey) => { setSelectedCategories((prev) => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); }, []); const selectAllCategories = useCallback(() => { setSelectedCategories((prev) => { const next = new Set(prev); const includeSpatial = targetIsNew || allResMatch; for (const cat of CLONE_CATEGORIES) { if (cat.newCameraOnly && !targetIsNew) continue; if (cat.group === "spatial" && !includeSpatial) continue; if (cat.group === "streams") continue; next.add(cat.key); } return next; }); }, [targetIsNew, allResMatch]); const selectNoneCategories = useCallback(() => { setSelectedCategories((prev) => { const next = new Set(); for (const cat of CLONE_CATEGORIES) { if (cat.group === "streams" && prev.has(cat.key)) { next.add(cat.key); } } return next; }); }, []); const visibleCategories = useMemo( () => CLONE_CATEGORIES.filter((c) => targetIsNew || !c.newCameraOnly), [targetIsNew], ); const groupedCategories = useMemo(() => { const groups: Record = { general: [], spatial: [], streams: [], }; for (const c of visibleCategories) { groups[c.group].push(c); } return groups; }, [visibleCategories]); const sourceFriendlyName = config?.cameras?.[sourceCamera]?.friendly_name ?? sourceCamera; const fullSchema = useConfigSchema(); const { send: sendRestart } = useRestart(); const statusBar = useContext(StatusBarMessagesContext); const [restartDialogOpen, setRestartDialogOpen] = useState(false); const watchedNewName = useWatch({ control: form.control, name: "newName" }) ?? ""; // Payloads grouped per destination camera. New mode has a single target; // existing mode fans out across every selected camera. const targetPayloads = useMemo< { target: string; payloads: ReturnType }[] >(() => { if (!config || !fullSchema || !srcCfg) { return []; } if (targetIsNew) { const finalName = processCameraName(watchedNewName || "").finalCameraName; if (!watchedNewName || !finalName) return []; return [ { target: finalName, payloads: buildClonedCameraPayloads({ sourceCfg: srcCfg, sourceName: sourceCamera, targetInput: watchedNewName, targetIsNew: true, selectedKeys: selectedCategories, fullConfig: config, fullSchema, rawPaths, }), }, ]; } return existingTargets .filter((cam) => config.cameras?.[cam]) .map((cam) => ({ target: cam, payloads: buildClonedCameraPayloads({ sourceCfg: srcCfg, sourceName: sourceCamera, targetInput: cam, targetIsNew: false, selectedKeys: selectedCategories, fullConfig: config, fullSchema, rawPaths, }), })); }, [ config, fullSchema, srcCfg, sourceCamera, targetIsNew, existingTargets, watchedNewName, selectedCategories, rawPaths, ]); const previewPayloads = useMemo( () => targetPayloads.flatMap((tp) => tp.payloads), [targetPayloads], ); const previewItems = useMemo( () => targetPayloads.flatMap((tp) => buildClonePreviewItems(tp.payloads, tp.target), ), [targetPayloads], ); const anyNeedsRestart = previewPayloads.some((p) => p.needsRestart); const changeCount = previewItems.length; const onSubmit = useCallback( async (values: CloneFormValues) => { if (!config || !srcCfg || !fullSchema) return; if (previewPayloads.length === 0) { toast.error( t("cameraManagement.clone.toast.submitError", { errorMessage: t("cameraManagement.clone.footer.changeCount", { count: 0, }), }), ); return; } const friendlyName = (cam: string) => config.cameras?.[cam]?.friendly_name ?? cam; const extractError = (error: unknown) => (axios.isAxiosError(error) && (error.response?.data?.message || error.response?.data?.detail)) || (error instanceof Error ? error.message : "Unknown error"); const restartAction = ( setRestartDialogOpen(true)}> ); const markRestartRequired = () => statusBar?.addMessage( "config_restart_required", t("configForm.restartRequiredFooter"), undefined, "config_restart_required", ); setIsSubmitting(true); if (targetIsNew) { const targetLabel = values.newName.trim(); const payloads = targetPayloads[0]?.payloads ?? []; let appliedCount = 0; let failedSection: string | undefined; let failureMessage: string | undefined; try { for (const payload of payloads) { try { await axios.put("config/set", { requires_restart: payload.needsRestart ? 1 : 0, update_topic: payload.updateTopic, config_data: buildConfigDataForPath( payload.basePath, payload.sanitizedOverrides, ), }); appliedCount += 1; } catch (error) { failedSection = payload.basePath; failureMessage = extractError(error); break; } } } finally { await swrMutate("config"); setIsSubmitting(false); } if (failedSection) { toast.error( appliedCount > 0 ? t("cameraManagement.clone.toast.newCameraPartialFailure", { cameraName: targetLabel, errorMessage: failureMessage, }) : t("cameraManagement.clone.toast.partialFailure", { successCount: appliedCount, failedSection, errorMessage: failureMessage, }), { position: "top-center" }, ); return; } if (anyNeedsRestart) { markRestartRequired(); toast.success( t("cameraManagement.clone.toast.successWithRestart", { cameraName: targetLabel, }), { position: "top-center", duration: 10000, action: restartAction }, ); } else { toast.success( t("cameraManagement.clone.toast.success", { cameraName: targetLabel, }), { position: "top-center" }, ); } onClose(); return; } // One or more existing cameras: keep going if a camera fails, summarize. const succeeded: string[] = []; const failed: string[] = []; let lastError: string | undefined; try { for (const { target, payloads } of targetPayloads) { let cameraError: string | undefined; for (const payload of payloads) { try { await axios.put("config/set", { requires_restart: payload.needsRestart ? 1 : 0, update_topic: payload.updateTopic, config_data: buildConfigDataForPath( payload.basePath, payload.sanitizedOverrides, ), }); } catch (error) { cameraError = extractError(error); break; } } if (cameraError) { failed.push(friendlyName(target)); lastError = cameraError; } else { succeeded.push(friendlyName(target)); } } } finally { await swrMutate("config"); setIsSubmitting(false); } if (failed.length > 0) { toast.error( t("cameraManagement.clone.toast.partialFailureMulti", { successCount: succeeded.length, failed: failed.join(", "), errorMessage: lastError, }), { position: "top-center", duration: 10000 }, ); return; } const singleLabel = succeeded.length === 1 ? succeeded[0] : undefined; if (anyNeedsRestart) { markRestartRequired(); toast.success( singleLabel ? t("cameraManagement.clone.toast.successWithRestart", { cameraName: singleLabel, }) : t("cameraManagement.clone.toast.successMultiWithRestart", { count: succeeded.length, }), { position: "top-center", duration: 10000, action: restartAction }, ); } else { toast.success( singleLabel ? t("cameraManagement.clone.toast.success", { cameraName: singleLabel, }) : t("cameraManagement.clone.toast.successMulti", { count: succeeded.length, }), { position: "top-center" }, ); } onClose(); }, [ config, srcCfg, fullSchema, previewPayloads, targetPayloads, targetIsNew, anyNeedsRestart, onClose, statusBar, t, ], ); return ( !o && onClose()}> e.preventDefault()} > {t("cameraManagement.clone.title")} {t("cameraManagement.clone.description")}
( )} />
(
{targetMode === "new" && ( {t( "cameraManagement.clone.target.newNameLabel", )} {form.formState.errors.newName?.message && (

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

)}

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

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

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

{t("cameraManagement.clone.categories.selectAll")} {t("cameraManagement.clone.categories.selectNone")}
{groupedCategories.general.map((cat) => ( ))}
{groupedCategories.spatial.length > 0 && (
{!targetIsNew && srcCfg?.detect && mismatchedTargets.length > 0 && ( {t( "cameraManagement.clone.categories.spatialWarningTitle", )} {t( "cameraManagement.clone.categories.spatialWarning", { srcCamera: sourceFriendlyName, srcWidth: srcCfg.detect.width, srcHeight: srcCfg.detect.height, cameras: mismatchedTargets .map( (c) => config?.cameras?.[c]?.friendly_name ?? c, ) .join(", "), }, )} )}
{groupedCategories.spatial.map((cat) => ( ))}
)} {targetIsNew && groupedCategories.streams.length > 0 && (
{groupedCategories.streams.map((cat) => ( ))}
)}
{changeCount > 0 && ( <>
{t("cameraManagement.clone.footer.changeCount", { count: changeCount, })} {changeCount > 0 && ( )}
{anyNeedsRestart ? t("cameraManagement.clone.footer.restartNeeded") : t("cameraManagement.clone.footer.liveOnly")} )}
setRestartDialogOpen(false)} onRestart={() => sendRestart("restart")} />
); }