mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-04-03 13:54:55 +03:00
Mask/zone editor fixes (#22732)
* add guards to reject missing sub commands * mask/zone bugfixes - fix websocket crash when creating a new mask or zone before a name is assigned - fix deleted masks and zones not disappearing from the list until navigating away - fix deleting profile override not reverting to the base mask in the list - fix inertia defaulting to nan * disable save button on invalid form state * fix validation for speed estimation * ensure polygon is closed before allowing save * require all masks and zones to be on the base config * clarify dialog message and tooltip when removing an override * clarify docs
This commit is contained in:
parent
adc8c2a6e8
commit
5059311c9d
@ -120,7 +120,7 @@ The following camera configuration sections can be overridden in a profile:
|
|||||||
|
|
||||||
:::note
|
:::note
|
||||||
|
|
||||||
Only the fields you explicitly set in a profile override are applied. All other fields retain their base configuration values. For zones, profile zones are merged with the camera's base zones — any zone defined in the profile will override or add to the base zones.
|
Only the fields you explicitly set in a profile override are applied. All other fields retain their base configuration values. For masks and zones, profile zones **override** the camera's base masks and zones. If configuring profiles via YAML, you should not define masks or zones in profiles that are not defined in the base config.
|
||||||
|
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
|||||||
@ -1224,6 +1224,15 @@ def camera_set(
|
|||||||
status_code=400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not sub_command and feature in _SUB_COMMAND_FEATURES:
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"success": False,
|
||||||
|
"message": f"Feature '{feature}' requires a sub-command (e.g. mask or zone name)",
|
||||||
|
},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
if camera_name == "*":
|
if camera_name == "*":
|
||||||
cameras = list(frigate_config.cameras.keys())
|
cameras = list(frigate_config.cameras.keys())
|
||||||
elif camera_name not in frigate_config.cameras:
|
elif camera_name not in frigate_config.cameras:
|
||||||
|
|||||||
@ -118,10 +118,21 @@ class Dispatcher:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if command_type == "set":
|
if command_type == "set":
|
||||||
|
# Commands that require a sub-command (mask/zone name)
|
||||||
|
sub_command_required = {
|
||||||
|
"motion_mask",
|
||||||
|
"object_mask",
|
||||||
|
"zone",
|
||||||
|
}
|
||||||
if sub_command:
|
if sub_command:
|
||||||
self._camera_settings_handlers[command](
|
self._camera_settings_handlers[command](
|
||||||
camera_name, sub_command, payload
|
camera_name, sub_command, payload
|
||||||
)
|
)
|
||||||
|
elif command in sub_command_required:
|
||||||
|
logger.error(
|
||||||
|
"Command %s requires a sub-command (mask/zone name)",
|
||||||
|
command,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
self._camera_settings_handlers[command](camera_name, payload)
|
self._camera_settings_handlers[command](camera_name, payload)
|
||||||
elif command_type == "ptz":
|
elif command_type == "ptz":
|
||||||
|
|||||||
@ -539,6 +539,7 @@
|
|||||||
},
|
},
|
||||||
"restart_required": "Restart required (masks/zones changed)",
|
"restart_required": "Restart required (masks/zones changed)",
|
||||||
"disabledInConfig": "Item is disabled in the config file",
|
"disabledInConfig": "Item is disabled in the config file",
|
||||||
|
"addDisabledProfile": "Add to the base config first, then override in the profile",
|
||||||
"profileBase": "(base)",
|
"profileBase": "(base)",
|
||||||
"profileOverride": "(override)",
|
"profileOverride": "(override)",
|
||||||
"toast": {
|
"toast": {
|
||||||
@ -613,6 +614,10 @@
|
|||||||
"desc": "Are you sure you want to delete the {{type}} <em>{{name}}</em>?",
|
"desc": "Are you sure you want to delete the {{type}} <em>{{name}}</em>?",
|
||||||
"success": "{{name}} has been deleted."
|
"success": "{{name}} has been deleted."
|
||||||
},
|
},
|
||||||
|
"revertOverride": {
|
||||||
|
"title": "Revert to Base Config",
|
||||||
|
"desc": "This will remove the profile override for the {{type}} <em>{{name}}</em> and revert to the base configuration."
|
||||||
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"mustBeFinished": "Polygon drawing must be finished before saving."
|
"mustBeFinished": "Polygon drawing must be finished before saving."
|
||||||
}
|
}
|
||||||
|
|||||||
@ -74,9 +74,11 @@ export default function MotionMaskEditPane({
|
|||||||
}
|
}
|
||||||
}, [polygons, activePolygonIndex]);
|
}, [polygons, activePolygonIndex]);
|
||||||
|
|
||||||
|
const maskCamera = polygon?.camera || "";
|
||||||
|
const maskName = polygon?.name || "";
|
||||||
const { send: sendMotionMaskState } = useMotionMaskState(
|
const { send: sendMotionMaskState } = useMotionMaskState(
|
||||||
polygon?.camera || "",
|
maskCamera,
|
||||||
polygon?.name || "",
|
maskName,
|
||||||
);
|
);
|
||||||
|
|
||||||
const cameraConfig = useMemo(() => {
|
const cameraConfig = useMemo(() => {
|
||||||
@ -154,7 +156,7 @@ export default function MotionMaskEditPane({
|
|||||||
message: t("masksAndZones.form.name.error.mustNotBeEmpty"),
|
message: t("masksAndZones.form.name.error.mustNotBeEmpty"),
|
||||||
}),
|
}),
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
isFinished: z.boolean().refine(() => polygon?.isFinished === true, {
|
isFinished: z.boolean().refine((val) => val === true, {
|
||||||
message: t("masksAndZones.form.polygonDrawing.error.mustBeFinished"),
|
message: t("masksAndZones.form.polygonDrawing.error.mustBeFinished"),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@ -170,6 +172,12 @@ export default function MotionMaskEditPane({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (polygon?.isFinished !== undefined) {
|
||||||
|
form.setValue("isFinished", polygon.isFinished, { shouldValidate: true });
|
||||||
|
}
|
||||||
|
}, [polygon?.isFinished, form]);
|
||||||
|
|
||||||
const saveToConfig = useCallback(
|
const saveToConfig = useCallback(
|
||||||
async ({
|
async ({
|
||||||
name: maskId,
|
name: maskId,
|
||||||
@ -250,8 +258,8 @@ export default function MotionMaskEditPane({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
updateConfig();
|
updateConfig();
|
||||||
// Only publish WS state for base config
|
// Only publish WS state for base config when mask has a name
|
||||||
if (!editingProfile) {
|
if (!editingProfile && maskName) {
|
||||||
sendMotionMaskState(enabled ? "ON" : "OFF");
|
sendMotionMaskState(enabled ? "ON" : "OFF");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -291,6 +299,7 @@ export default function MotionMaskEditPane({
|
|||||||
cameraConfig,
|
cameraConfig,
|
||||||
t,
|
t,
|
||||||
sendMotionMaskState,
|
sendMotionMaskState,
|
||||||
|
maskName,
|
||||||
editingProfile,
|
editingProfile,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@ -449,7 +458,7 @@ export default function MotionMaskEditPane({
|
|||||||
<Button
|
<Button
|
||||||
variant="select"
|
variant="select"
|
||||||
aria-label={t("button.save", { ns: "common" })}
|
aria-label={t("button.save", { ns: "common" })}
|
||||||
disabled={isLoading}
|
disabled={isLoading || !form.formState.isValid}
|
||||||
className="flex flex-1"
|
className="flex flex-1"
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -80,9 +80,10 @@ export default function ObjectMaskEditPane({
|
|||||||
}
|
}
|
||||||
}, [polygons, activePolygonIndex]);
|
}, [polygons, activePolygonIndex]);
|
||||||
|
|
||||||
|
const maskName = polygon?.name || "";
|
||||||
const { send: sendObjectMaskState } = useObjectMaskState(
|
const { send: sendObjectMaskState } = useObjectMaskState(
|
||||||
polygon?.camera || "",
|
polygon?.camera || "",
|
||||||
polygon?.name || "",
|
maskName,
|
||||||
);
|
);
|
||||||
|
|
||||||
const cameraConfig = useMemo(() => {
|
const cameraConfig = useMemo(() => {
|
||||||
@ -143,7 +144,7 @@ export default function ObjectMaskEditPane({
|
|||||||
}),
|
}),
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
objects: z.string(),
|
objects: z.string(),
|
||||||
isFinished: z.boolean().refine(() => polygon?.isFinished === true, {
|
isFinished: z.boolean().refine((val) => val === true, {
|
||||||
message: t("masksAndZones.form.polygonDrawing.error.mustBeFinished"),
|
message: t("masksAndZones.form.polygonDrawing.error.mustBeFinished"),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@ -160,6 +161,12 @@ export default function ObjectMaskEditPane({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (polygon?.isFinished !== undefined) {
|
||||||
|
form.setValue("isFinished", polygon.isFinished, { shouldValidate: true });
|
||||||
|
}
|
||||||
|
}, [polygon?.isFinished, form]);
|
||||||
|
|
||||||
const saveToConfig = useCallback(
|
const saveToConfig = useCallback(
|
||||||
async ({
|
async ({
|
||||||
name: maskId,
|
name: maskId,
|
||||||
@ -256,8 +263,8 @@ export default function ObjectMaskEditPane({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
updateConfig();
|
updateConfig();
|
||||||
// Only publish WS state for base config
|
// Only publish WS state for base config when mask has a name
|
||||||
if (!editingProfile) {
|
if (!editingProfile && maskName) {
|
||||||
sendObjectMaskState(enabled ? "ON" : "OFF");
|
sendObjectMaskState(enabled ? "ON" : "OFF");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -300,6 +307,7 @@ export default function ObjectMaskEditPane({
|
|||||||
cameraConfig,
|
cameraConfig,
|
||||||
t,
|
t,
|
||||||
sendObjectMaskState,
|
sendObjectMaskState,
|
||||||
|
maskName,
|
||||||
editingProfile,
|
editingProfile,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@ -454,7 +462,7 @@ export default function ObjectMaskEditPane({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="select"
|
variant="select"
|
||||||
disabled={isLoading}
|
disabled={isLoading || !form.formState.isValid}
|
||||||
className="flex flex-1"
|
className="flex flex-1"
|
||||||
aria-label={t("button.save", { ns: "common" })}
|
aria-label={t("button.save", { ns: "common" })}
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@ -30,7 +30,6 @@ import useSWR from "swr";
|
|||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { reviewQueries } from "@/utils/zoneEdutUtil";
|
import { reviewQueries } from "@/utils/zoneEdutUtil";
|
||||||
import IconWrapper from "../ui/icon-wrapper";
|
import IconWrapper from "../ui/icon-wrapper";
|
||||||
import { buttonVariants } from "../ui/button";
|
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import ActivityIndicator from "../indicators/activity-indicator";
|
import ActivityIndicator from "../indicators/activity-indicator";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@ -51,6 +50,7 @@ type PolygonItemProps = {
|
|||||||
setLoadingPolygonIndex: (index: number | undefined) => void;
|
setLoadingPolygonIndex: (index: number | undefined) => void;
|
||||||
editingProfile?: string | null;
|
editingProfile?: string | null;
|
||||||
allProfileNames?: string[];
|
allProfileNames?: string[];
|
||||||
|
onDeleted?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PolygonItem({
|
export default function PolygonItem({
|
||||||
@ -67,6 +67,7 @@ export default function PolygonItem({
|
|||||||
setLoadingPolygonIndex,
|
setLoadingPolygonIndex,
|
||||||
editingProfile,
|
editingProfile,
|
||||||
allProfileNames,
|
allProfileNames,
|
||||||
|
onDeleted,
|
||||||
}: PolygonItemProps) {
|
}: PolygonItemProps) {
|
||||||
const { t } = useTranslation("views/settings");
|
const { t } = useTranslation("views/settings");
|
||||||
const { data: config, mutate: updateConfig } =
|
const { data: config, mutate: updateConfig } =
|
||||||
@ -152,7 +153,19 @@ export default function PolygonItem({
|
|||||||
cameraConfig?.review.alerts.required_zones || [],
|
cameraConfig?.review.alerts.required_zones || [],
|
||||||
cameraConfig?.review.detections.required_zones || [],
|
cameraConfig?.review.detections.required_zones || [],
|
||||||
);
|
);
|
||||||
url = `cameras.${polygon.camera}.zones.${polygon.name}${alertQueries}${detectionQueries}`;
|
// Also delete from profiles that have overrides for this zone
|
||||||
|
let profileQueries = "";
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
url = `cameras.${polygon.camera}.zones.${polygon.name}${alertQueries}${detectionQueries}${profileQueries}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
await axios
|
await axios
|
||||||
@ -169,6 +182,7 @@ export default function PolygonItem({
|
|||||||
{ position: "top-center" },
|
{ position: "top-center" },
|
||||||
);
|
);
|
||||||
updateConfig();
|
updateConfig();
|
||||||
|
onDeleted?.();
|
||||||
} else {
|
} else {
|
||||||
toast.error(
|
toast.error(
|
||||||
t("toast.save.error.title", {
|
t("toast.save.error.title", {
|
||||||
@ -211,11 +225,41 @@ export default function PolygonItem({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 = {
|
||||||
cameras: {
|
cameras: {
|
||||||
[polygon.camera]: editingProfile
|
[polygon.camera]: cameraUpdate,
|
||||||
? { profiles: { [editingProfile]: deleteSection } }
|
|
||||||
: deleteSection,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -234,6 +278,7 @@ export default function PolygonItem({
|
|||||||
{ position: "top-center" },
|
{ position: "top-center" },
|
||||||
);
|
);
|
||||||
updateConfig();
|
updateConfig();
|
||||||
|
onDeleted?.();
|
||||||
} else {
|
} else {
|
||||||
toast.error(
|
toast.error(
|
||||||
t("toast.save.error.title", {
|
t("toast.save.error.title", {
|
||||||
@ -267,6 +312,8 @@ export default function PolygonItem({
|
|||||||
index,
|
index,
|
||||||
setLoadingPolygonIndex,
|
setLoadingPolygonIndex,
|
||||||
editingProfile,
|
editingProfile,
|
||||||
|
allProfileNames,
|
||||||
|
onDeleted,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -425,32 +472,51 @@ export default function PolygonItem({
|
|||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>
|
<AlertDialogTitle>
|
||||||
{t("masksAndZones.form.polygonDrawing.delete.title")}
|
{polygon.polygonSource === "override"
|
||||||
|
? t("masksAndZones.form.polygonDrawing.revertOverride.title")
|
||||||
|
: t("masksAndZones.form.polygonDrawing.delete.title")}
|
||||||
</AlertDialogTitle>
|
</AlertDialogTitle>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
<Trans
|
{polygon.polygonSource === "override" ? (
|
||||||
ns="views/settings"
|
<Trans
|
||||||
values={{
|
ns="views/settings"
|
||||||
type: t(
|
values={{
|
||||||
`masksAndZones.form.polygonDrawing.type.${polygon.type}`,
|
type: t(
|
||||||
{ ns: "views/settings" },
|
`masksAndZones.form.polygonDrawing.type.${polygon.type}`,
|
||||||
),
|
{ ns: "views/settings" },
|
||||||
name: polygon.friendly_name ?? polygon.name,
|
),
|
||||||
}}
|
name: polygon.friendly_name ?? polygon.name,
|
||||||
>
|
}}
|
||||||
masksAndZones.form.polygonDrawing.delete.desc
|
>
|
||||||
</Trans>
|
masksAndZones.form.polygonDrawing.revertOverride.desc
|
||||||
|
</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans
|
||||||
|
ns="views/settings"
|
||||||
|
values={{
|
||||||
|
type: t(
|
||||||
|
`masksAndZones.form.polygonDrawing.type.${polygon.type}`,
|
||||||
|
{ ns: "views/settings" },
|
||||||
|
),
|
||||||
|
name: polygon.friendly_name ?? polygon.name,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
masksAndZones.form.polygonDrawing.delete.desc
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>
|
<AlertDialogCancel>
|
||||||
{t("button.cancel", { ns: "common" })}
|
{t("button.cancel", { ns: "common" })}
|
||||||
</AlertDialogCancel>
|
</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
className={buttonVariants({ variant: "destructive" })}
|
className="bg-destructive text-white hover:bg-destructive/90"
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
>
|
>
|
||||||
{t("button.delete", { ns: "common" })}
|
{polygon.polygonSource === "override"
|
||||||
|
? t("masksAndZones.form.polygonDrawing.revertOverride.title")
|
||||||
|
: t("button.delete", { ns: "common" })}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
@ -563,7 +629,9 @@ export default function PolygonItem({
|
|||||||
/>
|
/>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
{t("button.delete", { ns: "common" })}
|
{polygon.polygonSource === "override"
|
||||||
|
? t("masksAndZones.form.polygonDrawing.revertOverride.title")
|
||||||
|
: t("button.delete", { ns: "common" })}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -91,10 +91,8 @@ export default function ZoneEditPane({
|
|||||||
}
|
}
|
||||||
}, [polygons, activePolygonIndex]);
|
}, [polygons, activePolygonIndex]);
|
||||||
|
|
||||||
const { send: sendZoneState } = useZoneState(
|
const zoneName = polygon?.name || "";
|
||||||
polygon?.camera || "",
|
const { send: sendZoneState } = useZoneState(polygon?.camera || "", zoneName);
|
||||||
polygon?.name || "",
|
|
||||||
);
|
|
||||||
|
|
||||||
const cameraConfig = useMemo(() => {
|
const cameraConfig = useMemo(() => {
|
||||||
if (polygon?.camera && config) {
|
if (polygon?.camera && config) {
|
||||||
@ -210,7 +208,7 @@ export default function ZoneEditPane({
|
|||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
.or(z.literal("")),
|
.or(z.literal("")),
|
||||||
isFinished: z.boolean().refine(() => polygon?.isFinished === true, {
|
isFinished: z.boolean().refine((val) => val === true, {
|
||||||
message: t("masksAndZones.form.polygonDrawing.error.mustBeFinished"),
|
message: t("masksAndZones.form.polygonDrawing.error.mustBeFinished"),
|
||||||
}),
|
}),
|
||||||
objects: z.array(z.string()).optional(),
|
objects: z.array(z.string()).optional(),
|
||||||
@ -295,7 +293,7 @@ export default function ZoneEditPane({
|
|||||||
|
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
mode: "onBlur",
|
mode: "onChange",
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: polygon?.name ?? "",
|
name: polygon?.name ?? "",
|
||||||
friendly_name: polygon?.friendly_name ?? polygon?.name ?? "",
|
friendly_name: polygon?.friendly_name ?? polygon?.name ?? "",
|
||||||
@ -303,8 +301,8 @@ export default function ZoneEditPane({
|
|||||||
resolvedZoneData?.enabled !== undefined
|
resolvedZoneData?.enabled !== undefined
|
||||||
? resolvedZoneData.enabled
|
? resolvedZoneData.enabled
|
||||||
: (polygon?.enabled ?? true),
|
: (polygon?.enabled ?? true),
|
||||||
inertia: resolvedZoneData?.inertia,
|
inertia: resolvedZoneData?.inertia ?? 3,
|
||||||
loitering_time: resolvedZoneData?.loitering_time,
|
loitering_time: resolvedZoneData?.loitering_time ?? 0,
|
||||||
isFinished: polygon?.isFinished ?? false,
|
isFinished: polygon?.isFinished ?? false,
|
||||||
objects: polygon?.objects ?? [],
|
objects: polygon?.objects ?? [],
|
||||||
speedEstimation: !!(lineA || lineB || lineC || lineD),
|
speedEstimation: !!(lineA || lineB || lineC || lineD),
|
||||||
@ -316,18 +314,31 @@ export default function ZoneEditPane({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const watchSpeedEstimation = form.watch("speedEstimation");
|
||||||
|
const watchLineA = form.watch("lineA");
|
||||||
|
const watchLineB = form.watch("lineB");
|
||||||
|
const watchLineC = form.watch("lineC");
|
||||||
|
const watchLineD = form.watch("lineD");
|
||||||
|
|
||||||
|
const canSave =
|
||||||
|
form.formState.isValid &&
|
||||||
|
(!watchSpeedEstimation ||
|
||||||
|
(!!watchLineA && !!watchLineB && !!watchLineC && !!watchLineD));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (watchSpeedEstimation && polygon && polygon.points.length !== 4) {
|
||||||
form.watch("speedEstimation") &&
|
|
||||||
polygon &&
|
|
||||||
polygon.points.length !== 4
|
|
||||||
) {
|
|
||||||
toast.error(
|
toast.error(
|
||||||
t("masksAndZones.zones.speedThreshold.toast.error.pointLengthError"),
|
t("masksAndZones.zones.speedThreshold.toast.error.pointLengthError"),
|
||||||
);
|
);
|
||||||
form.setValue("speedEstimation", false);
|
form.setValue("speedEstimation", false);
|
||||||
}
|
}
|
||||||
}, [polygon, form, t]);
|
}, [polygon, form, t, watchSpeedEstimation]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (polygon?.isFinished !== undefined) {
|
||||||
|
form.setValue("isFinished", polygon.isFinished, { shouldValidate: true });
|
||||||
|
}
|
||||||
|
}, [polygon?.isFinished, form]);
|
||||||
|
|
||||||
const saveToConfig = useCallback(
|
const saveToConfig = useCallback(
|
||||||
async (
|
async (
|
||||||
@ -516,8 +527,8 @@ export default function ZoneEditPane({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
updateConfig();
|
updateConfig();
|
||||||
// Only publish WS state for base config (not profiles)
|
// Only publish WS state for base config when zone has a name
|
||||||
if (!editingProfile) {
|
if (!editingProfile && zoneName) {
|
||||||
sendZoneState(enabled ? "ON" : "OFF");
|
sendZoneState(enabled ? "ON" : "OFF");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -968,7 +979,7 @@ export default function ZoneEditPane({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="select"
|
variant="select"
|
||||||
disabled={isLoading}
|
disabled={isLoading || !canSave}
|
||||||
className="flex flex-1"
|
className="flex flex-1"
|
||||||
aria-label={t("button.save", { ns: "common" })}
|
aria-label={t("button.save", { ns: "common" })}
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@ -201,6 +201,16 @@ export default function MasksAndZonesView({
|
|||||||
setUnsavedChanges(false);
|
setUnsavedChanges(false);
|
||||||
}, [editingPolygons, setUnsavedChanges]);
|
}, [editingPolygons, setUnsavedChanges]);
|
||||||
|
|
||||||
|
const handlePolygonDeleted = useCallback(() => {
|
||||||
|
// Temporarily clear the edit pane guard so the useEffect that
|
||||||
|
// rebuilds editingPolygons from config will run when the fresh
|
||||||
|
// config arrives via updateConfig(). This handles all cases:
|
||||||
|
// base deletes, profile override deletes (which revert to base),
|
||||||
|
// and profile-only deletes.
|
||||||
|
setEditPane(undefined);
|
||||||
|
setActivePolygonIndex(undefined);
|
||||||
|
}, [setEditPane, setActivePolygonIndex]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return;
|
return;
|
||||||
@ -806,20 +816,25 @@ export default function MasksAndZonesView({
|
|||||||
</HoverCard>
|
</HoverCard>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<span className="inline-flex">
|
||||||
variant="secondary"
|
<Button
|
||||||
className="size-6 rounded-md bg-secondary-foreground p-1 text-background"
|
variant="secondary"
|
||||||
aria-label={t("masksAndZones.zones.add")}
|
className="size-6 rounded-md bg-secondary-foreground p-1 text-background"
|
||||||
onClick={() => {
|
aria-label={t("masksAndZones.zones.add")}
|
||||||
setEditPane("zone");
|
disabled={!!currentEditingProfile}
|
||||||
handleNewPolygon("zone");
|
onClick={() => {
|
||||||
}}
|
setEditPane("zone");
|
||||||
>
|
handleNewPolygon("zone");
|
||||||
<LuPlus />
|
}}
|
||||||
</Button>
|
>
|
||||||
|
<LuPlus />
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
{t("masksAndZones.zones.add")}
|
{currentEditingProfile
|
||||||
|
? t("masksAndZones.addDisabledProfile")
|
||||||
|
: t("masksAndZones.zones.add")}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@ -843,6 +858,7 @@ export default function MasksAndZonesView({
|
|||||||
setLoadingPolygonIndex={setLoadingPolygonIndex}
|
setLoadingPolygonIndex={setLoadingPolygonIndex}
|
||||||
editingProfile={currentEditingProfile}
|
editingProfile={currentEditingProfile}
|
||||||
allProfileNames={profileState?.allProfileNames}
|
allProfileNames={profileState?.allProfileNames}
|
||||||
|
onDeleted={handlePolygonDeleted}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -880,20 +896,25 @@ export default function MasksAndZonesView({
|
|||||||
</HoverCard>
|
</HoverCard>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<span className="inline-flex">
|
||||||
variant="secondary"
|
<Button
|
||||||
className="size-6 rounded-md bg-secondary-foreground p-1 text-background"
|
variant="secondary"
|
||||||
aria-label={t("masksAndZones.motionMasks.add")}
|
className="size-6 rounded-md bg-secondary-foreground p-1 text-background"
|
||||||
onClick={() => {
|
aria-label={t("masksAndZones.motionMasks.add")}
|
||||||
setEditPane("motion_mask");
|
disabled={!!currentEditingProfile}
|
||||||
handleNewPolygon("motion_mask");
|
onClick={() => {
|
||||||
}}
|
setEditPane("motion_mask");
|
||||||
>
|
handleNewPolygon("motion_mask");
|
||||||
<LuPlus />
|
}}
|
||||||
</Button>
|
>
|
||||||
|
<LuPlus />
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
{t("masksAndZones.motionMasks.add")}
|
{currentEditingProfile
|
||||||
|
? t("masksAndZones.addDisabledProfile")
|
||||||
|
: t("masksAndZones.motionMasks.add")}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@ -919,6 +940,7 @@ export default function MasksAndZonesView({
|
|||||||
setLoadingPolygonIndex={setLoadingPolygonIndex}
|
setLoadingPolygonIndex={setLoadingPolygonIndex}
|
||||||
editingProfile={currentEditingProfile}
|
editingProfile={currentEditingProfile}
|
||||||
allProfileNames={profileState?.allProfileNames}
|
allProfileNames={profileState?.allProfileNames}
|
||||||
|
onDeleted={handlePolygonDeleted}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -956,20 +978,25 @@ export default function MasksAndZonesView({
|
|||||||
</HoverCard>
|
</HoverCard>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<span className="inline-flex">
|
||||||
variant="secondary"
|
<Button
|
||||||
className="size-6 rounded-md bg-secondary-foreground p-1 text-background"
|
variant="secondary"
|
||||||
aria-label={t("masksAndZones.objectMasks.add")}
|
className="size-6 rounded-md bg-secondary-foreground p-1 text-background"
|
||||||
onClick={() => {
|
aria-label={t("masksAndZones.objectMasks.add")}
|
||||||
setEditPane("object_mask");
|
disabled={!!currentEditingProfile}
|
||||||
handleNewPolygon("object_mask");
|
onClick={() => {
|
||||||
}}
|
setEditPane("object_mask");
|
||||||
>
|
handleNewPolygon("object_mask");
|
||||||
<LuPlus />
|
}}
|
||||||
</Button>
|
>
|
||||||
|
<LuPlus />
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
{t("masksAndZones.objectMasks.add")}
|
{currentEditingProfile
|
||||||
|
? t("masksAndZones.addDisabledProfile")
|
||||||
|
: t("masksAndZones.objectMasks.add")}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@ -995,6 +1022,7 @@ export default function MasksAndZonesView({
|
|||||||
setLoadingPolygonIndex={setLoadingPolygonIndex}
|
setLoadingPolygonIndex={setLoadingPolygonIndex}
|
||||||
editingProfile={currentEditingProfile}
|
editingProfile={currentEditingProfile}
|
||||||
allProfileNames={profileState?.allProfileNames}
|
allProfileNames={profileState?.allProfileNames}
|
||||||
|
onDeleted={handlePolygonDeleted}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user