mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-17 13:48:21 +03:00
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.
This commit is contained in:
parent
f7271e0a5b
commit
80239a8017
@ -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
|
||||
|
||||
@ -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 [
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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<string>([...labelmapLabels, ...sourceLabels]);
|
||||
const combinedLabels = new Set<string>([
|
||||
...labelmapLabels,
|
||||
...sourceLabels,
|
||||
...formDataLabels,
|
||||
]);
|
||||
return [...combinedLabels].sort();
|
||||
}
|
||||
|
||||
|
||||
@ -20,6 +20,7 @@ type FormContext = Pick<
|
||||
| "globalValue"
|
||||
| "fullCameraConfig"
|
||||
| "fullConfig"
|
||||
| "formData"
|
||||
| "t"
|
||||
| "level"
|
||||
> & {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -316,6 +316,8 @@ export interface CameraConfig {
|
||||
};
|
||||
};
|
||||
profiles?: Record<string, CameraProfileConfig>;
|
||||
/** Pre-profile base section configs, present only when a profile is active */
|
||||
base_config?: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export type CameraProfileConfig = {
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user