diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 17a60e9c5..92eb3b5ba 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -532,6 +532,8 @@ }, "restart_required": "Restart required (masks/zones changed)", "disabledInConfig": "Item is disabled in the config file", + "profileBase": "(base)", + "profileOverride": "(override)", "toast": { "success": { "copyCoordinates": "Copied coordinates for {{polyName}} to clipboard." diff --git a/web/src/components/settings/MotionMaskEditPane.tsx b/web/src/components/settings/MotionMaskEditPane.tsx index 3857c4060..6a9786db9 100644 --- a/web/src/components/settings/MotionMaskEditPane.tsx +++ b/web/src/components/settings/MotionMaskEditPane.tsx @@ -44,6 +44,7 @@ type MotionMaskEditPaneProps = { onCancel?: () => void; snapPoints: boolean; setSnapPoints: React.Dispatch>; + editingProfile?: string | null; }; export default function MotionMaskEditPane({ @@ -58,6 +59,7 @@ export default function MotionMaskEditPane({ onCancel, snapPoints, setSnapPoints, + editingProfile, }: MotionMaskEditPaneProps) { const { t } = useTranslation(["views/settings"]); const { getLocaleDocUrl } = useDocDomain(); @@ -192,16 +194,28 @@ export default function MotionMaskEditPane({ coordinates: coordinates, }; + // Build config path based on profile mode + const motionMaskPath = editingProfile + ? { + profiles: { + [editingProfile]: { + motion: { mask: { [maskId]: maskConfig } }, + }, + }, + } + : { motion: { mask: { [maskId]: maskConfig } } }; + // If renaming, we need to delete the old mask first if (renamingMask) { + const deleteQueryPath = editingProfile + ? `cameras.${polygon.camera}.profiles.${editingProfile}.motion.mask.${polygon.name}` + : `cameras.${polygon.camera}.motion.mask.${polygon.name}`; + try { - await axios.put( - `config/set?cameras.${polygon.camera}.motion.mask.${polygon.name}`, - { - requires_restart: 0, - }, - ); - } catch (error) { + await axios.put(`config/set?${deleteQueryPath}`, { + requires_restart: 0, + }); + } catch { toast.error(t("toast.save.error.noMessage", { ns: "common" }), { position: "top-center", }); @@ -210,22 +224,20 @@ export default function MotionMaskEditPane({ } } + const updateTopic = editingProfile + ? undefined + : `config/cameras/${polygon.camera}/motion`; + // Save the new/updated mask using JSON body axios .put("config/set", { config_data: { cameras: { - [polygon.camera]: { - motion: { - mask: { - [maskId]: maskConfig, - }, - }, - }, + [polygon.camera]: motionMaskPath, }, }, requires_restart: 0, - update_topic: `config/cameras/${polygon.camera}/motion`, + update_topic: updateTopic, }) .then((res) => { if (res.status === 200) { @@ -238,8 +250,10 @@ export default function MotionMaskEditPane({ }, ); updateConfig(); - // Publish the enabled state through websocket - sendMotionMaskState(enabled ? "ON" : "OFF"); + // Only publish WS state for base config + if (!editingProfile) { + sendMotionMaskState(enabled ? "ON" : "OFF"); + } } else { toast.error( t("toast.save.error.title", { @@ -277,6 +291,7 @@ export default function MotionMaskEditPane({ cameraConfig, t, sendMotionMaskState, + editingProfile, ], ); diff --git a/web/src/components/settings/ObjectMaskEditPane.tsx b/web/src/components/settings/ObjectMaskEditPane.tsx index 380c40be1..6637d4834 100644 --- a/web/src/components/settings/ObjectMaskEditPane.tsx +++ b/web/src/components/settings/ObjectMaskEditPane.tsx @@ -51,6 +51,7 @@ type ObjectMaskEditPaneProps = { onCancel?: () => void; snapPoints: boolean; setSnapPoints: React.Dispatch>; + editingProfile?: string | null; }; export default function ObjectMaskEditPane({ @@ -65,6 +66,7 @@ export default function ObjectMaskEditPane({ onCancel, snapPoints, setSnapPoints, + editingProfile, }: ObjectMaskEditPaneProps) { const { t } = useTranslation(["views/settings"]); const { data: config, mutate: updateConfig } = @@ -190,14 +192,22 @@ export default function ObjectMaskEditPane({ // Determine if old mask was global or per-object const wasGlobal = polygon.objects.length === 0 || polygon.objects[0] === "all_labels"; - const oldPath = wasGlobal - ? `cameras.${polygon.camera}.objects.mask.${polygon.name}` - : `cameras.${polygon.camera}.objects.filters.${polygon.objects[0]}.mask.${polygon.name}`; + + let oldPath: string; + if (editingProfile) { + oldPath = wasGlobal + ? `cameras.${polygon.camera}.profiles.${editingProfile}.objects.mask.${polygon.name}` + : `cameras.${polygon.camera}.profiles.${editingProfile}.objects.filters.${polygon.objects[0]}.mask.${polygon.name}`; + } else { + oldPath = wasGlobal + ? `cameras.${polygon.camera}.objects.mask.${polygon.name}` + : `cameras.${polygon.camera}.objects.filters.${polygon.objects[0]}.mask.${polygon.name}`; + } await axios.put(`config/set?${oldPath}`, { requires_restart: 0, }); - } catch (error) { + } catch { toast.error(t("toast.save.error.noMessage", { ns: "common" }), { position: "top-center", }); @@ -206,45 +216,32 @@ export default function ObjectMaskEditPane({ } } - // Build the config structure based on whether it's global or per-object - let configBody; - if (globalMask) { - configBody = { - config_data: { - cameras: { - [polygon.camera]: { - objects: { - mask: { - [maskId]: maskConfig, - }, - }, - }, + // Build config path based on profile mode + const objectsSection = globalMask + ? { objects: { mask: { [maskId]: maskConfig } } } + : { + objects: { + filters: { [form_objects]: { mask: { [maskId]: maskConfig } } }, }, + }; + + const cameraData = editingProfile + ? { profiles: { [editingProfile]: objectsSection } } + : objectsSection; + + const updateTopic = editingProfile + ? undefined + : `config/cameras/${polygon.camera}/objects`; + + const configBody = { + config_data: { + cameras: { + [polygon.camera]: cameraData, }, - requires_restart: 0, - update_topic: `config/cameras/${polygon.camera}/objects`, - }; - } else { - configBody = { - config_data: { - cameras: { - [polygon.camera]: { - objects: { - filters: { - [form_objects]: { - mask: { - [maskId]: maskConfig, - }, - }, - }, - }, - }, - }, - }, - requires_restart: 0, - update_topic: `config/cameras/${polygon.camera}/objects`, - }; - } + }, + requires_restart: 0, + update_topic: updateTopic, + }; axios .put("config/set", configBody) @@ -259,8 +256,10 @@ export default function ObjectMaskEditPane({ }, ); updateConfig(); - // Publish the enabled state through websocket - sendObjectMaskState(enabled ? "ON" : "OFF"); + // Only publish WS state for base config + if (!editingProfile) { + sendObjectMaskState(enabled ? "ON" : "OFF"); + } } else { toast.error( t("toast.save.error.title", { @@ -301,6 +300,7 @@ export default function ObjectMaskEditPane({ cameraConfig, t, sendObjectMaskState, + editingProfile, ], ); diff --git a/web/src/components/settings/PolygonItem.tsx b/web/src/components/settings/PolygonItem.tsx index 13522f9fc..c3f1b7742 100644 --- a/web/src/components/settings/PolygonItem.tsx +++ b/web/src/components/settings/PolygonItem.tsx @@ -35,6 +35,7 @@ import { Trans, useTranslation } from "react-i18next"; import ActivityIndicator from "../indicators/activity-indicator"; import { cn } from "@/lib/utils"; import { useMotionMaskState, useObjectMaskState, useZoneState } from "@/api/ws"; +import { getProfileColor } from "@/utils/profileColors"; type PolygonItemProps = { polygon: Polygon; @@ -48,6 +49,8 @@ type PolygonItemProps = { setIsLoading: (loading: boolean) => void; loadingPolygonIndex: number | undefined; setLoadingPolygonIndex: (index: number | undefined) => void; + editingProfile?: string | null; + allProfileNames?: string[]; }; export default function PolygonItem({ @@ -62,6 +65,8 @@ export default function PolygonItem({ setIsLoading, loadingPolygonIndex, setLoadingPolygonIndex, + editingProfile, + allProfileNames, }: PolygonItemProps) { const { t } = useTranslation("views/settings"); const { data: config, mutate: updateConfig } = @@ -107,6 +112,8 @@ export default function PolygonItem({ const PolygonItemIcon = polygon ? polygonTypeIcons[polygon.type] : undefined; + const isBasePolygon = !!editingProfile && polygon.polygonSource === "base"; + const saveToConfig = useCallback( async (polygon: Polygon) => { if (!polygon || !cameraConfig) { @@ -122,25 +129,36 @@ export default function PolygonItem({ ? "objects" : polygon.type; + const updateTopic = editingProfile + ? undefined + : `config/cameras/${polygon.camera}/${updateTopicType}`; + setIsLoading(true); setLoadingPolygonIndex(index); if (polygon.type === "zone") { - // Zones use query string format - const { alertQueries, detectionQueries } = reviewQueries( - polygon.name, - false, - false, - polygon.camera, - cameraConfig?.review.alerts.required_zones || [], - cameraConfig?.review.detections.required_zones || [], - ); - const url = `cameras.${polygon.camera}.zones.${polygon.name}${alertQueries}${detectionQueries}`; + let url: string; + + if (editingProfile) { + // Profile mode: just delete the profile zone + url = `cameras.${polygon.camera}.profiles.${editingProfile}.zones.${polygon.name}`; + } 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 || [], + ); + url = `cameras.${polygon.camera}.zones.${polygon.name}${alertQueries}${detectionQueries}`; + } await axios .put(`config/set?${url}`, { requires_restart: 0, - update_topic: `config/cameras/${polygon.camera}/${updateTopicType}`, + update_topic: updateTopic, }) .then((res) => { if (res.status === 200) { @@ -178,64 +196,34 @@ export default function PolygonItem({ } // Motion masks and object masks use JSON body format - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let configUpdate: any = {}; - - if (polygon.type === "motion_mask") { - // Delete mask from motion.mask dict by setting it to undefined - configUpdate = { - cameras: { - [polygon.camera]: { - motion: { - mask: { - [polygon.name]: null, // Setting to null will delete the key - }, - }, - }, - }, - }; - } - - if (polygon.type === "object_mask") { - // Determine if this is a global mask or object-specific mask - const isGlobalMask = !polygon.objects.length; - - if (isGlobalMask) { - configUpdate = { - cameras: { - [polygon.camera]: { - objects: { - mask: { - [polygon.name]: null, // Setting to null will delete the key - }, - }, - }, - }, - }; - } else { - configUpdate = { - cameras: { - [polygon.camera]: { + 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, // Setting to null will delete the key - }, + mask: { [polygon.name]: null }, }, }, }, - }, - }, - }; - } - } + }; + + const configUpdate = { + cameras: { + [polygon.camera]: editingProfile + ? { profiles: { [editingProfile]: deleteSection } } + : deleteSection, + }, + }; await axios .put("config/set", { config_data: configUpdate, requires_restart: 0, - update_topic: `config/cameras/${polygon.camera}/${updateTopicType}`, + update_topic: updateTopic, }) .then((res) => { if (res.status === 200) { @@ -278,6 +266,7 @@ export default function PolygonItem({ setIsLoading, index, setLoadingPolygonIndex, + editingProfile, ], ); @@ -289,14 +278,19 @@ export default function PolygonItem({ const handleToggleEnabled = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); - // Prevent toggling if disabled in config - if (polygon.enabled_in_config === false) { + // Prevent toggling if disabled in config or if this is a base polygon in profile mode + if (polygon.enabled_in_config === false || isBasePolygon) { return; } if (!polygon) { return; } + // Don't toggle via WS in profile mode + if (editingProfile) { + return; + } + const isEnabled = isPolygonEnabled; const nextState = isEnabled ? "OFF" : "ON"; @@ -320,6 +314,8 @@ export default function PolygonItem({ sendZoneState, sendMotionMaskState, sendObjectMaskState, + isBasePolygon, + editingProfile, ], ); @@ -358,7 +354,12 @@ export default function PolygonItem({