mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-21 03:41:55 +03:00
change source and allow cloning to multiple cameras
This commit is contained in:
parent
b852b65024
commit
fbde1e7549
@ -3,19 +3,42 @@
|
||||
*
|
||||
* Covers the design invariants that don't depend on per-camera resolution
|
||||
* differences in the mock fixture:
|
||||
* 1. Dialog opens from the Clone button on a camera row.
|
||||
* 2. "Stream URLs and roles" is forced on and disabled for new-camera target.
|
||||
* 3. Clicking Clone issues a PUT and shows a restart prompt.
|
||||
* 1. Dialog opens from the "Clone settings" button below Add/Delete.
|
||||
* 2. A source camera must be chosen inside the dialog before cloning.
|
||||
* 3. "Stream URLs and roles" is forced on and disabled for new-camera target.
|
||||
* 4. Cloning to a new camera issues a single add PUT and shows a restart prompt.
|
||||
* 5. The existing-camera target selects multiple destinations via a switch
|
||||
* popover (with an "All cameras" toggle and source exclusion); the closed
|
||||
* trigger summarizes the selection by name or as "All cameras".
|
||||
*
|
||||
* The spatial-mismatch warning path is exercised in unit-level review and
|
||||
* via manual QA — the shared mock fixture ships every camera at 1280×720,
|
||||
* so an E2E assertion for that path would silently pass without coverage.
|
||||
* The spatial-mismatch warning path is exercised in unit-level review and via
|
||||
* manual QA — the shared mock fixture ships every camera at 1280×720. The
|
||||
* existing-camera PUT fan-out is likewise not asserted here: the mock cameras
|
||||
* are identical apart from stream URLs (which existing-camera clones never
|
||||
* copy) and the schema mock is empty, so a clone onto them produces no diff
|
||||
* and no PUT. That path is covered by unit-level review and manual QA.
|
||||
*/
|
||||
|
||||
import { test, expect } from "../fixtures/frigate-test";
|
||||
|
||||
const CLONE_BUTTON_ARIA_PREFIX = "Clone settings from";
|
||||
const DIALOG_TITLE_PREFIX = "Clone settings from";
|
||||
async function openCloneDialog(frigateApp: {
|
||||
page: import("@playwright/test").Page;
|
||||
}) {
|
||||
await frigateApp.page
|
||||
.getByRole("button", { name: /^Clone settings$/i })
|
||||
.click();
|
||||
await expect(frigateApp.page.getByRole("dialog")).toBeVisible();
|
||||
}
|
||||
|
||||
async function selectSource(
|
||||
frigateApp: { page: import("@playwright/test").Page },
|
||||
source: string,
|
||||
) {
|
||||
await frigateApp.page.getByRole("dialog").getByRole("combobox").click();
|
||||
await frigateApp.page
|
||||
.getByRole("option", { name: source, exact: true })
|
||||
.click();
|
||||
}
|
||||
|
||||
test.describe("Camera clone dialog @medium @mobile", () => {
|
||||
test.beforeEach(async ({ frigateApp }) => {
|
||||
@ -25,31 +48,26 @@ test.describe("Camera clone dialog @medium @mobile", () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("opens the dialog from a camera row's Clone button", async ({
|
||||
test("opens the dialog from the Clone settings button", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const cloneButton = frigateApp.page
|
||||
.getByRole("button", { name: new RegExp(CLONE_BUTTON_ARIA_PREFIX, "i") })
|
||||
.first();
|
||||
await cloneButton.click();
|
||||
await openCloneDialog(frigateApp);
|
||||
|
||||
await expect(frigateApp.page.getByRole("dialog")).toBeVisible();
|
||||
await expect(
|
||||
frigateApp.page
|
||||
.getByRole("dialog")
|
||||
.getByText(new RegExp(DIALOG_TITLE_PREFIX, "i")),
|
||||
frigateApp.page.getByRole("dialog").getByText(/Clone camera settings/i),
|
||||
).toBeVisible();
|
||||
|
||||
// The Clone button is disabled until a source (and target) is chosen.
|
||||
await expect(
|
||||
frigateApp.page.getByRole("button", { name: /^Clone$/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test("forces Stream URLs and roles on for new-camera target", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const cloneButton = frigateApp.page
|
||||
.getByRole("button", { name: new RegExp(CLONE_BUTTON_ARIA_PREFIX, "i") })
|
||||
.first();
|
||||
await cloneButton.click();
|
||||
|
||||
await expect(frigateApp.page.getByRole("dialog")).toBeVisible();
|
||||
await openCloneDialog(frigateApp);
|
||||
await selectSource(frigateApp, "Front Door");
|
||||
|
||||
// The "New camera" radio is selected by default; the Streams group renders
|
||||
// the ffmpeg_live checkbox as forced-checked and disabled.
|
||||
@ -63,7 +81,7 @@ test.describe("Camera clone dialog @medium @mobile", () => {
|
||||
await expect(streamsCheckbox).toBeDisabled();
|
||||
});
|
||||
|
||||
test("issues a PUT and shows restart toast for new-camera target", async ({
|
||||
test("issues a single add PUT and shows restart toast for new-camera target", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const requests: { body: unknown }[] = [];
|
||||
@ -83,20 +101,15 @@ test.describe("Camera clone dialog @medium @mobile", () => {
|
||||
frigateApp.page.getByRole("heading", { name: /Manage Cameras/i }),
|
||||
).toBeVisible();
|
||||
|
||||
const cloneButton = frigateApp.page
|
||||
.getByRole("button", { name: new RegExp(CLONE_BUTTON_ARIA_PREFIX, "i") })
|
||||
.first();
|
||||
await cloneButton.click();
|
||||
|
||||
await expect(frigateApp.page.getByRole("dialog")).toBeVisible();
|
||||
await openCloneDialog(frigateApp);
|
||||
await selectSource(frigateApp, "Front Door");
|
||||
|
||||
const nameInput = frigateApp.page.getByPlaceholder(
|
||||
/e\.g\., back_door or Back Door/i,
|
||||
);
|
||||
await nameInput.fill("clone_target_one");
|
||||
|
||||
// After typing a valid name, the Clone button becomes enabled because
|
||||
// changeCount > 0 (the dialog's previewPayloads memo watches the name).
|
||||
// With a source picked and a valid name, changeCount > 0 enables Clone.
|
||||
await expect(
|
||||
frigateApp.page.getByRole("button", { name: /^Clone$/i }),
|
||||
).toBeEnabled({ timeout: 5_000 });
|
||||
@ -123,4 +136,46 @@ test.describe("Camera clone dialog @medium @mobile", () => {
|
||||
frigateApp.page.getByRole("button", { name: /Restart/i }).first(),
|
||||
).toBeVisible({ timeout: 8_000 });
|
||||
});
|
||||
|
||||
test("selects multiple existing destination cameras via a switch popover", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await openCloneDialog(frigateApp);
|
||||
await selectSource(frigateApp, "Front Door");
|
||||
|
||||
await frigateApp.page
|
||||
.getByRole("radio", { name: /Existing cameras/i })
|
||||
.click();
|
||||
|
||||
const dialog = frigateApp.page.getByRole("dialog");
|
||||
|
||||
// The destination trigger starts with the empty-selection placeholder.
|
||||
await dialog
|
||||
.getByRole("button", { name: /Select at least one camera/i })
|
||||
.click();
|
||||
|
||||
// The chosen source is excluded from the destination switch list.
|
||||
await expect(
|
||||
dialog.getByRole("switch", { name: /Backyard/i }),
|
||||
).toBeVisible();
|
||||
await expect(dialog.getByRole("switch", { name: /Garage/i })).toBeVisible();
|
||||
await expect(
|
||||
dialog.getByRole("switch", { name: /^Front Door$/i }),
|
||||
).toHaveCount(0);
|
||||
|
||||
// Selecting a single camera summarizes by name once the popover closes.
|
||||
await dialog.getByRole("switch", { name: /Backyard/i }).click();
|
||||
await frigateApp.page.keyboard.press("Escape");
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: /^Backyard$/i }),
|
||||
).toBeVisible();
|
||||
|
||||
// Reopen and select everything; the trigger collapses to "All cameras".
|
||||
await dialog.getByRole("button", { name: /^Backyard$/i }).click();
|
||||
await dialog.getByRole("switch", { name: /^All cameras$/i }).click();
|
||||
await frigateApp.page.keyboard.press("Escape");
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: /^All cameras$/i }),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@ -546,10 +546,16 @@
|
||||
"saveSuccess": "Updated camera type for {{cameraName}}. Restart Frigate to apply the changes."
|
||||
},
|
||||
"clone": {
|
||||
"trigger": "Clone camera or copy settings",
|
||||
"triggerAriaLabel": "Clone settings from {{cameraName}}",
|
||||
"title": "Clone settings from {{cameraName}}",
|
||||
"description": "Copy this camera's configuration to a new or existing camera. Identity (name, friendly name, web UI URL, display order) is never copied.",
|
||||
"sectionTitle": "Clone settings",
|
||||
"sectionDescription": "Copy configuration from one camera to another camera or a new one.",
|
||||
"button": "Clone settings",
|
||||
"title": "Clone camera settings",
|
||||
"description": "Copy a camera's configuration to one or more other cameras or a new camera. Identity (name, friendly name, web UI URL, display order) is never copied.",
|
||||
"source": {
|
||||
"label": "Source camera",
|
||||
"placeholder": "Select a source camera",
|
||||
"required": "Select a source camera"
|
||||
},
|
||||
"target": {
|
||||
"legend": "Target",
|
||||
"newRadio": "New camera",
|
||||
@ -558,9 +564,10 @@
|
||||
"newNameRequired": "Camera name is required",
|
||||
"newNameInvalid": "Invalid camera name",
|
||||
"newNameCollision": "A camera with this name already exists",
|
||||
"newStreamsForced": "Streams are always copied for a new camera. You'll edit the RTSP URLs after cloning.",
|
||||
"existingRadio": "Existing camera",
|
||||
"existingPlaceholder": "Choose camera…",
|
||||
"newStreamsForced": "Streams are always copied for a new camera.",
|
||||
"existingCamerasRadio": "Existing cameras",
|
||||
"allCameras": "All cameras",
|
||||
"existingPlaceholder": "Select at least one camera",
|
||||
"existingDisabled": "No other cameras to copy to"
|
||||
},
|
||||
"categories": {
|
||||
@ -572,7 +579,7 @@
|
||||
"spatial": "Spatial settings",
|
||||
"streams": "Streams",
|
||||
"spatialWarningTitle": "Resolution mismatch",
|
||||
"spatialWarning": "Source camera {{srcCamera}} detect resolution ({{srcWidth}}×{{srcHeight}}) differs from target camera {{dstCamera}} detect resolution ({{dstWidth}}×{{dstHeight}}). Polygons may not align. These defaults are off; enable to copy as-is.",
|
||||
"spatialWarning": "Source camera {{srcCamera}} detect resolution ({{srcWidth}}×{{srcHeight}}) differs from: {{cameras}}. Polygons may not align on those cameras. These defaults are off; enable to copy as-is.",
|
||||
"restartHint": "Restart required",
|
||||
"items": {
|
||||
"record": "Recording",
|
||||
@ -611,8 +618,13 @@
|
||||
},
|
||||
"toast": {
|
||||
"success": "Settings copied to {{cameraName}}",
|
||||
"successWithRestart": "Settings copied to {{cameraName}}. Restart Frigate to fully apply.",
|
||||
"successWithRestart": "Settings copied to {{cameraName}}. Restart Frigate to apply all changes.",
|
||||
"successMulti_one": "Settings copied to {{count}} camera",
|
||||
"successMulti_other": "Settings copied to {{count}} cameras",
|
||||
"successMultiWithRestart_one": "Settings copied to {{count}} camera. Restart Frigate to apply all changes.",
|
||||
"successMultiWithRestart_other": "Settings copied to {{count}} cameras. Restart Frigate to apply all changes.",
|
||||
"partialFailure": "{{successCount}} sections applied; '{{failedSection}}' failed: {{errorMessage}}",
|
||||
"partialFailureMulti": "Copied to {{successCount}} camera(s); failed for {{failed}}: {{errorMessage}}",
|
||||
"newCameraPartialFailure": "Camera {{cameraName}} was created but some settings failed to copy: {{errorMessage}}",
|
||||
"sourceMissing": "Source camera no longer exists",
|
||||
"submitError": "Failed to clone camera: {{errorMessage}}"
|
||||
|
||||
@ -1,4 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useForm, useWatch } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
@ -39,7 +46,12 @@ 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 {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { LuChevronDown, LuTriangleAlert } from "react-icons/lu";
|
||||
import {
|
||||
CLONE_CATEGORIES,
|
||||
type CloneCategoryKey,
|
||||
@ -53,38 +65,39 @@ import {
|
||||
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;
|
||||
sourceCamera: string;
|
||||
};
|
||||
|
||||
type CloneFormValues = {
|
||||
sourceCamera: string;
|
||||
targetMode: "new" | "existing";
|
||||
newName: string;
|
||||
existingTarget: string;
|
||||
existingTargets: string[];
|
||||
};
|
||||
|
||||
export default function CloneCameraDialog({
|
||||
open,
|
||||
onClose,
|
||||
sourceCamera,
|
||||
}: CloneCameraDialogProps) {
|
||||
const { t } = useTranslation(["views/settings", "common"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const { data: rawPaths } = useSWR<RawCameraPaths>("config/raw_paths");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const otherCameras = useMemo(() => {
|
||||
const sourceCameras = useMemo(() => {
|
||||
if (!config) return [];
|
||||
return Object.keys(config.cameras)
|
||||
.filter((c) => c !== sourceCamera && !isReplayCamera(c))
|
||||
.filter((c) => !isReplayCamera(c))
|
||||
.sort();
|
||||
}, [config, sourceCamera]);
|
||||
}, [config]);
|
||||
|
||||
const formSchema = useMemo(() => {
|
||||
const reservedNames = new Set<string>([
|
||||
@ -93,11 +106,19 @@ export default function CloneCameraDialog({
|
||||
]);
|
||||
return z
|
||||
.object({
|
||||
sourceCamera: z.string(),
|
||||
targetMode: z.enum(["new", "existing"]),
|
||||
newName: z.string(),
|
||||
existingTarget: 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) {
|
||||
@ -124,10 +145,10 @@ export default function CloneCameraDialog({
|
||||
message: t("cameraManagement.clone.target.newNameCollision"),
|
||||
});
|
||||
}
|
||||
} else if (!data.existingTarget) {
|
||||
} else if (data.existingTargets.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["existingTarget"],
|
||||
path: ["existingTargets"],
|
||||
message: t("cameraManagement.clone.target.existingPlaceholder"),
|
||||
});
|
||||
}
|
||||
@ -137,27 +158,40 @@ export default function CloneCameraDialog({
|
||||
const form = useForm<CloneFormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
sourceCamera: "",
|
||||
targetMode: "new",
|
||||
newName: "",
|
||||
existingTarget: "",
|
||||
existingTargets: [],
|
||||
},
|
||||
});
|
||||
|
||||
const sourceCamera = form.watch("sourceCamera");
|
||||
const targetMode = form.watch("targetMode");
|
||||
const existingTarget = form.watch("existingTarget");
|
||||
const existingTargets = form.watch("existingTargets");
|
||||
|
||||
const targetIsNew = targetMode === "new";
|
||||
|
||||
const srcCfg = config?.cameras?.[sourceCamera];
|
||||
const dstCfg =
|
||||
!targetIsNew && existingTarget
|
||||
? config?.cameras?.[existingTarget]
|
||||
: undefined;
|
||||
const otherCameras = useMemo(() => {
|
||||
if (!config) return [];
|
||||
return Object.keys(config.cameras)
|
||||
.filter((c) => c !== sourceCamera && !isReplayCamera(c))
|
||||
.sort();
|
||||
}, [config, sourceCamera]);
|
||||
|
||||
const resMatch = useMemo(
|
||||
() => resolutionsMatch(srcCfg?.detect, dstCfg?.detect),
|
||||
[srcCfg, dstCfg],
|
||||
);
|
||||
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<CloneCategoryKey>
|
||||
@ -169,15 +203,28 @@ export default function CloneCameraDialog({
|
||||
if (open && !wasOpenRef.current) {
|
||||
wasOpenRef.current = true;
|
||||
form.reset({
|
||||
sourceCamera: "",
|
||||
targetMode: "new",
|
||||
newName: "",
|
||||
existingTarget: otherCameras[0] ?? "",
|
||||
existingTargets: [],
|
||||
});
|
||||
setSelectedCategories(getCategoryDefaults(true));
|
||||
} else if (!open) {
|
||||
wasOpenRef.current = false;
|
||||
}
|
||||
}, [open, form, otherCameras]);
|
||||
}, [open, form]);
|
||||
|
||||
// Drop the source camera from the target selection if it gets picked.
|
||||
useEffect(() => {
|
||||
if (!sourceCamera) return;
|
||||
const current = form.getValues("existingTargets");
|
||||
if (current.includes(sourceCamera)) {
|
||||
form.setValue(
|
||||
"existingTargets",
|
||||
current.filter((c) => c !== sourceCamera),
|
||||
);
|
||||
}
|
||||
}, [sourceCamera, form]);
|
||||
|
||||
// Reset selection to per-mode defaults when the user switches target mode.
|
||||
useEffect(() => {
|
||||
@ -196,7 +243,7 @@ export default function CloneCameraDialog({
|
||||
const selectAllCategories = useCallback(() => {
|
||||
setSelectedCategories((prev) => {
|
||||
const next = new Set(prev);
|
||||
const includeSpatial = targetIsNew || resMatch;
|
||||
const includeSpatial = targetIsNew || allResMatch;
|
||||
for (const cat of CLONE_CATEGORIES) {
|
||||
if (cat.newCameraOnly && !targetIsNew) continue;
|
||||
if (cat.group === "spatial" && !includeSpatial) continue;
|
||||
@ -205,7 +252,7 @@ export default function CloneCameraDialog({
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [targetIsNew, resMatch]);
|
||||
}, [targetIsNew, allResMatch]);
|
||||
|
||||
const selectNoneCategories = useCallback(() => {
|
||||
setSelectedCategories((prev) => {
|
||||
@ -241,48 +288,77 @@ export default function CloneCameraDialog({
|
||||
|
||||
const fullSchema = useConfigSchema();
|
||||
const { send: sendRestart } = useRestart();
|
||||
const statusBar = useContext(StatusBarMessagesContext);
|
||||
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,
|
||||
});
|
||||
// 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<typeof buildClonedCameraPayloads> }[]
|
||||
>(() => {
|
||||
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,
|
||||
existingTarget,
|
||||
existingTargets,
|
||||
watchedNewName,
|
||||
selectedCategories,
|
||||
rawPaths,
|
||||
]);
|
||||
|
||||
const previewTarget = targetIsNew
|
||||
? processCameraName(watchedNewName || "").finalCameraName
|
||||
: existingTarget;
|
||||
const previewPayloads = useMemo(
|
||||
() => targetPayloads.flatMap((tp) => tp.payloads),
|
||||
[targetPayloads],
|
||||
);
|
||||
|
||||
const previewItems = useMemo(
|
||||
() =>
|
||||
previewTarget
|
||||
? buildClonePreviewItems(previewPayloads, previewTarget)
|
||||
: [],
|
||||
[previewPayloads, previewTarget],
|
||||
targetPayloads.flatMap((tp) =>
|
||||
buildClonePreviewItems(tp.payloads, tp.target),
|
||||
),
|
||||
[targetPayloads],
|
||||
);
|
||||
|
||||
const anyNeedsRestart = previewPayloads.some((p) => p.needsRestart);
|
||||
@ -302,38 +378,126 @@ export default function CloneCameraDialog({
|
||||
return;
|
||||
}
|
||||
|
||||
const target = targetIsNew
|
||||
? processCameraName(values.newName.trim()).finalCameraName
|
||||
: values.existingTarget;
|
||||
const targetLabel = targetIsNew
|
||||
? values.newName.trim()
|
||||
: (config.cameras?.[target]?.friendly_name ?? target);
|
||||
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 = (
|
||||
<a onClick={() => setRestartDialogOpen(true)}>
|
||||
<Button>{t("restart.button", { ns: "components/dialog" })}</Button>
|
||||
</a>
|
||||
);
|
||||
|
||||
const markRestartRequired = () =>
|
||||
statusBar?.addMessage(
|
||||
"config_restart_required",
|
||||
t("configForm.restartRequiredFooter"),
|
||||
undefined,
|
||||
"config_restart_required",
|
||||
);
|
||||
|
||||
setIsSubmitting(true);
|
||||
let appliedCount = 0;
|
||||
let failedSection: string | undefined;
|
||||
let failureMessage: string | undefined;
|
||||
|
||||
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 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;
|
||||
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 {
|
||||
@ -341,50 +505,41 @@ export default function CloneCameraDialog({
|
||||
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" },
|
||||
);
|
||||
}
|
||||
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(
|
||||
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>
|
||||
),
|
||||
},
|
||||
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(
|
||||
t("cameraManagement.clone.toast.success", {
|
||||
cameraName: targetLabel,
|
||||
}),
|
||||
singleLabel
|
||||
? t("cameraManagement.clone.toast.success", {
|
||||
cameraName: singleLabel,
|
||||
})
|
||||
: t("cameraManagement.clone.toast.successMulti", {
|
||||
count: succeeded.length,
|
||||
}),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
}
|
||||
@ -396,9 +551,11 @@ export default function CloneCameraDialog({
|
||||
srcCfg,
|
||||
fullSchema,
|
||||
previewPayloads,
|
||||
targetPayloads,
|
||||
targetIsNew,
|
||||
anyNeedsRestart,
|
||||
onClose,
|
||||
statusBar,
|
||||
t,
|
||||
],
|
||||
);
|
||||
@ -407,16 +564,12 @@ export default function CloneCameraDialog({
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"scrollbar-container max-h-[90dvh] max-w-3xl overflow-y-auto",
|
||||
"scrollbar-container max-h-[90dvh] max-w-4xl overflow-y-auto",
|
||||
)}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("cameraManagement.clone.title", {
|
||||
cameraName: sourceFriendlyName,
|
||||
})}
|
||||
</DialogTitle>
|
||||
<DialogTitle>{t("cameraManagement.clone.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("cameraManagement.clone.description")}
|
||||
</DialogDescription>
|
||||
@ -424,6 +577,43 @@ export default function CloneCameraDialog({
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base">
|
||||
{t("cameraManagement.clone.source.label")}
|
||||
</Label>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sourceCamera"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger className="w-full max-w-xs">
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"cameraManagement.clone.source.placeholder",
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sourceCameras.map((cam) => (
|
||||
<SelectItem key={cam} value={cam}>
|
||||
{config?.cameras?.[cam]?.friendly_name ?? cam}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base">
|
||||
{t("cameraManagement.clone.target.legend")}
|
||||
@ -467,6 +657,7 @@ export default function CloneCameraDialog({
|
||||
<FormControl>
|
||||
<Input
|
||||
{...form.register("newName")}
|
||||
className="max-w-xs"
|
||||
placeholder={t(
|
||||
"cameraManagement.clone.target.newNamePlaceholder",
|
||||
)}
|
||||
@ -509,7 +700,9 @@ export default function CloneCameraDialog({
|
||||
"text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{t("cameraManagement.clone.target.existingRadio")}
|
||||
{t(
|
||||
"cameraManagement.clone.target.existingCamerasRadio",
|
||||
)}
|
||||
{otherCameras.length === 0 && (
|
||||
<span className="ml-2 text-xs">
|
||||
(
|
||||
@ -525,34 +718,102 @@ export default function CloneCameraDialog({
|
||||
otherCameras.length > 0 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="existingTarget"
|
||||
render={({ field: tgtField }) => (
|
||||
<FormItem className="ml-7">
|
||||
<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>
|
||||
)}
|
||||
name="existingTargets"
|
||||
render={({ field: tgtField }) => {
|
||||
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 (
|
||||
<FormItem className="ml-7">
|
||||
<Popover>
|
||||
<FormControl>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isSubmitting}
|
||||
className="w-full max-w-xs justify-between font-normal"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"truncate",
|
||||
selectedNames.length === 0 &&
|
||||
"text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{summary}
|
||||
</span>
|
||||
<LuChevronDown className="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</FormControl>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
disablePortal
|
||||
className="w-[--radix-popover-trigger-width] p-0"
|
||||
>
|
||||
<div className="scrollbar-container max-h-60 space-y-2.5 overflow-y-auto p-4">
|
||||
<FilterSwitch
|
||||
label={t(
|
||||
"cameraManagement.clone.target.allCameras",
|
||||
)}
|
||||
isChecked={allSelected}
|
||||
disabled={isSubmitting}
|
||||
onCheckedChange={(checked) =>
|
||||
tgtField.onChange(
|
||||
checked
|
||||
? [...otherCameras]
|
||||
: [],
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className="border-t border-border/40" />
|
||||
{otherCameras.map((cam) => (
|
||||
<FilterSwitch
|
||||
key={cam}
|
||||
label={cam}
|
||||
type="camera"
|
||||
isChecked={selected.includes(
|
||||
cam,
|
||||
)}
|
||||
disabled={isSubmitting}
|
||||
onCheckedChange={(checked) =>
|
||||
tgtField.onChange(
|
||||
checked
|
||||
? [...selected, cam]
|
||||
: selected.filter(
|
||||
(c) => c !== cam,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@ -617,9 +878,8 @@ export default function CloneCameraDialog({
|
||||
{t("cameraManagement.clone.categories.spatial")}
|
||||
</Label>
|
||||
{!targetIsNew &&
|
||||
!resMatch &&
|
||||
srcCfg?.detect &&
|
||||
dstCfg?.detect && (
|
||||
mismatchedTargets.length > 0 && (
|
||||
<Alert variant="warning">
|
||||
<LuTriangleAlert className="size-5" />
|
||||
<AlertTitle>
|
||||
@ -632,13 +892,14 @@ export default function CloneCameraDialog({
|
||||
"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,
|
||||
cameras: mismatchedTargets
|
||||
.map(
|
||||
(c) =>
|
||||
config?.cameras?.[c]?.friendly_name ?? c,
|
||||
)
|
||||
.join(", "),
|
||||
},
|
||||
)}
|
||||
</AlertDescription>
|
||||
|
||||
@ -53,7 +53,12 @@ function resolveDef(schema: RJSFSchema, name: string): RJSFSchema | undefined {
|
||||
return defs ? defs[name] : undefined;
|
||||
}
|
||||
|
||||
/** Remove filter entries that exactly match the backend's auto-default. */
|
||||
/**
|
||||
* Reduce each filter entry to the fields that differ from the backend's
|
||||
* auto-default. An entry that is entirely auto-populated drops out; a
|
||||
* partially-customized entry keeps only its customized fields, so cloning
|
||||
* doesn't copy the auto-populated default for every other field.
|
||||
*/
|
||||
function stripAutoDefaultFilters(
|
||||
section: string,
|
||||
sourceSection: JsonObject,
|
||||
@ -80,20 +85,56 @@ function stripAutoDefaultFilters(
|
||||
)
|
||||
: new Set<string>();
|
||||
|
||||
// Ignore runtime-only `mask`/`raw_mask`: the API ships them as `{}` while the
|
||||
// schema default omits them, which would otherwise break the equality check.
|
||||
const withoutRuntimeFields = (entry: JsonValue): JsonValue => {
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
return entry;
|
||||
}
|
||||
const copy = { ...(entry as JsonObject) };
|
||||
delete copy.mask;
|
||||
delete copy.raw_mask;
|
||||
return copy;
|
||||
};
|
||||
|
||||
const cleaned: JsonObject = {};
|
||||
for (const [label, value] of Object.entries(filters as JsonObject)) {
|
||||
const expected = attributeSet.has(label) ? attributeDefaults : baseDefaults;
|
||||
if (isEqual(value, expected)) continue;
|
||||
cleaned[label] = value as JsonValue;
|
||||
const valNorm = withoutRuntimeFields(value as JsonValue);
|
||||
const expNorm = withoutRuntimeFields(expected as JsonValue);
|
||||
|
||||
// Non-object filter value: keep only if it differs from the default.
|
||||
if (
|
||||
!valNorm ||
|
||||
typeof valNorm !== "object" ||
|
||||
Array.isArray(valNorm) ||
|
||||
!expNorm ||
|
||||
typeof expNorm !== "object" ||
|
||||
Array.isArray(expNorm)
|
||||
) {
|
||||
if (!isEqual(valNorm, expNorm)) {
|
||||
cleaned[label] = value as JsonValue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const diff: JsonObject = {};
|
||||
for (const [field, fieldValue] of Object.entries(valNorm as JsonObject)) {
|
||||
if (!isEqual(fieldValue, (expNorm as JsonObject)[field])) {
|
||||
diff[field] = fieldValue as JsonValue;
|
||||
}
|
||||
}
|
||||
if (Object.keys(diff).length > 0) {
|
||||
cleaned[label] = diff;
|
||||
}
|
||||
}
|
||||
return { ...sourceSection, filters: cleaned };
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip named runtime-only fields from each entry in a dict-of-objects
|
||||
* (mask `enabled_in_config`/`raw_coordinates`, zone `color`). The settings
|
||||
* UI doesn't need this because BaseSection's form never exposes these
|
||||
* sub-collections; we do because clone re-injects them from the API.
|
||||
* Strip runtime-only fields from each entry of a dict-of-objects (mask
|
||||
* `enabled_in_config`/`raw_coordinates`, zone `color`) that clone re-injects
|
||||
* from the API.
|
||||
*/
|
||||
function stripDictEntryFields(
|
||||
dict: unknown,
|
||||
@ -135,11 +176,9 @@ function stripResetMarkers(
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse per-section payloads into one camera-level payload + `…/add`
|
||||
* topic. New cameras are created atomically by the backend's `add`
|
||||
* handler, so a single PUT avoids the intermediate-validation ordering
|
||||
* problem (e.g. a `review` required_zone referencing zones not yet written)
|
||||
* that the per-section path is subject to.
|
||||
* Collapse per-section payloads into one camera-level `…/add` payload. The
|
||||
* backend's `add` handler validates atomically, avoiding the per-section
|
||||
* ordering problem (e.g. `review.required_zones` referencing unwritten zones).
|
||||
*/
|
||||
function bundleNewCameraPayload(
|
||||
payloads: SectionSavePayload[],
|
||||
@ -166,10 +205,8 @@ function bundleNewCameraPayload(
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop empty `*_args` arrays from ffmpeg inputs. Mirrors
|
||||
* `sanitizeOverridesForSection`'s ffmpeg cleanup, which we don't go
|
||||
* through here because the establishing payload uses `buildOverrides`
|
||||
* directly.
|
||||
* Drop empty `*_args` arrays from ffmpeg inputs — the establishing payload
|
||||
* uses `buildOverrides` directly, bypassing `sanitizeOverridesForSection`.
|
||||
*/
|
||||
function cleanupFfmpegInputArgs(
|
||||
ffmpeg: JsonValue | undefined,
|
||||
@ -225,10 +262,9 @@ function restoreFfmpegPaths(
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay the backend's per-camera detect-field formulas (`frigate/config/
|
||||
* config.py`) on the synthetic side so they cancel out of the diff. The
|
||||
* global config doesn't get per-camera derivation, so without this the
|
||||
* source's computed values surface as overrides.
|
||||
* Replay the backend's per-camera detect-field formulas on the synthetic
|
||||
* baseline so the source's computed values cancel out of the diff (the global
|
||||
* config has no per-camera derivation).
|
||||
*/
|
||||
function applyDetectComputedDefaults(
|
||||
detect: JsonObject,
|
||||
@ -473,12 +509,9 @@ export function buildClonedCameraPayloads({
|
||||
}
|
||||
}
|
||||
|
||||
// Section-backed categories — flow through prepareSectionSavePayload
|
||||
// for matching restart/update-topic behavior. Order matters for the
|
||||
// existing-camera multi-PUT path: each PUT re-validates the whole
|
||||
// config, so dependency providers must precede consumers — `detect`
|
||||
// (resolution) then `zones` before sections that reference zones via
|
||||
// `required_zones` (review, objects, snapshots, mqtt).
|
||||
// Order matters for the existing-camera multi-PUT path (each PUT re-validates
|
||||
// the whole config): `detect` then `zones` must precede sections that
|
||||
// reference zones via `required_zones` (review, objects, snapshots, mqtt).
|
||||
const SECTION_KEYS: Array<{ key: CloneCategoryKey; section: string }> = [
|
||||
{ key: "detect", section: "detect" },
|
||||
{ key: "zones", section: "zones" },
|
||||
@ -500,14 +533,11 @@ export function buildClonedCameraPayloads({
|
||||
{ key: "genai", section: "genai" },
|
||||
];
|
||||
|
||||
// Synthetic target so we can reuse prepareSectionSavePayload unchanged.
|
||||
// For new-camera target, seed sections where camera schema accepts all
|
||||
// global fields — gives buildOverrides the right inheritance baseline.
|
||||
// Sections with divergent per-camera Pydantic classes (mqtt, birdseye,
|
||||
// lpr, face_recognition, semantic_search, audio_transcription, genai)
|
||||
// are left unset so prepareSectionSavePayload's schema defaults handle
|
||||
// filtering instead — seeding from global would emit its extra fields
|
||||
// as Reset markers.
|
||||
// Synthetic target reused as the diff baseline. New-camera: seed sections
|
||||
// whose camera schema accepts all global fields (correct inheritance
|
||||
// baseline), but leave divergent per-camera sections (mqtt, birdseye, lpr,
|
||||
// face_recognition, semantic_search, audio_transcription, genai) unset —
|
||||
// seeding from global would surface its extra fields as Reset markers.
|
||||
const GLOBAL_INHERITED_SECTIONS = [
|
||||
"detect",
|
||||
"objects",
|
||||
@ -531,24 +561,39 @@ export function buildClonedCameraPayloads({
|
||||
]).filter(([, value]) => value !== undefined && value !== null),
|
||||
),
|
||||
} as unknown as FrigateConfig["cameras"][string])
|
||||
: (fullConfig.cameras?.[target] ??
|
||||
({ enabled: true } as unknown as FrigateConfig["cameras"][string]));
|
||||
: ((fullConfig.cameras?.[target]
|
||||
? cloneDeep(fullConfig.cameras[target])
|
||||
: { enabled: true }) as unknown as FrigateConfig["cameras"][string]);
|
||||
|
||||
// Symmetric filter strip: same treatment as the per-section source
|
||||
// strip below, so default-only entries cancel out of the diff.
|
||||
// Strip auto-default filters from the baseline (matching the per-section
|
||||
// source strip) so default-only entries cancel. Includes `base_config` (the
|
||||
// pre-profile parse getBaseCameraSectionValue reads) — otherwise its
|
||||
// auto-populated entries become `""` resets and the backend KeyErrors
|
||||
// deleting a key not in the YAML. Cloned above so this won't mutate the cache.
|
||||
const syntheticCameraObj = syntheticTargetCamera as unknown as JsonObject;
|
||||
const baseConfigObj = syntheticCameraObj.base_config as
|
||||
| Record<string, JsonObject>
|
||||
| undefined;
|
||||
for (const section of Object.keys(FILTER_SECTION_DEFS)) {
|
||||
const syntheticSection = (syntheticTargetCamera as unknown as JsonObject)[
|
||||
section
|
||||
];
|
||||
const syntheticSection = syntheticCameraObj[section];
|
||||
if (syntheticSection && typeof syntheticSection === "object") {
|
||||
(syntheticTargetCamera as unknown as JsonObject)[section] =
|
||||
stripAutoDefaultFilters(
|
||||
section,
|
||||
syntheticSection as JsonObject,
|
||||
fullSchema,
|
||||
fullConfig,
|
||||
syntheticTargetCamera as CameraConfig,
|
||||
);
|
||||
syntheticCameraObj[section] = stripAutoDefaultFilters(
|
||||
section,
|
||||
syntheticSection as JsonObject,
|
||||
fullSchema,
|
||||
fullConfig,
|
||||
syntheticTargetCamera as CameraConfig,
|
||||
);
|
||||
}
|
||||
const baseSection = baseConfigObj?.[section];
|
||||
if (baseConfigObj && baseSection && typeof baseSection === "object") {
|
||||
baseConfigObj[section] = stripAutoDefaultFilters(
|
||||
section,
|
||||
baseSection,
|
||||
fullSchema,
|
||||
fullConfig,
|
||||
syntheticTargetCamera as CameraConfig,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -556,7 +601,6 @@ export function buildClonedCameraPayloads({
|
||||
// so apply the formulas using source's fps to keep both sides aligned.
|
||||
// Existing-camera target already has the values from its own parse.
|
||||
if (targetIsNew && sourceCfg.detect) {
|
||||
const syntheticCameraObj = syntheticTargetCamera as unknown as JsonObject;
|
||||
const syntheticDetect = syntheticCameraObj.detect;
|
||||
if (syntheticDetect && typeof syntheticDetect === "object") {
|
||||
syntheticCameraObj.detect = applyDetectComputedDefaults(
|
||||
@ -583,10 +627,9 @@ export function buildClonedCameraPayloads({
|
||||
)[section];
|
||||
if (sourceSectionValue == null) continue;
|
||||
|
||||
// Sanitize the source the same way BaseSection's form does
|
||||
// implicitly: strips runtime/derived fields and function-resolved
|
||||
// hidden paths (e.g. `hideAttributeFilters` removing untracked-
|
||||
// attribute entries based on source's track list).
|
||||
// Sanitize the source like BaseSection's form does: strip runtime/derived
|
||||
// and hidden-path fields (e.g. `hideAttributeFilters` drops untracked
|
||||
// attributes based on the source's track list).
|
||||
const sectionConfig = getSectionConfig(section, "camera");
|
||||
const resolvedHiddenFields = resolveHiddenFieldEntries(
|
||||
sectionConfig.hiddenFields,
|
||||
|
||||
@ -1,12 +1,18 @@
|
||||
import Heading from "@/components/ui/heading";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
CONTROL_COLUMN_CLASS_NAME,
|
||||
SettingsGroupCard,
|
||||
SPLIT_ROW_CLASS_NAME,
|
||||
} from "@/components/card/SettingsGroupCard";
|
||||
import { toast } from "sonner";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import useSWR from "swr";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
@ -52,6 +58,7 @@ import {
|
||||
import type { ProfileState } from "@/types/profile";
|
||||
import { getProfileColor } from "@/utils/profileColors";
|
||||
import { isReplayCamera } from "@/utils/cameraUtil";
|
||||
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Select,
|
||||
@ -90,9 +97,7 @@ export default function CameraManagementView({
|
||||
|
||||
const [showWizard, setShowWizard] = useState(false);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [cloneSourceCamera, setCloneSourceCamera] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [showCloneDialog, setShowCloneDialog] = useState(false);
|
||||
|
||||
// State for restart dialog when enabling a disabled camera
|
||||
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
|
||||
@ -222,12 +227,6 @@ export default function CameraManagementView({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toaster
|
||||
richColors
|
||||
className="z-[1000]"
|
||||
position="top-center"
|
||||
closeButton
|
||||
/>
|
||||
<div className="flex size-full space-y-6">
|
||||
<div className="scrollbar-container flex-1 overflow-y-auto pb-2">
|
||||
<Heading as="h4" className="mb-2">
|
||||
@ -259,6 +258,27 @@ export default function CameraManagementView({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{enabledCameras.length + disabledCameras.length > 0 && (
|
||||
<div className="mb-5 space-y-3">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-md font-medium">
|
||||
{t("cameraManagement.clone.sectionTitle")}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("cameraManagement.clone.sectionDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="select"
|
||||
onClick={() => setShowCloneDialog(true)}
|
||||
className="flex max-w-48 items-center gap-2"
|
||||
>
|
||||
<LuCopy className="h-4 w-4" />
|
||||
{t("cameraManagement.clone.button")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(enabledCameras.length > 0 || disabledCameras.length > 0) && (
|
||||
<SettingsGroupCard
|
||||
title={
|
||||
@ -293,7 +313,6 @@ export default function CameraManagementView({
|
||||
onConfigChanged={updateConfig}
|
||||
onDragEnd={handleReorderDragEnd}
|
||||
setRestartDialogOpen={setRestartDialogOpen}
|
||||
onClone={setCloneSourceCamera}
|
||||
/>
|
||||
))}
|
||||
</Reorder.Group>
|
||||
@ -313,7 +332,6 @@ export default function CameraManagementView({
|
||||
camera={camera}
|
||||
onConfigChanged={updateConfig}
|
||||
setRestartDialogOpen={setRestartDialogOpen}
|
||||
onClone={setCloneSourceCamera}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@ -372,9 +390,8 @@ export default function CameraManagementView({
|
||||
onRestart={() => sendRestart("restart")}
|
||||
/>
|
||||
<CloneCameraDialog
|
||||
open={cloneSourceCamera !== null}
|
||||
onClose={() => setCloneSourceCamera(null)}
|
||||
sourceCamera={cloneSourceCamera ?? ""}
|
||||
open={showCloneDialog}
|
||||
onClose={() => setShowCloneDialog(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@ -416,7 +433,6 @@ type ActiveCameraRowProps = {
|
||||
onConfigChanged: () => Promise<unknown>;
|
||||
onDragEnd: () => void;
|
||||
setRestartDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
onClone: (camera: string) => void;
|
||||
};
|
||||
|
||||
function ActiveCameraRow({
|
||||
@ -424,7 +440,6 @@ function ActiveCameraRow({
|
||||
onConfigChanged,
|
||||
onDragEnd,
|
||||
setRestartDialogOpen,
|
||||
onClone,
|
||||
}: ActiveCameraRowProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const controls = useDragControls();
|
||||
@ -452,22 +467,6 @@ function ActiveCameraRow({
|
||||
cameraName={camera}
|
||||
onConfigChanged={onConfigChanged}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
aria-label={t("cameraManagement.clone.triggerAriaLabel", {
|
||||
cameraName: camera,
|
||||
})}
|
||||
onClick={() => onClone(camera)}
|
||||
>
|
||||
<LuCopy className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("cameraManagement.clone.trigger")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<CameraStatusSelect
|
||||
cameraName={camera}
|
||||
@ -483,17 +482,13 @@ type DisabledCameraRowProps = {
|
||||
camera: string;
|
||||
onConfigChanged: () => Promise<unknown>;
|
||||
setRestartDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
onClone: (camera: string) => void;
|
||||
};
|
||||
|
||||
function DisabledCameraRow({
|
||||
camera,
|
||||
onConfigChanged,
|
||||
setRestartDialogOpen,
|
||||
onClone,
|
||||
}: DisabledCameraRowProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
@ -502,22 +497,6 @@ function DisabledCameraRow({
|
||||
cameraName={camera}
|
||||
onConfigChanged={onConfigChanged}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
aria-label={t("cameraManagement.clone.triggerAriaLabel", {
|
||||
cameraName: camera,
|
||||
})}
|
||||
onClick={() => onClone(camera)}
|
||||
>
|
||||
<LuCopy className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("cameraManagement.clone.trigger")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<CameraStatusSelect
|
||||
cameraName={camera}
|
||||
@ -551,6 +530,7 @@ function CameraStatusSelect({
|
||||
]);
|
||||
const { payload: enabledState, send: sendEnabled } =
|
||||
useEnabledState(cameraName);
|
||||
const statusBar = useContext(StatusBarMessagesContext);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const currentStatus: CameraStatus = isDisabledInConfig
|
||||
@ -636,6 +616,12 @@ function CameraStatusSelect({
|
||||
},
|
||||
});
|
||||
await onConfigChanged();
|
||||
statusBar?.addMessage(
|
||||
"config_restart_required",
|
||||
t("configForm.restartRequiredFooter", { ns: "views/settings" }),
|
||||
undefined,
|
||||
"config_restart_required",
|
||||
);
|
||||
toast.success(
|
||||
t("cameraManagement.streams.disableSuccess", {
|
||||
ns: "views/settings",
|
||||
@ -667,6 +653,7 @@ function CameraStatusSelect({
|
||||
onConfigChanged,
|
||||
sendEnabled,
|
||||
setRestartDialogOpen,
|
||||
statusBar,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user