From 80239a8017192c7ead97bcd45de7f64ba483630d Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 16 Mar 2026 12:23:07 -0500 Subject: [PATCH] fix settings showing profile-merged values when editing base config When a profile is active, the in-memory config contains effective (profile-merged) values. The settings UI was displaying these merged values even when the "Base Config" view was selected. Backend: snapshot pre-profile base configs in ProfileManager and expose them via a `base_config` key in the /api/config camera response when a profile is active. The top-level sections continue to reflect the effective running config. Frontend: read from `base_config` when available in BaseSection, useConfigOverride, useAllCameraOverrides, and prepareSectionSavePayload. Include formData labels in Object/Audio switches widgets so that labels added only by a profile override remain visible when editing that profile. --- frigate/api/app.py | 15 ++++++ frigate/config/profile_manager.py | 20 +++++++- frigate/test/test_profiles.py | 28 +++++++++++ .../config-form/sections/BaseSection.tsx | 13 ++++- .../widgets/AudioLabelSwitchesWidget.tsx | 44 +++++++---------- .../widgets/ObjectLabelSwitchesWidget.tsx | 49 +++++++++---------- .../theme/widgets/SwitchesWidget.tsx | 1 + web/src/hooks/use-config-override.ts | 13 ++++- web/src/types/frigateConfig.ts | 2 + web/src/utils/configUtil.ts | 31 ++++++++++-- 10 files changed, 158 insertions(+), 58 deletions(-) diff --git a/frigate/api/app.py b/frigate/api/app.py index 383b76151..d7abb8e47 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -170,6 +170,21 @@ def config(request: Request): ) ) + # When a profile is active, the top-level camera sections contain + # profile-merged (effective) values. Include the original base + # configs so the frontend settings can display them separately. + if ( + config_obj.active_profile is not None + and request.app.profile_manager is not None + ): + base_sections = ( + request.app.profile_manager.get_base_configs_for_api( + camera_name + ) + ) + if base_sections: + camera_dict["base_config"] = base_sections + # remove go2rtc stream passwords go2rtc: dict[str, Any] = config_obj.go2rtc.model_dump( mode="json", warnings="none", exclude_none=True diff --git a/frigate/config/profile_manager.py b/frigate/config/profile_manager.py index d5cd6f921..36c299989 100644 --- a/frigate/config/profile_manager.py +++ b/frigate/config/profile_manager.py @@ -47,6 +47,7 @@ class ProfileManager: self.config: FrigateConfig = config self.config_updater = config_updater self._base_configs: dict[str, dict[str, dict]] = {} + self._base_api_configs: dict[str, dict[str, dict]] = {} self._base_enabled: dict[str, bool] = {} self._base_zones: dict[str, dict[str, ZoneConfig]] = {} self._snapshot_base_configs() @@ -55,12 +56,20 @@ class ProfileManager: """Snapshot each camera's current section configs, enabled, and zones.""" for cam_name, cam_config in self.config.cameras.items(): self._base_configs[cam_name] = {} + self._base_api_configs[cam_name] = {} self._base_enabled[cam_name] = cam_config.enabled self._base_zones[cam_name] = copy.deepcopy(cam_config.zones) for section in PROFILE_SECTION_UPDATES: section_config = getattr(cam_config, section, None) if section_config is not None: - self._base_configs[cam_name][section] = section_config.model_dump() + self._base_configs[cam_name][section] = ( + section_config.model_dump() + ) + self._base_api_configs[cam_name][section] = ( + section_config.model_dump( + mode="json", warnings="none", exclude_none=True + ) + ) def update_config(self, new_config) -> None: """Update config reference after config/set replaces the in-memory config. @@ -74,6 +83,7 @@ class ProfileManager: # Re-snapshot base configs from the new config (which has base values) self._base_configs.clear() + self._base_api_configs.clear() self._base_enabled.clear() self._base_zones.clear() self._snapshot_base_configs() @@ -260,6 +270,14 @@ class ProfileManager: logger.exception("Failed to load persisted profile") return None + def get_base_configs_for_api(self, camera_name: str) -> dict[str, dict]: + """Return base (pre-profile) section configs for a camera. + + These are JSON-serializable dicts suitable for direct inclusion in + the /api/config response, with None values already excluded. + """ + return self._base_api_configs.get(camera_name, {}) + def get_available_profiles(self) -> list[dict[str, str]]: """Get list of all profile definitions from the top-level config.""" return [ diff --git a/frigate/test/test_profiles.py b/frigate/test/test_profiles.py index 430aca97b..b77d3ebb6 100644 --- a/frigate/test/test_profiles.py +++ b/frigate/test/test_profiles.py @@ -557,6 +557,34 @@ class TestProfileManager(unittest.TestCase): assert "armed" in names assert "disarmed" in names + @patch.object(ProfileManager, "_persist_active_profile") + def test_base_configs_for_api_unchanged_after_activation(self, mock_persist): + """API base configs reflect pre-profile values after activation.""" + base_track = self.config.cameras["front"].objects.track[:] + assert base_track == ["person"] + + self.manager.activate_profile("armed") + + # In-memory config has the profile-merged values + assert self.config.cameras["front"].objects.track == [ + "person", + "car", + "package", + ] + + # But the API base configs still return the original base values + api_base = self.manager.get_base_configs_for_api("front") + assert "objects" in api_base + assert api_base["objects"]["track"] == ["person"] + + def test_base_configs_for_api_are_json_serializable(self): + """API base configs are JSON-serializable (mode='json').""" + import json + + api_base = self.manager.get_base_configs_for_api("front") + # Should not raise + json.dumps(api_base) + class TestProfilePersistence(unittest.TestCase): """Test profile persistence to disk.""" diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index dd88f9d19..d2be6ded4 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -65,6 +65,7 @@ import { globalCameraDefaultSections, buildOverrides, buildConfigDataForPath, + getBaseCameraSectionValue, sanitizeSectionData as sharedSanitizeSectionData, requiresRestartForOverrides as sharedRequiresRestartForOverrides, } from "@/utils/configUtil"; @@ -303,12 +304,20 @@ export function ConfigSection({ profileOverridesSection ? "profile" : isOverridden ? "global" : undefined; // Get current form data - // When editing a profile, show base camera config deep-merged with profile overrides + // When a profile is active the top-level camera sections contain the + // effective (profile-merged) values. For the base-config view we read + // from `base_config` (original values before the profile was applied). + // When editing a profile, we merge the base value with profile overrides. const rawSectionValue = useMemo(() => { if (!config) return undefined; if (effectiveLevel === "camera" && cameraName) { - const baseValue = get(config.cameras?.[cameraName], sectionPath); + // Base value: prefer base_config (pre-profile) over effective value + const baseValue = getBaseCameraSectionValue( + config, + cameraName, + sectionPath, + ); if (profileName) { const profileOverrides = get( config.cameras?.[cameraName], diff --git a/web/src/components/config-form/theme/widgets/AudioLabelSwitchesWidget.tsx b/web/src/components/config-form/theme/widgets/AudioLabelSwitchesWidget.tsx index 7b82a3836..9a79ebce1 100644 --- a/web/src/components/config-form/theme/widgets/AudioLabelSwitchesWidget.tsx +++ b/web/src/components/config-form/theme/widgets/AudioLabelSwitchesWidget.tsx @@ -7,41 +7,35 @@ import type { FormContext } from "./SwitchesWidget"; import { getTranslatedLabel } from "@/utils/i18n"; import { JsonObject } from "@/types/configForm"; +function extractListenLabels(value: unknown): string[] { + if (value && typeof value === "object" && !Array.isArray(value)) { + const listenValue = (value as JsonObject).listen; + if (Array.isArray(listenValue)) { + return listenValue.filter( + (item): item is string => typeof item === "string", + ); + } + } + return []; +} + function getEnabledAudioLabels(context: FormContext): string[] { let cameraLabels: string[] = []; let globalLabels: string[] = []; + let formDataLabels: string[] = []; if (context) { // context.cameraValue and context.globalValue should be the entire audio section - if ( - context.cameraValue && - typeof context.cameraValue === "object" && - !Array.isArray(context.cameraValue) - ) { - const listenValue = (context.cameraValue as JsonObject).listen; - if (Array.isArray(listenValue)) { - cameraLabels = listenValue.filter( - (item): item is string => typeof item === "string", - ); - } - } + cameraLabels = extractListenLabels(context.cameraValue); + globalLabels = extractListenLabels(context.globalValue); - if ( - context.globalValue && - typeof context.globalValue === "object" && - !Array.isArray(context.globalValue) - ) { - const globalListenValue = (context.globalValue as JsonObject).listen; - if (Array.isArray(globalListenValue)) { - globalLabels = globalListenValue.filter( - (item): item is string => typeof item === "string", - ); - } - } + // Include labels from the current form data so that labels added via + // profile overrides (or user edits) are always visible as switches. + formDataLabels = extractListenLabels(context.formData); } const sourceLabels = cameraLabels.length > 0 ? cameraLabels : globalLabels; - return [...sourceLabels].sort(); + return [...new Set([...sourceLabels, ...formDataLabels])].sort(); } function getAudioLabelDisplayName(label: string): string { diff --git a/web/src/components/config-form/theme/widgets/ObjectLabelSwitchesWidget.tsx b/web/src/components/config-form/theme/widgets/ObjectLabelSwitchesWidget.tsx index bb54c9529..d3d6bdbe3 100644 --- a/web/src/components/config-form/theme/widgets/ObjectLabelSwitchesWidget.tsx +++ b/web/src/components/config-form/theme/widgets/ObjectLabelSwitchesWidget.tsx @@ -40,43 +40,42 @@ function getLabelmapLabels(context: FormContext): string[] { return [...labels]; } +// Extract track labels from an objects section value. +function extractTrackLabels(value: unknown): string[] { + if (value && typeof value === "object" && !Array.isArray(value)) { + const trackValue = (value as JsonObject).track; + if (Array.isArray(trackValue)) { + return trackValue.filter( + (item): item is string => typeof item === "string", + ); + } + } + return []; +} + // Build the list of labels for switches (labelmap + configured track list). function getObjectLabels(context: FormContext): string[] { const labelmapLabels = getLabelmapLabels(context); let cameraLabels: string[] = []; let globalLabels: string[] = []; + let formDataLabels: string[] = []; if (context) { // context.cameraValue and context.globalValue should be the entire objects section - if ( - context.cameraValue && - typeof context.cameraValue === "object" && - !Array.isArray(context.cameraValue) - ) { - const trackValue = (context.cameraValue as JsonObject).track; - if (Array.isArray(trackValue)) { - cameraLabels = trackValue.filter( - (item): item is string => typeof item === "string", - ); - } - } + cameraLabels = extractTrackLabels(context.cameraValue); + globalLabels = extractTrackLabels(context.globalValue); - if ( - context.globalValue && - typeof context.globalValue === "object" && - !Array.isArray(context.globalValue) - ) { - const globalTrackValue = (context.globalValue as JsonObject).track; - if (Array.isArray(globalTrackValue)) { - globalLabels = globalTrackValue.filter( - (item): item is string => typeof item === "string", - ); - } - } + // Include labels from the current form data so that labels added via + // profile overrides (or user edits) are always visible as switches. + formDataLabels = extractTrackLabels(context.formData); } const sourceLabels = cameraLabels.length > 0 ? cameraLabels : globalLabels; - const combinedLabels = new Set([...labelmapLabels, ...sourceLabels]); + const combinedLabels = new Set([ + ...labelmapLabels, + ...sourceLabels, + ...formDataLabels, + ]); return [...combinedLabels].sort(); } diff --git a/web/src/components/config-form/theme/widgets/SwitchesWidget.tsx b/web/src/components/config-form/theme/widgets/SwitchesWidget.tsx index 0a56c47b8..272629a1a 100644 --- a/web/src/components/config-form/theme/widgets/SwitchesWidget.tsx +++ b/web/src/components/config-form/theme/widgets/SwitchesWidget.tsx @@ -20,6 +20,7 @@ type FormContext = Pick< | "globalValue" | "fullCameraConfig" | "fullConfig" + | "formData" | "t" | "level" > & { diff --git a/web/src/hooks/use-config-override.ts b/web/src/hooks/use-config-override.ts index d0577a6c6..cd878e08f 100644 --- a/web/src/hooks/use-config-override.ts +++ b/web/src/hooks/use-config-override.ts @@ -6,6 +6,7 @@ import set from "lodash/set"; import { FrigateConfig } from "@/types/frigateConfig"; import { JsonObject, JsonValue } from "@/types/configForm"; import { isJsonObject } from "@/lib/utils"; +import { getBaseCameraSectionValue } from "@/utils/configUtil"; const INTERNAL_FIELD_SUFFIXES = ["enabled_in_config", "raw_mask"]; @@ -144,7 +145,13 @@ export function useConfigOverride({ }; } - const cameraValue = get(cameraConfig, sectionPath); + // Prefer the base (pre-profile) value so that override detection and + // widget context reflect the camera's own config, not profile effects. + const cameraValue = getBaseCameraSectionValue( + config, + cameraName, + sectionPath, + ); const normalizedGlobalValue = normalizeConfigValue(globalValue); const normalizedCameraValue = normalizeConfigValue(cameraValue); @@ -256,7 +263,9 @@ export function useAllCameraOverrides( for (const { key, compareFields } of sectionsToCheck) { const globalValue = normalizeConfigValue(get(config, key)); - const cameraValue = normalizeConfigValue(get(cameraConfig, key)); + const cameraValue = normalizeConfigValue( + getBaseCameraSectionValue(config, cameraName, key), + ); const comparisonGlobal = compareFields ? pickFields(globalValue, compareFields) diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index 13a7acfe7..1efa47fbc 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -316,6 +316,8 @@ export interface CameraConfig { }; }; profiles?: Record; + /** Pre-profile base section configs, present only when a profile is active */ + base_config?: Record>; } export type CameraProfileConfig = { diff --git a/web/src/utils/configUtil.ts b/web/src/utils/configUtil.ts index 1707bc720..bbd73fdab 100644 --- a/web/src/utils/configUtil.ts +++ b/web/src/utils/configUtil.ts @@ -73,6 +73,25 @@ export const globalCameraDefaultSections = new Set([ // Profile helpers // --------------------------------------------------------------------------- +/** + * Get the base (pre-profile) value for a camera section. + * + * When a profile is active the API populates `base_config` with original + * section values. This helper returns that value when available, falling + * back to the top-level (effective) value otherwise. + */ +export function getBaseCameraSectionValue( + config: FrigateConfig | undefined, + cameraName: string | undefined, + sectionPath: string, +): unknown { + if (!config || !cameraName) return undefined; + const cam = config.cameras?.[cameraName]; + if (!cam) return undefined; + const base = cam.base_config?.[sectionPath]; + return base !== undefined ? base : get(cam, sectionPath); +} + /** Sections that can appear inside a camera profile definition. */ export const PROFILE_ELIGIBLE_SECTIONS = new Set([ "audio", @@ -504,8 +523,9 @@ export function prepareSectionSavePayload(opts: { let rawSectionValue: unknown; if (level === "camera" && cameraName) { if (profileInfo.isProfile) { - const baseValue = get( - config.cameras?.[cameraName], + const baseValue = getBaseCameraSectionValue( + config, + cameraName, profileInfo.actualSection, ); const profileOverrides = get(config.cameras?.[cameraName], sectionPath); @@ -523,7 +543,12 @@ export function prepareSectionSavePayload(opts: { rawSectionValue = baseValue; } } else { - rawSectionValue = get(config.cameras?.[cameraName], sectionPath); + // Use base (pre-profile) value so the diff matches what the form shows + rawSectionValue = getBaseCameraSectionValue( + config, + cameraName, + sectionPath, + ); } } else { rawSectionValue = get(config, sectionPath);