mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-21 03:41:55 +03:00
Clone camera settings (#23339)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* add clone dialog * i18n * tweaks * add to camera management pane * add e2e test * optional disable portal prop * radio and checkbox tweaks * tweak i18n * add select all/select none * fixes * reset form only on open transition * unselect all targets for existing camera * fix test * reorder sections for save and collapse to single put for new camera * change source and allow cloning to multiple cameras * tweak language * fix overflowing text in save all popover * tweaks * fix per label object masks * use grid for source and target * language tweak
This commit is contained in:
parent
50f17e6852
commit
bc65713ae4
181
web/e2e/specs/clone-camera.spec.ts
Normal file
181
web/e2e/specs/clone-camera.spec.ts
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
/**
|
||||||
|
* Camera clone dialog E2E tests.
|
||||||
|
*
|
||||||
|
* Covers the design invariants that don't depend on per-camera resolution
|
||||||
|
* differences in the mock fixture:
|
||||||
|
* 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. 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";
|
||||||
|
|
||||||
|
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 }) => {
|
||||||
|
await frigateApp.goto("/settings?page=cameraManagement");
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.getByRole("heading", { name: /Manage Cameras/i }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("opens the dialog from the Clone settings button", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
await openCloneDialog(frigateApp);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
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,
|
||||||
|
}) => {
|
||||||
|
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.
|
||||||
|
const streamsLabel = frigateApp.page
|
||||||
|
.locator("label")
|
||||||
|
.filter({ hasText: /Stream URLs and roles/i });
|
||||||
|
await expect(streamsLabel).toBeVisible();
|
||||||
|
|
||||||
|
const streamsCheckbox = streamsLabel.getByRole("checkbox");
|
||||||
|
await expect(streamsCheckbox).toBeChecked();
|
||||||
|
await expect(streamsCheckbox).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("issues a single add PUT and shows restart toast for new-camera target", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
const requests: { body: unknown }[] = [];
|
||||||
|
|
||||||
|
await frigateApp.page.route("**/api/config/set", async (route) => {
|
||||||
|
const body = route.request().postDataJSON();
|
||||||
|
requests.push({ body });
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ success: true, require_restart: false }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await frigateApp.goto("/settings?page=cameraManagement");
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.getByRole("heading", { name: /Manage Cameras/i }),
|
||||||
|
).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");
|
||||||
|
|
||||||
|
// 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 });
|
||||||
|
|
||||||
|
await frigateApp.page.getByRole("button", { name: /^Clone$/i }).click();
|
||||||
|
|
||||||
|
// New-camera clones bundle into a single atomic add PUT (avoids
|
||||||
|
// per-section validation ordering issues).
|
||||||
|
await expect.poll(() => requests.length, { timeout: 10_000 }).toBe(1);
|
||||||
|
|
||||||
|
const firstBody = requests[0].body as {
|
||||||
|
requires_restart?: number;
|
||||||
|
update_topic?: string;
|
||||||
|
};
|
||||||
|
expect(firstBody.update_topic).toMatch(
|
||||||
|
/config\/cameras\/clone_target_one\/add/,
|
||||||
|
);
|
||||||
|
expect(firstBody.requires_restart).toBe(1);
|
||||||
|
|
||||||
|
// The toast offers a Restart action because new-camera always needs restart.
|
||||||
|
// .first() avoids strict-mode rejection when both the toast action and the
|
||||||
|
// RestartDialog trigger render concurrently.
|
||||||
|
await expect(
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -544,6 +544,92 @@
|
|||||||
"normal": "Normal",
|
"normal": "Normal",
|
||||||
"dedicatedLpr": "Dedicated LPR",
|
"dedicatedLpr": "Dedicated LPR",
|
||||||
"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": {
|
||||||
|
"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",
|
||||||
|
"newNameLabel": "Camera name",
|
||||||
|
"newNamePlaceholder": "e.g., back_door or Back Door",
|
||||||
|
"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.",
|
||||||
|
"existingCamerasRadio": "Existing cameras",
|
||||||
|
"allCameras": "All cameras",
|
||||||
|
"existingPlaceholder": "Select at least one camera",
|
||||||
|
"existingDisabled": "No other cameras to copy to"
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"legend": "Settings to clone",
|
||||||
|
"description": "Choose which settings to copy from the source camera.",
|
||||||
|
"selectAll": "Select all",
|
||||||
|
"selectNone": "Select none",
|
||||||
|
"resetDefaults": "Reset to defaults",
|
||||||
|
"general": "General",
|
||||||
|
"spatial": "Spatial settings",
|
||||||
|
"streams": "Streams",
|
||||||
|
"spatialWarningTitle": "Resolution mismatch",
|
||||||
|
"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",
|
||||||
|
"snapshots": "Snapshots",
|
||||||
|
"review": "Review",
|
||||||
|
"motion": "Motion detection",
|
||||||
|
"objects": "Objects",
|
||||||
|
"audio": "Audio detection",
|
||||||
|
"audio_transcription": "Audio transcription",
|
||||||
|
"notifications": "Notifications",
|
||||||
|
"birdseye": "Birdseye",
|
||||||
|
"mqtt": "MQTT",
|
||||||
|
"timestamp_style": "Timestamp style",
|
||||||
|
"onvif": "ONVIF",
|
||||||
|
"lpr": "License plate recognition",
|
||||||
|
"face_recognition": "Face recognition",
|
||||||
|
"semantic_search": "Semantic search",
|
||||||
|
"genai": "Generative AI",
|
||||||
|
"type": "Camera type (normal / dedicated LPR)",
|
||||||
|
"profiles": "Profiles",
|
||||||
|
"detect": "Detect dimensions",
|
||||||
|
"zones": "Zones",
|
||||||
|
"motion_mask": "Motion masks",
|
||||||
|
"object_masks": "Object masks",
|
||||||
|
"ffmpeg_live": "Stream URLs and roles"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"changeCount_zero": "No changes selected",
|
||||||
|
"changeCount_one": "{{count}} change will be applied",
|
||||||
|
"changeCount_other": "{{count}} changes will be applied",
|
||||||
|
"restartNeeded": "Restart will be required for some changes.",
|
||||||
|
"liveOnly": "All changes will apply live without a restart.",
|
||||||
|
"submit": "Clone",
|
||||||
|
"submitting": "Cloning…"
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"success": "Settings copied to {{cameraName}}",
|
||||||
|
"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}}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cameraReview": {
|
"cameraReview": {
|
||||||
|
|||||||
@ -22,6 +22,7 @@ type SaveAllPreviewPopoverProps = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
align?: "start" | "center" | "end";
|
align?: "start" | "center" | "end";
|
||||||
side?: "top" | "bottom" | "left" | "right";
|
side?: "top" | "bottom" | "left" | "right";
|
||||||
|
disablePortal?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SaveAllPreviewPopover({
|
export default function SaveAllPreviewPopover({
|
||||||
@ -29,6 +30,7 @@ export default function SaveAllPreviewPopover({
|
|||||||
className,
|
className,
|
||||||
align = "end",
|
align = "end",
|
||||||
side = "bottom",
|
side = "bottom",
|
||||||
|
disablePortal = false,
|
||||||
}: SaveAllPreviewPopoverProps) {
|
}: SaveAllPreviewPopoverProps) {
|
||||||
const { t } = useTranslation(["views/settings", "common"]);
|
const { t } = useTranslation(["views/settings", "common"]);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@ -67,6 +69,7 @@ export default function SaveAllPreviewPopover({
|
|||||||
<PopoverContent
|
<PopoverContent
|
||||||
align={align}
|
align={align}
|
||||||
side={side}
|
side={side}
|
||||||
|
disablePortal={disablePortal}
|
||||||
className="w-[90vw] max-w-sm border bg-background p-4 shadow-lg"
|
className="w-[90vw] max-w-sm border bg-background p-4 shadow-lg"
|
||||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||||
>
|
>
|
||||||
@ -108,13 +111,13 @@ export default function SaveAllPreviewPopover({
|
|||||||
}`}
|
}`}
|
||||||
className="rounded-md border border-secondary bg-background_alt p-2"
|
className="rounded-md border border-secondary bg-background_alt p-2"
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
|
<div className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1 text-xs">
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{t("saveAllPreview.scope.label", {
|
{t("saveAllPreview.scope.label", {
|
||||||
ns: "views/settings",
|
ns: "views/settings",
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate">{scopeLabel}</span>
|
<span className="min-w-0 truncate">{scopeLabel}</span>
|
||||||
{item.profileName && (
|
{item.profileName && (
|
||||||
<>
|
<>
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
@ -122,7 +125,7 @@ export default function SaveAllPreviewPopover({
|
|||||||
ns: "views/settings",
|
ns: "views/settings",
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate font-medium">
|
<span className="min-w-0 truncate font-medium">
|
||||||
{item.profileName}
|
{item.profileName}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
@ -132,7 +135,7 @@ export default function SaveAllPreviewPopover({
|
|||||||
ns: "views/settings",
|
ns: "views/settings",
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
<span className="break-all font-mono">
|
<span className="min-w-0 break-all font-mono">
|
||||||
{item.fieldPath}
|
{item.fieldPath}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
@ -140,7 +143,7 @@ export default function SaveAllPreviewPopover({
|
|||||||
ns: "views/settings",
|
ns: "views/settings",
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
<span className="whitespace-pre-wrap break-words font-mono">
|
<span className="min-w-0 whitespace-pre-wrap break-all font-mono">
|
||||||
{formatValue(item.value)}
|
{formatValue(item.value)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
1046
web/src/components/settings/CloneCameraDialog.tsx
Normal file
1046
web/src/components/settings/CloneCameraDialog.tsx
Normal file
File diff suppressed because it is too large
Load Diff
856
web/src/utils/cameraClone.ts
Normal file
856
web/src/utils/cameraClone.ts
Normal file
@ -0,0 +1,856 @@
|
|||||||
|
import cloneDeep from "lodash/cloneDeep";
|
||||||
|
import isEqual from "lodash/isEqual";
|
||||||
|
import merge from "lodash/merge";
|
||||||
|
import type { RJSFSchema } from "@rjsf/utils";
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildOverrides,
|
||||||
|
cameraUpdateTopicMap,
|
||||||
|
flattenOverrides,
|
||||||
|
getEffectiveAttributeLabels,
|
||||||
|
getSectionConfig,
|
||||||
|
prepareSectionSavePayload,
|
||||||
|
resolveHiddenFieldEntries,
|
||||||
|
sanitizeSectionData,
|
||||||
|
type SectionSavePayload,
|
||||||
|
} from "@/utils/configUtil";
|
||||||
|
import { applySchemaDefaults } from "@/lib/config-schema";
|
||||||
|
import type { SaveAllPreviewItem } from "@/components/overlay/detail/SaveAllPreviewPopover";
|
||||||
|
import type { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
||||||
|
import type {
|
||||||
|
ConfigSectionData,
|
||||||
|
JsonObject,
|
||||||
|
JsonValue,
|
||||||
|
} from "@/types/configForm";
|
||||||
|
import { processCameraName } from "@/utils/cameraUtil";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sections whose `filters` dict is auto-populated by the backend at parse
|
||||||
|
* time. `attributeBump` reflects the global-level `min_score=0.7` override
|
||||||
|
* the backend applies to attribute labels (face, license_plate, Frigate+
|
||||||
|
* couriers) — see `frigate/config/config.py`.
|
||||||
|
*/
|
||||||
|
const FILTER_SECTION_DEFS: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
listField: string;
|
||||||
|
filterDef: string;
|
||||||
|
attributeBump?: { min_score: number };
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
objects: {
|
||||||
|
listField: "track",
|
||||||
|
filterDef: "FilterConfig",
|
||||||
|
attributeBump: { min_score: 0.7 },
|
||||||
|
},
|
||||||
|
audio: { listField: "listen", filterDef: "AudioFilterConfig" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveDef(schema: RJSFSchema, name: string): RJSFSchema | undefined {
|
||||||
|
const defs =
|
||||||
|
(schema as { $defs?: Record<string, RJSFSchema> }).$defs ??
|
||||||
|
(schema as { definitions?: Record<string, RJSFSchema> }).definitions;
|
||||||
|
return defs ? defs[name] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
fullSchema: RJSFSchema,
|
||||||
|
fullConfig: FrigateConfig,
|
||||||
|
fullCameraConfig: CameraConfig,
|
||||||
|
): JsonObject {
|
||||||
|
const def = FILTER_SECTION_DEFS[section];
|
||||||
|
if (!def) return sourceSection;
|
||||||
|
const filters = sourceSection.filters;
|
||||||
|
if (!filters || typeof filters !== "object" || Array.isArray(filters)) {
|
||||||
|
return sourceSection;
|
||||||
|
}
|
||||||
|
const filterDef = resolveDef(fullSchema, def.filterDef);
|
||||||
|
if (!filterDef) return sourceSection;
|
||||||
|
const baseDefaults = applySchemaDefaults(filterDef, {}) as JsonObject;
|
||||||
|
const attributeDefaults = def.attributeBump
|
||||||
|
? ({ ...baseDefaults, ...def.attributeBump } as JsonObject)
|
||||||
|
: baseDefaults;
|
||||||
|
const attributeSet =
|
||||||
|
section === "objects"
|
||||||
|
? new Set(
|
||||||
|
getEffectiveAttributeLabels(fullConfig, fullCameraConfig, "camera"),
|
||||||
|
)
|
||||||
|
: 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;
|
||||||
|
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 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,
|
||||||
|
fieldsToStrip: readonly string[],
|
||||||
|
): unknown {
|
||||||
|
if (!dict || typeof dict !== "object" || Array.isArray(dict)) return dict;
|
||||||
|
const result: JsonObject = {};
|
||||||
|
for (const [key, value] of Object.entries(dict as JsonObject)) {
|
||||||
|
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||||
|
const cleaned = { ...(value as JsonObject) };
|
||||||
|
for (const field of fieldsToStrip) {
|
||||||
|
delete cleaned[field];
|
||||||
|
}
|
||||||
|
result[key] = cleaned as JsonValue;
|
||||||
|
} else {
|
||||||
|
result[key] = value as JsonValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-object masks (`objects.filters.<label>.mask`) for the labels that define
|
||||||
|
* one, stripped of runtime fields. The objects form hides `filters.*.mask`, so
|
||||||
|
* clone re-injects these like the camera-wide `objects.mask`.
|
||||||
|
*/
|
||||||
|
function extractFilterMasks(objectsSection: unknown): JsonObject | undefined {
|
||||||
|
if (!objectsSection || typeof objectsSection !== "object") return undefined;
|
||||||
|
const filters = (objectsSection as JsonObject).filters;
|
||||||
|
if (!filters || typeof filters !== "object" || Array.isArray(filters)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const result: JsonObject = {};
|
||||||
|
for (const [label, filter] of Object.entries(filters as JsonObject)) {
|
||||||
|
if (!filter || typeof filter !== "object" || Array.isArray(filter))
|
||||||
|
continue;
|
||||||
|
const mask = (filter as JsonObject).mask;
|
||||||
|
if (
|
||||||
|
mask &&
|
||||||
|
typeof mask === "object" &&
|
||||||
|
!Array.isArray(mask) &&
|
||||||
|
Object.keys(mask as JsonObject).length > 0
|
||||||
|
) {
|
||||||
|
result[label] = {
|
||||||
|
mask: stripDictEntryFields(mask, [
|
||||||
|
"enabled_in_config",
|
||||||
|
"raw_coordinates",
|
||||||
|
]) as JsonValue,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.keys(result).length > 0 ? result : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop `""` (Reset) markers — meaningless for a new camera and unsafe
|
||||||
|
* (backend `update_yaml` raises KeyError trying to `del` a missing key).
|
||||||
|
*/
|
||||||
|
function stripResetMarkers(
|
||||||
|
value: JsonValue | undefined,
|
||||||
|
): JsonValue | undefined {
|
||||||
|
if (value === undefined || value === "") return undefined;
|
||||||
|
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
const result: JsonObject = {};
|
||||||
|
for (const [key, child] of Object.entries(value as JsonObject)) {
|
||||||
|
const cleaned = stripResetMarkers(child);
|
||||||
|
if (cleaned !== undefined) result[key] = cleaned;
|
||||||
|
}
|
||||||
|
return Object.keys(result).length > 0 ? result : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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[],
|
||||||
|
target: string,
|
||||||
|
): SectionSavePayload {
|
||||||
|
const prefix = `cameras.${target}`;
|
||||||
|
const camera: JsonObject = {};
|
||||||
|
for (const p of payloads) {
|
||||||
|
if (p.basePath === prefix) {
|
||||||
|
merge(camera, p.sanitizedOverrides);
|
||||||
|
} else if (p.basePath.startsWith(`${prefix}.`)) {
|
||||||
|
merge(camera, {
|
||||||
|
[p.basePath.slice(prefix.length + 1)]: p.sanitizedOverrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
basePath: prefix,
|
||||||
|
sanitizedOverrides: camera,
|
||||||
|
updateTopic: `config/cameras/${target}/add`,
|
||||||
|
needsRestart: true,
|
||||||
|
pendingDataKey: `${target}::add`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop empty `*_args` arrays from ffmpeg inputs — the establishing payload
|
||||||
|
* uses `buildOverrides` directly, bypassing `sanitizeOverridesForSection`.
|
||||||
|
*/
|
||||||
|
function cleanupFfmpegInputArgs(
|
||||||
|
ffmpeg: JsonValue | undefined,
|
||||||
|
): JsonValue | undefined {
|
||||||
|
if (!ffmpeg || typeof ffmpeg !== "object" || Array.isArray(ffmpeg)) {
|
||||||
|
return ffmpeg;
|
||||||
|
}
|
||||||
|
const obj = ffmpeg as JsonObject;
|
||||||
|
const inputs = obj.inputs;
|
||||||
|
if (!Array.isArray(inputs)) return ffmpeg;
|
||||||
|
const cleanedInputs = inputs.map((input) => {
|
||||||
|
if (!input || typeof input !== "object" || Array.isArray(input))
|
||||||
|
return input;
|
||||||
|
const cleaned = { ...(input as JsonObject) };
|
||||||
|
for (const argsKey of ["global_args", "hwaccel_args", "input_args"]) {
|
||||||
|
const v = cleaned[argsKey];
|
||||||
|
if (Array.isArray(v) && v.length === 0) delete cleaned[argsKey];
|
||||||
|
}
|
||||||
|
return cleaned as JsonValue;
|
||||||
|
});
|
||||||
|
return { ...obj, inputs: cleanedInputs as JsonValue };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subset of `/api/config/raw_paths` used to unmask source credentials. */
|
||||||
|
export type RawCameraPaths = {
|
||||||
|
cameras?: Record<
|
||||||
|
string,
|
||||||
|
{ ffmpeg?: { inputs?: Array<{ path?: string; roles?: string[] }> } }
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace each ffmpeg input's `path` with the unmasked value from
|
||||||
|
* `rawInputs` at the same index. Mirrors `_restore_masked_camera_paths`.
|
||||||
|
*/
|
||||||
|
function restoreFfmpegPaths(
|
||||||
|
ffmpeg: unknown,
|
||||||
|
rawInputs: Array<{ path?: string }> | undefined,
|
||||||
|
): unknown {
|
||||||
|
if (!ffmpeg || typeof ffmpeg !== "object" || Array.isArray(ffmpeg)) {
|
||||||
|
return ffmpeg;
|
||||||
|
}
|
||||||
|
const obj = cloneDeep(ffmpeg) as JsonObject;
|
||||||
|
const inputs = obj.inputs;
|
||||||
|
if (!Array.isArray(inputs) || !rawInputs) return obj;
|
||||||
|
inputs.forEach((input, i) => {
|
||||||
|
if (!input || typeof input !== "object" || Array.isArray(input)) return;
|
||||||
|
const rawPath = rawInputs[i]?.path;
|
||||||
|
if (typeof rawPath !== "string") return;
|
||||||
|
(input as JsonObject).path = rawPath;
|
||||||
|
});
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
fpsOverride?: number,
|
||||||
|
): JsonObject {
|
||||||
|
const result = { ...detect };
|
||||||
|
const fps =
|
||||||
|
typeof fpsOverride === "number"
|
||||||
|
? fpsOverride
|
||||||
|
: typeof result.fps === "number"
|
||||||
|
? result.fps
|
||||||
|
: 5;
|
||||||
|
if (result.min_initialized == null) {
|
||||||
|
result.min_initialized = Math.max(Math.floor(fps / 2), 2);
|
||||||
|
}
|
||||||
|
if (result.max_disappeared == null) {
|
||||||
|
result.max_disappeared = fps * 5;
|
||||||
|
}
|
||||||
|
const threshold = fps * 10;
|
||||||
|
const stationary = result.stationary;
|
||||||
|
const stat: JsonObject =
|
||||||
|
stationary && typeof stationary === "object" && !Array.isArray(stationary)
|
||||||
|
? { ...(stationary as JsonObject) }
|
||||||
|
: {};
|
||||||
|
if (stat.threshold == null) stat.threshold = threshold;
|
||||||
|
if (stat.interval == null) stat.interval = threshold;
|
||||||
|
result.stationary = stat as JsonValue;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Categories the dialog exposes. Most map 1:1 to a section config and flow
|
||||||
|
* through `prepareSectionSavePayload`. Special cases:
|
||||||
|
* - `motion_mask`/`object_masks`: carve-outs merged into the parent
|
||||||
|
* section's payload, or emitted standalone if the parent is unselected.
|
||||||
|
* - `ffmpeg_live`: new-camera target only.
|
||||||
|
* - `type`/`profiles`: not schema-driven; built directly below.
|
||||||
|
*/
|
||||||
|
export type CloneCategoryKey =
|
||||||
|
| "record"
|
||||||
|
| "snapshots"
|
||||||
|
| "review"
|
||||||
|
| "motion"
|
||||||
|
| "objects"
|
||||||
|
| "audio"
|
||||||
|
| "audio_transcription"
|
||||||
|
| "notifications"
|
||||||
|
| "birdseye"
|
||||||
|
| "mqtt"
|
||||||
|
| "timestamp_style"
|
||||||
|
| "onvif"
|
||||||
|
| "lpr"
|
||||||
|
| "face_recognition"
|
||||||
|
| "semantic_search"
|
||||||
|
| "genai"
|
||||||
|
| "type"
|
||||||
|
| "profiles"
|
||||||
|
| "detect"
|
||||||
|
| "zones"
|
||||||
|
| "motion_mask"
|
||||||
|
| "object_masks"
|
||||||
|
| "ffmpeg_live";
|
||||||
|
|
||||||
|
export type CloneCategoryGroup = "general" | "spatial" | "streams";
|
||||||
|
|
||||||
|
export type CloneCategory = {
|
||||||
|
key: CloneCategoryKey;
|
||||||
|
group: CloneCategoryGroup;
|
||||||
|
/** True when this category is only valid for "new camera" targets. */
|
||||||
|
newCameraOnly?: boolean;
|
||||||
|
/** True when this category is forced selected for new-camera targets. */
|
||||||
|
forcedForNewCamera?: boolean;
|
||||||
|
/** Default selection state for "existing camera" targets when resolutions match. */
|
||||||
|
defaultOnExisting: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CLONE_CATEGORIES: readonly CloneCategory[] = [
|
||||||
|
// General
|
||||||
|
{ key: "record", group: "general", defaultOnExisting: true },
|
||||||
|
{ key: "snapshots", group: "general", defaultOnExisting: true },
|
||||||
|
{ key: "review", group: "general", defaultOnExisting: true },
|
||||||
|
{ key: "motion", group: "general", defaultOnExisting: true },
|
||||||
|
{ key: "objects", group: "general", defaultOnExisting: true },
|
||||||
|
{ key: "audio", group: "general", defaultOnExisting: true },
|
||||||
|
{ key: "audio_transcription", group: "general", defaultOnExisting: true },
|
||||||
|
{ key: "notifications", group: "general", defaultOnExisting: true },
|
||||||
|
{ key: "birdseye", group: "general", defaultOnExisting: true },
|
||||||
|
{ key: "mqtt", group: "general", defaultOnExisting: true },
|
||||||
|
{ key: "timestamp_style", group: "general", defaultOnExisting: true },
|
||||||
|
{ key: "onvif", group: "general", defaultOnExisting: false },
|
||||||
|
{ key: "lpr", group: "general", defaultOnExisting: true },
|
||||||
|
{ key: "face_recognition", group: "general", defaultOnExisting: true },
|
||||||
|
{ key: "semantic_search", group: "general", defaultOnExisting: true },
|
||||||
|
{ key: "genai", group: "general", defaultOnExisting: true },
|
||||||
|
{ key: "type", group: "general", defaultOnExisting: false },
|
||||||
|
{ key: "profiles", group: "general", defaultOnExisting: true },
|
||||||
|
// Spatial — defaults computed via resolutionsMatch()
|
||||||
|
{ key: "detect", group: "spatial", defaultOnExisting: true },
|
||||||
|
{ key: "zones", group: "spatial", defaultOnExisting: true },
|
||||||
|
{ key: "motion_mask", group: "spatial", defaultOnExisting: true },
|
||||||
|
{ key: "object_masks", group: "spatial", defaultOnExisting: true },
|
||||||
|
// Streams — only for new-camera target, forced on
|
||||||
|
{
|
||||||
|
key: "ffmpeg_live",
|
||||||
|
group: "streams",
|
||||||
|
newCameraOnly: true,
|
||||||
|
forcedForNewCamera: true,
|
||||||
|
defaultOnExisting: false,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exact-match detect dimensions. Aspect-ratio tolerance isn't safe because
|
||||||
|
* zone/mask coords may be stored as explicit pixels, not just 0-1 relative.
|
||||||
|
*/
|
||||||
|
export function resolutionsMatch(
|
||||||
|
srcDetect: CameraConfig["detect"] | undefined,
|
||||||
|
dstDetect: CameraConfig["detect"] | undefined,
|
||||||
|
): boolean {
|
||||||
|
if (!srcDetect || !dstDetect) return false;
|
||||||
|
if (
|
||||||
|
typeof srcDetect.width !== "number" ||
|
||||||
|
typeof srcDetect.height !== "number"
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof dstDetect.width !== "number" ||
|
||||||
|
typeof dstDetect.height !== "number"
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
srcDetect.width === dstDetect.width && srcDetect.height === dstDetect.height
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initial selection set. Existing-camera targets start empty — copying onto
|
||||||
|
* a configured camera is destructive, so the user opts in explicitly.
|
||||||
|
* New-camera targets pre-select `defaultOnExisting` categories plus
|
||||||
|
* `forcedForNewCamera`.
|
||||||
|
*/
|
||||||
|
export function getCategoryDefaults(
|
||||||
|
targetIsNew: boolean,
|
||||||
|
): Set<CloneCategoryKey> {
|
||||||
|
const selected = new Set<CloneCategoryKey>();
|
||||||
|
if (!targetIsNew) return selected;
|
||||||
|
for (const cat of CLONE_CATEGORIES) {
|
||||||
|
if (cat.forcedForNewCamera || cat.defaultOnExisting) selected.add(cat.key);
|
||||||
|
}
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
type BuildClonedPayloadsArgs = {
|
||||||
|
sourceCfg: CameraConfig;
|
||||||
|
sourceName: string;
|
||||||
|
/** Raw user input for new camera, or the existing-camera key. */
|
||||||
|
targetInput: string;
|
||||||
|
targetIsNew: boolean;
|
||||||
|
selectedKeys: Set<CloneCategoryKey>;
|
||||||
|
fullConfig: FrigateConfig;
|
||||||
|
fullSchema: RJSFSchema;
|
||||||
|
rawPaths?: RawCameraPaths;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the ordered payloads to PUT. Order: new-camera `…/add`, then
|
||||||
|
* `type` (LPR vs normal affects attribute resolution for later payloads),
|
||||||
|
* then per-section, then `profiles` (no hot-reload topic).
|
||||||
|
*/
|
||||||
|
export function buildClonedCameraPayloads({
|
||||||
|
sourceCfg,
|
||||||
|
sourceName,
|
||||||
|
targetInput,
|
||||||
|
targetIsNew,
|
||||||
|
selectedKeys,
|
||||||
|
fullConfig,
|
||||||
|
fullSchema,
|
||||||
|
rawPaths,
|
||||||
|
}: BuildClonedPayloadsArgs): SectionSavePayload[] {
|
||||||
|
const payloads: SectionSavePayload[] = [];
|
||||||
|
|
||||||
|
const { finalCameraName: target, friendlyName } = targetIsNew
|
||||||
|
? processCameraName(targetInput)
|
||||||
|
: { finalCameraName: targetInput, friendlyName: undefined };
|
||||||
|
|
||||||
|
// New-camera establishing payload (carries the `…/add` topic).
|
||||||
|
if (targetIsNew) {
|
||||||
|
const addOverrides: Record<string, unknown> = {
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
if (friendlyName) {
|
||||||
|
addOverrides.friendly_name = friendlyName;
|
||||||
|
}
|
||||||
|
// Diff ffmpeg/live against the global config so fields matching
|
||||||
|
// inherited defaults drop out. Required fields (ffmpeg.inputs) come
|
||||||
|
// along because the source differs from global there.
|
||||||
|
if (selectedKeys.has("ffmpeg_live") && sourceCfg.ffmpeg) {
|
||||||
|
// /api/config masks `user:pass` as `*:*`; backend's restoration
|
||||||
|
// only handles existing cameras, so we unmask here for new ones.
|
||||||
|
const ffmpegWithRealPaths = restoreFfmpegPaths(
|
||||||
|
sourceCfg.ffmpeg,
|
||||||
|
rawPaths?.cameras?.[sourceName]?.ffmpeg?.inputs,
|
||||||
|
);
|
||||||
|
const diff = buildOverrides(
|
||||||
|
ffmpegWithRealPaths,
|
||||||
|
undefined,
|
||||||
|
fullConfig.ffmpeg,
|
||||||
|
);
|
||||||
|
const cleaned = cleanupFfmpegInputArgs(diff as JsonValue | undefined);
|
||||||
|
if (cleaned !== undefined) addOverrides.ffmpeg = cleaned;
|
||||||
|
}
|
||||||
|
if (selectedKeys.has("ffmpeg_live") && sourceCfg.live) {
|
||||||
|
const diff = buildOverrides(
|
||||||
|
sourceCfg.live,
|
||||||
|
undefined,
|
||||||
|
(fullConfig as unknown as JsonObject).live,
|
||||||
|
);
|
||||||
|
if (diff !== undefined) addOverrides.live = diff;
|
||||||
|
}
|
||||||
|
payloads.push({
|
||||||
|
basePath: `cameras.${target}`,
|
||||||
|
sanitizedOverrides: addOverrides as JsonObject,
|
||||||
|
updateTopic: `config/cameras/${target}/add`,
|
||||||
|
needsRestart: true,
|
||||||
|
pendingDataKey: `${target}::__add__`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Camera type — top-level scalar, no schema-driven section.
|
||||||
|
if (selectedKeys.has("type")) {
|
||||||
|
const srcType = (sourceCfg as { type?: string | null }).type;
|
||||||
|
if (srcType !== undefined && srcType !== null) {
|
||||||
|
payloads.push({
|
||||||
|
basePath: `cameras.${target}`,
|
||||||
|
sanitizedOverrides: { type: srcType },
|
||||||
|
updateTopic: undefined,
|
||||||
|
needsRestart: true,
|
||||||
|
pendingDataKey: `${target}::type`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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" },
|
||||||
|
{ key: "motion", section: "motion" },
|
||||||
|
{ key: "objects", section: "objects" },
|
||||||
|
{ key: "record", section: "record" },
|
||||||
|
{ key: "snapshots", section: "snapshots" },
|
||||||
|
{ key: "review", section: "review" },
|
||||||
|
{ key: "audio", section: "audio" },
|
||||||
|
{ key: "audio_transcription", section: "audio_transcription" },
|
||||||
|
{ key: "notifications", section: "notifications" },
|
||||||
|
{ key: "birdseye", section: "birdseye" },
|
||||||
|
{ key: "mqtt", section: "mqtt" },
|
||||||
|
{ key: "timestamp_style", section: "timestamp_style" },
|
||||||
|
{ key: "onvif", section: "onvif" },
|
||||||
|
{ key: "lpr", section: "lpr" },
|
||||||
|
{ key: "face_recognition", section: "face_recognition" },
|
||||||
|
{ key: "semantic_search", section: "semantic_search" },
|
||||||
|
{ key: "genai", section: "genai" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 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",
|
||||||
|
"motion",
|
||||||
|
"record",
|
||||||
|
"snapshots",
|
||||||
|
"review",
|
||||||
|
"audio",
|
||||||
|
"notifications",
|
||||||
|
"ffmpeg",
|
||||||
|
"live",
|
||||||
|
"timestamp_style",
|
||||||
|
];
|
||||||
|
const syntheticTargetCamera = targetIsNew
|
||||||
|
? ({
|
||||||
|
enabled: true,
|
||||||
|
...Object.fromEntries(
|
||||||
|
GLOBAL_INHERITED_SECTIONS.map((s) => [
|
||||||
|
s,
|
||||||
|
cloneDeep((fullConfig as unknown as JsonObject)[s]),
|
||||||
|
]).filter(([, value]) => value !== undefined && value !== null),
|
||||||
|
),
|
||||||
|
} as unknown as FrigateConfig["cameras"][string])
|
||||||
|
: ((fullConfig.cameras?.[target]
|
||||||
|
? cloneDeep(fullConfig.cameras[target])
|
||||||
|
: { enabled: true }) as unknown as FrigateConfig["cameras"][string]);
|
||||||
|
|
||||||
|
// 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 = syntheticCameraObj[section];
|
||||||
|
if (syntheticSection && typeof syntheticSection === "object") {
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// New-camera: synthetic's detect is from global (no per-camera derive),
|
||||||
|
// 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 syntheticDetect = syntheticCameraObj.detect;
|
||||||
|
if (syntheticDetect && typeof syntheticDetect === "object") {
|
||||||
|
syntheticCameraObj.detect = applyDetectComputedDefaults(
|
||||||
|
syntheticDetect as JsonObject,
|
||||||
|
typeof sourceCfg.detect.fps === "number"
|
||||||
|
? sourceCfg.detect.fps
|
||||||
|
: undefined,
|
||||||
|
) as JsonValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const syntheticConfig: FrigateConfig = {
|
||||||
|
...fullConfig,
|
||||||
|
cameras: {
|
||||||
|
...fullConfig.cameras,
|
||||||
|
[target]: syntheticTargetCamera,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const { key, section } of SECTION_KEYS) {
|
||||||
|
if (!selectedKeys.has(key)) continue;
|
||||||
|
const sourceSectionValue = (
|
||||||
|
sourceCfg as unknown as Record<string, unknown>
|
||||||
|
)[section];
|
||||||
|
if (sourceSectionValue == null) continue;
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
{
|
||||||
|
fullConfig,
|
||||||
|
fullCameraConfig: sourceCfg,
|
||||||
|
level: "camera",
|
||||||
|
formData: sourceSectionValue as ConfigSectionData,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let pendingSectionValue: unknown = sanitizeSectionData(
|
||||||
|
cloneDeep(sourceSectionValue) as ConfigSectionData,
|
||||||
|
resolvedHiddenFields,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (FILTER_SECTION_DEFS[section]) {
|
||||||
|
pendingSectionValue = stripAutoDefaultFilters(
|
||||||
|
section,
|
||||||
|
pendingSectionValue as JsonObject,
|
||||||
|
fullSchema,
|
||||||
|
fullConfig,
|
||||||
|
syntheticTargetCamera as CameraConfig,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-inject masks the parent section's hiddenFields just stripped,
|
||||||
|
// when the mask category is also selected. `raw_mask` is never in
|
||||||
|
// the API response; `enabled_in_config` is runtime-only.
|
||||||
|
if (key === "motion" && selectedKeys.has("motion_mask")) {
|
||||||
|
const srcMask = (sourceSectionValue as { mask?: unknown }).mask;
|
||||||
|
if (srcMask !== undefined) {
|
||||||
|
pendingSectionValue = {
|
||||||
|
...(pendingSectionValue as object),
|
||||||
|
mask: stripDictEntryFields(srcMask, [
|
||||||
|
"enabled_in_config",
|
||||||
|
"raw_coordinates",
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (key === "objects" && selectedKeys.has("object_masks")) {
|
||||||
|
const next = { ...(pendingSectionValue as JsonObject) };
|
||||||
|
// Camera-wide object mask (applies to all objects).
|
||||||
|
const srcMask = (sourceSectionValue as { mask?: unknown }).mask;
|
||||||
|
if (srcMask !== undefined) {
|
||||||
|
next.mask = stripDictEntryFields(srcMask, [
|
||||||
|
"enabled_in_config",
|
||||||
|
"raw_coordinates",
|
||||||
|
]) as JsonValue;
|
||||||
|
}
|
||||||
|
// Per-object masks (objects.filters.<label>.mask), stripped by the
|
||||||
|
// section's hiddenFields above. Merge them onto the reduced filters
|
||||||
|
// (creating the entry when the filter was otherwise all-default).
|
||||||
|
const filterMasks = extractFilterMasks(sourceSectionValue);
|
||||||
|
if (filterMasks) {
|
||||||
|
const mergedFilters: JsonObject = {
|
||||||
|
...((next.filters as JsonObject) ?? {}),
|
||||||
|
};
|
||||||
|
for (const [label, overlay] of Object.entries(filterMasks)) {
|
||||||
|
mergedFilters[label] = {
|
||||||
|
...((mergedFilters[label] as JsonObject) ?? {}),
|
||||||
|
...(overlay as JsonObject),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
next.filters = mergedFilters;
|
||||||
|
}
|
||||||
|
pendingSectionValue = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `color` is a Pydantic PrivateAttr (runtime-only).
|
||||||
|
if (key === "zones") {
|
||||||
|
pendingSectionValue = stripDictEntryFields(pendingSectionValue, [
|
||||||
|
"color",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = prepareSectionSavePayload({
|
||||||
|
pendingDataKey: `${target}::${section}`,
|
||||||
|
pendingData: pendingSectionValue,
|
||||||
|
config: syntheticConfig,
|
||||||
|
fullSchema,
|
||||||
|
});
|
||||||
|
if (payload) {
|
||||||
|
payloads.push(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standalone mask payloads — only when the parent section isn't also
|
||||||
|
// selected (otherwise the masks were merged into its payload above).
|
||||||
|
if (selectedKeys.has("motion_mask") && !selectedKeys.has("motion")) {
|
||||||
|
const srcMask = (sourceCfg.motion as { mask?: unknown } | undefined)?.mask;
|
||||||
|
if (srcMask !== undefined) {
|
||||||
|
payloads.push({
|
||||||
|
basePath: `cameras.${target}.motion`,
|
||||||
|
sanitizedOverrides: {
|
||||||
|
mask: stripDictEntryFields(srcMask, [
|
||||||
|
"enabled_in_config",
|
||||||
|
"raw_coordinates",
|
||||||
|
]) as JsonValue,
|
||||||
|
},
|
||||||
|
updateTopic: `config/cameras/${target}/${cameraUpdateTopicMap.motion}`,
|
||||||
|
needsRestart: false,
|
||||||
|
pendingDataKey: `${target}::motion.masks`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (selectedKeys.has("object_masks") && !selectedKeys.has("objects")) {
|
||||||
|
const overrides: JsonObject = {};
|
||||||
|
const srcMask = (sourceCfg.objects as { mask?: unknown } | undefined)?.mask;
|
||||||
|
if (srcMask !== undefined) {
|
||||||
|
overrides.mask = stripDictEntryFields(srcMask, [
|
||||||
|
"enabled_in_config",
|
||||||
|
"raw_coordinates",
|
||||||
|
]) as JsonValue;
|
||||||
|
}
|
||||||
|
const filterMasks = extractFilterMasks(sourceCfg.objects);
|
||||||
|
if (filterMasks) {
|
||||||
|
overrides.filters = filterMasks;
|
||||||
|
}
|
||||||
|
if (Object.keys(overrides).length > 0) {
|
||||||
|
payloads.push({
|
||||||
|
basePath: `cameras.${target}.objects`,
|
||||||
|
sanitizedOverrides: overrides,
|
||||||
|
updateTopic: `config/cameras/${target}/${cameraUpdateTopicMap.objects}`,
|
||||||
|
needsRestart: false,
|
||||||
|
pendingDataKey: `${target}::objects.masks`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Profiles — wholesale dict replacement; no hot-reload topic.
|
||||||
|
if (selectedKeys.has("profiles")) {
|
||||||
|
const srcProfiles = (sourceCfg as { profiles?: unknown }).profiles;
|
||||||
|
if (srcProfiles && typeof srcProfiles === "object") {
|
||||||
|
payloads.push({
|
||||||
|
basePath: `cameras.${target}.profiles`,
|
||||||
|
sanitizedOverrides: cloneDeep(srcProfiles) as JsonObject,
|
||||||
|
updateTopic: undefined,
|
||||||
|
needsRestart: true,
|
||||||
|
pendingDataKey: `${target}::profiles`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// New camera: scrub Reset markers (see stripResetMarkers), then bundle
|
||||||
|
// into one atomic `…/add` PUT so the backend validates the full camera
|
||||||
|
// at once (avoids per-section ordering issues).
|
||||||
|
if (targetIsNew) {
|
||||||
|
const scrubbed = payloads
|
||||||
|
.map((p) => {
|
||||||
|
const cleaned = stripResetMarkers(p.sanitizedOverrides as JsonValue);
|
||||||
|
return cleaned === undefined
|
||||||
|
? null
|
||||||
|
: { ...p, sanitizedOverrides: cleaned as JsonObject };
|
||||||
|
})
|
||||||
|
.filter((p): p is SectionSavePayload => p !== null);
|
||||||
|
return [bundleNewCameraPayload(scrubbed, target)];
|
||||||
|
}
|
||||||
|
|
||||||
|
return payloads;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flatten payloads to `SaveAllPreviewItem`s with camera-relative
|
||||||
|
* `fieldPath`s (matches BaseSection's per-section preview).
|
||||||
|
*/
|
||||||
|
export function buildClonePreviewItems(
|
||||||
|
payloads: SectionSavePayload[],
|
||||||
|
targetCamera: string,
|
||||||
|
): SaveAllPreviewItem[] {
|
||||||
|
const cameraBase = `cameras.${targetCamera}`;
|
||||||
|
return payloads.flatMap((p) => {
|
||||||
|
const flattened = flattenOverrides(p.sanitizedOverrides as JsonValue);
|
||||||
|
const sectionRelativeBase =
|
||||||
|
p.basePath === cameraBase
|
||||||
|
? ""
|
||||||
|
: p.basePath.startsWith(`${cameraBase}.`)
|
||||||
|
? p.basePath.slice(cameraBase.length + 1)
|
||||||
|
: p.basePath;
|
||||||
|
return flattened.map(({ path, value }) => ({
|
||||||
|
scope: "camera" as const,
|
||||||
|
cameraName: targetCamera,
|
||||||
|
fieldPath: path
|
||||||
|
? sectionRelativeBase
|
||||||
|
? `${sectionRelativeBase}.${path}`
|
||||||
|
: path
|
||||||
|
: sectionRelativeBase,
|
||||||
|
value,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -81,6 +81,7 @@ export const cameraUpdateTopicMap: Record<string, string> = {
|
|||||||
mqtt: "mqtt",
|
mqtt: "mqtt",
|
||||||
onvif: "onvif",
|
onvif: "onvif",
|
||||||
ui: "ui",
|
ui: "ui",
|
||||||
|
zones: "zones",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sections where global config serves as the default for per-camera config.
|
// Sections where global config serves as the default for per-camera config.
|
||||||
|
|||||||
@ -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";
|
||||||
@ -15,6 +21,7 @@ import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
|
|||||||
import DeleteCameraDialog from "@/components/overlay/dialog/DeleteCameraDialog";
|
import DeleteCameraDialog from "@/components/overlay/dialog/DeleteCameraDialog";
|
||||||
import {
|
import {
|
||||||
LuCheck,
|
LuCheck,
|
||||||
|
LuCopy,
|
||||||
LuExternalLink,
|
LuExternalLink,
|
||||||
LuGripVertical,
|
LuGripVertical,
|
||||||
LuPencil,
|
LuPencil,
|
||||||
@ -22,6 +29,7 @@ import {
|
|||||||
LuRefreshCcw,
|
LuRefreshCcw,
|
||||||
LuTrash2,
|
LuTrash2,
|
||||||
} from "react-icons/lu";
|
} from "react-icons/lu";
|
||||||
|
import CloneCameraDialog from "@/components/settings/CloneCameraDialog";
|
||||||
import { Reorder, useDragControls } from "framer-motion";
|
import { Reorder, useDragControls } from "framer-motion";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||||
@ -50,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,
|
||||||
@ -88,6 +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 [showCloneDialog, setShowCloneDialog] = useState(false);
|
||||||
|
|
||||||
// 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);
|
||||||
@ -217,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">
|
||||||
@ -254,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={
|
||||||
@ -364,6 +389,10 @@ export default function CameraManagementView({
|
|||||||
onClose={() => setRestartDialogOpen(false)}
|
onClose={() => setRestartDialogOpen(false)}
|
||||||
onRestart={() => sendRestart("restart")}
|
onRestart={() => sendRestart("restart")}
|
||||||
/>
|
/>
|
||||||
|
<CloneCameraDialog
|
||||||
|
open={showCloneDialog}
|
||||||
|
onClose={() => setShowCloneDialog(false)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -501,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
|
||||||
@ -586,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",
|
||||||
@ -617,6 +653,7 @@ function CameraStatusSelect({
|
|||||||
onConfigChanged,
|
onConfigChanged,
|
||||||
sendEnabled,
|
sendEnabled,
|
||||||
setRestartDialogOpen,
|
setRestartDialogOpen,
|
||||||
|
statusBar,
|
||||||
t,
|
t,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user