import { useCallback, useEffect, useMemo, 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 { 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 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 { data: rawPaths } = useSWR("config/raw_paths"); 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 selectAllCategories = useCallback(() => { setSelectedCategories((prev) => { const next = new Set(prev); const includeSpatial = targetIsNew || resMatch; 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, resMatch]); 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 [restartDialogOpen, setRestartDialogOpen] = useState(false); const watchedNewName = useWatch({ control: form.control, name: "newName" }) ?? ""; const previewPayloads = useMemo(() => { if (!config || !fullSchema || !srcCfg) return []; const targetInput = targetIsNew ? watchedNewName : existingTarget; if (!targetInput) return []; if (!targetIsNew && !config.cameras?.[targetInput]) return []; return buildClonedCameraPayloads({ sourceCfg: srcCfg, sourceName: sourceCamera, targetInput, targetIsNew, selectedKeys: selectedCategories, fullConfig: config, fullSchema, rawPaths, }); }, [ config, fullSchema, srcCfg, sourceCamera, targetIsNew, existingTarget, watchedNewName, selectedCategories, rawPaths, ]); const previewTarget = targetIsNew ? processCameraName(watchedNewName || "").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")}
(
{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 && ( ( )} /> )}
)} />
{t("cameraManagement.clone.categories.selectAll")} {t("cameraManagement.clone.categories.selectNone")}
{groupedCategories.general.map((cat) => ( ))}
{groupedCategories.spatial.length > 0 && (
{!targetIsNew && !resMatch && srcCfg?.detect && dstCfg?.detect && ( {t( "cameraManagement.clone.categories.spatialWarningTitle", )} {t( "cameraManagement.clone.categories.spatialWarning", { srcCamera: sourceFriendlyName, dstCamera: config?.cameras?.[existingTarget] ?.friendly_name ?? existingTarget, srcWidth: srcCfg.detect.width, srcHeight: srcCfg.detect.height, dstWidth: dstCfg.detect.width, dstHeight: dstCfg.detect.height, }, )} )}
{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")} />
); }