mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-24 18:26:51 +03:00
Improve UI zone operations (#24376)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
* improve zone renaming Zone rename now saves as one JSON body via config/set, moving required_zones and profile overrides instead of leaving stale references * fixes
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
"""Tests for renaming and deleting a zone through config_set's JSON body."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import ruamel.yaml
|
||||
from fastapi import Request
|
||||
|
||||
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
|
||||
from frigate.api.fastapi_app import create_fastapi_app
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.camera.updater import CameraConfigUpdatePublisher
|
||||
from frigate.models import Event, Recordings, ReviewSegment
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
|
||||
|
||||
class TestConfigSetZones(BaseTestHttp):
|
||||
def setUp(self):
|
||||
super().setUp(models=[Event, Recordings, ReviewSegment])
|
||||
self.minimal_config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"profiles": {"armed": {"friendly_name": "Armed"}},
|
||||
"snapshots": {"required_zones": ["driveway"]},
|
||||
"cameras": {
|
||||
"front": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||
]
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
"zones": {
|
||||
"driveway": {
|
||||
"coordinates": "0,0,1,0,1,1",
|
||||
"inertia": 5,
|
||||
"filters": {"person": {"min_area": 5000}},
|
||||
},
|
||||
"porch": {"coordinates": "0,0,0.5,0,0.5,0.5"},
|
||||
},
|
||||
"review": {
|
||||
"alerts": {
|
||||
"labels": ["person"],
|
||||
"required_zones": ["driveway"],
|
||||
},
|
||||
},
|
||||
"mqtt": {"required_zones": ["driveway", "porch"]},
|
||||
"profiles": {
|
||||
"armed": {
|
||||
"zones": {
|
||||
"driveway": {
|
||||
"coordinates": "0,0,1,0,1,1",
|
||||
"objects": ["car"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
yaml = ruamel.yaml.YAML()
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".yml", delete=False
|
||||
) as config_file:
|
||||
yaml.dump(self.minimal_config, config_file)
|
||||
self.config_path = config_file.name
|
||||
self.addCleanup(os.unlink, self.config_path)
|
||||
|
||||
def _create_app(self):
|
||||
publisher = Mock(spec=CameraConfigUpdatePublisher)
|
||||
publisher.publisher = MagicMock()
|
||||
app = create_fastapi_app(
|
||||
FrigateConfig(**self.minimal_config),
|
||||
self.db,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
publisher,
|
||||
None,
|
||||
enforce_default_admin=False,
|
||||
)
|
||||
|
||||
async def mock_get_current_user(request: Request):
|
||||
return {
|
||||
"username": request.headers.get("remote-user"),
|
||||
"role": request.headers.get("remote-role"),
|
||||
}
|
||||
|
||||
async def mock_get_allowed_cameras_for_filter(request: Request):
|
||||
return ["front"]
|
||||
|
||||
app.dependency_overrides[get_current_user] = mock_get_current_user
|
||||
app.dependency_overrides[get_allowed_cameras_for_filter] = (
|
||||
mock_get_allowed_cameras_for_filter
|
||||
)
|
||||
return app
|
||||
|
||||
def _put(self, camera_data: dict):
|
||||
with patch("frigate.api.app.find_config_file", return_value=self.config_path):
|
||||
with AuthTestClient(self._create_app()) as client:
|
||||
return client.put(
|
||||
"/config/set",
|
||||
json={
|
||||
"config_data": {"cameras": {"front": camera_data}},
|
||||
"requires_restart": 0,
|
||||
"update_topic": "config/cameras/front/zones",
|
||||
},
|
||||
)
|
||||
|
||||
def _front(self) -> dict:
|
||||
with open(self.config_path) as f:
|
||||
return ruamel.yaml.YAML().load(f)["cameras"]["front"]
|
||||
|
||||
def test_rename_moves_zone_references_in_one_request(self):
|
||||
"""The new base zone exists when validation checks the moved override."""
|
||||
resp = self._put(
|
||||
{
|
||||
"zones": {
|
||||
"driveway": None,
|
||||
"front_drive": {
|
||||
"coordinates": "0,0,1,0,1,1",
|
||||
"enabled": True,
|
||||
# as /api/config returns them, with defaults filled in
|
||||
"filters": {
|
||||
"person": {
|
||||
"min_area": 5000,
|
||||
"max_area": 24000000,
|
||||
"min_ratio": 0.0,
|
||||
"max_ratio": 24000000.0,
|
||||
"threshold": 0.7,
|
||||
"min_score": 0.5,
|
||||
"mask": {},
|
||||
}
|
||||
},
|
||||
"inertia": 5,
|
||||
},
|
||||
},
|
||||
"review": {"alerts": {"required_zones": ["front_drive"]}},
|
||||
# inherited from the global snapshots config, so no camera key
|
||||
"snapshots": {"required_zones": ["front_drive"]},
|
||||
"mqtt": {"required_zones": ["front_drive", "porch"]},
|
||||
"profiles": {
|
||||
"armed": {
|
||||
"zones": {
|
||||
"driveway": None,
|
||||
"front_drive": {
|
||||
"coordinates": "0,0,1,0,1,1",
|
||||
"objects": ["car"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200, resp.json())
|
||||
front = self._front()
|
||||
self.assertEqual(list(front["zones"]), ["porch", "front_drive"])
|
||||
self.assertEqual(front["zones"]["front_drive"]["inertia"], 5)
|
||||
self.assertEqual(
|
||||
front["zones"]["front_drive"]["filters"]["person"]["min_area"], 5000
|
||||
)
|
||||
self.assertEqual(front["review"]["alerts"]["labels"], ["person"])
|
||||
self.assertEqual(front["review"]["alerts"]["required_zones"], ["front_drive"])
|
||||
self.assertEqual(front["snapshots"]["required_zones"], ["front_drive"])
|
||||
self.assertEqual(front["mqtt"]["required_zones"], ["front_drive", "porch"])
|
||||
self.assertEqual(
|
||||
dict(front["profiles"]["armed"]["zones"]),
|
||||
{"front_drive": {"coordinates": "0,0,1,0,1,1", "objects": ["car"]}},
|
||||
)
|
||||
|
||||
def test_delete_empties_lists_without_dropping_their_section(self):
|
||||
resp = self._put(
|
||||
{
|
||||
"zones": {"driveway": None},
|
||||
"review": {"alerts": {"required_zones": []}},
|
||||
"snapshots": {"required_zones": []},
|
||||
"mqtt": {"required_zones": ["porch"]},
|
||||
"profiles": {"armed": {"zones": {"driveway": None}}},
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200, resp.json())
|
||||
front = self._front()
|
||||
self.assertEqual(list(front["zones"]), ["porch"])
|
||||
self.assertEqual(front["review"]["alerts"]["labels"], ["person"])
|
||||
self.assertEqual(front["review"]["alerts"]["required_zones"], [])
|
||||
self.assertEqual(front["mqtt"]["required_zones"], ["porch"])
|
||||
self.assertNotIn("driveway", front["profiles"]["armed"]["zones"] or {})
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Zone rename and delete tests -- MEDIUM tier.
|
||||
*
|
||||
* A zone's name also lives in required_zones lists and profile overrides.
|
||||
* These tests pin that renaming or deleting a zone sends one config/set JSON
|
||||
* body that moves or drops every reference, with nothing in the query string,
|
||||
* and flags a restart. Editing the name alone must not rename the zone.
|
||||
*/
|
||||
|
||||
import { test, expect } from "../../fixtures/frigate-test";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { configFactory } from "../../fixtures/mock-data/config";
|
||||
|
||||
const SETTINGS_URL = "/settings?page=masksAndZones&camera=front_door";
|
||||
const COORDINATES = "0.1,0.1,0.5,0.1,0.5,0.5,0.1,0.5";
|
||||
|
||||
// /api/config returns zone filters with defaults filled in
|
||||
const FILTERS = {
|
||||
person: {
|
||||
min_area: 5000,
|
||||
max_area: 24000000,
|
||||
min_ratio: 0,
|
||||
max_ratio: 24000000,
|
||||
threshold: 0.7,
|
||||
min_score: 0.5,
|
||||
mask: {},
|
||||
},
|
||||
};
|
||||
|
||||
type ConfigSetRequest = { url: string; body: Record<string, unknown> };
|
||||
|
||||
async function installRoutes(page: Page) {
|
||||
const config = configFactory({
|
||||
profiles: { armed: { friendly_name: "Armed" } },
|
||||
cameras: {
|
||||
front_door: {
|
||||
zones: {
|
||||
driveway: {
|
||||
coordinates: COORDINATES,
|
||||
enabled: true,
|
||||
inertia: 3,
|
||||
loitering_time: 0,
|
||||
objects: [],
|
||||
filters: FILTERS,
|
||||
color: [128, 128, 0],
|
||||
},
|
||||
},
|
||||
review: { alerts: { required_zones: ["driveway"] } },
|
||||
snapshots: { required_zones: ["driveway"] },
|
||||
mqtt: { required_zones: ["driveway"] },
|
||||
profiles: {
|
||||
armed: {
|
||||
zones: { driveway: { coordinates: COORDINATES, inertia: 6 } },
|
||||
review: { alerts: { required_zones: ["driveway"] } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const requests: ConfigSetRequest[] = [];
|
||||
|
||||
await page.route("**/api/config", (route) => route.fulfill({ json: config }));
|
||||
await page.route("**/api/config/set**", async (route) => {
|
||||
requests.push({
|
||||
url: route.request().url(),
|
||||
body: route.request().postDataJSON(),
|
||||
});
|
||||
await route.fulfill({ json: { success: true } });
|
||||
});
|
||||
|
||||
return requests;
|
||||
}
|
||||
|
||||
async function openZoneAction(
|
||||
page: Page,
|
||||
isMobile: boolean,
|
||||
action: "Edit" | "Delete",
|
||||
) {
|
||||
const row = page.locator("[data-index]", { hasText: "Driveway" });
|
||||
|
||||
if (isMobile) {
|
||||
await row.locator("button[aria-haspopup='menu']").click();
|
||||
await page.getByRole("menuitem", { name: action }).click();
|
||||
return;
|
||||
}
|
||||
|
||||
// Desktop shows the actions on hover
|
||||
await row.hover();
|
||||
await row.getByLabel(action, { exact: true }).click();
|
||||
}
|
||||
|
||||
test.describe("zone rename and delete @medium @mobile", () => {
|
||||
test("editing the name of a referenced zone keeps its id", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const requests = await installRoutes(frigateApp.page);
|
||||
await frigateApp.goto(SETTINGS_URL);
|
||||
|
||||
await openZoneAction(frigateApp.page, frigateApp.isMobile, "Edit");
|
||||
await frigateApp.page
|
||||
.getByRole("textbox", { name: "Name", exact: true })
|
||||
.fill("Main Driveway");
|
||||
await expect(
|
||||
frigateApp.page.getByRole("textbox", { name: "ID", exact: true }),
|
||||
).toHaveValue("driveway");
|
||||
await frigateApp.page.getByRole("button", { name: "Save" }).click();
|
||||
|
||||
await expect.poll(() => requests.length).toBe(1);
|
||||
expect(new URL(requests[0].url).search).toBe("");
|
||||
expect(requests[0].body).toEqual({
|
||||
requires_restart: 0,
|
||||
update_topic: "config/cameras/front_door/zones",
|
||||
config_data: {
|
||||
cameras: {
|
||||
front_door: {
|
||||
zones: {
|
||||
driveway: {
|
||||
coordinates: COORDINATES,
|
||||
enabled: true,
|
||||
inertia: 3,
|
||||
loitering_time: 0,
|
||||
friendly_name: "Main Driveway",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("turning speed estimation on and off sends no distances delete", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
// The zone has no distances in the YAML, and config/set fails to delete
|
||||
// a missing key
|
||||
const requests = await installRoutes(frigateApp.page);
|
||||
await frigateApp.goto(SETTINGS_URL);
|
||||
|
||||
await openZoneAction(frigateApp.page, frigateApp.isMobile, "Edit");
|
||||
const speedEstimation = frigateApp.page
|
||||
.getByText("Speed Estimation", { exact: true })
|
||||
.locator("..")
|
||||
.getByRole("switch");
|
||||
await speedEstimation.click();
|
||||
for (const line of ["A", "B", "C", "D"]) {
|
||||
await frigateApp.page
|
||||
.getByRole("textbox", { name: new RegExp(`^Line ${line} distance`) })
|
||||
.fill("5");
|
||||
}
|
||||
await speedEstimation.click();
|
||||
await frigateApp.page.getByRole("button", { name: "Save" }).click();
|
||||
|
||||
await expect.poll(() => requests.length).toBe(1);
|
||||
expect(requests[0].body).toMatchObject({
|
||||
config_data: { cameras: { front_door: { zones: { driveway: {} } } } },
|
||||
});
|
||||
const body = requests[0].body as {
|
||||
config_data: {
|
||||
cameras: { front_door: { zones: { driveway: object } } };
|
||||
};
|
||||
};
|
||||
expect(
|
||||
body.config_data.cameras.front_door.zones.driveway,
|
||||
).not.toHaveProperty("distances");
|
||||
});
|
||||
|
||||
test("renaming a referenced zone moves every reference in one request", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const requests = await installRoutes(frigateApp.page);
|
||||
await frigateApp.goto(SETTINGS_URL);
|
||||
|
||||
await openZoneAction(frigateApp.page, frigateApp.isMobile, "Edit");
|
||||
await frigateApp.page
|
||||
.getByRole("textbox", { name: "ID", exact: true })
|
||||
.fill("front_drive");
|
||||
await frigateApp.page.getByRole("button", { name: "Save" }).click();
|
||||
|
||||
await expect.poll(() => requests.length).toBe(1);
|
||||
expect(new URL(requests[0].url).search).toBe("");
|
||||
expect(requests[0].body).toEqual({
|
||||
requires_restart: 1,
|
||||
update_topic: "config/cameras/front_door/zones",
|
||||
config_data: {
|
||||
cameras: {
|
||||
front_door: {
|
||||
zones: {
|
||||
driveway: null,
|
||||
front_drive: {
|
||||
coordinates: COORDINATES,
|
||||
enabled: true,
|
||||
filters: FILTERS,
|
||||
inertia: 3,
|
||||
loitering_time: 0,
|
||||
},
|
||||
},
|
||||
review: { alerts: { required_zones: ["front_drive"] } },
|
||||
snapshots: { required_zones: ["front_drive"] },
|
||||
mqtt: { required_zones: ["front_drive"] },
|
||||
profiles: {
|
||||
armed: {
|
||||
review: { alerts: { required_zones: ["front_drive"] } },
|
||||
zones: {
|
||||
driveway: null,
|
||||
front_drive: { coordinates: COORDINATES, inertia: 6 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("deleting a referenced zone drops every reference in one request", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const requests = await installRoutes(frigateApp.page);
|
||||
await frigateApp.goto(SETTINGS_URL);
|
||||
|
||||
await openZoneAction(frigateApp.page, frigateApp.isMobile, "Delete");
|
||||
await frigateApp.page
|
||||
.getByRole("alertdialog")
|
||||
.getByRole("button", { name: "Delete" })
|
||||
.click();
|
||||
|
||||
await expect.poll(() => requests.length).toBe(1);
|
||||
expect(new URL(requests[0].url).search).toBe("");
|
||||
expect(requests[0].body).toEqual({
|
||||
requires_restart: 1,
|
||||
update_topic: "config/cameras/front_door/zones",
|
||||
config_data: {
|
||||
cameras: {
|
||||
front_door: {
|
||||
zones: { driveway: null },
|
||||
review: { alerts: { required_zones: [] } },
|
||||
snapshots: { required_zones: [] },
|
||||
mqtt: { required_zones: [] },
|
||||
profiles: {
|
||||
armed: {
|
||||
review: { alerts: { required_zones: [] } },
|
||||
zones: { driveway: null },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,8 @@ type NameAndIdFieldsProps<T extends FieldValues = FieldValues> = {
|
||||
placeholderId?: string;
|
||||
idVisible?: boolean;
|
||||
idDisabled?: boolean;
|
||||
// Derive the id from the name as the user types
|
||||
autoFillId?: boolean;
|
||||
};
|
||||
|
||||
export default function NameAndIdFields<T extends FieldValues = FieldValues>({
|
||||
@@ -43,6 +45,7 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
|
||||
placeholderId,
|
||||
idVisible,
|
||||
idDisabled,
|
||||
autoFillId = true,
|
||||
}: NameAndIdFieldsProps<T>) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const { watch, setValue, trigger, formState } = useFormContext<T>();
|
||||
@@ -61,7 +64,7 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
|
||||
const effectiveProcessId = processId || defaultProcessId;
|
||||
|
||||
useEffect(() => {
|
||||
if (idDisabled) {
|
||||
if (idDisabled || !autoFillId) {
|
||||
return;
|
||||
}
|
||||
const subscription = watch((value, { name }) => {
|
||||
@@ -81,6 +84,7 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
|
||||
idField,
|
||||
effectiveProcessId,
|
||||
idDisabled,
|
||||
autoFillId,
|
||||
]);
|
||||
|
||||
// Auto-expand if there's an error on the ID field after user has typed
|
||||
|
||||
@@ -22,12 +22,12 @@ import { HiOutlineDotsVertical, HiTrash } from "react-icons/hi";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { toRGBColorString } from "@/utils/canvasUtil";
|
||||
import { Polygon, PolygonType } from "@/types/canvas";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useContext, useMemo, useState } from "react";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { removeRequiredZoneQuery, reviewQueries } from "@/utils/zoneEdutUtil";
|
||||
import { zoneReferenceUpdates } from "@/utils/zoneEdutUtil";
|
||||
import IconWrapper from "../ui/icon-wrapper";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
@@ -35,6 +35,7 @@ import ActivityIndicator from "../indicators/activity-indicator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useMotionMaskState, useObjectMaskState, useZoneState } from "@/api/ws";
|
||||
import { getProfileColor } from "@/utils/profileColors";
|
||||
import { StatusBarMessagesContext } from "@/context/statusbar-context";
|
||||
|
||||
type PolygonItemProps = {
|
||||
polygon: Polygon;
|
||||
@@ -72,6 +73,7 @@ export default function PolygonItem({
|
||||
const { t } = useTranslation("views/settings");
|
||||
const { data: config, mutate: updateConfig } =
|
||||
useSWR<FrigateConfig>("config");
|
||||
const statusBar = useContext(StatusBarMessagesContext);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const { payload: motionMaskState, send: sendMotionMaskState } =
|
||||
useMotionMaskState(polygon.camera, polygon.name);
|
||||
@@ -137,148 +139,66 @@ export default function PolygonItem({
|
||||
setIsLoading(true);
|
||||
setLoadingPolygonIndex(index);
|
||||
|
||||
let cameraUpdate: Record<string, unknown>;
|
||||
let needsRestart = false;
|
||||
|
||||
if (polygon.type === "zone") {
|
||||
let url: string;
|
||||
const deleteSection = { zones: { [polygon.name]: null } };
|
||||
|
||||
if (editingProfile) {
|
||||
// Profile mode: just delete the profile zone
|
||||
url = `cameras.${polygon.camera}.profiles.${editingProfile}.zones.${polygon.name}`;
|
||||
cameraUpdate = { profiles: { [editingProfile]: deleteSection } };
|
||||
} else {
|
||||
// Base mode: handle review queries
|
||||
const { alertQueries, detectionQueries } = reviewQueries(
|
||||
polygon.name,
|
||||
false,
|
||||
false,
|
||||
polygon.camera,
|
||||
cameraConfig?.review.alerts.required_zones || [],
|
||||
cameraConfig?.review.detections.required_zones || [],
|
||||
);
|
||||
const genaiQueries = removeRequiredZoneQuery(
|
||||
polygon.name,
|
||||
polygon.camera,
|
||||
"objects.genai",
|
||||
cameraConfig?.objects.genai.required_zones || [],
|
||||
);
|
||||
const snapshotQueries = removeRequiredZoneQuery(
|
||||
polygon.name,
|
||||
polygon.camera,
|
||||
"snapshots",
|
||||
cameraConfig?.snapshots.required_zones || [],
|
||||
);
|
||||
const mqttQueries = removeRequiredZoneQuery(
|
||||
polygon.name,
|
||||
polygon.camera,
|
||||
"mqtt",
|
||||
cameraConfig?.mqtt.required_zones || [],
|
||||
);
|
||||
const autotrackQueries = removeRequiredZoneQuery(
|
||||
polygon.name,
|
||||
polygon.camera,
|
||||
"onvif.autotracking",
|
||||
cameraConfig?.onvif.autotracking.required_zones || [],
|
||||
);
|
||||
// Also delete from profiles that have overrides for this zone
|
||||
let profileQueries = "";
|
||||
const references = zoneReferenceUpdates(cameraConfig, polygon.name);
|
||||
// Processes other than object tracking only reload these on restart
|
||||
needsRestart = Object.keys(references).length > 0;
|
||||
cameraUpdate = { ...deleteSection, ...references };
|
||||
}
|
||||
} else {
|
||||
const deleteSection =
|
||||
polygon.type === "motion_mask"
|
||||
? { motion: { mask: { [polygon.name]: null } } }
|
||||
: !polygon.objects.length
|
||||
? { objects: { mask: { [polygon.name]: null } } }
|
||||
: {
|
||||
objects: {
|
||||
filters: {
|
||||
[polygon.objects[0]]: {
|
||||
mask: { [polygon.name]: null },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (editingProfile) {
|
||||
cameraUpdate = { profiles: { [editingProfile]: deleteSection } };
|
||||
} else {
|
||||
// Base mode: also delete from profiles that have overrides for this mask
|
||||
const profileDeletes: Record<string, unknown> = {};
|
||||
if (allProfileNames && cameraConfig) {
|
||||
for (const profileName of allProfileNames) {
|
||||
if (
|
||||
cameraConfig.profiles?.[profileName]?.zones?.[polygon.name] !==
|
||||
undefined
|
||||
) {
|
||||
profileQueries += `&cameras.${polygon.camera}.profiles.${profileName}.zones.${polygon.name}`;
|
||||
const profileData = cameraConfig.profiles?.[profileName];
|
||||
if (!profileData) continue;
|
||||
|
||||
const hasMask =
|
||||
polygon.type === "motion_mask"
|
||||
? profileData.motion?.mask?.[polygon.name] !== undefined
|
||||
: polygon.type === "object_mask"
|
||||
? profileData.objects?.mask?.[polygon.name] !== undefined ||
|
||||
Object.values(profileData.objects?.filters || {}).some(
|
||||
(f) => f?.mask?.[polygon.name] !== undefined,
|
||||
)
|
||||
: false;
|
||||
|
||||
if (hasMask) {
|
||||
profileDeletes[profileName] = deleteSection;
|
||||
}
|
||||
}
|
||||
}
|
||||
url = `cameras.${polygon.camera}.zones.${polygon.name}${alertQueries}${detectionQueries}${genaiQueries}${snapshotQueries}${mqttQueries}${autotrackQueries}${profileQueries}`;
|
||||
cameraUpdate =
|
||||
Object.keys(profileDeletes).length > 0
|
||||
? { ...deleteSection, profiles: profileDeletes }
|
||||
: deleteSection;
|
||||
}
|
||||
|
||||
await axios
|
||||
.put(`config/set?${url}`, {
|
||||
requires_restart: 0,
|
||||
update_topic: updateTopic,
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
toast.success(
|
||||
t("masksAndZones.form.polygonDrawing.delete.success", {
|
||||
name: polygon?.friendly_name ?? polygon?.name,
|
||||
}),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
updateConfig();
|
||||
onDeleted?.();
|
||||
} else {
|
||||
toast.error(
|
||||
t("toast.save.error.title", {
|
||||
ns: "common",
|
||||
errorMessage: res.statusText,
|
||||
}),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(
|
||||
t("toast.save.error.title", { errorMessage, ns: "common" }),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Motion masks and object masks use JSON body format
|
||||
const deleteSection =
|
||||
polygon.type === "motion_mask"
|
||||
? { motion: { mask: { [polygon.name]: null } } }
|
||||
: !polygon.objects.length
|
||||
? { objects: { mask: { [polygon.name]: null } } }
|
||||
: {
|
||||
objects: {
|
||||
filters: {
|
||||
[polygon.objects[0]]: {
|
||||
mask: { [polygon.name]: null },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let cameraUpdate: Record<string, unknown>;
|
||||
if (editingProfile) {
|
||||
cameraUpdate = { profiles: { [editingProfile]: deleteSection } };
|
||||
} else {
|
||||
// Base mode: also delete from profiles that have overrides for this mask
|
||||
const profileDeletes: Record<string, unknown> = {};
|
||||
if (allProfileNames && cameraConfig) {
|
||||
for (const profileName of allProfileNames) {
|
||||
const profileData = cameraConfig.profiles?.[profileName];
|
||||
if (!profileData) continue;
|
||||
|
||||
const hasMask =
|
||||
polygon.type === "motion_mask"
|
||||
? profileData.motion?.mask?.[polygon.name] !== undefined
|
||||
: polygon.type === "object_mask"
|
||||
? profileData.objects?.mask?.[polygon.name] !== undefined ||
|
||||
Object.values(profileData.objects?.filters || {}).some(
|
||||
(f) => f?.mask?.[polygon.name] !== undefined,
|
||||
)
|
||||
: false;
|
||||
|
||||
if (hasMask) {
|
||||
profileDeletes[profileName] = deleteSection;
|
||||
}
|
||||
}
|
||||
}
|
||||
cameraUpdate =
|
||||
Object.keys(profileDeletes).length > 0
|
||||
? { ...deleteSection, profiles: profileDeletes }
|
||||
: deleteSection;
|
||||
}
|
||||
|
||||
const configUpdate = {
|
||||
@@ -290,17 +210,29 @@ export default function PolygonItem({
|
||||
await axios
|
||||
.put("config/set", {
|
||||
config_data: configUpdate,
|
||||
requires_restart: 0,
|
||||
requires_restart: needsRestart ? 1 : 0,
|
||||
update_topic: updateTopic,
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
toast.success(
|
||||
t("masksAndZones.form.polygonDrawing.delete.success", {
|
||||
name: polygon?.friendly_name ?? polygon?.name,
|
||||
}),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
if (needsRestart) {
|
||||
statusBar?.addMessage(
|
||||
"config_restart_required",
|
||||
t("configForm.restartRequiredFooter"),
|
||||
undefined,
|
||||
"config_restart_required",
|
||||
);
|
||||
toast.success(t("toast.successRestartRequired"), {
|
||||
position: "top-center",
|
||||
});
|
||||
} else {
|
||||
toast.success(
|
||||
t("masksAndZones.form.polygonDrawing.delete.success", {
|
||||
name: polygon?.friendly_name ?? polygon?.name,
|
||||
}),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
}
|
||||
updateConfig();
|
||||
onDeleted?.();
|
||||
} else {
|
||||
@@ -338,6 +270,7 @@ export default function PolygonItem({
|
||||
editingProfile,
|
||||
allProfileNames,
|
||||
onDeleted,
|
||||
statusBar,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -593,6 +526,7 @@ export default function PolygonItem({
|
||||
<TooltipTrigger asChild>
|
||||
<IconWrapper
|
||||
icon={LuPencil}
|
||||
aria-label={t("button.edit", { ns: "common" })}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
"size-[15px] cursor-pointer",
|
||||
@@ -637,6 +571,7 @@ export default function PolygonItem({
|
||||
<TooltipTrigger asChild>
|
||||
<IconWrapper
|
||||
icon={HiTrash}
|
||||
aria-label={t("button.delete", { ns: "common" })}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
"size-[15px] cursor-pointer",
|
||||
|
||||
@@ -11,14 +11,14 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import useSWR from "swr";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { ZoneFormValuesType, Polygon } from "@/types/canvas";
|
||||
import { reviewQueries } from "@/utils/zoneEdutUtil";
|
||||
import { zoneReferenceUpdates } from "@/utils/zoneEdutUtil";
|
||||
import { Switch } from "../ui/switch";
|
||||
import { Label } from "../ui/label";
|
||||
import PolygonEditControls from "./PolygonEditControls";
|
||||
@@ -35,6 +35,7 @@ import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import NameAndIdFields from "../input/NameAndIdFields";
|
||||
import { useZoneState } from "@/api/ws";
|
||||
import { StatusBarMessagesContext } from "@/context/statusbar-context";
|
||||
|
||||
type ZoneEditPaneProps = {
|
||||
polygons?: Polygon[];
|
||||
@@ -71,6 +72,7 @@ export default function ZoneEditPane({
|
||||
const { getLocaleDocUrl } = useDocDomain();
|
||||
const { data: config, mutate: updateConfig } =
|
||||
useSWR<FrigateConfig>("config");
|
||||
const statusBar = useContext(StatusBarMessagesContext);
|
||||
|
||||
const cameras = useMemo(() => {
|
||||
if (!config) {
|
||||
@@ -95,29 +97,8 @@ export default function ZoneEditPane({
|
||||
|
||||
const isExistingZone = !!polygon && polygon.name.length > 0;
|
||||
|
||||
const idDisabled = useMemo(() => {
|
||||
if (!isExistingZone || !polygon) {
|
||||
return false;
|
||||
}
|
||||
if (editingProfile) {
|
||||
return true;
|
||||
}
|
||||
const cam = config?.cameras[polygon.camera];
|
||||
if (!cam) {
|
||||
return false;
|
||||
}
|
||||
const inRequiredZones =
|
||||
cam.review.alerts.required_zones.includes(polygon.name) ||
|
||||
cam.review.detections.required_zones.includes(polygon.name) ||
|
||||
cam.objects.genai.required_zones.includes(polygon.name) ||
|
||||
cam.snapshots.required_zones.includes(polygon.name) ||
|
||||
cam.mqtt.required_zones.includes(polygon.name) ||
|
||||
cam.onvif.autotracking.required_zones.includes(polygon.name);
|
||||
const hasProfileOverride = Object.values(cam.profiles ?? {}).some(
|
||||
(profile) => profile?.zones && polygon.name in profile.zones,
|
||||
);
|
||||
return inRequiredZones || hasProfileOverride;
|
||||
}, [config, polygon, editingProfile, isExistingZone]);
|
||||
// A profile zone overrides a base zone by name, so its id is fixed
|
||||
const idDisabled = isExistingZone && !!editingProfile;
|
||||
|
||||
const cameraConfig = useMemo(() => {
|
||||
if (polygon?.camera && config) {
|
||||
@@ -370,7 +351,7 @@ export default function ZoneEditPane({
|
||||
}, [polygon?.isFinished, form]);
|
||||
|
||||
const saveToConfig = useCallback(
|
||||
async (
|
||||
(
|
||||
{
|
||||
name: zoneName,
|
||||
friendly_name,
|
||||
@@ -391,169 +372,130 @@ export default function ZoneEditPane({
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine config path prefix based on profile mode
|
||||
const pathPrefix = editingProfile
|
||||
? `cameras.${polygon.camera}.profiles.${editingProfile}.zones.${zoneName}`
|
||||
: `cameras.${polygon.camera}.zones.${zoneName}`;
|
||||
|
||||
const oldPathPrefix = editingProfile
|
||||
? `cameras.${polygon.camera}.profiles.${editingProfile}.zones.${polygon.name}`
|
||||
: `cameras.${polygon.camera}.zones.${polygon.name}`;
|
||||
|
||||
let mutatedConfig: typeof config;
|
||||
let alertQueries = "";
|
||||
let detectionQueries = "";
|
||||
|
||||
const renamingZone = zoneName != polygon.name && polygon.name != "";
|
||||
|
||||
// config/set fails to delete a key the YAML lacks. The base zone comes
|
||||
// back with defaults filled in, so only a non-empty value proves the key
|
||||
// is written. Profile overrides hold only keys set in the YAML.
|
||||
const writtenZone = (
|
||||
renamingZone
|
||||
? undefined
|
||||
: editingProfile
|
||||
? cameraConfig?.profiles?.[editingProfile]?.zones?.[polygon.name]
|
||||
: (cameraConfig?.base_config?.zones ?? cameraConfig?.zones)?.[
|
||||
polygon.name
|
||||
]
|
||||
) as Record<string, unknown> | undefined;
|
||||
const canRemove = (key: string) => {
|
||||
const value = writtenZone?.[key];
|
||||
return value != null && !(Array.isArray(value) && value.length == 0);
|
||||
};
|
||||
|
||||
const zoneData: Record<string, unknown> = {
|
||||
coordinates: flattenPoints(
|
||||
interpolatePoints(polygon.points, scaledWidth, scaledHeight, 1, 1),
|
||||
).join(","),
|
||||
enabled,
|
||||
};
|
||||
|
||||
if (renamingZone) {
|
||||
// rename - delete old zone and replace with new
|
||||
// Only handle review queries for base config (not profiles)
|
||||
if (!editingProfile) {
|
||||
const zoneInAlerts =
|
||||
cameraConfig?.review.alerts.required_zones.includes(polygon.name) ??
|
||||
false;
|
||||
const zoneInDetections =
|
||||
cameraConfig?.review.detections.required_zones.includes(
|
||||
polygon.name,
|
||||
) ?? false;
|
||||
// The form has no filters field, so carry the old zone's over
|
||||
const baseZones = (cameraConfig?.base_config?.zones ??
|
||||
cameraConfig?.zones) as
|
||||
Record<string, { filters?: Record<string, unknown> }> | undefined;
|
||||
const filters = baseZones?.[polygon.name]?.filters;
|
||||
|
||||
const {
|
||||
alertQueries: renameAlertQueries,
|
||||
detectionQueries: renameDetectionQueries,
|
||||
} = reviewQueries(
|
||||
polygon.name,
|
||||
false,
|
||||
false,
|
||||
polygon.camera,
|
||||
cameraConfig?.review.alerts.required_zones || [],
|
||||
cameraConfig?.review.detections.required_zones || [],
|
||||
);
|
||||
|
||||
try {
|
||||
await axios.put(
|
||||
`config/set?${oldPathPrefix}${renameAlertQueries}${renameDetectionQueries}`,
|
||||
{
|
||||
requires_restart: 0,
|
||||
update_topic: `config/cameras/${polygon.camera}/zones`,
|
||||
},
|
||||
);
|
||||
|
||||
// Wait for the config to be updated
|
||||
mutatedConfig = await updateConfig();
|
||||
} catch {
|
||||
toast.error(t("toast.save.error.noMessage", { ns: "common" }), {
|
||||
position: "top-center",
|
||||
});
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// make sure new zone name is readded to review
|
||||
({ alertQueries, detectionQueries } = reviewQueries(
|
||||
zoneName,
|
||||
zoneInAlerts,
|
||||
zoneInDetections,
|
||||
polygon.camera,
|
||||
mutatedConfig?.cameras[polygon.camera]?.review.alerts
|
||||
.required_zones || [],
|
||||
mutatedConfig?.cameras[polygon.camera]?.review.detections
|
||||
.required_zones || [],
|
||||
));
|
||||
} else {
|
||||
// Profile mode: just delete the old profile zone path
|
||||
try {
|
||||
await axios.put(`config/set?${oldPathPrefix}`, {
|
||||
requires_restart: 0,
|
||||
});
|
||||
mutatedConfig = await updateConfig();
|
||||
} catch {
|
||||
toast.error(t("toast.save.error.noMessage", { ns: "common" }), {
|
||||
position: "top-center",
|
||||
});
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
if (filters && Object.keys(filters).length > 0) {
|
||||
zoneData.filters = filters;
|
||||
}
|
||||
}
|
||||
|
||||
const coordinates = flattenPoints(
|
||||
interpolatePoints(polygon.points, scaledWidth, scaledHeight, 1, 1),
|
||||
).join(",");
|
||||
|
||||
let objectQueries = objects
|
||||
.map((object) => `&${pathPrefix}.objects=${object}`)
|
||||
.join("");
|
||||
|
||||
const same_objects =
|
||||
form_objects.length == objects.length &&
|
||||
form_objects.every(function (element, index) {
|
||||
return element === objects[index];
|
||||
});
|
||||
|
||||
// deleting objects
|
||||
if (!objectQueries && !same_objects && !renamingZone) {
|
||||
objectQueries = `&${pathPrefix}.objects`;
|
||||
if (objects.length > 0) {
|
||||
zoneData.objects = objects;
|
||||
} else if (!same_objects && canRemove("objects")) {
|
||||
zoneData.objects = null;
|
||||
}
|
||||
|
||||
let inertiaQuery = "";
|
||||
if (inertia) {
|
||||
inertiaQuery = `&${pathPrefix}.inertia=${inertia}`;
|
||||
zoneData.inertia = inertia;
|
||||
}
|
||||
|
||||
let loiteringTimeQuery = "";
|
||||
if (loitering_time >= 0) {
|
||||
loiteringTimeQuery = `&${pathPrefix}.loitering_time=${loitering_time}`;
|
||||
if (typeof loitering_time === "number") {
|
||||
zoneData.loitering_time = loitering_time;
|
||||
}
|
||||
|
||||
let distancesQuery = "";
|
||||
const distances = [lineA, lineB, lineC, lineD].filter(Boolean).join(",");
|
||||
if (speedEstimation) {
|
||||
distancesQuery = `&${pathPrefix}.distances=${distances}`;
|
||||
} else {
|
||||
if (distances != "") {
|
||||
distancesQuery = `&${pathPrefix}.distances`;
|
||||
}
|
||||
zoneData.distances = distances;
|
||||
} else if (canRemove("distances")) {
|
||||
zoneData.distances = null;
|
||||
}
|
||||
|
||||
let speedThresholdQuery = "";
|
||||
if (speed_threshold >= 0 && speedEstimation) {
|
||||
speedThresholdQuery = `&${pathPrefix}.speed_threshold=${speed_threshold}`;
|
||||
} else {
|
||||
if (resolvedZoneData?.speed_threshold) {
|
||||
speedThresholdQuery = `&${pathPrefix}.speed_threshold`;
|
||||
}
|
||||
if (typeof speed_threshold === "number" && speedEstimation) {
|
||||
zoneData.speed_threshold = speed_threshold;
|
||||
} else if (canRemove("speed_threshold")) {
|
||||
zoneData.speed_threshold = null;
|
||||
}
|
||||
|
||||
let friendlyNameQuery = "";
|
||||
if (friendly_name && friendly_name !== zoneName) {
|
||||
friendlyNameQuery = `&${pathPrefix}.friendly_name=${encodeURIComponent(friendly_name)}`;
|
||||
// The name field falls back to the old id when the zone has no name
|
||||
const unnamed = !polygon.friendly_name && friendly_name === polygon.name;
|
||||
if (friendly_name && friendly_name !== zoneName && !unnamed) {
|
||||
zoneData.friendly_name = friendly_name;
|
||||
}
|
||||
|
||||
const enabledQuery = `&${pathPrefix}.enabled=${enabled ? "True" : "False"}`;
|
||||
const zones = renamingZone
|
||||
? { [polygon.name]: null, [zoneName]: zoneData }
|
||||
: { [zoneName]: zoneData };
|
||||
|
||||
const references =
|
||||
renamingZone && !editingProfile && cameraConfig
|
||||
? zoneReferenceUpdates(cameraConfig, polygon.name, zoneName)
|
||||
: {};
|
||||
// Processes other than object tracking only reload these on restart
|
||||
const needsRestart = Object.keys(references).length > 0;
|
||||
|
||||
const cameraData = editingProfile
|
||||
? { profiles: { [editingProfile]: { zones } } }
|
||||
: { zones, ...references };
|
||||
|
||||
const updateTopic = editingProfile
|
||||
? undefined
|
||||
: `config/cameras/${polygon.camera}/zones`;
|
||||
|
||||
axios
|
||||
.put(
|
||||
`config/set?${pathPrefix}.coordinates=${coordinates}${enabledQuery}${inertiaQuery}${loiteringTimeQuery}${speedThresholdQuery}${distancesQuery}${objectQueries}${friendlyNameQuery}${alertQueries}${detectionQueries}`,
|
||||
{
|
||||
requires_restart: 0,
|
||||
update_topic: updateTopic,
|
||||
},
|
||||
)
|
||||
.put("config/set", {
|
||||
config_data: { cameras: { [polygon.camera]: cameraData } },
|
||||
requires_restart: needsRestart ? 1 : 0,
|
||||
update_topic: updateTopic,
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
toast.success(
|
||||
t("masksAndZones.zones.toast.success", {
|
||||
zoneName: friendly_name || zoneName,
|
||||
}),
|
||||
{
|
||||
if (needsRestart) {
|
||||
statusBar?.addMessage(
|
||||
"config_restart_required",
|
||||
t("configForm.restartRequiredFooter"),
|
||||
undefined,
|
||||
"config_restart_required",
|
||||
);
|
||||
toast.success(t("toast.successRestartRequired"), {
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
});
|
||||
} else {
|
||||
toast.success(
|
||||
t("masksAndZones.zones.toast.success", {
|
||||
zoneName: friendly_name || zoneName,
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
}
|
||||
updateConfig();
|
||||
// Only publish WS state for base config when zone has a name and
|
||||
// wasn't renamed (the hook is bound to the old name).
|
||||
@@ -601,7 +543,7 @@ export default function ZoneEditPane({
|
||||
t,
|
||||
sendZoneState,
|
||||
editingProfile,
|
||||
resolvedZoneData,
|
||||
statusBar,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -678,6 +620,7 @@ export default function ZoneEditPane({
|
||||
nameDescription={t("masksAndZones.zones.name.tips")}
|
||||
placeholderName={t("masksAndZones.zones.name.inputPlaceHolder")}
|
||||
idDisabled={idDisabled}
|
||||
autoFillId={!isExistingZone}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
@@ -1,78 +1,93 @@
|
||||
// Build a config/set query fragment that removes `name` from a
|
||||
// required_zones list on the given camera section (e.g. "snapshots",
|
||||
// "mqtt", "objects.genai", "onvif.autotracking"), rebuilding the
|
||||
// remaining entries. When removing the name empties the list, the
|
||||
// required_zones key itself is deleted so the field reverts to its
|
||||
// default instead of retaining the now-stale zone name. Returns an empty
|
||||
// string when `name` is not present so unrelated sections are untouched.
|
||||
export const removeRequiredZoneQuery = (
|
||||
import get from "lodash/get";
|
||||
import setWith from "lodash/setWith";
|
||||
import { CameraConfig } from "@/types/frigateConfig";
|
||||
|
||||
// Camera sections whose required_zones lists name zones
|
||||
const REQUIRED_ZONES_SECTIONS = [
|
||||
"review.alerts",
|
||||
"review.detections",
|
||||
"objects.genai",
|
||||
"snapshots",
|
||||
"mqtt",
|
||||
"onvif.autotracking",
|
||||
];
|
||||
|
||||
// The subset a profile can override
|
||||
const PROFILE_REQUIRED_ZONES_SECTIONS = [
|
||||
"review.alerts",
|
||||
"review.detections",
|
||||
"objects.genai",
|
||||
"snapshots",
|
||||
];
|
||||
|
||||
/**
|
||||
* Build the camera-level config_data that follows a zone rename or delete.
|
||||
*
|
||||
* With newName, every required_zones list and profile zone override moves to
|
||||
* the new name. Without it, they drop the zone. Lists are written whole, so a
|
||||
* list the camera inherits from the global config gets a camera-level copy.
|
||||
* An empty result means nothing else names the zone.
|
||||
*/
|
||||
export const zoneReferenceUpdates = (
|
||||
camera: CameraConfig,
|
||||
name: string,
|
||||
camera: string,
|
||||
section: string,
|
||||
zones: string[],
|
||||
) => {
|
||||
const remaining = new Set<string>(zones || []);
|
||||
newName?: string,
|
||||
): Record<string, unknown> => {
|
||||
const updates: Record<string, unknown> = {};
|
||||
|
||||
if (!remaining.has(name)) {
|
||||
return "";
|
||||
// Object paths keep a numeric profile name from becoming an array index
|
||||
const put = (path: string[], value: unknown) =>
|
||||
setWith(updates, path, value, Object);
|
||||
|
||||
const moveInLists = (
|
||||
source: unknown,
|
||||
prefix: string[],
|
||||
sections: string[],
|
||||
) => {
|
||||
for (const section of sections) {
|
||||
const path = section.split(".");
|
||||
const zones: string[] | undefined = get(source, [
|
||||
...path,
|
||||
"required_zones",
|
||||
]);
|
||||
|
||||
if (!zones?.includes(name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const renamed = zones.flatMap((zone) =>
|
||||
zone !== name ? [zone] : newName ? [newName] : [],
|
||||
);
|
||||
put([...prefix, ...path, "required_zones"], [...new Set(renamed)]);
|
||||
}
|
||||
};
|
||||
|
||||
// An active profile merges into the top-level sections, so read the base
|
||||
moveInLists(
|
||||
{ ...camera, ...camera.base_config },
|
||||
[],
|
||||
REQUIRED_ZONES_SECTIONS,
|
||||
);
|
||||
|
||||
for (const [profile, override] of Object.entries(camera.profiles ?? {})) {
|
||||
moveInLists(
|
||||
override,
|
||||
["profiles", profile],
|
||||
PROFILE_REQUIRED_ZONES_SECTIONS,
|
||||
);
|
||||
|
||||
const zone = override?.zones?.[name];
|
||||
|
||||
if (zone === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
put(["profiles", profile, "zones", name], null);
|
||||
|
||||
if (newName) {
|
||||
put(["profiles", profile, "zones", newName], zone);
|
||||
}
|
||||
}
|
||||
|
||||
remaining.delete(name);
|
||||
|
||||
const key = `cameras.${camera}.${section}.required_zones`;
|
||||
|
||||
if (remaining.size === 0) {
|
||||
return `&${key}`;
|
||||
}
|
||||
|
||||
return [...remaining].map((zone) => `&${key}=${zone}`).join("");
|
||||
};
|
||||
|
||||
export const reviewQueries = (
|
||||
name: string,
|
||||
review_alerts: boolean,
|
||||
review_detections: boolean,
|
||||
camera: string,
|
||||
alertsZones: string[],
|
||||
detectionsZones: string[],
|
||||
) => {
|
||||
let same_alerts = false;
|
||||
let same_detections = false;
|
||||
|
||||
const alerts = new Set<string>(alertsZones || []);
|
||||
|
||||
if (review_alerts) {
|
||||
alerts.add(name);
|
||||
} else {
|
||||
same_alerts = !alerts.has(name);
|
||||
alerts.delete(name);
|
||||
}
|
||||
|
||||
let alertQueries = [...alerts]
|
||||
.map((zone) => `&cameras.${camera}.review.alerts.required_zones=${zone}`)
|
||||
.join("");
|
||||
|
||||
const detections = new Set<string>(detectionsZones || []);
|
||||
|
||||
if (review_detections) {
|
||||
detections.add(name);
|
||||
} else {
|
||||
same_detections = !detections.has(name);
|
||||
detections.delete(name);
|
||||
}
|
||||
|
||||
let detectionQueries = [...detections]
|
||||
.map(
|
||||
(zone) => `&cameras.${camera}.review.detections.required_zones=${zone}`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
if (!alertQueries && !same_alerts) {
|
||||
alertQueries = `&cameras.${camera}.review.alerts`;
|
||||
}
|
||||
if (!detectionQueries && !same_detections) {
|
||||
detectionQueries = `&cameras.${camera}.review.detections`;
|
||||
}
|
||||
|
||||
return { alertQueries, detectionQueries };
|
||||
return updates;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user