diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 3316254d3..2513e58d2 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -16,6 +16,12 @@ "maintenance": "Maintenance - Frigate", "profiles": "Profiles - Frigate" }, + "button": { + "overriddenGlobal": "Overridden (Global)", + "overriddenGlobalTooltip": "This camera overrides global configuration settings in this section", + "overriddenBaseConfig": "Overridden (Base Config)", + "overriddenBaseConfigTooltip": "The {{profile}} profile overrides configuration settings in this section" + }, "menu": { "general": "General", "globalConfig": "Global configuration", @@ -1453,6 +1459,8 @@ "deactivated": "Profile deactivated", "noProfiles": "No profiles defined. Add a profile from any camera section.", "noOverrides": "No overrides", + "cameraCount_one": "{{count}} camera", + "cameraCount_other": "{{count}} cameras", "baseConfig": "Base Config", "addProfile": "Add Profile", "newProfile": "New Profile", diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index d2f392944..ed2c624ae 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -28,6 +28,11 @@ import { useConfigOverride } from "@/hooks/use-config-override"; import { useSectionSchema } from "@/hooks/use-config-schema"; import type { FrigateConfig } from "@/types/frigateConfig"; import { Badge } from "@/components/ui/badge"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { Button } from "@/components/ui/button"; import { LuChevronDown, LuChevronRight } from "react-icons/lu"; import Heading from "@/components/ui/heading"; @@ -127,6 +132,7 @@ export interface BaseSectionProps { onStatusChange?: (status: { hasChanges: boolean; isOverridden: boolean; + overrideSource?: "global" | "profile"; hasValidationErrors: boolean; }) => void; /** Pending form data keyed by "sectionKey" or "cameraName::sectionKey" */ @@ -139,6 +145,8 @@ export interface BaseSectionProps { ) => void; /** When set, editing this profile's overrides instead of the base config */ profileName?: string; + /** Border color class for profile override badge (e.g., "border-amber-500") */ + profileBorderColor?: string; } export interface CreateSectionOptions { @@ -170,6 +178,7 @@ export function ConfigSection({ pendingDataBySection, onPendingDataChange, profileName, + profileBorderColor, }: ConfigSectionProps) { // For replay level, treat as camera-level config access const effectiveLevel = level === "replay" ? "camera" : level; @@ -267,7 +276,7 @@ export function ConfigSection({ [sectionPath, level, sectionSchema], ); - // Get override status + // Get override status (camera vs global) const { isOverridden, globalValue, cameraValue } = useConfigOverride({ config, cameraName: effectiveLevel === "camera" ? cameraName : undefined, @@ -275,6 +284,16 @@ export function ConfigSection({ compareFields: sectionConfig.overrideFields, }); + // Check if the active profile overrides the base config for this section + const profileOverridesSection = useMemo(() => { + if (!profileName || !cameraName || !config) return false; + const profileData = config.cameras?.[cameraName]?.profiles?.[profileName]; + return !!profileData?.[sectionPath as keyof typeof profileData]; + }, [profileName, cameraName, config, sectionPath]); + + const overrideSource: "global" | "profile" | undefined = + profileOverridesSection ? "profile" : isOverridden ? "global" : undefined; + // Get current form data // When editing a profile, show base camera config deep-merged with profile overrides const rawSectionValue = useMemo(() => { @@ -409,8 +428,20 @@ export function ConfigSection({ }, [formData, pendingData, extraHasChanges]); useEffect(() => { - onStatusChange?.({ hasChanges, isOverridden, hasValidationErrors }); - }, [hasChanges, isOverridden, hasValidationErrors, onStatusChange]); + onStatusChange?.({ + hasChanges, + isOverridden: profileOverridesSection || isOverridden, + overrideSource, + hasValidationErrors, + }); + }, [ + hasChanges, + isOverridden, + profileOverridesSection, + overrideSource, + hasValidationErrors, + onStatusChange, + ]); // Handle form data change const handleChange = useCallback( @@ -991,13 +1022,32 @@ export function ConfigSection({ {title} {showOverrideIndicator && effectiveLevel === "camera" && - isOverridden && ( - - {t("button.overridden", { - ns: "common", - defaultValue: "Overridden", - })} - + (profileOverridesSection || isOverridden) && ( + + + + {overrideSource === "profile" + ? t("button.overriddenBaseConfig", { + ns: "common", + defaultValue: "Overridden (Base Config)", + }) + : t("button.overriddenGlobal", { + ns: "views/settings", + defaultValue: "Overridden (Global)", + })} + + + + {overrideSource === "profile" + ? t("button.overriddenBaseConfigTooltip", { + ns: "common", + profile: profileName, + }) + : t("button.overriddenGlobalTooltip", { + ns: "views/settings", + })} + + )} {hasChanges && ( @@ -1035,16 +1085,40 @@ export function ConfigSection({ {title} {showOverrideIndicator && effectiveLevel === "camera" && - isOverridden && ( - - {t("button.overridden", { - ns: "common", - defaultValue: "Overridden", - })} - + (profileOverridesSection || isOverridden) && ( + + + + {overrideSource === "profile" + ? t("button.overriddenBaseConfig", { + ns: "common", + defaultValue: "Overridden (Base Config)", + }) + : t("button.overriddenGlobal", { + ns: "views/settings", + defaultValue: "Overridden (Global)", + })} + + + + {overrideSource === "profile" + ? t("button.overriddenBaseConfigTooltip", { + ns: "common", + profile: profileName, + }) + : t("button.overriddenGlobalTooltip", { + ns: "views/settings", + })} + + )} {hasChanges && ( + activeEditingProfile + ? getProfileColor(activeEditingProfile, allProfileNames) + : undefined, + [activeEditingProfile, allProfileNames], + ); + // Initialize override status for all camera sections useEffect(() => { if (!selectedCamera || !cameraOverrides) return; const overrideMap: Partial< - Record> + Record< + SettingsType, + Pick + > > = {}; // Build a set of menu keys that have pending changes for this camera @@ -1327,13 +1344,29 @@ export default function Settings() { } } + // Get profile data if a profile is being edited + const profileData = activeEditingProfile + ? config?.cameras?.[selectedCamera]?.profiles?.[activeEditingProfile] + : undefined; + // Set override status for all camera sections using the shared mapping Object.entries(CAMERA_SECTION_MAPPING).forEach( ([sectionKey, settingsKey]) => { - const isOverridden = cameraOverrides.includes(sectionKey); + const globalOverridden = cameraOverrides.includes(sectionKey); + + // Check if the active profile overrides this section + const profileOverrides = profileData + ? !!profileData[sectionKey as keyof typeof profileData] + : false; + overrideMap[settingsKey] = { hasChanges: pendingMenuKeys.has(settingsKey), - isOverridden, + isOverridden: profileOverrides || globalOverridden, + overrideSource: profileOverrides + ? "profile" + : globalOverridden + ? "global" + : undefined, }; }, ); @@ -1346,6 +1379,7 @@ export default function Settings() { merged[key as SettingsType] = { hasChanges: status.hasChanges, isOverridden: status.isOverridden, + overrideSource: status.overrideSource, hasValidationErrors: existingStatus?.hasValidationErrors ?? false, }; }); @@ -1356,6 +1390,8 @@ export default function Settings() { cameraOverrides, pendingDataBySection, pendingKeyToMenuKey, + activeEditingProfile, + config, ]); const renderMenuItemLabel = useCallback( @@ -1365,13 +1401,20 @@ export default function Settings() { CAMERA_SECTION_KEYS.has(key) && status?.isOverridden; const showUnsavedDot = status?.hasChanges; + const dotColor = + status?.overrideSource === "profile" && activeProfileColor + ? activeProfileColor.dot + : "bg-selected"; + return (
{t("menu." + key)}
{(showOverrideDot || showUnsavedDot) && (
{showOverrideDot && ( - + )} {showUnsavedDot && ( @@ -1381,7 +1424,7 @@ export default function Settings() {
); }, - [sectionStatusByKey, t], + [sectionStatusByKey, t, activeProfileColor], ); if (isMobile) { diff --git a/web/src/types/profile.ts b/web/src/types/profile.ts index 3a6441a26..c4dc66513 100644 --- a/web/src/types/profile.ts +++ b/web/src/types/profile.ts @@ -2,6 +2,7 @@ export type ProfileColor = { bg: string; text: string; dot: string; + border: string; bgMuted: string; }; diff --git a/web/src/utils/profileColors.ts b/web/src/utils/profileColors.ts index 305bda877..26a3ac609 100644 --- a/web/src/utils/profileColors.ts +++ b/web/src/utils/profileColors.ts @@ -5,48 +5,56 @@ const PROFILE_COLORS: ProfileColor[] = [ bg: "bg-amber-500", text: "text-amber-500", dot: "bg-amber-500", + border: "border-amber-500", bgMuted: "bg-amber-500/20", }, { bg: "bg-purple-500", text: "text-purple-500", dot: "bg-purple-500", + border: "border-purple-500", bgMuted: "bg-purple-500/20", }, { bg: "bg-rose-500", text: "text-rose-500", dot: "bg-rose-500", + border: "border-rose-500", bgMuted: "bg-rose-500/20", }, { bg: "bg-cyan-500", text: "text-cyan-500", dot: "bg-cyan-500", + border: "border-cyan-500", bgMuted: "bg-cyan-500/20", }, { bg: "bg-orange-500", text: "text-orange-500", dot: "bg-orange-500", + border: "border-orange-500", bgMuted: "bg-orange-500/20", }, { bg: "bg-teal-500", text: "text-teal-500", dot: "bg-teal-500", + border: "border-teal-500", bgMuted: "bg-teal-500/20", }, { bg: "bg-emerald-500", text: "text-emerald-500", dot: "bg-emerald-500", + border: "border-emerald-500", bgMuted: "bg-emerald-500/20", }, { bg: "bg-blue-500", text: "text-blue-500", dot: "bg-blue-500", + border: "border-blue-500", bgMuted: "bg-blue-500/20", }, ]; diff --git a/web/src/views/settings/ProfilesView.tsx b/web/src/views/settings/ProfilesView.tsx index 0551d0b94..f91edc9f6 100644 --- a/web/src/views/settings/ProfilesView.tsx +++ b/web/src/views/settings/ProfilesView.tsx @@ -3,7 +3,8 @@ import { useTranslation } from "react-i18next"; import useSWR from "swr"; import axios from "axios"; import { toast } from "sonner"; -import { Camera, Trash2 } from "lucide-react"; +import { Trash2 } from "lucide-react"; +import { LuChevronDown, LuChevronRight, LuPlus } from "react-icons/lu"; import type { FrigateConfig } from "@/types/frigateConfig"; import type { ProfileState } from "@/types/profile"; import { getProfileColor } from "@/utils/profileColors"; @@ -13,6 +14,7 @@ import { cn } from "@/lib/utils"; import Heading from "@/components/ui/heading"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; import { Select, SelectContent, @@ -20,6 +22,18 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { AlertDialog, AlertDialogAction, @@ -32,6 +46,7 @@ import { } from "@/components/ui/alert-dialog"; import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; +import ActivityIndicator from "@/components/indicators/activity-indicator"; type ProfilesApiResponse = { profiles: string[]; @@ -59,6 +74,12 @@ export default function ProfilesView({ const [activating, setActivating] = useState(false); const [deleteProfile, setDeleteProfile] = useState(null); const [deleting, setDeleting] = useState(false); + const [expandedProfiles, setExpandedProfiles] = useState>( + new Set(), + ); + const [addDialogOpen, setAddDialogOpen] = useState(false); + const [newProfileName, setNewProfileName] = useState(""); + const [nameError, setNameError] = useState(null); useEffect(() => { document.title = t("documentTitle.profiles", { @@ -105,13 +126,33 @@ export default function ProfilesView({ 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 validateName = useCallback( + (name: string): string | null => { + if (!name.trim()) return null; + if (!/^[a-z0-9_]+$/.test(name)) { + return t("profiles.nameInvalid", { ns: "views/settings" }); + } + if (allProfileNames.includes(name)) { + return t("profiles.nameDuplicate", { ns: "views/settings" }); + } + return null; + }, + [allProfileNames, t], + ); + + const handleAddSubmit = useCallback(() => { + const name = newProfileName.trim(); + if (!name) return; + const error = validateName(name); + if (error) { + setNameError(error); + return; + } + profileState?.onAddProfile(name); + setAddDialogOpen(false); + setNewProfileName(""); + setNameError(null); + }, [newProfileName, validateName, profileState]); const handleActivateProfile = useCallback( async (profile: string | null) => { @@ -215,6 +256,18 @@ export default function ProfilesView({ t, ]); + const toggleExpanded = useCallback((profile: string) => { + setExpandedProfiles((prev) => { + const next = new Set(prev); + if (next.has(profile)) { + next.delete(profile); + } else { + next.add(profile); + } + return next; + }); + }, []); + if (!config || !profilesData) { return null; } @@ -244,67 +297,67 @@ export default function ProfilesView({
)} - {profilesUIEnabled && ( + {profilesUIEnabled && !hasProfiles && (

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

)} - {/* Active Profile Section — only when profiles exist */} - {hasProfiles && ( -
-
- {t("profiles.activeProfile", { ns: "views/settings" })} -
-
- - {activeProfile && ( - + {hasProfiles && ( +
+ + {t("profiles.activeProfile", { ns: "views/settings" })} + + + {activating && } +
+ )} +
)} - {/* Profile Cards */} + {/* Profile List */} {!hasProfiles ? ( profilesUIEnabled ? (

@@ -314,104 +367,171 @@ export default function ProfilesView({

) ) : ( -
+
{allProfileNames.map((profile) => { const color = getProfileColor(profile, allProfileNames); const isActive = activeProfile === profile; const cameraData = profileOverviewData[profile] ?? {}; const cameras = Object.keys(cameraData).sort(); + const isExpanded = expandedProfiles.has(profile); return ( -
toggleExpanded(profile)} > -
-
- - {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 ( -
+ +
+
+ {isExpanded ? ( + + ) : ( + + )} + + {profile} + {isActive && ( + + {t("profiles.active", { ns: "views/settings" })} + + )} +
+
+ + {cameras.length > 0 + ? t("profiles.cameraCount", { + ns: "views/settings", + count: cameras.length, + }) + : t("profiles.noOverrides", { + ns: "views/settings", + })} + + +
+
+
+ + {cameras.length > 0 ? ( +
+ {cameras.map((camera) => { + const sections = cameraData[camera]; + return ( +
+ + {resolveCameraName(config, camera)} + + + {sections + .map((section) => + t(`configForm.sections.${section}`, { + ns: "views/settings", + defaultValue: section, + }), + ) + .join(", ")} +
-
- {sections.map((section) => ( - - {t(`configForm.sections.${section}`, { - ns: "views/settings", - defaultValue: section, - })} - - ))} -
-
-
- ); - })} -
- )} -
+ ); + })} +
+ ) : ( +
+ {t("profiles.noOverrides", { ns: "views/settings" })} +
+ )} + +
+ ); })}
)} + {/* Add Profile Dialog */} + { + setAddDialogOpen(open); + if (!open) { + setNewProfileName(""); + setNameError(null); + } + }} + > + + + + {t("profiles.newProfile", { ns: "views/settings" })} + + +
+ { + setNewProfileName(e.target.value); + setNameError(validateName(e.target.value)); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleAddSubmit(); + } + }} + autoFocus + /> + {nameError && ( +

{nameError}

+ )} +
+ + + + +
+
+ {/* Delete Profile Confirmation */} + currentEditingProfile && profileState?.allProfileNames + ? getProfileColor(currentEditingProfile, profileState.allProfileNames) + : undefined, + [currentEditingProfile, profileState?.allProfileNames], + ); + const handleSectionStatusChange = useCallback( (status: SectionStatus) => { setSectionStatus(status); @@ -136,15 +153,40 @@ export function SingleSectionPage({ {level === "camera" && showOverrideIndicator && sectionStatus.isOverridden && ( - - {t("button.overridden", { - ns: "common", - defaultValue: "Overridden", - })} - + + + + {sectionStatus.overrideSource === "profile" + ? t("button.overriddenBaseConfig", { + ns: "views/settings", + defaultValue: "Overridden (Base Config)", + }) + : t("button.overriddenGlobal", { + ns: "views/settings", + defaultValue: "Overridden (Global)", + })} + + + + {sectionStatus.overrideSource === "profile" + ? t("button.overriddenBaseConfigTooltip", { + ns: "common", + profile: currentEditingProfile, + }) + : t("button.overriddenGlobalTooltip", { + ns: "views/settings", + })} + + )} {sectionStatus.hasChanges && (
);