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

* 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:
Josh Hawkins
2026-09-16 12:07:05 -06:00
committed by GitHub
parent 64d6366ac4
commit 10a0d5ea37
6 changed files with 709 additions and 368 deletions
@@ -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 {})
+251
View File
@@ -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 },
},
},
},
},
},
});
});
});
+5 -1
View File
@@ -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
+76 -141
View File
@@ -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",
+96 -153
View File
@@ -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}
+88 -73
View File
@@ -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;
};