mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-01 04:16:50 +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:
@@ -27,6 +27,8 @@ type NameAndIdFieldsProps<T extends FieldValues = FieldValues> = {
|
||||
placeholderId?: string;
|
||||
idVisible?: boolean;
|
||||
idDisabled?: boolean;
|
||||
// Derive the id from the name as the user types
|
||||
autoFillId?: boolean;
|
||||
};
|
||||
|
||||
export default function NameAndIdFields<T extends FieldValues = FieldValues>({
|
||||
@@ -43,6 +45,7 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
|
||||
placeholderId,
|
||||
idVisible,
|
||||
idDisabled,
|
||||
autoFillId = true,
|
||||
}: NameAndIdFieldsProps<T>) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const { watch, setValue, trigger, formState } = useFormContext<T>();
|
||||
@@ -61,7 +64,7 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
|
||||
const effectiveProcessId = processId || defaultProcessId;
|
||||
|
||||
useEffect(() => {
|
||||
if (idDisabled) {
|
||||
if (idDisabled || !autoFillId) {
|
||||
return;
|
||||
}
|
||||
const subscription = watch((value, { name }) => {
|
||||
@@ -81,6 +84,7 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
|
||||
idField,
|
||||
effectiveProcessId,
|
||||
idDisabled,
|
||||
autoFillId,
|
||||
]);
|
||||
|
||||
// Auto-expand if there's an error on the ID field after user has typed
|
||||
|
||||
@@ -22,12 +22,12 @@ import { HiOutlineDotsVertical, HiTrash } from "react-icons/hi";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { toRGBColorString } from "@/utils/canvasUtil";
|
||||
import { Polygon, PolygonType } from "@/types/canvas";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useContext, useMemo, useState } from "react";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { removeRequiredZoneQuery, reviewQueries } from "@/utils/zoneEdutUtil";
|
||||
import { zoneReferenceUpdates } from "@/utils/zoneEdutUtil";
|
||||
import IconWrapper from "../ui/icon-wrapper";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
@@ -35,6 +35,7 @@ import ActivityIndicator from "../indicators/activity-indicator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useMotionMaskState, useObjectMaskState, useZoneState } from "@/api/ws";
|
||||
import { getProfileColor } from "@/utils/profileColors";
|
||||
import { StatusBarMessagesContext } from "@/context/statusbar-context";
|
||||
|
||||
type PolygonItemProps = {
|
||||
polygon: Polygon;
|
||||
@@ -72,6 +73,7 @@ export default function PolygonItem({
|
||||
const { t } = useTranslation("views/settings");
|
||||
const { data: config, mutate: updateConfig } =
|
||||
useSWR<FrigateConfig>("config");
|
||||
const statusBar = useContext(StatusBarMessagesContext);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const { payload: motionMaskState, send: sendMotionMaskState } =
|
||||
useMotionMaskState(polygon.camera, polygon.name);
|
||||
@@ -137,148 +139,66 @@ export default function PolygonItem({
|
||||
setIsLoading(true);
|
||||
setLoadingPolygonIndex(index);
|
||||
|
||||
let cameraUpdate: Record<string, unknown>;
|
||||
let needsRestart = false;
|
||||
|
||||
if (polygon.type === "zone") {
|
||||
let url: string;
|
||||
const deleteSection = { zones: { [polygon.name]: null } };
|
||||
|
||||
if (editingProfile) {
|
||||
// Profile mode: just delete the profile zone
|
||||
url = `cameras.${polygon.camera}.profiles.${editingProfile}.zones.${polygon.name}`;
|
||||
cameraUpdate = { profiles: { [editingProfile]: deleteSection } };
|
||||
} else {
|
||||
// Base mode: handle review queries
|
||||
const { alertQueries, detectionQueries } = reviewQueries(
|
||||
polygon.name,
|
||||
false,
|
||||
false,
|
||||
polygon.camera,
|
||||
cameraConfig?.review.alerts.required_zones || [],
|
||||
cameraConfig?.review.detections.required_zones || [],
|
||||
);
|
||||
const genaiQueries = removeRequiredZoneQuery(
|
||||
polygon.name,
|
||||
polygon.camera,
|
||||
"objects.genai",
|
||||
cameraConfig?.objects.genai.required_zones || [],
|
||||
);
|
||||
const snapshotQueries = removeRequiredZoneQuery(
|
||||
polygon.name,
|
||||
polygon.camera,
|
||||
"snapshots",
|
||||
cameraConfig?.snapshots.required_zones || [],
|
||||
);
|
||||
const mqttQueries = removeRequiredZoneQuery(
|
||||
polygon.name,
|
||||
polygon.camera,
|
||||
"mqtt",
|
||||
cameraConfig?.mqtt.required_zones || [],
|
||||
);
|
||||
const autotrackQueries = removeRequiredZoneQuery(
|
||||
polygon.name,
|
||||
polygon.camera,
|
||||
"onvif.autotracking",
|
||||
cameraConfig?.onvif.autotracking.required_zones || [],
|
||||
);
|
||||
// Also delete from profiles that have overrides for this zone
|
||||
let profileQueries = "";
|
||||
const references = zoneReferenceUpdates(cameraConfig, polygon.name);
|
||||
// Processes other than object tracking only reload these on restart
|
||||
needsRestart = Object.keys(references).length > 0;
|
||||
cameraUpdate = { ...deleteSection, ...references };
|
||||
}
|
||||
} else {
|
||||
const deleteSection =
|
||||
polygon.type === "motion_mask"
|
||||
? { motion: { mask: { [polygon.name]: null } } }
|
||||
: !polygon.objects.length
|
||||
? { objects: { mask: { [polygon.name]: null } } }
|
||||
: {
|
||||
objects: {
|
||||
filters: {
|
||||
[polygon.objects[0]]: {
|
||||
mask: { [polygon.name]: null },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (editingProfile) {
|
||||
cameraUpdate = { profiles: { [editingProfile]: deleteSection } };
|
||||
} else {
|
||||
// Base mode: also delete from profiles that have overrides for this mask
|
||||
const profileDeletes: Record<string, unknown> = {};
|
||||
if (allProfileNames && cameraConfig) {
|
||||
for (const profileName of allProfileNames) {
|
||||
if (
|
||||
cameraConfig.profiles?.[profileName]?.zones?.[polygon.name] !==
|
||||
undefined
|
||||
) {
|
||||
profileQueries += `&cameras.${polygon.camera}.profiles.${profileName}.zones.${polygon.name}`;
|
||||
const profileData = cameraConfig.profiles?.[profileName];
|
||||
if (!profileData) continue;
|
||||
|
||||
const hasMask =
|
||||
polygon.type === "motion_mask"
|
||||
? profileData.motion?.mask?.[polygon.name] !== undefined
|
||||
: polygon.type === "object_mask"
|
||||
? profileData.objects?.mask?.[polygon.name] !== undefined ||
|
||||
Object.values(profileData.objects?.filters || {}).some(
|
||||
(f) => f?.mask?.[polygon.name] !== undefined,
|
||||
)
|
||||
: false;
|
||||
|
||||
if (hasMask) {
|
||||
profileDeletes[profileName] = deleteSection;
|
||||
}
|
||||
}
|
||||
}
|
||||
url = `cameras.${polygon.camera}.zones.${polygon.name}${alertQueries}${detectionQueries}${genaiQueries}${snapshotQueries}${mqttQueries}${autotrackQueries}${profileQueries}`;
|
||||
cameraUpdate =
|
||||
Object.keys(profileDeletes).length > 0
|
||||
? { ...deleteSection, profiles: profileDeletes }
|
||||
: deleteSection;
|
||||
}
|
||||
|
||||
await axios
|
||||
.put(`config/set?${url}`, {
|
||||
requires_restart: 0,
|
||||
update_topic: updateTopic,
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
toast.success(
|
||||
t("masksAndZones.form.polygonDrawing.delete.success", {
|
||||
name: polygon?.friendly_name ?? polygon?.name,
|
||||
}),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
updateConfig();
|
||||
onDeleted?.();
|
||||
} else {
|
||||
toast.error(
|
||||
t("toast.save.error.title", {
|
||||
ns: "common",
|
||||
errorMessage: res.statusText,
|
||||
}),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(
|
||||
t("toast.save.error.title", { errorMessage, ns: "common" }),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Motion masks and object masks use JSON body format
|
||||
const deleteSection =
|
||||
polygon.type === "motion_mask"
|
||||
? { motion: { mask: { [polygon.name]: null } } }
|
||||
: !polygon.objects.length
|
||||
? { objects: { mask: { [polygon.name]: null } } }
|
||||
: {
|
||||
objects: {
|
||||
filters: {
|
||||
[polygon.objects[0]]: {
|
||||
mask: { [polygon.name]: null },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let cameraUpdate: Record<string, unknown>;
|
||||
if (editingProfile) {
|
||||
cameraUpdate = { profiles: { [editingProfile]: deleteSection } };
|
||||
} else {
|
||||
// Base mode: also delete from profiles that have overrides for this mask
|
||||
const profileDeletes: Record<string, unknown> = {};
|
||||
if (allProfileNames && cameraConfig) {
|
||||
for (const profileName of allProfileNames) {
|
||||
const profileData = cameraConfig.profiles?.[profileName];
|
||||
if (!profileData) continue;
|
||||
|
||||
const hasMask =
|
||||
polygon.type === "motion_mask"
|
||||
? profileData.motion?.mask?.[polygon.name] !== undefined
|
||||
: polygon.type === "object_mask"
|
||||
? profileData.objects?.mask?.[polygon.name] !== undefined ||
|
||||
Object.values(profileData.objects?.filters || {}).some(
|
||||
(f) => f?.mask?.[polygon.name] !== undefined,
|
||||
)
|
||||
: false;
|
||||
|
||||
if (hasMask) {
|
||||
profileDeletes[profileName] = deleteSection;
|
||||
}
|
||||
}
|
||||
}
|
||||
cameraUpdate =
|
||||
Object.keys(profileDeletes).length > 0
|
||||
? { ...deleteSection, profiles: profileDeletes }
|
||||
: deleteSection;
|
||||
}
|
||||
|
||||
const configUpdate = {
|
||||
@@ -290,17 +210,29 @@ export default function PolygonItem({
|
||||
await axios
|
||||
.put("config/set", {
|
||||
config_data: configUpdate,
|
||||
requires_restart: 0,
|
||||
requires_restart: needsRestart ? 1 : 0,
|
||||
update_topic: updateTopic,
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
toast.success(
|
||||
t("masksAndZones.form.polygonDrawing.delete.success", {
|
||||
name: polygon?.friendly_name ?? polygon?.name,
|
||||
}),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
if (needsRestart) {
|
||||
statusBar?.addMessage(
|
||||
"config_restart_required",
|
||||
t("configForm.restartRequiredFooter"),
|
||||
undefined,
|
||||
"config_restart_required",
|
||||
);
|
||||
toast.success(t("toast.successRestartRequired"), {
|
||||
position: "top-center",
|
||||
});
|
||||
} else {
|
||||
toast.success(
|
||||
t("masksAndZones.form.polygonDrawing.delete.success", {
|
||||
name: polygon?.friendly_name ?? polygon?.name,
|
||||
}),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
}
|
||||
updateConfig();
|
||||
onDeleted?.();
|
||||
} else {
|
||||
@@ -338,6 +270,7 @@ export default function PolygonItem({
|
||||
editingProfile,
|
||||
allProfileNames,
|
||||
onDeleted,
|
||||
statusBar,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -593,6 +526,7 @@ export default function PolygonItem({
|
||||
<TooltipTrigger asChild>
|
||||
<IconWrapper
|
||||
icon={LuPencil}
|
||||
aria-label={t("button.edit", { ns: "common" })}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
"size-[15px] cursor-pointer",
|
||||
@@ -637,6 +571,7 @@ export default function PolygonItem({
|
||||
<TooltipTrigger asChild>
|
||||
<IconWrapper
|
||||
icon={HiTrash}
|
||||
aria-label={t("button.delete", { ns: "common" })}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
"size-[15px] cursor-pointer",
|
||||
|
||||
@@ -11,14 +11,14 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import useSWR from "swr";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { ZoneFormValuesType, Polygon } from "@/types/canvas";
|
||||
import { reviewQueries } from "@/utils/zoneEdutUtil";
|
||||
import { zoneReferenceUpdates } from "@/utils/zoneEdutUtil";
|
||||
import { Switch } from "../ui/switch";
|
||||
import { Label } from "../ui/label";
|
||||
import PolygonEditControls from "./PolygonEditControls";
|
||||
@@ -35,6 +35,7 @@ import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import NameAndIdFields from "../input/NameAndIdFields";
|
||||
import { useZoneState } from "@/api/ws";
|
||||
import { StatusBarMessagesContext } from "@/context/statusbar-context";
|
||||
|
||||
type ZoneEditPaneProps = {
|
||||
polygons?: Polygon[];
|
||||
@@ -71,6 +72,7 @@ export default function ZoneEditPane({
|
||||
const { getLocaleDocUrl } = useDocDomain();
|
||||
const { data: config, mutate: updateConfig } =
|
||||
useSWR<FrigateConfig>("config");
|
||||
const statusBar = useContext(StatusBarMessagesContext);
|
||||
|
||||
const cameras = useMemo(() => {
|
||||
if (!config) {
|
||||
@@ -95,29 +97,8 @@ export default function ZoneEditPane({
|
||||
|
||||
const isExistingZone = !!polygon && polygon.name.length > 0;
|
||||
|
||||
const idDisabled = useMemo(() => {
|
||||
if (!isExistingZone || !polygon) {
|
||||
return false;
|
||||
}
|
||||
if (editingProfile) {
|
||||
return true;
|
||||
}
|
||||
const cam = config?.cameras[polygon.camera];
|
||||
if (!cam) {
|
||||
return false;
|
||||
}
|
||||
const inRequiredZones =
|
||||
cam.review.alerts.required_zones.includes(polygon.name) ||
|
||||
cam.review.detections.required_zones.includes(polygon.name) ||
|
||||
cam.objects.genai.required_zones.includes(polygon.name) ||
|
||||
cam.snapshots.required_zones.includes(polygon.name) ||
|
||||
cam.mqtt.required_zones.includes(polygon.name) ||
|
||||
cam.onvif.autotracking.required_zones.includes(polygon.name);
|
||||
const hasProfileOverride = Object.values(cam.profiles ?? {}).some(
|
||||
(profile) => profile?.zones && polygon.name in profile.zones,
|
||||
);
|
||||
return inRequiredZones || hasProfileOverride;
|
||||
}, [config, polygon, editingProfile, isExistingZone]);
|
||||
// A profile zone overrides a base zone by name, so its id is fixed
|
||||
const idDisabled = isExistingZone && !!editingProfile;
|
||||
|
||||
const cameraConfig = useMemo(() => {
|
||||
if (polygon?.camera && config) {
|
||||
@@ -370,7 +351,7 @@ export default function ZoneEditPane({
|
||||
}, [polygon?.isFinished, form]);
|
||||
|
||||
const saveToConfig = useCallback(
|
||||
async (
|
||||
(
|
||||
{
|
||||
name: zoneName,
|
||||
friendly_name,
|
||||
@@ -391,169 +372,130 @@ export default function ZoneEditPane({
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine config path prefix based on profile mode
|
||||
const pathPrefix = editingProfile
|
||||
? `cameras.${polygon.camera}.profiles.${editingProfile}.zones.${zoneName}`
|
||||
: `cameras.${polygon.camera}.zones.${zoneName}`;
|
||||
|
||||
const oldPathPrefix = editingProfile
|
||||
? `cameras.${polygon.camera}.profiles.${editingProfile}.zones.${polygon.name}`
|
||||
: `cameras.${polygon.camera}.zones.${polygon.name}`;
|
||||
|
||||
let mutatedConfig: typeof config;
|
||||
let alertQueries = "";
|
||||
let detectionQueries = "";
|
||||
|
||||
const renamingZone = zoneName != polygon.name && polygon.name != "";
|
||||
|
||||
// config/set fails to delete a key the YAML lacks. The base zone comes
|
||||
// back with defaults filled in, so only a non-empty value proves the key
|
||||
// is written. Profile overrides hold only keys set in the YAML.
|
||||
const writtenZone = (
|
||||
renamingZone
|
||||
? undefined
|
||||
: editingProfile
|
||||
? cameraConfig?.profiles?.[editingProfile]?.zones?.[polygon.name]
|
||||
: (cameraConfig?.base_config?.zones ?? cameraConfig?.zones)?.[
|
||||
polygon.name
|
||||
]
|
||||
) as Record<string, unknown> | undefined;
|
||||
const canRemove = (key: string) => {
|
||||
const value = writtenZone?.[key];
|
||||
return value != null && !(Array.isArray(value) && value.length == 0);
|
||||
};
|
||||
|
||||
const zoneData: Record<string, unknown> = {
|
||||
coordinates: flattenPoints(
|
||||
interpolatePoints(polygon.points, scaledWidth, scaledHeight, 1, 1),
|
||||
).join(","),
|
||||
enabled,
|
||||
};
|
||||
|
||||
if (renamingZone) {
|
||||
// rename - delete old zone and replace with new
|
||||
// Only handle review queries for base config (not profiles)
|
||||
if (!editingProfile) {
|
||||
const zoneInAlerts =
|
||||
cameraConfig?.review.alerts.required_zones.includes(polygon.name) ??
|
||||
false;
|
||||
const zoneInDetections =
|
||||
cameraConfig?.review.detections.required_zones.includes(
|
||||
polygon.name,
|
||||
) ?? false;
|
||||
// The form has no filters field, so carry the old zone's over
|
||||
const baseZones = (cameraConfig?.base_config?.zones ??
|
||||
cameraConfig?.zones) as
|
||||
Record<string, { filters?: Record<string, unknown> }> | undefined;
|
||||
const filters = baseZones?.[polygon.name]?.filters;
|
||||
|
||||
const {
|
||||
alertQueries: renameAlertQueries,
|
||||
detectionQueries: renameDetectionQueries,
|
||||
} = reviewQueries(
|
||||
polygon.name,
|
||||
false,
|
||||
false,
|
||||
polygon.camera,
|
||||
cameraConfig?.review.alerts.required_zones || [],
|
||||
cameraConfig?.review.detections.required_zones || [],
|
||||
);
|
||||
|
||||
try {
|
||||
await axios.put(
|
||||
`config/set?${oldPathPrefix}${renameAlertQueries}${renameDetectionQueries}`,
|
||||
{
|
||||
requires_restart: 0,
|
||||
update_topic: `config/cameras/${polygon.camera}/zones`,
|
||||
},
|
||||
);
|
||||
|
||||
// Wait for the config to be updated
|
||||
mutatedConfig = await updateConfig();
|
||||
} catch {
|
||||
toast.error(t("toast.save.error.noMessage", { ns: "common" }), {
|
||||
position: "top-center",
|
||||
});
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// make sure new zone name is readded to review
|
||||
({ alertQueries, detectionQueries } = reviewQueries(
|
||||
zoneName,
|
||||
zoneInAlerts,
|
||||
zoneInDetections,
|
||||
polygon.camera,
|
||||
mutatedConfig?.cameras[polygon.camera]?.review.alerts
|
||||
.required_zones || [],
|
||||
mutatedConfig?.cameras[polygon.camera]?.review.detections
|
||||
.required_zones || [],
|
||||
));
|
||||
} else {
|
||||
// Profile mode: just delete the old profile zone path
|
||||
try {
|
||||
await axios.put(`config/set?${oldPathPrefix}`, {
|
||||
requires_restart: 0,
|
||||
});
|
||||
mutatedConfig = await updateConfig();
|
||||
} catch {
|
||||
toast.error(t("toast.save.error.noMessage", { ns: "common" }), {
|
||||
position: "top-center",
|
||||
});
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
if (filters && Object.keys(filters).length > 0) {
|
||||
zoneData.filters = filters;
|
||||
}
|
||||
}
|
||||
|
||||
const coordinates = flattenPoints(
|
||||
interpolatePoints(polygon.points, scaledWidth, scaledHeight, 1, 1),
|
||||
).join(",");
|
||||
|
||||
let objectQueries = objects
|
||||
.map((object) => `&${pathPrefix}.objects=${object}`)
|
||||
.join("");
|
||||
|
||||
const same_objects =
|
||||
form_objects.length == objects.length &&
|
||||
form_objects.every(function (element, index) {
|
||||
return element === objects[index];
|
||||
});
|
||||
|
||||
// deleting objects
|
||||
if (!objectQueries && !same_objects && !renamingZone) {
|
||||
objectQueries = `&${pathPrefix}.objects`;
|
||||
if (objects.length > 0) {
|
||||
zoneData.objects = objects;
|
||||
} else if (!same_objects && canRemove("objects")) {
|
||||
zoneData.objects = null;
|
||||
}
|
||||
|
||||
let inertiaQuery = "";
|
||||
if (inertia) {
|
||||
inertiaQuery = `&${pathPrefix}.inertia=${inertia}`;
|
||||
zoneData.inertia = inertia;
|
||||
}
|
||||
|
||||
let loiteringTimeQuery = "";
|
||||
if (loitering_time >= 0) {
|
||||
loiteringTimeQuery = `&${pathPrefix}.loitering_time=${loitering_time}`;
|
||||
if (typeof loitering_time === "number") {
|
||||
zoneData.loitering_time = loitering_time;
|
||||
}
|
||||
|
||||
let distancesQuery = "";
|
||||
const distances = [lineA, lineB, lineC, lineD].filter(Boolean).join(",");
|
||||
if (speedEstimation) {
|
||||
distancesQuery = `&${pathPrefix}.distances=${distances}`;
|
||||
} else {
|
||||
if (distances != "") {
|
||||
distancesQuery = `&${pathPrefix}.distances`;
|
||||
}
|
||||
zoneData.distances = distances;
|
||||
} else if (canRemove("distances")) {
|
||||
zoneData.distances = null;
|
||||
}
|
||||
|
||||
let speedThresholdQuery = "";
|
||||
if (speed_threshold >= 0 && speedEstimation) {
|
||||
speedThresholdQuery = `&${pathPrefix}.speed_threshold=${speed_threshold}`;
|
||||
} else {
|
||||
if (resolvedZoneData?.speed_threshold) {
|
||||
speedThresholdQuery = `&${pathPrefix}.speed_threshold`;
|
||||
}
|
||||
if (typeof speed_threshold === "number" && speedEstimation) {
|
||||
zoneData.speed_threshold = speed_threshold;
|
||||
} else if (canRemove("speed_threshold")) {
|
||||
zoneData.speed_threshold = null;
|
||||
}
|
||||
|
||||
let friendlyNameQuery = "";
|
||||
if (friendly_name && friendly_name !== zoneName) {
|
||||
friendlyNameQuery = `&${pathPrefix}.friendly_name=${encodeURIComponent(friendly_name)}`;
|
||||
// The name field falls back to the old id when the zone has no name
|
||||
const unnamed = !polygon.friendly_name && friendly_name === polygon.name;
|
||||
if (friendly_name && friendly_name !== zoneName && !unnamed) {
|
||||
zoneData.friendly_name = friendly_name;
|
||||
}
|
||||
|
||||
const enabledQuery = `&${pathPrefix}.enabled=${enabled ? "True" : "False"}`;
|
||||
const zones = renamingZone
|
||||
? { [polygon.name]: null, [zoneName]: zoneData }
|
||||
: { [zoneName]: zoneData };
|
||||
|
||||
const references =
|
||||
renamingZone && !editingProfile && cameraConfig
|
||||
? zoneReferenceUpdates(cameraConfig, polygon.name, zoneName)
|
||||
: {};
|
||||
// Processes other than object tracking only reload these on restart
|
||||
const needsRestart = Object.keys(references).length > 0;
|
||||
|
||||
const cameraData = editingProfile
|
||||
? { profiles: { [editingProfile]: { zones } } }
|
||||
: { zones, ...references };
|
||||
|
||||
const updateTopic = editingProfile
|
||||
? undefined
|
||||
: `config/cameras/${polygon.camera}/zones`;
|
||||
|
||||
axios
|
||||
.put(
|
||||
`config/set?${pathPrefix}.coordinates=${coordinates}${enabledQuery}${inertiaQuery}${loiteringTimeQuery}${speedThresholdQuery}${distancesQuery}${objectQueries}${friendlyNameQuery}${alertQueries}${detectionQueries}`,
|
||||
{
|
||||
requires_restart: 0,
|
||||
update_topic: updateTopic,
|
||||
},
|
||||
)
|
||||
.put("config/set", {
|
||||
config_data: { cameras: { [polygon.camera]: cameraData } },
|
||||
requires_restart: needsRestart ? 1 : 0,
|
||||
update_topic: updateTopic,
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
toast.success(
|
||||
t("masksAndZones.zones.toast.success", {
|
||||
zoneName: friendly_name || zoneName,
|
||||
}),
|
||||
{
|
||||
if (needsRestart) {
|
||||
statusBar?.addMessage(
|
||||
"config_restart_required",
|
||||
t("configForm.restartRequiredFooter"),
|
||||
undefined,
|
||||
"config_restart_required",
|
||||
);
|
||||
toast.success(t("toast.successRestartRequired"), {
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
});
|
||||
} else {
|
||||
toast.success(
|
||||
t("masksAndZones.zones.toast.success", {
|
||||
zoneName: friendly_name || zoneName,
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
}
|
||||
updateConfig();
|
||||
// Only publish WS state for base config when zone has a name and
|
||||
// wasn't renamed (the hook is bound to the old name).
|
||||
@@ -601,7 +543,7 @@ export default function ZoneEditPane({
|
||||
t,
|
||||
sendZoneState,
|
||||
editingProfile,
|
||||
resolvedZoneData,
|
||||
statusBar,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -678,6 +620,7 @@ export default function ZoneEditPane({
|
||||
nameDescription={t("masksAndZones.zones.name.tips")}
|
||||
placeholderName={t("masksAndZones.zones.name.inputPlaceHolder")}
|
||||
idDisabled={idDisabled}
|
||||
autoFillId={!isExistingZone}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
@@ -1,78 +1,93 @@
|
||||
// Build a config/set query fragment that removes `name` from a
|
||||
// required_zones list on the given camera section (e.g. "snapshots",
|
||||
// "mqtt", "objects.genai", "onvif.autotracking"), rebuilding the
|
||||
// remaining entries. When removing the name empties the list, the
|
||||
// required_zones key itself is deleted so the field reverts to its
|
||||
// default instead of retaining the now-stale zone name. Returns an empty
|
||||
// string when `name` is not present so unrelated sections are untouched.
|
||||
export const removeRequiredZoneQuery = (
|
||||
import get from "lodash/get";
|
||||
import setWith from "lodash/setWith";
|
||||
import { CameraConfig } from "@/types/frigateConfig";
|
||||
|
||||
// Camera sections whose required_zones lists name zones
|
||||
const REQUIRED_ZONES_SECTIONS = [
|
||||
"review.alerts",
|
||||
"review.detections",
|
||||
"objects.genai",
|
||||
"snapshots",
|
||||
"mqtt",
|
||||
"onvif.autotracking",
|
||||
];
|
||||
|
||||
// The subset a profile can override
|
||||
const PROFILE_REQUIRED_ZONES_SECTIONS = [
|
||||
"review.alerts",
|
||||
"review.detections",
|
||||
"objects.genai",
|
||||
"snapshots",
|
||||
];
|
||||
|
||||
/**
|
||||
* Build the camera-level config_data that follows a zone rename or delete.
|
||||
*
|
||||
* With newName, every required_zones list and profile zone override moves to
|
||||
* the new name. Without it, they drop the zone. Lists are written whole, so a
|
||||
* list the camera inherits from the global config gets a camera-level copy.
|
||||
* An empty result means nothing else names the zone.
|
||||
*/
|
||||
export const zoneReferenceUpdates = (
|
||||
camera: CameraConfig,
|
||||
name: string,
|
||||
camera: string,
|
||||
section: string,
|
||||
zones: string[],
|
||||
) => {
|
||||
const remaining = new Set<string>(zones || []);
|
||||
newName?: string,
|
||||
): Record<string, unknown> => {
|
||||
const updates: Record<string, unknown> = {};
|
||||
|
||||
if (!remaining.has(name)) {
|
||||
return "";
|
||||
// Object paths keep a numeric profile name from becoming an array index
|
||||
const put = (path: string[], value: unknown) =>
|
||||
setWith(updates, path, value, Object);
|
||||
|
||||
const moveInLists = (
|
||||
source: unknown,
|
||||
prefix: string[],
|
||||
sections: string[],
|
||||
) => {
|
||||
for (const section of sections) {
|
||||
const path = section.split(".");
|
||||
const zones: string[] | undefined = get(source, [
|
||||
...path,
|
||||
"required_zones",
|
||||
]);
|
||||
|
||||
if (!zones?.includes(name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const renamed = zones.flatMap((zone) =>
|
||||
zone !== name ? [zone] : newName ? [newName] : [],
|
||||
);
|
||||
put([...prefix, ...path, "required_zones"], [...new Set(renamed)]);
|
||||
}
|
||||
};
|
||||
|
||||
// An active profile merges into the top-level sections, so read the base
|
||||
moveInLists(
|
||||
{ ...camera, ...camera.base_config },
|
||||
[],
|
||||
REQUIRED_ZONES_SECTIONS,
|
||||
);
|
||||
|
||||
for (const [profile, override] of Object.entries(camera.profiles ?? {})) {
|
||||
moveInLists(
|
||||
override,
|
||||
["profiles", profile],
|
||||
PROFILE_REQUIRED_ZONES_SECTIONS,
|
||||
);
|
||||
|
||||
const zone = override?.zones?.[name];
|
||||
|
||||
if (zone === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
put(["profiles", profile, "zones", name], null);
|
||||
|
||||
if (newName) {
|
||||
put(["profiles", profile, "zones", newName], zone);
|
||||
}
|
||||
}
|
||||
|
||||
remaining.delete(name);
|
||||
|
||||
const key = `cameras.${camera}.${section}.required_zones`;
|
||||
|
||||
if (remaining.size === 0) {
|
||||
return `&${key}`;
|
||||
}
|
||||
|
||||
return [...remaining].map((zone) => `&${key}=${zone}`).join("");
|
||||
};
|
||||
|
||||
export const reviewQueries = (
|
||||
name: string,
|
||||
review_alerts: boolean,
|
||||
review_detections: boolean,
|
||||
camera: string,
|
||||
alertsZones: string[],
|
||||
detectionsZones: string[],
|
||||
) => {
|
||||
let same_alerts = false;
|
||||
let same_detections = false;
|
||||
|
||||
const alerts = new Set<string>(alertsZones || []);
|
||||
|
||||
if (review_alerts) {
|
||||
alerts.add(name);
|
||||
} else {
|
||||
same_alerts = !alerts.has(name);
|
||||
alerts.delete(name);
|
||||
}
|
||||
|
||||
let alertQueries = [...alerts]
|
||||
.map((zone) => `&cameras.${camera}.review.alerts.required_zones=${zone}`)
|
||||
.join("");
|
||||
|
||||
const detections = new Set<string>(detectionsZones || []);
|
||||
|
||||
if (review_detections) {
|
||||
detections.add(name);
|
||||
} else {
|
||||
same_detections = !detections.has(name);
|
||||
detections.delete(name);
|
||||
}
|
||||
|
||||
let detectionQueries = [...detections]
|
||||
.map(
|
||||
(zone) => `&cameras.${camera}.review.detections.required_zones=${zone}`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
if (!alertQueries && !same_alerts) {
|
||||
alertQueries = `&cameras.${camera}.review.alerts`;
|
||||
}
|
||||
if (!detectionQueries && !same_detections) {
|
||||
detectionQueries = `&cameras.${camera}.review.detections`;
|
||||
}
|
||||
|
||||
return { alertQueries, detectionQueries };
|
||||
return updates;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user