diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 7dfd0ea98a..3aaf60c639 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -20,7 +20,18 @@ "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" + "overriddenBaseConfigTooltip": "The {{profile}} profile overrides configuration settings in this section", + "overriddenInCameras": { + "label_one": "Overridden in {{count}} camera", + "label_other": "Overridden in {{count}} cameras", + "tooltip_one": "{{count}} camera overrides values in this section. Click to see details.", + "tooltip_other": "{{count}} cameras override values in this section. Click to see details.", + "heading_one": "This global section has fields that are overridden in {{count}} camera.", + "heading_other": "This global section has fields that are overridden in {{count}} cameras.", + "othersField_one": "{{count}} other", + "othersField_other": "{{count}} others", + "profilePrefix": "{{profile}} profile: {{fields}}" + } }, "menu": { "general": "General", diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index bd16a98bd2..df248d2715 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -25,6 +25,7 @@ import { } from "./section-special-cases"; import { getSectionValidation } from "../section-validations"; import { useConfigOverride } from "@/hooks/use-config-override"; +import { CameraOverridesBadge } from "./CameraOverridesBadge"; import { useSectionSchema } from "@/hooks/use-config-schema"; import type { FrigateConfig } from "@/types/frigateConfig"; import { Badge } from "@/components/ui/badge"; @@ -1263,6 +1264,9 @@ export function ConfigSection({ )} + {showOverrideIndicator && effectiveLevel === "global" && ( + + )} {hasChanges && ( {t("button.modified", { @@ -1334,6 +1338,9 @@ export function ConfigSection({ )} + {showOverrideIndicator && effectiveLevel === "global" && ( + + )} {hasChanges && ( = { + detect: "cameraDetect", + ffmpeg: "cameraFfmpeg", + record: "cameraRecording", + snapshots: "cameraSnapshots", + motion: "cameraMotion", + objects: "cameraObjects", + review: "cameraReview", + audio: "cameraAudioEvents", + audio_transcription: "cameraAudioTranscription", + notifications: "cameraNotifications", + live: "cameraLivePlayback", + birdseye: "cameraBirdseye", + face_recognition: "cameraFaceRecognition", + lpr: "cameraLpr", + timestamp_style: "cameraTimestampStyle", +}; + +const MAX_FIELDS_PER_CAMERA = 5; + +/** + * Enrichment sections where the cross-camera override badge should be + * suppressed because they're effectively global-only (or per-camera + * configuration there isn't a useful affordance to surface here). + * Face recognition and LPR are intentionally omitted so the badge does show + * on those enrichment pages. + */ +const SECTIONS_WITHOUT_OVERRIDE_BADGE = new Set([ + "semantic_search", + "genai", + "classification", + "audio_transcription", +]); + +/** + * 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; + cameraPage?: string; +}; + +type SourceGroup = { + /** undefined → camera-level; string → profile name */ + profileName: string | undefined; + deltas: FieldDelta[]; +}; + +function groupDeltasBySource(deltas: FieldDelta[]): SourceGroup[] { + const cameraDeltas: FieldDelta[] = []; + const byProfile = new Map(); + for (const delta of deltas) { + if (delta.profileName) { + const arr = byProfile.get(delta.profileName) ?? []; + arr.push(delta); + byProfile.set(delta.profileName, arr); + } else { + cameraDeltas.push(delta); + } + } + const groups: SourceGroup[] = []; + if (cameraDeltas.length > 0) { + groups.push({ profileName: undefined, deltas: cameraDeltas }); + } + for (const [profileName, group] of byProfile) { + groups.push({ profileName, deltas: group }); + } + return groups; +} + +function CameraEntry({ sectionPath, entry, cameraPage }: CameraEntryProps) { + const { t, i18n } = useTranslation([ + "config/global", + "views/settings", + "objects", + ]); + const friendlyName = useCameraFriendlyName(entry.camera); + const { data: profilesData } = useSWR("profiles"); + + const profileFriendlyNames = useMemo(() => { + const map = new Map(); + profilesData?.profiles?.forEach((p) => map.set(p.name, p.friendly_name)); + 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) + .map((delta) => fieldLabel(delta.fieldPath)); + const hiddenCount = deltas.length - visibleLabels.length; + const labelsForList = + hiddenCount > 0 + ? [ + ...visibleLabels, + t("button.overriddenInCameras.othersField", { + ns: "views/settings", + count: hiddenCount, + }), + ] + : visibleLabels; + return formatList(labelsForList); + }; + + const groups = groupDeltasBySource(entry.fieldDeltas); + + return ( +
+ {cameraPage ? ( + + {friendlyName} + + ) : ( + {friendlyName} + )} + {groups.map((group) => ( + + {group.profileName + ? t("button.overriddenInCameras.profilePrefix", { + ns: "views/settings", + profile: + profileFriendlyNames.get(group.profileName) ?? + group.profileName, + fields: formatDeltas(group.deltas), + }) + : formatDeltas(group.deltas)} + + ))} +
+ ); +} + +type Props = { + sectionPath: string; + className?: string; +}; + +export function CameraOverridesBadge({ sectionPath, className }: Props) { + const { data: config } = useSWR("config"); + const { t } = useTranslation(["views/settings"]); + const rawEntries = useCamerasOverridingSection(config, sectionPath); + + const entries = useMemo(() => { + const hiddenFields = + getSectionConfig(sectionPath, "global").hiddenFields ?? []; + if (hiddenFields.length === 0) return rawEntries; + return rawEntries + .map((entry) => ({ + ...entry, + fieldDeltas: entry.fieldDeltas.filter( + (delta) => + !hiddenFields.some((pattern) => + pathMatchesHiddenPattern(delta.fieldPath, pattern), + ), + ), + })) + .filter((entry) => entry.fieldDeltas.length > 0); + }, [rawEntries, sectionPath]); + + if (SECTIONS_WITHOUT_OVERRIDE_BADGE.has(sectionPath)) { + return null; + } + + if (entries.length === 0) { + return null; + } + + const cameraPage = CAMERA_PAGE_BY_SECTION[sectionPath]; + const count = entries.length; + + return ( + + + + + {t("button.overriddenInCameras.label", { + ns: "views/settings", + count: count, + })} + + + + + +
+
+ {t("button.overriddenInCameras.heading", { + ns: "views/settings", + count: count, + })} +
+
+ {entries.map((entry) => ( + + ))} +
+
+
+
+ ); +} diff --git a/web/src/hooks/use-config-override.ts b/web/src/hooks/use-config-override.ts index cd878e08f4..7596672153 100644 --- a/web/src/hooks/use-config-override.ts +++ b/web/src/hooks/use-config-override.ts @@ -202,6 +202,49 @@ export function useConfigOverride({ }, [config, cameraName, sectionPath, compareFields]); } +/** + * Sections that can be overridden per-camera, with optional compareFields + * filters that scope the override comparison to a subset of fields. + */ +export const OVERRIDABLE_SECTIONS: ReadonlyArray<{ + key: string; + compareFields?: string[]; +}> = [ + { key: "detect" }, + { key: "record" }, + { key: "snapshots" }, + { key: "motion" }, + { key: "objects" }, + { key: "review" }, + { key: "audio" }, + { key: "notifications" }, + { key: "live" }, + { key: "timestamp_style" }, + { + key: "audio_transcription", + compareFields: ["enabled", "live_enabled"], + }, + { key: "birdseye", compareFields: ["enabled", "mode"] }, + { key: "face_recognition", compareFields: ["enabled", "min_area"] }, + { + key: "ffmpeg", + compareFields: [ + "path", + "global_args", + "hwaccel_args", + "input_args", + "output_args", + "retry_interval", + "apple_compatibility", + "gpu", + ], + }, + { + key: "lpr", + compareFields: ["enabled", "min_area", "enhancement"], + }, +]; + /** * Hook to get all overridden fields for a camera */ @@ -221,47 +264,7 @@ export function useAllCameraOverrides( const overriddenSections: string[] = []; - // Check each section that can be overridden - const sectionsToCheck: Array<{ - key: string; - compareFields?: string[]; - }> = [ - { key: "detect" }, - { key: "record" }, - { key: "snapshots" }, - { key: "motion" }, - { key: "objects" }, - { key: "review" }, - { key: "audio" }, - { key: "notifications" }, - { key: "live" }, - { key: "timestamp_style" }, - { - key: "audio_transcription", - compareFields: ["enabled", "live_enabled"], - }, - { key: "birdseye", compareFields: ["enabled", "mode"] }, - { key: "face_recognition", compareFields: ["enabled", "min_area"] }, - { - key: "ffmpeg", - compareFields: [ - "path", - "global_args", - "hwaccel_args", - "input_args", - "output_args", - "retry_interval", - "apple_compatibility", - "gpu", - ], - }, - { - key: "lpr", - compareFields: ["enabled", "min_area", "enhancement"], - }, - ]; - - for (const { key, compareFields } of sectionsToCheck) { + for (const { key, compareFields } of OVERRIDABLE_SECTIONS) { const globalValue = normalizeConfigValue(get(config, key)); const cameraValue = normalizeConfigValue( getBaseCameraSectionValue(config, cameraName, key), @@ -286,3 +289,252 @@ export function useAllCameraOverrides( return overriddenSections; }, [config, cameraName]); } + +export interface FieldDelta { + /** Path relative to the section (e.g. "genai.enabled") */ + fieldPath: string; + globalValue: unknown; + cameraValue: unknown; + /** Profile name when the override originates from a profile; undefined for camera-level overrides */ + profileName?: string; +} + +export interface CameraOverrideEntry { + camera: string; + fieldDeltas: FieldDelta[]; +} + +/** + * Collect leaf-level field differences between a global section value + * and a camera section value. When compareFields is provided, only those + * paths are compared; otherwise the objects are walked recursively. + */ +function collectFieldDeltas( + globalValue: JsonValue, + cameraValue: JsonValue, + compareFields?: string[], + pathPrefix = "", +): FieldDelta[] { + if (compareFields) { + if (compareFields.length === 0) { + return []; + } + const deltas: FieldDelta[] = []; + for (const path of compareFields) { + const g = get(globalValue, path); + const c = get(cameraValue, path); + if (!isEqual(g, c)) { + deltas.push({ fieldPath: path, globalValue: g, cameraValue: c }); + } + } + return deltas; + } + + if (isJsonObject(globalValue) && isJsonObject(cameraValue)) { + const deltas: FieldDelta[] = []; + const keys = new Set([ + ...Object.keys(globalValue), + ...Object.keys(cameraValue), + ]); + for (const key of keys) { + const g = (globalValue as JsonObject)[key]; + const c = (cameraValue as JsonObject)[key]; + if (isEqual(g, c)) continue; + const childPath = pathPrefix ? `${pathPrefix}.${key}` : key; + if (isJsonObject(g) && isJsonObject(c)) { + deltas.push(...collectFieldDeltas(g, c, undefined, childPath)); + } else { + deltas.push({ fieldPath: childPath, globalValue: g, cameraValue: c }); + } + } + return deltas; + } + + if (!isEqual(globalValue, cameraValue)) { + return [{ fieldPath: pathPrefix, globalValue, cameraValue }]; + } + return []; +} + +/** + * Walk a partial config object and return the dot-paths of every leaf value + * (primitive or array) actually defined on it. Used to limit profile-vs-global + * diffs to keys the profile actually sets, avoiding false "undefined" deltas + * for fields the profile leaves unspecified. + */ +function collectDefinedLeafPaths(value: JsonValue, prefix = ""): string[] { + if (!isJsonObject(value)) { + return prefix ? [prefix] : []; + } + const paths: string[] = []; + for (const [key, val] of Object.entries(value as JsonObject)) { + const childPath = prefix ? `${prefix}.${key}` : key; + if (isJsonObject(val)) { + paths.push(...collectDefinedLeafPaths(val as JsonValue, childPath)); + } else { + paths.push(childPath); + } + } + return paths; +} + +function isPathAllowed(path: string, compareFields?: string[]): boolean { + if (!compareFields) return true; + return compareFields.some( + (allowed) => path === allowed || path.startsWith(`${allowed}.`), + ); +} + +/** + * Some Frigate sections (notably `motion`) are dumped by the backend with + * `exclude_unset=True`, so when the user hasn't explicitly written the section + * in their global YAML the API returns null even though every camera still + * gets defaults applied at runtime. To still detect cross-camera differences + * in those sections we synthesize a baseline by taking the modal (most common) + * value at each leaf path across cameras — cameras whose value diverges from + * the modal are treated as overriding. + */ +function deriveSyntheticGlobalValue( + cameraSectionValues: JsonValue[], + compareFields?: string[], +): JsonObject { + const cameras = cameraSectionValues.filter(isJsonObject) as JsonObject[]; + if (cameras.length === 0) return {}; + + const allPaths = new Set(); + for (const cam of cameras) { + for (const path of collectDefinedLeafPaths(cam as JsonValue)) { + if (!isPathAllowed(path, compareFields)) continue; + allPaths.add(path); + } + } + + const baseline: JsonObject = {}; + for (const path of allPaths) { + const counts = new Map(); + for (const cam of cameras) { + const v = get(cam, path); + const key = JSON.stringify(v ?? null); + const existing = counts.get(key); + if (existing) { + existing.count += 1; + } else { + counts.set(key, { value: v, count: 1 }); + } + } + let modal: { value: unknown; count: number } | undefined; + for (const entry of counts.values()) { + if (!modal || entry.count > modal.count) modal = entry; + } + if (modal) { + set(baseline, path, modal.value); + } + } + return baseline; +} + +/** + * Paths that are intentionally hidden from the cross-camera override summary + * because they're inherently per-camera (mask polygons, zone definitions) and + * would otherwise dominate the popover with noise. Excludes any path where + * `mask` appears as a path segment, so nested keys under a mask dict (e.g. + * `mask.global_object_mask_1.coordinates`) are also filtered. + */ +function isCrossCameraIgnoredPath(path: string): boolean { + if (!path) return false; + return path.split(".").includes("mask"); +} + +/** + * Hook to find every camera that overrides a given global section. Returns + * one entry per overriding camera with the specific field-level deltas. + * Considers both the camera's own (pre-profile) section value and any of its + * defined profiles, so a field overridden only inside a profile still surfaces. + * + * @example + * ```tsx + * const entries = useCamerasOverridingSection(config, "review"); + * // [{ camera: "front_door", fieldDeltas: [{ fieldPath: "genai.enabled", ... }] }] + * ``` + */ +export function useCamerasOverridingSection( + config: FrigateConfig | undefined, + sectionPath: string, +): CameraOverrideEntry[] { + return useMemo(() => { + if (!config?.cameras || !sectionPath) { + return []; + } + + const sectionMeta = OVERRIDABLE_SECTIONS.find((s) => s.key === sectionPath); + const compareFields = sectionMeta?.compareFields; + + const cameraNames = Object.keys(config.cameras); + const cameraSectionValues = cameraNames.map((name) => + normalizeConfigValue( + getBaseCameraSectionValue(config, name, sectionPath), + ), + ); + + const rawGlobalValue = get(config, sectionPath); + const globalValue: JsonValue = + rawGlobalValue == null + ? deriveSyntheticGlobalValue(cameraSectionValues, compareFields) + : normalizeConfigValue(rawGlobalValue); + + const entries: CameraOverrideEntry[] = []; + for (let idx = 0; idx < cameraNames.length; idx += 1) { + const cameraName = cameraNames[idx]; + const cameraConfig = config.cameras[cameraName]; + const deltasByPath = new Map(); + + // 1. Camera-level overrides (uses base_config when a profile is active) + const cameraValue = cameraSectionValues[idx]; + for (const delta of collectFieldDeltas( + globalValue, + cameraValue, + compareFields, + )) { + if (isCrossCameraIgnoredPath(delta.fieldPath)) continue; + deltasByPath.set(delta.fieldPath, delta); + } + + // 2. Profile-level overrides — diff only the paths each profile actually + // defines, so unspecified-in-profile fields don't register as deltas. + const profiles = cameraConfig?.profiles ?? {}; + for (const profileName of Object.keys(profiles)) { + const profileSection = ( + profiles[profileName] as Record | undefined + )?.[sectionPath]; + if (profileSection === undefined) continue; + const normalizedProfile = normalizeConfigValue( + profileSection as JsonValue, + ); + for (const path of collectDefinedLeafPaths(normalizedProfile)) { + if (deltasByPath.has(path)) continue; + if (isCrossCameraIgnoredPath(path)) continue; + if (!isPathAllowed(path, compareFields)) continue; + const g = get(globalValue, path); + const p = get(normalizedProfile, path); + if (!isEqual(g, p)) { + deltasByPath.set(path, { + fieldPath: path, + globalValue: g, + cameraValue: p, + profileName, + }); + } + } + } + + if (deltasByPath.size > 0) { + entries.push({ + camera: cameraName, + fieldDeltas: Array.from(deltasByPath.values()), + }); + } + } + + return entries; + }, [config, sectionPath]); +} diff --git a/web/src/views/settings/SingleSectionPage.tsx b/web/src/views/settings/SingleSectionPage.tsx index c054ba7f18..1ae684fbf8 100644 --- a/web/src/views/settings/SingleSectionPage.tsx +++ b/web/src/views/settings/SingleSectionPage.tsx @@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from "react"; 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 type { PolygonType } from "@/types/canvas"; import { Badge } from "@/components/ui/badge"; import { @@ -167,6 +168,9 @@ export function SingleSectionPage({ {/* Desktop: badge inline next to title */}
+ {level === "global" && showOverrideIndicator && ( + + )} {level === "camera" && showOverrideIndicator && sectionStatus.isOverridden && ( @@ -224,6 +228,9 @@ export function SingleSectionPage({
{/* Mobile: badge below title/description */}
+ {level === "global" && showOverrideIndicator && ( + + )} {level === "camera" && showOverrideIndicator && sectionStatus.isOverridden && (