From fe7aa2ba3de7b448f562b0756f415c46b147024b Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:52:26 -0500 Subject: [PATCH] add profiles summary page with card-based layout and fix backend zone comparison bug --- frigate/config/profile_manager.py | 12 +- web/src/pages/Settings.tsx | 7 +- web/src/views/settings/ProfilesView.tsx | 379 ++++++++++++++++++++++++ 3 files changed, 390 insertions(+), 8 deletions(-) create mode 100644 web/src/views/settings/ProfilesView.tsx diff --git a/frigate/config/profile_manager.py b/frigate/config/profile_manager.py index ebf4bb0c0..f05830693 100644 --- a/frigate/config/profile_manager.py +++ b/frigate/config/profile_manager.py @@ -71,8 +71,7 @@ class ProfileManager: """ if profile_name is not None: has_profile = any( - profile_name in cam.profiles - for cam in self.config.cameras.values() + profile_name in cam.profiles for cam in self.config.cameras.values() ) if not has_profile: return f"Profile '{profile_name}' not found on any camera" @@ -109,9 +108,10 @@ class ProfileManager: cam_config.enabled = base_enabled changed.setdefault(cam_name, set()).add("enabled") - # Restore zones + # Restore zones (always restore from snapshot; direct Pydantic + # comparison fails when ZoneConfig contains numpy arrays) base_zones = self._base_zones.get(cam_name) - if base_zones is not None and cam_config.zones != base_zones: + if base_zones is not None: cam_config.zones = copy.deepcopy(base_zones) changed.setdefault(cam_name, set()).add("zones") @@ -195,9 +195,7 @@ class ProfileManager: if section == "zones": self.config_updater.publish_update( - CameraConfigUpdateTopic( - CameraConfigUpdateEnum.zones, cam_name - ), + CameraConfigUpdateTopic(CameraConfigUpdateEnum.zones, cam_name), cam_config.zones, ) continue diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 7835ed1c3..3137f14f4 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -39,6 +39,7 @@ import MasksAndZonesView from "@/views/settings/MasksAndZonesView"; import UsersView from "@/views/settings/UsersView"; import RolesView from "@/views/settings/RolesView"; import UiSettingsView from "@/views/settings/UiSettingsView"; +import ProfilesView from "@/views/settings/ProfilesView"; import FrigatePlusSettingsView from "@/views/settings/FrigatePlusSettingsView"; import MediaSyncSettingsView from "@/views/settings/MediaSyncSettingsView"; import RegionGridSettingsView from "@/views/settings/RegionGridSettingsView"; @@ -100,6 +101,7 @@ import { useRestart } from "@/api/ws"; const allSettingsViews = [ "profileSettings", + "profiles", "globalDetect", "globalRecording", "globalSnapshots", @@ -310,7 +312,10 @@ const CameraTimestampStyleSettingsPage = createSectionPage( const settingsGroups = [ { label: "general", - items: [{ key: "profileSettings", component: UiSettingsView }], + items: [ + { key: "profileSettings", component: UiSettingsView }, + { key: "profiles", component: ProfilesView }, + ], }, { label: "globalConfig", diff --git a/web/src/views/settings/ProfilesView.tsx b/web/src/views/settings/ProfilesView.tsx new file mode 100644 index 000000000..60f8e1694 --- /dev/null +++ b/web/src/views/settings/ProfilesView.tsx @@ -0,0 +1,379 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import useSWR from "swr"; +import axios from "axios"; +import { toast } from "sonner"; +import { Camera, Trash2 } from "lucide-react"; +import type { FrigateConfig } from "@/types/frigateConfig"; +import type { ProfileState } from "@/types/profile"; +import { getProfileColor } from "@/utils/profileColors"; +import { PROFILE_ELIGIBLE_SECTIONS } from "@/utils/configUtil"; +import { cn } from "@/lib/utils"; +import Heading from "@/components/ui/heading"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; + +type ProfilesApiResponse = { + profiles: string[]; + active_profile: string | null; +}; + +type ProfilesViewProps = { + setUnsavedChanges?: React.Dispatch>; + profileState?: ProfileState; +}; + +export default function ProfilesView({ profileState }: ProfilesViewProps) { + const { t } = useTranslation(["views/settings", "common"]); + const { data: config, mutate: updateConfig } = + useSWR("config"); + const { data: profilesData, mutate: updateProfiles } = + useSWR("profiles"); + + const [activating, setActivating] = useState(false); + const [deleteProfile, setDeleteProfile] = useState(null); + const [deleting, setDeleting] = useState(false); + + useEffect(() => { + document.title = t("documentTitle.profiles", { + ns: "views/settings", + }); + }, [t]); + + const allProfileNames = useMemo( + () => profileState?.allProfileNames ?? [], + [profileState?.allProfileNames], + ); + const activeProfile = profilesData?.active_profile ?? null; + + // Build overview data: for each profile, which cameras have which sections + const profileOverviewData = useMemo(() => { + if (!config || allProfileNames.length === 0) return {}; + + const data: Record> = {}; + const cameras = Object.keys(config.cameras).sort(); + + for (const profile of allProfileNames) { + data[profile] = {}; + for (const camera of cameras) { + const profileData = config.cameras[camera]?.profiles?.[profile]; + if (!profileData) continue; + + const sections: string[] = []; + for (const section of PROFILE_ELIGIBLE_SECTIONS) { + if ( + profileData[section as keyof typeof profileData] !== undefined && + profileData[section as keyof typeof profileData] !== null + ) { + sections.push(section); + } + } + if (profileData.enabled !== undefined && profileData.enabled !== null) { + sections.push("enabled"); + } + if (sections.length > 0) { + data[profile][camera] = sections; + } + } + } + return data; + }, [config, allProfileNames]); + + const cameraCount = useMemo(() => { + if (!config) return 0; + return Object.keys(profileOverviewData).reduce((max, profile) => { + const count = Object.keys(profileOverviewData[profile] ?? {}).length; + return Math.max(max, count); + }, 0); + }, [config, profileOverviewData]); + + const handleActivateProfile = useCallback( + async (profile: string | null) => { + setActivating(true); + try { + await axios.put("profile/set", { + profile: profile || null, + }); + await updateProfiles(); + toast.success( + profile + ? t("profiles.activated", { + ns: "views/settings", + profile, + }) + : t("profiles.deactivated", { ns: "views/settings" }), + { position: "top-center" }, + ); + } catch { + toast.error(t("toast.save.error.title", { ns: "common" }), { + position: "top-center", + }); + } finally { + setActivating(false); + } + }, + [updateProfiles, t], + ); + + const handleDeleteProfile = useCallback(async () => { + if (!deleteProfile || !config) return; + setDeleting(true); + + try { + // If this profile is active, deactivate it first + if (activeProfile === deleteProfile) { + await axios.put("profile/set", { profile: null }); + } + + // Remove the profile from all cameras via config/set + const configData: Record = {}; + for (const camera of Object.keys(config.cameras)) { + if (config.cameras[camera]?.profiles?.[deleteProfile]) { + configData[camera] = { + profiles: { [deleteProfile]: "" }, + }; + } + } + + if (Object.keys(configData).length > 0) { + await axios.put("config/set", { + config_data: { cameras: configData }, + }); + } + + await updateConfig(); + await updateProfiles(); + + toast.success( + t("profiles.deleteSuccess", { + ns: "views/settings", + profile: deleteProfile, + }), + { position: "top-center" }, + ); + } catch { + toast.error(t("toast.save.error.title", { ns: "common" }), { + position: "top-center", + }); + } finally { + setDeleting(false); + setDeleteProfile(null); + } + }, [deleteProfile, activeProfile, config, updateConfig, updateProfiles, t]); + + if (!config || !profilesData) { + return null; + } + + return ( +
+ + {t("profiles.title", { ns: "views/settings" })} + + + {/* Active Profile Section */} +
+
+ {t("profiles.activeProfile", { ns: "views/settings" })} +
+
+ + {activeProfile && ( + + {t("profiles.active", { ns: "views/settings" })} + + )} +
+
+ + {/* Profile Cards */} + {allProfileNames.length === 0 ? ( +
+

{t("profiles.noProfiles", { ns: "views/settings" })}

+
+ ) : ( +
+ {allProfileNames.map((profile) => { + const color = getProfileColor(profile, allProfileNames); + const isActive = activeProfile === profile; + const cameraData = profileOverviewData[profile] ?? {}; + const cameras = Object.keys(cameraData).sort(); + + return ( +
+
+
+ + {profile} + {isActive && ( + + {t("profiles.active", { ns: "views/settings" })} + + )} +
+ +
+ + {cameras.length === 0 ? ( +

+ {t("profiles.noOverrides", { ns: "views/settings" })} +

+ ) : ( +
+ {cameras.map((camera) => { + const sections = cameraData[camera]; + return ( +
+ +
+
+ {camera} +
+
+ {sections.map((section) => ( + + {section} + + ))} +
+
+
+ ); + })} +
+ )} +
+ ); + })} +
+ )} + + {/* Delete Profile Confirmation */} + { + if (!open) setDeleteProfile(null); + }} + > + + + + {t("profiles.deleteProfile", { ns: "views/settings" })} + + + {t("profiles.deleteProfileConfirm", { + ns: "views/settings", + profile: deleteProfile, + })} + + + + + {t("button.cancel", { ns: "common" })} + + + {t("button.delete", { ns: "common" })} + + + + +
+ ); +}