diff --git a/frigate/test/http_api/test_http_config_set_zones.py b/frigate/test/http_api/test_http_config_set_zones.py new file mode 100644 index 0000000000..3f4065cb13 --- /dev/null +++ b/frigate/test/http_api/test_http_config_set_zones.py @@ -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 {}) diff --git a/web/e2e/specs/settings/zones.spec.ts b/web/e2e/specs/settings/zones.spec.ts new file mode 100644 index 0000000000..64eaab094f --- /dev/null +++ b/web/e2e/specs/settings/zones.spec.ts @@ -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 }; + +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 }, + }, + }, + }, + }, + }, + }); + }); +}); diff --git a/web/src/components/input/NameAndIdFields.tsx b/web/src/components/input/NameAndIdFields.tsx index 0651914a08..24ebaeea57 100644 --- a/web/src/components/input/NameAndIdFields.tsx +++ b/web/src/components/input/NameAndIdFields.tsx @@ -27,6 +27,8 @@ type NameAndIdFieldsProps = { placeholderId?: string; idVisible?: boolean; idDisabled?: boolean; + // Derive the id from the name as the user types + autoFillId?: boolean; }; export default function NameAndIdFields({ @@ -43,6 +45,7 @@ export default function NameAndIdFields({ placeholderId, idVisible, idDisabled, + autoFillId = true, }: NameAndIdFieldsProps) { const { t } = useTranslation(["common"]); const { watch, setValue, trigger, formState } = useFormContext(); @@ -61,7 +64,7 @@ export default function NameAndIdFields({ const effectiveProcessId = processId || defaultProcessId; useEffect(() => { - if (idDisabled) { + if (idDisabled || !autoFillId) { return; } const subscription = watch((value, { name }) => { @@ -81,6 +84,7 @@ export default function NameAndIdFields({ idField, effectiveProcessId, idDisabled, + autoFillId, ]); // Auto-expand if there's an error on the ID field after user has typed diff --git a/web/src/components/settings/PolygonItem.tsx b/web/src/components/settings/PolygonItem.tsx index 2ba196f0be..d26f948969 100644 --- a/web/src/components/settings/PolygonItem.tsx +++ b/web/src/components/settings/PolygonItem.tsx @@ -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("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; + 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 = {}; 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; - if (editingProfile) { - cameraUpdate = { profiles: { [editingProfile]: deleteSection } }; - } else { - // Base mode: also delete from profiles that have overrides for this mask - const profileDeletes: Record = {}; - 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({ ("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 | undefined; + const canRemove = (key: string) => { + const value = writtenZone?.[key]; + return value != null && !(Array.isArray(value) && value.length == 0); + }; + + const zoneData: Record = { + 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 }> | 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} /> { - const remaining = new Set(zones || []); + newName?: string, +): Record => { + const updates: Record = {}; - 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(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(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; };