diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index b73534d5d6..2d444986c3 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -19,8 +19,14 @@ "button": { "overriddenGlobal": "Overridden (Global)", "overriddenGlobalTooltip": "This camera overrides global configuration settings in this section", + "overriddenGlobalHeading_one": "This camera overrides {{count}} field from the global config:", + "overriddenGlobalHeading_other": "This camera overrides {{count}} fields from the global config:", + "overriddenGlobalNoDeltas": "This section is marked as overridden, but no field values differ from the global config.", "overriddenBaseConfig": "Overridden (Base Config)", "overriddenBaseConfigTooltip": "The {{profile}} profile overrides configuration settings in this section", + "overriddenBaseConfigHeading_one": "The {{profile}} profile overrides {{count}} field from the base config:", + "overriddenBaseConfigHeading_other": "The {{profile}} profile overrides {{count}} fields from the base config:", + "overriddenBaseConfigNoDeltas": "The {{profile}} profile overrides this section, but no field values differ from the base config.", "overriddenInCameras": { "label_one": "Overridden in {{count}} camera", "label_other": "Overridden in {{count}} cameras", diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index 7ad88fd3d2..66cc2cc4ab 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -27,14 +27,11 @@ import { import { getSectionValidation } from "../section-validations"; import { useConfigOverride } from "@/hooks/use-config-override"; import { CameraOverridesBadge } from "./CameraOverridesBadge"; +import { GlobalOverridesBadge } from "./GlobalOverridesBadge"; +import { ProfileOverridesBadge } from "./ProfileOverridesBadge"; 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"; @@ -1257,33 +1254,22 @@ export function ConfigSection({ {title} {showOverrideIndicator && effectiveLevel === "camera" && - (profileOverridesSection || isOverridden) && ( - - - - {overrideSource === "profile" - ? t("button.overriddenBaseConfig", { - ns: "views/settings", - defaultValue: "Overridden (Base Config)", - }) - : t("button.overriddenGlobal", { - ns: "views/settings", - defaultValue: "Overridden (Global)", - })} - - - - {overrideSource === "profile" - ? t("button.overriddenBaseConfigTooltip", { - ns: "views/settings", - profile: profileFriendlyName ?? profileName, - }) - : t("button.overriddenGlobalTooltip", { - ns: "views/settings", - })} - - - )} + (profileOverridesSection || isOverridden) && + cameraName && + (overrideSource === "profile" && profileName ? ( + + ) : ( + + ))} {showOverrideIndicator && effectiveLevel === "global" && ( )} @@ -1323,41 +1309,22 @@ export function ConfigSection({ {title} {showOverrideIndicator && effectiveLevel === "camera" && - (profileOverridesSection || isOverridden) && ( - - - - {overrideSource === "profile" - ? t("button.overriddenBaseConfig", { - ns: "views/settings", - defaultValue: "Overridden (Base Config)", - }) - : t("button.overriddenGlobal", { - ns: "views/settings", - defaultValue: "Overridden (Global)", - })} - - - - {overrideSource === "profile" - ? t("button.overriddenBaseConfigTooltip", { - ns: "views/settings", - profile: profileFriendlyName ?? profileName, - }) - : t("button.overriddenGlobalTooltip", { - ns: "views/settings", - })} - - - )} + (profileOverridesSection || isOverridden) && + cameraName && + (overrideSource === "profile" && profileName ? ( + + ) : ( + + ))} {showOverrideIndicator && effectiveLevel === "global" && ( )} diff --git a/web/src/components/config-form/sections/CameraOverridesBadge.tsx b/web/src/components/config-form/sections/CameraOverridesBadge.tsx index 6ccbb028ca..466934a770 100644 --- a/web/src/components/config-form/sections/CameraOverridesBadge.tsx +++ b/web/src/components/config-form/sections/CameraOverridesBadge.tsx @@ -17,10 +17,13 @@ import { } from "@/hooks/use-config-override"; import type { FrigateConfig } from "@/types/frigateConfig"; import type { ProfilesApiResponse } from "@/types/profile"; -import { humanizeKey } from "@/components/config-form/theme/utils/i18n"; import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name"; import { formatList } from "@/utils/stringUtil"; -import { getEffectiveHiddenFields } from "@/utils/configUtil"; +import { + getEffectiveHiddenFields, + pathMatchesHiddenPattern, +} from "@/utils/configUtil"; +import { useOverrideFieldLabel } from "./useOverrideFieldLabel"; const CAMERA_PAGE_BY_SECTION: Record = { detect: "cameraDetect", @@ -72,26 +75,6 @@ const SECTIONS_WITHOUT_OVERRIDE_BADGE = new Set([ "model", ]); -/** - * Match a delta path against a hidden-field pattern. Supports literal prefixes - * (so a hidden field "streams" also hides "streams.foo.bar") and `*` wildcards - * matching exactly one path segment (e.g. "filters.*.mask"). - */ -function pathMatchesHiddenPattern(path: string, pattern: string): boolean { - if (!pattern) return false; - if (!pattern.includes("*")) { - return path === pattern || path.startsWith(`${pattern}.`); - } - const patternSegments = pattern.split("."); - const pathSegments = path.split("."); - if (pathSegments.length < patternSegments.length) return false; - for (let i = 0; i < patternSegments.length; i += 1) { - if (patternSegments[i] === "*") continue; - if (patternSegments[i] !== pathSegments[i]) return false; - } - return true; -} - type CameraEntryProps = { sectionPath: string; entry: CameraOverrideEntry; @@ -127,11 +110,8 @@ function groupDeltasBySource(deltas: FieldDelta[]): SourceGroup[] { } function CameraEntry({ sectionPath, entry, cameraPage }: CameraEntryProps) { - const { t, i18n } = useTranslation([ - "config/global", - "views/settings", - "objects", - ]); + const { t } = useTranslation(["views/settings"]); + const fieldLabel = useOverrideFieldLabel(sectionPath); const friendlyName = useCameraFriendlyName(entry.camera); const { data: profilesData } = useSWR("profiles"); @@ -141,49 +121,6 @@ function CameraEntry({ sectionPath, entry, cameraPage }: CameraEntryProps) { return map; }, [profilesData]); - const fieldLabel = (fieldPath: string) => { - if (!fieldPath) { - const sectionKey = `${sectionPath}.label`; - return i18n.exists(sectionKey, { ns: "config/global" }) - ? t(sectionKey, { ns: "config/global" }) - : humanizeKey(sectionPath); - } - - const segments = fieldPath.split("."); - - // Most specific: try the full nested path - const fullKey = `${sectionPath}.${fieldPath}.label`; - if (i18n.exists(fullKey, { ns: "config/global" })) { - return t(fullKey, { ns: "config/global" }); - } - - // Try dropping each intermediate segment in turn — those are typically - // user-defined dict keys (object class names, zone names, etc.) that - // don't have their own label entries. Prepend the dropped segment as - // context to disambiguate (e.g. "Person · Minimum object area"). - for (let i = 0; i < segments.length; i++) { - const reduced = [...segments.slice(0, i), ...segments.slice(i + 1)].join( - ".", - ); - if (!reduced) continue; - const reducedKey = `${sectionPath}.${reduced}.label`; - if (i18n.exists(reducedKey, { ns: "config/global" })) { - const resolvedLabel = t(reducedKey, { ns: "config/global" }); - const dropped = segments[i]; - // Object class names ("person", "car", "fox") have translations in - // the `objects` namespace; fall back to humanizing the raw key for - // anything that isn't a known label. - const droppedLabel = i18n.exists(dropped, { ns: "objects" }) - ? t(dropped, { ns: "objects" }) - : humanizeKey(dropped); - return `${droppedLabel} · ${resolvedLabel}`; - } - } - - // Last resort: humanize the leaf segment - return humanizeKey(segments[segments.length - 1]); - }; - const formatDeltas = (deltas: FieldDelta[]) => { const visibleLabels = deltas .slice(0, MAX_FIELDS_PER_CAMERA) diff --git a/web/src/components/config-form/sections/GlobalOverridesBadge.tsx b/web/src/components/config-form/sections/GlobalOverridesBadge.tsx new file mode 100644 index 0000000000..d6bc9b3e4b --- /dev/null +++ b/web/src/components/config-form/sections/GlobalOverridesBadge.tsx @@ -0,0 +1,44 @@ +import useSWR from "swr"; +import { useTranslation } from "react-i18next"; + +import { useCameraSectionDeltas } from "@/hooks/use-config-override"; +import type { FrigateConfig } from "@/types/frigateConfig"; +import { OverrideDeltaPopover } from "./OverrideDeltaPopover"; + +type Props = { + sectionPath: string; + cameraName: string; + className?: string; +}; + +export function GlobalOverridesBadge({ + sectionPath, + cameraName, + className, +}: Props) { + const { data: config } = useSWR("config"); + const { t } = useTranslation(["views/settings"]); + const deltas = useCameraSectionDeltas(config, cameraName, sectionPath); + + return ( + + ); +} diff --git a/web/src/components/config-form/sections/OverrideDeltaPopover.tsx b/web/src/components/config-form/sections/OverrideDeltaPopover.tsx new file mode 100644 index 0000000000..5cd22a5215 --- /dev/null +++ b/web/src/components/config-form/sections/OverrideDeltaPopover.tsx @@ -0,0 +1,78 @@ +import { LuChevronDown } from "react-icons/lu"; + +import { Badge } from "@/components/ui/badge"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import type { FieldDelta } from "@/hooks/use-config-override"; +import { cn } from "@/lib/utils"; +import { useOverrideFieldLabel } from "./useOverrideFieldLabel"; + +type Props = { + sectionPath: string; + deltas: FieldDelta[]; + /** Translated label shown inside the badge */ + badgeLabel: string; + /** Accessible label for the badge trigger */ + ariaLabel: string; + /** Heading rendered at the top of the popover content */ + heading: string; + /** Message shown when there are zero field deltas */ + noDeltasMessage: string; + /** Border color class for the badge (defaults to selected) */ + borderColorClass?: string; + className?: string; +}; + +/** + * Shared popover layout for "this scope overrides these fields" badges + * (e.g. profile overrides base config, camera overrides global config). + */ +export function OverrideDeltaPopover({ + sectionPath, + deltas, + badgeLabel, + ariaLabel, + heading, + noDeltasMessage, + borderColorClass, + className, +}: Props) { + const fieldLabel = useOverrideFieldLabel(sectionPath); + const count = deltas.length; + + return ( + + e.stopPropagation()}> + + {badgeLabel} + + + + +
+
+ {count > 0 ? heading : noDeltasMessage} +
+ {count > 0 && ( +
    + {deltas.map((delta) => ( +
  • {fieldLabel(delta.fieldPath)}
  • + ))} +
+ )} +
+
+
+ ); +} diff --git a/web/src/components/config-form/sections/ProfileOverridesBadge.tsx b/web/src/components/config-form/sections/ProfileOverridesBadge.tsx new file mode 100644 index 0000000000..2d8d5d7e1a --- /dev/null +++ b/web/src/components/config-form/sections/ProfileOverridesBadge.tsx @@ -0,0 +1,62 @@ +import useSWR from "swr"; +import { useTranslation } from "react-i18next"; + +import { useProfileSectionDeltas } from "@/hooks/use-config-override"; +import type { FrigateConfig } from "@/types/frigateConfig"; +import { OverrideDeltaPopover } from "./OverrideDeltaPopover"; + +type Props = { + sectionPath: string; + cameraName: string; + profileName: string; + profileFriendlyName?: string; + /** Border color class for profile-themed badge (e.g., "border-amber-500") */ + profileBorderColor?: string; + className?: string; +}; + +export function ProfileOverridesBadge({ + sectionPath, + cameraName, + profileName, + profileFriendlyName, + profileBorderColor, + className, +}: Props) { + const { data: config } = useSWR("config"); + const { t } = useTranslation(["views/settings"]); + const deltas = useProfileSectionDeltas( + config, + cameraName, + profileName, + sectionPath, + ); + + const displayProfile = profileFriendlyName ?? profileName; + + return ( + + ); +} diff --git a/web/src/components/config-form/sections/useOverrideFieldLabel.ts b/web/src/components/config-form/sections/useOverrideFieldLabel.ts new file mode 100644 index 0000000000..0ec7398de0 --- /dev/null +++ b/web/src/components/config-form/sections/useOverrideFieldLabel.ts @@ -0,0 +1,53 @@ +import { useTranslation } from "react-i18next"; +import { humanizeKey } from "@/components/config-form/theme/utils/i18n"; + +/** + * Resolve a translated label for a config field path within a section, falling + * back through reduced paths (dropping each intermediate segment in turn) so + * dict-keyed paths like `filters.person.threshold` still surface a meaningful + * label. Dropped segments are prepended as context (e.g. "Person · Threshold"). + * + * Shared between override badges that need to render field labels (e.g. + * CameraOverridesBadge, ProfileOverridesBadge). + */ +export function useOverrideFieldLabel(sectionPath: string) { + const { t, i18n } = useTranslation([ + "config/global", + "views/settings", + "objects", + ]); + + return (fieldPath: string): string => { + if (!fieldPath) { + const sectionKey = `${sectionPath}.label`; + return i18n.exists(sectionKey, { ns: "config/global" }) + ? t(sectionKey, { ns: "config/global" }) + : humanizeKey(sectionPath); + } + + const segments = fieldPath.split("."); + + const fullKey = `${sectionPath}.${fieldPath}.label`; + if (i18n.exists(fullKey, { ns: "config/global" })) { + return t(fullKey, { ns: "config/global" }); + } + + for (let i = 0; i < segments.length; i++) { + const reduced = [...segments.slice(0, i), ...segments.slice(i + 1)].join( + ".", + ); + if (!reduced) continue; + const reducedKey = `${sectionPath}.${reduced}.label`; + if (i18n.exists(reducedKey, { ns: "config/global" })) { + const resolvedLabel = t(reducedKey, { ns: "config/global" }); + const dropped = segments[i]; + const droppedLabel = i18n.exists(dropped, { ns: "objects" }) + ? t(dropped, { ns: "objects" }) + : humanizeKey(dropped); + return `${droppedLabel} · ${resolvedLabel}`; + } + } + + return humanizeKey(segments[segments.length - 1]); + }; +} diff --git a/web/src/hooks/use-config-override.ts b/web/src/hooks/use-config-override.ts index 8e0732e050..689e99f4b1 100644 --- a/web/src/hooks/use-config-override.ts +++ b/web/src/hooks/use-config-override.ts @@ -12,6 +12,7 @@ import { isJsonObject } from "@/lib/utils"; import { getBaseCameraSectionValue, getEffectiveHiddenFields, + pathMatchesHiddenPattern, unsetWithWildcard, } from "@/utils/configUtil"; import { extractSectionSchema } from "@/hooks/use-config-schema"; @@ -663,3 +664,138 @@ export function useCamerasOverridingSection( return entries; }, [config, sectionPath, schema]); } + +/** + * Hook returning the field-level deltas between a single camera's base + * (pre-profile) section value and the effective global baseline. Mirrors + * `useConfigOverride`'s comparison logic but exposes per-field deltas so a + * popover can list the overridden fields. + * + * @example + * ```tsx + * const deltas = useCameraSectionDeltas(config, "front_door", "detect"); + * // [{ fieldPath: "fps", globalValue: 5, cameraValue: 10 }] + * ``` + */ +export function useCameraSectionDeltas( + config: FrigateConfig | undefined, + cameraName: string | undefined, + sectionPath: string, +): FieldDelta[] { + const { data: schema } = useSWR("config/schema.json"); + return useMemo(() => { + if (!config?.cameras || !cameraName || !sectionPath) { + return []; + } + const cameraConfig = config.cameras[cameraName]; + if (!cameraConfig) return []; + + const sectionMeta = OVERRIDABLE_SECTIONS.find((s) => s.key === sectionPath); + const compareFields = sectionMeta?.compareFields; + + const globalValue = collapseEmpty( + getEffectiveGlobalBaseline(config, sectionPath, compareFields, schema), + ); + const cameraValue = collapseEmpty( + normalizeConfigValue( + getBaseCameraSectionValue(config, cameraName, sectionPath), + ), + ); + + const hiddenFields = getEffectiveHiddenFields( + sectionPath, + "camera", + config, + ); + + const deltas: FieldDelta[] = []; + for (const delta of collectFieldDeltas( + globalValue, + cameraValue, + compareFields, + )) { + if ( + hiddenFields.some((pattern) => + pathMatchesHiddenPattern(delta.fieldPath, pattern), + ) + ) { + continue; + } + deltas.push(delta); + } + return deltas; + }, [config, cameraName, sectionPath, schema]); +} + +/** + * Hook returning the field-level deltas between a single profile's overrides + * and the camera's base (pre-profile) section value. Honors per-section + * `compareFields` filters and hidden-field patterns so the result matches + * what's actually exposed in the UI. + * + * @example + * ```tsx + * const deltas = useProfileSectionDeltas(config, "front_door", "night", "detect"); + * // [{ fieldPath: "fps", globalValue: 5, cameraValue: 10, profileName: "night" }] + * ``` + */ +export function useProfileSectionDeltas( + config: FrigateConfig | undefined, + cameraName: string | undefined, + profileName: string | undefined, + sectionPath: string, +): FieldDelta[] { + return useMemo(() => { + if (!config?.cameras || !cameraName || !profileName || !sectionPath) { + return []; + } + const cameraConfig = config.cameras[cameraName]; + if (!cameraConfig) return []; + + const profileSection = ( + cameraConfig.profiles?.[profileName] as + | Record + | undefined + )?.[sectionPath]; + if (profileSection == null) return []; + + const sectionMeta = OVERRIDABLE_SECTIONS.find((s) => s.key === sectionPath); + const compareFields = sectionMeta?.compareFields; + + const baseValue = collapseEmpty( + normalizeConfigValue( + getBaseCameraSectionValue(config, cameraName, sectionPath), + ), + ); + const profileValue = collapseEmpty( + normalizeConfigValue(profileSection as JsonValue), + ); + + const hiddenFields = getEffectiveHiddenFields( + sectionPath, + "camera", + config, + ); + + const deltas: FieldDelta[] = []; + for (const path of collectDefinedLeafPaths(profileValue)) { + if (!isPathAllowed(path, compareFields)) continue; + if ( + hiddenFields.some((pattern) => pathMatchesHiddenPattern(path, pattern)) + ) { + continue; + } + const baseField = get(baseValue, path); + const profileField = get(profileValue, path); + if (!isEqual(baseField, profileField)) { + deltas.push({ + fieldPath: path, + globalValue: baseField, + cameraValue: profileField, + profileName, + }); + } + } + return deltas; + }, [config, cameraName, profileName, sectionPath]); +} diff --git a/web/src/utils/configUtil.ts b/web/src/utils/configUtil.ts index 54de9d9ec9..80c940cb70 100644 --- a/web/src/utils/configUtil.ts +++ b/web/src/utils/configUtil.ts @@ -763,3 +763,26 @@ export function resolveHiddenFieldEntries( } return result; } + +/** + * Match a delta path against a hidden-field pattern. Supports literal prefixes + * (so a hidden field "streams" also hides "streams.foo.bar") and `*` wildcards + * matching exactly one path segment (e.g. "filters.*.mask"). + */ +export function pathMatchesHiddenPattern( + path: string, + pattern: string, +): boolean { + if (!pattern) return false; + if (!pattern.includes("*")) { + return path === pattern || path.startsWith(`${pattern}.`); + } + const patternSegments = pattern.split("."); + const pathSegments = path.split("."); + if (pathSegments.length < patternSegments.length) return false; + for (let i = 0; i < patternSegments.length; i += 1) { + if (patternSegments[i] === "*") continue; + if (patternSegments[i] !== pathSegments[i]) return false; + } + return true; +} diff --git a/web/src/views/settings/SingleSectionPage.tsx b/web/src/views/settings/SingleSectionPage.tsx index 1ae684fbf8..f352421f52 100644 --- a/web/src/views/settings/SingleSectionPage.tsx +++ b/web/src/views/settings/SingleSectionPage.tsx @@ -3,18 +3,14 @@ import { useTranslation } from "react-i18next"; import type { SectionConfig } from "@/components/config-form/sections"; import { ConfigSectionTemplate } from "@/components/config-form/sections"; import { CameraOverridesBadge } from "@/components/config-form/sections/CameraOverridesBadge"; +import { GlobalOverridesBadge } from "@/components/config-form/sections/GlobalOverridesBadge"; +import { ProfileOverridesBadge } from "@/components/config-form/sections/ProfileOverridesBadge"; import type { PolygonType } from "@/types/canvas"; import { Badge } from "@/components/ui/badge"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; import type { ConfigSectionData } from "@/types/configForm"; import type { ProfileState } from "@/types/profile"; import { getSectionConfig } from "@/utils/configUtil"; import { getProfileColor } from "@/utils/profileColors"; -import { cn } from "@/lib/utils"; import { useDocDomain } from "@/hooks/use-doc-domain"; import { Link } from "react-router-dom"; import { LuExternalLink } from "react-icons/lu"; @@ -173,46 +169,25 @@ export function SingleSectionPage({ )} {level === "camera" && showOverrideIndicator && - sectionStatus.isOverridden && ( - - - - {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: "views/settings", - profile: currentEditingProfile - ? (profileState?.profileFriendlyNames.get( - currentEditingProfile, - ) ?? currentEditingProfile) - : "", - }) - : t("button.overriddenGlobalTooltip", { - ns: "views/settings", - })} - - - )} + sectionStatus.isOverridden && + selectedCamera && + (sectionStatus.overrideSource === "profile" && + currentEditingProfile ? ( + + ) : ( + + ))} {sectionStatus.hasChanges && ( - {sectionStatus.overrideSource === "profile" - ? t("button.overriddenBaseConfig", { - ns: "views/settings", - defaultValue: "Overridden (Base Config)", - }) - : t("button.overriddenGlobal", { - ns: "views/settings", - defaultValue: "Overridden (Global)", - })} - - )} + profileBorderColor={profileColor?.border} + /> + ) : ( + + ))} {sectionStatus.hasChanges && (