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