mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-27 17:04:42 +03:00
Improve UI zone operations (#24376)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
* improve zone renaming Zone rename now saves as one JSON body via config/set, moving required_zones and profile overrides instead of leaving stale references * fixes
This commit is contained in:
@@ -0,0 +1,193 @@
|
|||||||
|
"""Tests for renaming and deleting a zone through config_set's JSON body."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from unittest.mock import MagicMock, Mock, patch
|
||||||
|
|
||||||
|
import ruamel.yaml
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
|
||||||
|
from frigate.api.fastapi_app import create_fastapi_app
|
||||||
|
from frigate.config import FrigateConfig
|
||||||
|
from frigate.config.camera.updater import CameraConfigUpdatePublisher
|
||||||
|
from frigate.models import Event, Recordings, ReviewSegment
|
||||||
|
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigSetZones(BaseTestHttp):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp(models=[Event, Recordings, ReviewSegment])
|
||||||
|
self.minimal_config = {
|
||||||
|
"mqtt": {"host": "mqtt"},
|
||||||
|
"profiles": {"armed": {"friendly_name": "Armed"}},
|
||||||
|
"snapshots": {"required_zones": ["driveway"]},
|
||||||
|
"cameras": {
|
||||||
|
"front": {
|
||||||
|
"ffmpeg": {
|
||||||
|
"inputs": [
|
||||||
|
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||||
|
"zones": {
|
||||||
|
"driveway": {
|
||||||
|
"coordinates": "0,0,1,0,1,1",
|
||||||
|
"inertia": 5,
|
||||||
|
"filters": {"person": {"min_area": 5000}},
|
||||||
|
},
|
||||||
|
"porch": {"coordinates": "0,0,0.5,0,0.5,0.5"},
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"alerts": {
|
||||||
|
"labels": ["person"],
|
||||||
|
"required_zones": ["driveway"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"mqtt": {"required_zones": ["driveway", "porch"]},
|
||||||
|
"profiles": {
|
||||||
|
"armed": {
|
||||||
|
"zones": {
|
||||||
|
"driveway": {
|
||||||
|
"coordinates": "0,0,1,0,1,1",
|
||||||
|
"objects": ["car"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
yaml = ruamel.yaml.YAML()
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".yml", delete=False
|
||||||
|
) as config_file:
|
||||||
|
yaml.dump(self.minimal_config, config_file)
|
||||||
|
self.config_path = config_file.name
|
||||||
|
self.addCleanup(os.unlink, self.config_path)
|
||||||
|
|
||||||
|
def _create_app(self):
|
||||||
|
publisher = Mock(spec=CameraConfigUpdatePublisher)
|
||||||
|
publisher.publisher = MagicMock()
|
||||||
|
app = create_fastapi_app(
|
||||||
|
FrigateConfig(**self.minimal_config),
|
||||||
|
self.db,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
publisher,
|
||||||
|
None,
|
||||||
|
enforce_default_admin=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def mock_get_current_user(request: Request):
|
||||||
|
return {
|
||||||
|
"username": request.headers.get("remote-user"),
|
||||||
|
"role": request.headers.get("remote-role"),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def mock_get_allowed_cameras_for_filter(request: Request):
|
||||||
|
return ["front"]
|
||||||
|
|
||||||
|
app.dependency_overrides[get_current_user] = mock_get_current_user
|
||||||
|
app.dependency_overrides[get_allowed_cameras_for_filter] = (
|
||||||
|
mock_get_allowed_cameras_for_filter
|
||||||
|
)
|
||||||
|
return app
|
||||||
|
|
||||||
|
def _put(self, camera_data: dict):
|
||||||
|
with patch("frigate.api.app.find_config_file", return_value=self.config_path):
|
||||||
|
with AuthTestClient(self._create_app()) as client:
|
||||||
|
return client.put(
|
||||||
|
"/config/set",
|
||||||
|
json={
|
||||||
|
"config_data": {"cameras": {"front": camera_data}},
|
||||||
|
"requires_restart": 0,
|
||||||
|
"update_topic": "config/cameras/front/zones",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _front(self) -> dict:
|
||||||
|
with open(self.config_path) as f:
|
||||||
|
return ruamel.yaml.YAML().load(f)["cameras"]["front"]
|
||||||
|
|
||||||
|
def test_rename_moves_zone_references_in_one_request(self):
|
||||||
|
"""The new base zone exists when validation checks the moved override."""
|
||||||
|
resp = self._put(
|
||||||
|
{
|
||||||
|
"zones": {
|
||||||
|
"driveway": None,
|
||||||
|
"front_drive": {
|
||||||
|
"coordinates": "0,0,1,0,1,1",
|
||||||
|
"enabled": True,
|
||||||
|
# as /api/config returns them, with defaults filled in
|
||||||
|
"filters": {
|
||||||
|
"person": {
|
||||||
|
"min_area": 5000,
|
||||||
|
"max_area": 24000000,
|
||||||
|
"min_ratio": 0.0,
|
||||||
|
"max_ratio": 24000000.0,
|
||||||
|
"threshold": 0.7,
|
||||||
|
"min_score": 0.5,
|
||||||
|
"mask": {},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"inertia": 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"review": {"alerts": {"required_zones": ["front_drive"]}},
|
||||||
|
# inherited from the global snapshots config, so no camera key
|
||||||
|
"snapshots": {"required_zones": ["front_drive"]},
|
||||||
|
"mqtt": {"required_zones": ["front_drive", "porch"]},
|
||||||
|
"profiles": {
|
||||||
|
"armed": {
|
||||||
|
"zones": {
|
||||||
|
"driveway": None,
|
||||||
|
"front_drive": {
|
||||||
|
"coordinates": "0,0,1,0,1,1",
|
||||||
|
"objects": ["car"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(resp.status_code, 200, resp.json())
|
||||||
|
front = self._front()
|
||||||
|
self.assertEqual(list(front["zones"]), ["porch", "front_drive"])
|
||||||
|
self.assertEqual(front["zones"]["front_drive"]["inertia"], 5)
|
||||||
|
self.assertEqual(
|
||||||
|
front["zones"]["front_drive"]["filters"]["person"]["min_area"], 5000
|
||||||
|
)
|
||||||
|
self.assertEqual(front["review"]["alerts"]["labels"], ["person"])
|
||||||
|
self.assertEqual(front["review"]["alerts"]["required_zones"], ["front_drive"])
|
||||||
|
self.assertEqual(front["snapshots"]["required_zones"], ["front_drive"])
|
||||||
|
self.assertEqual(front["mqtt"]["required_zones"], ["front_drive", "porch"])
|
||||||
|
self.assertEqual(
|
||||||
|
dict(front["profiles"]["armed"]["zones"]),
|
||||||
|
{"front_drive": {"coordinates": "0,0,1,0,1,1", "objects": ["car"]}},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_delete_empties_lists_without_dropping_their_section(self):
|
||||||
|
resp = self._put(
|
||||||
|
{
|
||||||
|
"zones": {"driveway": None},
|
||||||
|
"review": {"alerts": {"required_zones": []}},
|
||||||
|
"snapshots": {"required_zones": []},
|
||||||
|
"mqtt": {"required_zones": ["porch"]},
|
||||||
|
"profiles": {"armed": {"zones": {"driveway": None}}},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(resp.status_code, 200, resp.json())
|
||||||
|
front = self._front()
|
||||||
|
self.assertEqual(list(front["zones"]), ["porch"])
|
||||||
|
self.assertEqual(front["review"]["alerts"]["labels"], ["person"])
|
||||||
|
self.assertEqual(front["review"]["alerts"]["required_zones"], [])
|
||||||
|
self.assertEqual(front["mqtt"]["required_zones"], ["porch"])
|
||||||
|
self.assertNotIn("driveway", front["profiles"]["armed"]["zones"] or {})
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
/**
|
||||||
|
* Zone rename and delete tests -- MEDIUM tier.
|
||||||
|
*
|
||||||
|
* A zone's name also lives in required_zones lists and profile overrides.
|
||||||
|
* These tests pin that renaming or deleting a zone sends one config/set JSON
|
||||||
|
* body that moves or drops every reference, with nothing in the query string,
|
||||||
|
* and flags a restart. Editing the name alone must not rename the zone.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect } from "../../fixtures/frigate-test";
|
||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
import { configFactory } from "../../fixtures/mock-data/config";
|
||||||
|
|
||||||
|
const SETTINGS_URL = "/settings?page=masksAndZones&camera=front_door";
|
||||||
|
const COORDINATES = "0.1,0.1,0.5,0.1,0.5,0.5,0.1,0.5";
|
||||||
|
|
||||||
|
// /api/config returns zone filters with defaults filled in
|
||||||
|
const FILTERS = {
|
||||||
|
person: {
|
||||||
|
min_area: 5000,
|
||||||
|
max_area: 24000000,
|
||||||
|
min_ratio: 0,
|
||||||
|
max_ratio: 24000000,
|
||||||
|
threshold: 0.7,
|
||||||
|
min_score: 0.5,
|
||||||
|
mask: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
type ConfigSetRequest = { url: string; body: Record<string, unknown> };
|
||||||
|
|
||||||
|
async function installRoutes(page: Page) {
|
||||||
|
const config = configFactory({
|
||||||
|
profiles: { armed: { friendly_name: "Armed" } },
|
||||||
|
cameras: {
|
||||||
|
front_door: {
|
||||||
|
zones: {
|
||||||
|
driveway: {
|
||||||
|
coordinates: COORDINATES,
|
||||||
|
enabled: true,
|
||||||
|
inertia: 3,
|
||||||
|
loitering_time: 0,
|
||||||
|
objects: [],
|
||||||
|
filters: FILTERS,
|
||||||
|
color: [128, 128, 0],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
review: { alerts: { required_zones: ["driveway"] } },
|
||||||
|
snapshots: { required_zones: ["driveway"] },
|
||||||
|
mqtt: { required_zones: ["driveway"] },
|
||||||
|
profiles: {
|
||||||
|
armed: {
|
||||||
|
zones: { driveway: { coordinates: COORDINATES, inertia: 6 } },
|
||||||
|
review: { alerts: { required_zones: ["driveway"] } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const requests: ConfigSetRequest[] = [];
|
||||||
|
|
||||||
|
await page.route("**/api/config", (route) => route.fulfill({ json: config }));
|
||||||
|
await page.route("**/api/config/set**", async (route) => {
|
||||||
|
requests.push({
|
||||||
|
url: route.request().url(),
|
||||||
|
body: route.request().postDataJSON(),
|
||||||
|
});
|
||||||
|
await route.fulfill({ json: { success: true } });
|
||||||
|
});
|
||||||
|
|
||||||
|
return requests;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openZoneAction(
|
||||||
|
page: Page,
|
||||||
|
isMobile: boolean,
|
||||||
|
action: "Edit" | "Delete",
|
||||||
|
) {
|
||||||
|
const row = page.locator("[data-index]", { hasText: "Driveway" });
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
await row.locator("button[aria-haspopup='menu']").click();
|
||||||
|
await page.getByRole("menuitem", { name: action }).click();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop shows the actions on hover
|
||||||
|
await row.hover();
|
||||||
|
await row.getByLabel(action, { exact: true }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("zone rename and delete @medium @mobile", () => {
|
||||||
|
test("editing the name of a referenced zone keeps its id", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
const requests = await installRoutes(frigateApp.page);
|
||||||
|
await frigateApp.goto(SETTINGS_URL);
|
||||||
|
|
||||||
|
await openZoneAction(frigateApp.page, frigateApp.isMobile, "Edit");
|
||||||
|
await frigateApp.page
|
||||||
|
.getByRole("textbox", { name: "Name", exact: true })
|
||||||
|
.fill("Main Driveway");
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.getByRole("textbox", { name: "ID", exact: true }),
|
||||||
|
).toHaveValue("driveway");
|
||||||
|
await frigateApp.page.getByRole("button", { name: "Save" }).click();
|
||||||
|
|
||||||
|
await expect.poll(() => requests.length).toBe(1);
|
||||||
|
expect(new URL(requests[0].url).search).toBe("");
|
||||||
|
expect(requests[0].body).toEqual({
|
||||||
|
requires_restart: 0,
|
||||||
|
update_topic: "config/cameras/front_door/zones",
|
||||||
|
config_data: {
|
||||||
|
cameras: {
|
||||||
|
front_door: {
|
||||||
|
zones: {
|
||||||
|
driveway: {
|
||||||
|
coordinates: COORDINATES,
|
||||||
|
enabled: true,
|
||||||
|
inertia: 3,
|
||||||
|
loitering_time: 0,
|
||||||
|
friendly_name: "Main Driveway",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("turning speed estimation on and off sends no distances delete", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
// The zone has no distances in the YAML, and config/set fails to delete
|
||||||
|
// a missing key
|
||||||
|
const requests = await installRoutes(frigateApp.page);
|
||||||
|
await frigateApp.goto(SETTINGS_URL);
|
||||||
|
|
||||||
|
await openZoneAction(frigateApp.page, frigateApp.isMobile, "Edit");
|
||||||
|
const speedEstimation = frigateApp.page
|
||||||
|
.getByText("Speed Estimation", { exact: true })
|
||||||
|
.locator("..")
|
||||||
|
.getByRole("switch");
|
||||||
|
await speedEstimation.click();
|
||||||
|
for (const line of ["A", "B", "C", "D"]) {
|
||||||
|
await frigateApp.page
|
||||||
|
.getByRole("textbox", { name: new RegExp(`^Line ${line} distance`) })
|
||||||
|
.fill("5");
|
||||||
|
}
|
||||||
|
await speedEstimation.click();
|
||||||
|
await frigateApp.page.getByRole("button", { name: "Save" }).click();
|
||||||
|
|
||||||
|
await expect.poll(() => requests.length).toBe(1);
|
||||||
|
expect(requests[0].body).toMatchObject({
|
||||||
|
config_data: { cameras: { front_door: { zones: { driveway: {} } } } },
|
||||||
|
});
|
||||||
|
const body = requests[0].body as {
|
||||||
|
config_data: {
|
||||||
|
cameras: { front_door: { zones: { driveway: object } } };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
body.config_data.cameras.front_door.zones.driveway,
|
||||||
|
).not.toHaveProperty("distances");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renaming a referenced zone moves every reference in one request", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
const requests = await installRoutes(frigateApp.page);
|
||||||
|
await frigateApp.goto(SETTINGS_URL);
|
||||||
|
|
||||||
|
await openZoneAction(frigateApp.page, frigateApp.isMobile, "Edit");
|
||||||
|
await frigateApp.page
|
||||||
|
.getByRole("textbox", { name: "ID", exact: true })
|
||||||
|
.fill("front_drive");
|
||||||
|
await frigateApp.page.getByRole("button", { name: "Save" }).click();
|
||||||
|
|
||||||
|
await expect.poll(() => requests.length).toBe(1);
|
||||||
|
expect(new URL(requests[0].url).search).toBe("");
|
||||||
|
expect(requests[0].body).toEqual({
|
||||||
|
requires_restart: 1,
|
||||||
|
update_topic: "config/cameras/front_door/zones",
|
||||||
|
config_data: {
|
||||||
|
cameras: {
|
||||||
|
front_door: {
|
||||||
|
zones: {
|
||||||
|
driveway: null,
|
||||||
|
front_drive: {
|
||||||
|
coordinates: COORDINATES,
|
||||||
|
enabled: true,
|
||||||
|
filters: FILTERS,
|
||||||
|
inertia: 3,
|
||||||
|
loitering_time: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
review: { alerts: { required_zones: ["front_drive"] } },
|
||||||
|
snapshots: { required_zones: ["front_drive"] },
|
||||||
|
mqtt: { required_zones: ["front_drive"] },
|
||||||
|
profiles: {
|
||||||
|
armed: {
|
||||||
|
review: { alerts: { required_zones: ["front_drive"] } },
|
||||||
|
zones: {
|
||||||
|
driveway: null,
|
||||||
|
front_drive: { coordinates: COORDINATES, inertia: 6 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deleting a referenced zone drops every reference in one request", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
const requests = await installRoutes(frigateApp.page);
|
||||||
|
await frigateApp.goto(SETTINGS_URL);
|
||||||
|
|
||||||
|
await openZoneAction(frigateApp.page, frigateApp.isMobile, "Delete");
|
||||||
|
await frigateApp.page
|
||||||
|
.getByRole("alertdialog")
|
||||||
|
.getByRole("button", { name: "Delete" })
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await expect.poll(() => requests.length).toBe(1);
|
||||||
|
expect(new URL(requests[0].url).search).toBe("");
|
||||||
|
expect(requests[0].body).toEqual({
|
||||||
|
requires_restart: 1,
|
||||||
|
update_topic: "config/cameras/front_door/zones",
|
||||||
|
config_data: {
|
||||||
|
cameras: {
|
||||||
|
front_door: {
|
||||||
|
zones: { driveway: null },
|
||||||
|
review: { alerts: { required_zones: [] } },
|
||||||
|
snapshots: { required_zones: [] },
|
||||||
|
mqtt: { required_zones: [] },
|
||||||
|
profiles: {
|
||||||
|
armed: {
|
||||||
|
review: { alerts: { required_zones: [] } },
|
||||||
|
zones: { driveway: null },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -27,6 +27,8 @@ type NameAndIdFieldsProps<T extends FieldValues = FieldValues> = {
|
|||||||
placeholderId?: string;
|
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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user