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; placeholderId?: string;
idVisible?: boolean; idVisible?: boolean;
idDisabled?: boolean; idDisabled?: boolean;
// Derive the id from the name as the user types
autoFillId?: boolean;
}; };
export default function NameAndIdFields<T extends FieldValues = FieldValues>({ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
@@ -43,6 +45,7 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
placeholderId, placeholderId,
idVisible, idVisible,
idDisabled, idDisabled,
autoFillId = true,
}: NameAndIdFieldsProps<T>) { }: NameAndIdFieldsProps<T>) {
const { t } = useTranslation(["common"]); const { t } = useTranslation(["common"]);
const { watch, setValue, trigger, formState } = useFormContext<T>(); const { watch, setValue, trigger, formState } = useFormContext<T>();
@@ -61,7 +64,7 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
const effectiveProcessId = processId || defaultProcessId; const effectiveProcessId = processId || defaultProcessId;
useEffect(() => { useEffect(() => {
if (idDisabled) { if (idDisabled || !autoFillId) {
return; return;
} }
const subscription = watch((value, { name }) => { const subscription = watch((value, { name }) => {
@@ -81,6 +84,7 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
idField, idField,
effectiveProcessId, effectiveProcessId,
idDisabled, idDisabled,
autoFillId,
]); ]);
// Auto-expand if there's an error on the ID field after user has typed // 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 { isMobile } from "react-device-detect";
import { toRGBColorString } from "@/utils/canvasUtil"; import { toRGBColorString } from "@/utils/canvasUtil";
import { Polygon, PolygonType } from "@/types/canvas"; import { Polygon, PolygonType } from "@/types/canvas";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useContext, useMemo, useState } from "react";
import axios from "axios"; import axios from "axios";
import { toast } from "sonner"; import { toast } from "sonner";
import useSWR from "swr"; import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { removeRequiredZoneQuery, reviewQueries } from "@/utils/zoneEdutUtil"; import { zoneReferenceUpdates } from "@/utils/zoneEdutUtil";
import IconWrapper from "../ui/icon-wrapper"; import IconWrapper from "../ui/icon-wrapper";
import { buttonVariants } from "@/components/ui/button"; import { buttonVariants } from "@/components/ui/button";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
@@ -35,6 +35,7 @@ import ActivityIndicator from "../indicators/activity-indicator";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useMotionMaskState, useObjectMaskState, useZoneState } from "@/api/ws"; import { useMotionMaskState, useObjectMaskState, useZoneState } from "@/api/ws";
import { getProfileColor } from "@/utils/profileColors"; import { getProfileColor } from "@/utils/profileColors";
import { StatusBarMessagesContext } from "@/context/statusbar-context";
type PolygonItemProps = { type PolygonItemProps = {
polygon: Polygon; polygon: Polygon;
@@ -72,6 +73,7 @@ export default function PolygonItem({
const { t } = useTranslation("views/settings"); const { t } = useTranslation("views/settings");
const { data: config, mutate: updateConfig } = const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config"); useSWR<FrigateConfig>("config");
const statusBar = useContext(StatusBarMessagesContext);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const { payload: motionMaskState, send: sendMotionMaskState } = const { payload: motionMaskState, send: sendMotionMaskState } =
useMotionMaskState(polygon.camera, polygon.name); useMotionMaskState(polygon.camera, polygon.name);
@@ -137,148 +139,66 @@ export default function PolygonItem({
setIsLoading(true); setIsLoading(true);
setLoadingPolygonIndex(index); setLoadingPolygonIndex(index);
let cameraUpdate: Record<string, unknown>;
let needsRestart = false;
if (polygon.type === "zone") { if (polygon.type === "zone") {
let url: string; const deleteSection = { zones: { [polygon.name]: null } };
if (editingProfile) { if (editingProfile) {
// Profile mode: just delete the profile zone cameraUpdate = { profiles: { [editingProfile]: deleteSection } };
url = `cameras.${polygon.camera}.profiles.${editingProfile}.zones.${polygon.name}`;
} else { } else {
// Base mode: handle review queries const references = zoneReferenceUpdates(cameraConfig, polygon.name);
const { alertQueries, detectionQueries } = reviewQueries( // Processes other than object tracking only reload these on restart
polygon.name, needsRestart = Object.keys(references).length > 0;
false, cameraUpdate = { ...deleteSection, ...references };
false, }
polygon.camera, } else {
cameraConfig?.review.alerts.required_zones || [], const deleteSection =
cameraConfig?.review.detections.required_zones || [], polygon.type === "motion_mask"
); ? { motion: { mask: { [polygon.name]: null } } }
const genaiQueries = removeRequiredZoneQuery( : !polygon.objects.length
polygon.name, ? { objects: { mask: { [polygon.name]: null } } }
polygon.camera, : {
"objects.genai", objects: {
cameraConfig?.objects.genai.required_zones || [], filters: {
); [polygon.objects[0]]: {
const snapshotQueries = removeRequiredZoneQuery( mask: { [polygon.name]: null },
polygon.name, },
polygon.camera, },
"snapshots", },
cameraConfig?.snapshots.required_zones || [], };
);
const mqttQueries = removeRequiredZoneQuery( if (editingProfile) {
polygon.name, cameraUpdate = { profiles: { [editingProfile]: deleteSection } };
polygon.camera, } else {
"mqtt", // Base mode: also delete from profiles that have overrides for this mask
cameraConfig?.mqtt.required_zones || [], const profileDeletes: Record<string, unknown> = {};
);
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 = "";
if (allProfileNames && cameraConfig) { if (allProfileNames && cameraConfig) {
for (const profileName of allProfileNames) { for (const profileName of allProfileNames) {
if ( const profileData = cameraConfig.profiles?.[profileName];
cameraConfig.profiles?.[profileName]?.zones?.[polygon.name] !== if (!profileData) continue;
undefined
) { const hasMask =
profileQueries += `&cameras.${polygon.camera}.profiles.${profileName}.zones.${polygon.name}`; 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 = { const configUpdate = {
@@ -290,17 +210,29 @@ export default function PolygonItem({
await axios await axios
.put("config/set", { .put("config/set", {
config_data: configUpdate, config_data: configUpdate,
requires_restart: 0, requires_restart: needsRestart ? 1 : 0,
update_topic: updateTopic, update_topic: updateTopic,
}) })
.then((res) => { .then((res) => {
if (res.status === 200) { if (res.status === 200) {
toast.success( if (needsRestart) {
t("masksAndZones.form.polygonDrawing.delete.success", { statusBar?.addMessage(
name: polygon?.friendly_name ?? polygon?.name, "config_restart_required",
}), t("configForm.restartRequiredFooter"),
{ position: "top-center" }, 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(); updateConfig();
onDeleted?.(); onDeleted?.();
} else { } else {
@@ -338,6 +270,7 @@ export default function PolygonItem({
editingProfile, editingProfile,
allProfileNames, allProfileNames,
onDeleted, onDeleted,
statusBar,
], ],
); );
@@ -593,6 +526,7 @@ export default function PolygonItem({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<IconWrapper <IconWrapper
icon={LuPencil} icon={LuPencil}
aria-label={t("button.edit", { ns: "common" })}
disabled={isLoading} disabled={isLoading}
className={cn( className={cn(
"size-[15px] cursor-pointer", "size-[15px] cursor-pointer",
@@ -637,6 +571,7 @@ export default function PolygonItem({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<IconWrapper <IconWrapper
icon={HiTrash} icon={HiTrash}
aria-label={t("button.delete", { ns: "common" })}
disabled={isLoading} disabled={isLoading}
className={cn( className={cn(
"size-[15px] cursor-pointer", "size-[15px] cursor-pointer",
+96 -153
View File
@@ -11,14 +11,14 @@ import {
FormMessage, FormMessage,
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; 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 { FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr"; import useSWR from "swr";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { ZoneFormValuesType, Polygon } from "@/types/canvas"; import { ZoneFormValuesType, Polygon } from "@/types/canvas";
import { reviewQueries } from "@/utils/zoneEdutUtil"; import { zoneReferenceUpdates } from "@/utils/zoneEdutUtil";
import { Switch } from "../ui/switch"; import { Switch } from "../ui/switch";
import { Label } from "../ui/label"; import { Label } from "../ui/label";
import PolygonEditControls from "./PolygonEditControls"; import PolygonEditControls from "./PolygonEditControls";
@@ -35,6 +35,7 @@ import { useDocDomain } from "@/hooks/use-doc-domain";
import { getTranslatedLabel } from "@/utils/i18n"; import { getTranslatedLabel } from "@/utils/i18n";
import NameAndIdFields from "../input/NameAndIdFields"; import NameAndIdFields from "../input/NameAndIdFields";
import { useZoneState } from "@/api/ws"; import { useZoneState } from "@/api/ws";
import { StatusBarMessagesContext } from "@/context/statusbar-context";
type ZoneEditPaneProps = { type ZoneEditPaneProps = {
polygons?: Polygon[]; polygons?: Polygon[];
@@ -71,6 +72,7 @@ export default function ZoneEditPane({
const { getLocaleDocUrl } = useDocDomain(); const { getLocaleDocUrl } = useDocDomain();
const { data: config, mutate: updateConfig } = const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config"); useSWR<FrigateConfig>("config");
const statusBar = useContext(StatusBarMessagesContext);
const cameras = useMemo(() => { const cameras = useMemo(() => {
if (!config) { if (!config) {
@@ -95,29 +97,8 @@ export default function ZoneEditPane({
const isExistingZone = !!polygon && polygon.name.length > 0; const isExistingZone = !!polygon && polygon.name.length > 0;
const idDisabled = useMemo(() => { // A profile zone overrides a base zone by name, so its id is fixed
if (!isExistingZone || !polygon) { const idDisabled = isExistingZone && !!editingProfile;
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]);
const cameraConfig = useMemo(() => { const cameraConfig = useMemo(() => {
if (polygon?.camera && config) { if (polygon?.camera && config) {
@@ -370,7 +351,7 @@ export default function ZoneEditPane({
}, [polygon?.isFinished, form]); }, [polygon?.isFinished, form]);
const saveToConfig = useCallback( const saveToConfig = useCallback(
async ( (
{ {
name: zoneName, name: zoneName,
friendly_name, friendly_name,
@@ -391,169 +372,130 @@ export default function ZoneEditPane({
return; 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 != ""; 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) { if (renamingZone) {
// rename - delete old zone and replace with new // The form has no filters field, so carry the old zone's over
// Only handle review queries for base config (not profiles) const baseZones = (cameraConfig?.base_config?.zones ??
if (!editingProfile) { cameraConfig?.zones) as
const zoneInAlerts = Record<string, { filters?: Record<string, unknown> }> | undefined;
cameraConfig?.review.alerts.required_zones.includes(polygon.name) ?? const filters = baseZones?.[polygon.name]?.filters;
false;
const zoneInDetections =
cameraConfig?.review.detections.required_zones.includes(
polygon.name,
) ?? false;
const { if (filters && Object.keys(filters).length > 0) {
alertQueries: renameAlertQueries, zoneData.filters = filters;
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;
}
} }
} }
const coordinates = flattenPoints(
interpolatePoints(polygon.points, scaledWidth, scaledHeight, 1, 1),
).join(",");
let objectQueries = objects
.map((object) => `&${pathPrefix}.objects=${object}`)
.join("");
const same_objects = const same_objects =
form_objects.length == objects.length && form_objects.length == objects.length &&
form_objects.every(function (element, index) { form_objects.every(function (element, index) {
return element === objects[index]; return element === objects[index];
}); });
// deleting objects if (objects.length > 0) {
if (!objectQueries && !same_objects && !renamingZone) { zoneData.objects = objects;
objectQueries = `&${pathPrefix}.objects`; } else if (!same_objects && canRemove("objects")) {
zoneData.objects = null;
} }
let inertiaQuery = "";
if (inertia) { if (inertia) {
inertiaQuery = `&${pathPrefix}.inertia=${inertia}`; zoneData.inertia = inertia;
} }
let loiteringTimeQuery = ""; if (typeof loitering_time === "number") {
if (loitering_time >= 0) { zoneData.loitering_time = loitering_time;
loiteringTimeQuery = `&${pathPrefix}.loitering_time=${loitering_time}`;
} }
let distancesQuery = "";
const distances = [lineA, lineB, lineC, lineD].filter(Boolean).join(","); const distances = [lineA, lineB, lineC, lineD].filter(Boolean).join(",");
if (speedEstimation) { if (speedEstimation) {
distancesQuery = `&${pathPrefix}.distances=${distances}`; zoneData.distances = distances;
} else { } else if (canRemove("distances")) {
if (distances != "") { zoneData.distances = null;
distancesQuery = `&${pathPrefix}.distances`;
}
} }
let speedThresholdQuery = ""; if (typeof speed_threshold === "number" && speedEstimation) {
if (speed_threshold >= 0 && speedEstimation) { zoneData.speed_threshold = speed_threshold;
speedThresholdQuery = `&${pathPrefix}.speed_threshold=${speed_threshold}`; } else if (canRemove("speed_threshold")) {
} else { zoneData.speed_threshold = null;
if (resolvedZoneData?.speed_threshold) {
speedThresholdQuery = `&${pathPrefix}.speed_threshold`;
}
} }
let friendlyNameQuery = ""; // The name field falls back to the old id when the zone has no name
if (friendly_name && friendly_name !== zoneName) { const unnamed = !polygon.friendly_name && friendly_name === polygon.name;
friendlyNameQuery = `&${pathPrefix}.friendly_name=${encodeURIComponent(friendly_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 const updateTopic = editingProfile
? undefined ? undefined
: `config/cameras/${polygon.camera}/zones`; : `config/cameras/${polygon.camera}/zones`;
axios axios
.put( .put("config/set", {
`config/set?${pathPrefix}.coordinates=${coordinates}${enabledQuery}${inertiaQuery}${loiteringTimeQuery}${speedThresholdQuery}${distancesQuery}${objectQueries}${friendlyNameQuery}${alertQueries}${detectionQueries}`, config_data: { cameras: { [polygon.camera]: cameraData } },
{ requires_restart: needsRestart ? 1 : 0,
requires_restart: 0, update_topic: updateTopic,
update_topic: updateTopic, })
},
)
.then((res) => { .then((res) => {
if (res.status === 200) { if (res.status === 200) {
toast.success( if (needsRestart) {
t("masksAndZones.zones.toast.success", { statusBar?.addMessage(
zoneName: friendly_name || zoneName, "config_restart_required",
}), t("configForm.restartRequiredFooter"),
{ undefined,
"config_restart_required",
);
toast.success(t("toast.successRestartRequired"), {
position: "top-center", position: "top-center",
}, });
); } else {
toast.success(
t("masksAndZones.zones.toast.success", {
zoneName: friendly_name || zoneName,
}),
{
position: "top-center",
},
);
}
updateConfig(); updateConfig();
// Only publish WS state for base config when zone has a name and // Only publish WS state for base config when zone has a name and
// wasn't renamed (the hook is bound to the old name). // wasn't renamed (the hook is bound to the old name).
@@ -601,7 +543,7 @@ export default function ZoneEditPane({
t, t,
sendZoneState, sendZoneState,
editingProfile, editingProfile,
resolvedZoneData, statusBar,
], ],
); );
@@ -678,6 +620,7 @@ export default function ZoneEditPane({
nameDescription={t("masksAndZones.zones.name.tips")} nameDescription={t("masksAndZones.zones.name.tips")}
placeholderName={t("masksAndZones.zones.name.inputPlaceHolder")} placeholderName={t("masksAndZones.zones.name.inputPlaceHolder")}
idDisabled={idDisabled} idDisabled={idDisabled}
autoFillId={!isExistingZone}
/> />
<FormField <FormField
control={form.control} control={form.control}
+88 -73
View File
@@ -1,78 +1,93 @@
// Build a config/set query fragment that removes `name` from a import get from "lodash/get";
// required_zones list on the given camera section (e.g. "snapshots", import setWith from "lodash/setWith";
// "mqtt", "objects.genai", "onvif.autotracking"), rebuilding the import { CameraConfig } from "@/types/frigateConfig";
// remaining entries. When removing the name empties the list, the
// required_zones key itself is deleted so the field reverts to its // Camera sections whose required_zones lists name zones
// default instead of retaining the now-stale zone name. Returns an empty const REQUIRED_ZONES_SECTIONS = [
// string when `name` is not present so unrelated sections are untouched. "review.alerts",
export const removeRequiredZoneQuery = ( "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, name: string,
camera: string, newName?: string,
section: string, ): Record<string, unknown> => {
zones: string[], const updates: Record<string, unknown> = {};
) => {
const remaining = new Set<string>(zones || []);
if (!remaining.has(name)) { // Object paths keep a numeric profile name from becoming an array index
return ""; 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); return updates;
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 };
}; };