mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-30 09:01:14 +03:00
add clone dialog
This commit is contained in:
parent
39a3667f39
commit
1ea44305ed
665
web/src/components/settings/CloneCameraDialog.tsx
Normal file
665
web/src/components/settings/CloneCameraDialog.tsx
Normal file
@ -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<FrigateConfig>("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<string>([
|
||||||
|
...(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<CloneFormValues>({
|
||||||
|
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<CloneCategoryKey>
|
||||||
|
>(() => 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<CloneCategoryGroup, typeof visibleCategories> = {
|
||||||
|
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: (
|
||||||
|
<a onClick={() => setRestartDialogOpen(true)}>
|
||||||
|
<Button>
|
||||||
|
{t("restart.button", { ns: "components/dialog" })}
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.success(
|
||||||
|
t("cameraManagement.clone.toast.success", {
|
||||||
|
cameraName: targetLabel,
|
||||||
|
}),
|
||||||
|
{ position: "top-center" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
[
|
||||||
|
config,
|
||||||
|
srcCfg,
|
||||||
|
fullSchema,
|
||||||
|
previewPayloads,
|
||||||
|
targetIsNew,
|
||||||
|
anyNeedsRestart,
|
||||||
|
onClose,
|
||||||
|
t,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent
|
||||||
|
className={cn(
|
||||||
|
"scrollbar-container max-h-[90dvh] max-w-3xl overflow-y-auto",
|
||||||
|
)}
|
||||||
|
onInteractOutside={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{t("cameraManagement.clone.title", {
|
||||||
|
cameraName: sourceFriendlyName,
|
||||||
|
})}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t("cameraManagement.clone.description")}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<fieldset className="space-y-3">
|
||||||
|
<legend className="text-sm font-medium">
|
||||||
|
{t("cameraManagement.clone.target.legend")}
|
||||||
|
</legend>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="targetMode"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<RadioGroup
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
className="space-y-3"
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RadioGroupItem value="new" id="clone-target-new" />
|
||||||
|
<label
|
||||||
|
htmlFor="clone-target-new"
|
||||||
|
className="text-sm"
|
||||||
|
>
|
||||||
|
{t("cameraManagement.clone.target.newRadio")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{targetMode === "new" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="newName"
|
||||||
|
render={({ field: nameField }) => (
|
||||||
|
<FormItem className="ml-6">
|
||||||
|
<FormLabel className="sr-only">
|
||||||
|
{t(
|
||||||
|
"cameraManagement.clone.target.newNameLabel",
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...nameField}
|
||||||
|
placeholder={t(
|
||||||
|
"cameraManagement.clone.target.newNamePlaceholder",
|
||||||
|
)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"cameraManagement.clone.target.newStreamsForced",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RadioGroupItem
|
||||||
|
value="existing"
|
||||||
|
id="clone-target-existing"
|
||||||
|
disabled={otherCameras.length === 0}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="clone-target-existing"
|
||||||
|
className={cn(
|
||||||
|
"text-sm",
|
||||||
|
otherCameras.length === 0 &&
|
||||||
|
"text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t("cameraManagement.clone.target.existingRadio")}
|
||||||
|
{otherCameras.length === 0 && (
|
||||||
|
<span className="ml-2 text-xs">
|
||||||
|
(
|
||||||
|
{t(
|
||||||
|
"cameraManagement.clone.target.existingDisabled",
|
||||||
|
)}
|
||||||
|
)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{targetMode === "existing" &&
|
||||||
|
otherCameras.length > 0 && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="existingTarget"
|
||||||
|
render={({ field: tgtField }) => (
|
||||||
|
<FormItem className="ml-6">
|
||||||
|
<FormControl>
|
||||||
|
<Select
|
||||||
|
value={tgtField.value}
|
||||||
|
onValueChange={tgtField.onChange}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full max-w-xs">
|
||||||
|
<SelectValue
|
||||||
|
placeholder={t(
|
||||||
|
"cameraManagement.clone.target.existingPlaceholder",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{otherCameras.map((cam) => (
|
||||||
|
<SelectItem key={cam} value={cam}>
|
||||||
|
{config?.cameras?.[cam]
|
||||||
|
?.friendly_name ?? cam}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset className="space-y-4">
|
||||||
|
<legend className="text-sm font-medium">
|
||||||
|
{t("cameraManagement.clone.categories.legend")}
|
||||||
|
</legend>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
{t("cameraManagement.clone.categories.general")}
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
|
{groupedCategories.general.map((cat) => (
|
||||||
|
<label
|
||||||
|
key={cat.key}
|
||||||
|
className="flex items-center gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedCategories.has(cat.key)}
|
||||||
|
onCheckedChange={() => toggleCategory(cat.key)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
{t(`cameraManagement.clone.categories.items.${cat.key}`)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{groupedCategories.spatial.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
{t("cameraManagement.clone.categories.spatial")}
|
||||||
|
</p>
|
||||||
|
{!targetIsNew &&
|
||||||
|
!resMatch &&
|
||||||
|
srcCfg?.detect &&
|
||||||
|
dstCfg?.detect && (
|
||||||
|
<Alert
|
||||||
|
variant="default"
|
||||||
|
role="alert"
|
||||||
|
className="border-warning"
|
||||||
|
>
|
||||||
|
<LuTriangleAlert className="size-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
{t(
|
||||||
|
"cameraManagement.clone.categories.spatialWarning",
|
||||||
|
{
|
||||||
|
srcWidth: srcCfg.detect.width,
|
||||||
|
srcHeight: srcCfg.detect.height,
|
||||||
|
dstWidth: dstCfg.detect.width,
|
||||||
|
dstHeight: dstCfg.detect.height,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
|
{groupedCategories.spatial.map((cat) => (
|
||||||
|
<label
|
||||||
|
key={cat.key}
|
||||||
|
className="flex items-center gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedCategories.has(cat.key)}
|
||||||
|
onCheckedChange={() => toggleCategory(cat.key)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
{t(
|
||||||
|
`cameraManagement.clone.categories.items.${cat.key}`,
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{targetIsNew && groupedCategories.streams.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
{t("cameraManagement.clone.categories.streams")}
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
{groupedCategories.streams.map((cat) => (
|
||||||
|
<label
|
||||||
|
key={cat.key}
|
||||||
|
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Checkbox checked disabled />
|
||||||
|
{t(
|
||||||
|
`cameraManagement.clone.categories.items.${cat.key}`,
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<DialogFooter className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{t("cameraManagement.clone.footer.changeCount", {
|
||||||
|
count: changeCount,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
{changeCount > 0 && (
|
||||||
|
<SaveAllPreviewPopover
|
||||||
|
items={previewItems}
|
||||||
|
className="h-7 w-7"
|
||||||
|
align="start"
|
||||||
|
side="top"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="ml-2 text-xs">
|
||||||
|
{anyNeedsRestart
|
||||||
|
? t("cameraManagement.clone.footer.restartNeeded")
|
||||||
|
: t("cameraManagement.clone.footer.liveOnly")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="button" disabled={isSubmitting} onClick={onClose}>
|
||||||
|
{t("button.cancel", { ns: "common" })}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="select"
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting || changeCount === 0}
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ActivityIndicator className="size-4" />
|
||||||
|
<span>
|
||||||
|
{t("cameraManagement.clone.footer.submitting")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
t("cameraManagement.clone.footer.submit")
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
<RestartDialog
|
||||||
|
isOpen={restartDialogOpen}
|
||||||
|
onClose={() => setRestartDialogOpen(false)}
|
||||||
|
onRestart={() => sendRestart("restart")}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
425
web/src/utils/cameraClone.ts
Normal file
425
web/src/utils/cameraClone.ts
Normal file
@ -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.<name>` 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<CloneCategoryKey> {
|
||||||
|
const selected = new Set<CloneCategoryKey>();
|
||||||
|
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<CloneCategoryKey>;
|
||||||
|
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/<target>/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<string, unknown> = {
|
||||||
|
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<string, unknown>
|
||||||
|
)[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.<target>.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,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -81,6 +81,7 @@ export const cameraUpdateTopicMap: Record<string, string> = {
|
|||||||
mqtt: "mqtt",
|
mqtt: "mqtt",
|
||||||
onvif: "onvif",
|
onvif: "onvif",
|
||||||
ui: "ui",
|
ui: "ui",
|
||||||
|
zones: "zones",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sections where global config serves as the default for per-camera config.
|
// Sections where global config serves as the default for per-camera config.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user