fix override detection for sections unset in the global config

Override badges and the blue dot now compare against schema defaults for sections like motion that the API serializes as null when omitted from the global YAML, instead of treating any populated camera config as an override
This commit is contained in:
Josh Hawkins 2026-05-06 21:28:50 -05:00
parent e7a5f76f26
commit 88755513c8
2 changed files with 113 additions and 28 deletions

View File

@ -1,12 +1,16 @@
// Hook to detect when camera config overrides global defaults // Hook to detect when camera config overrides global defaults
import { useMemo } from "react"; import { useMemo } from "react";
import useSWR from "swr";
import isEqual from "lodash/isEqual"; import isEqual from "lodash/isEqual";
import get from "lodash/get"; import get from "lodash/get";
import set from "lodash/set"; import set from "lodash/set";
import type { RJSFSchema } from "@rjsf/utils";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { JsonObject, JsonValue } from "@/types/configForm"; import { JsonObject, JsonValue } from "@/types/configForm";
import { isJsonObject } from "@/lib/utils"; import { isJsonObject } from "@/lib/utils";
import { getBaseCameraSectionValue } from "@/utils/configUtil"; import { getBaseCameraSectionValue } from "@/utils/configUtil";
import { extractSectionSchema } from "@/hooks/use-config-schema";
import { applySchemaDefaults } from "@/lib/config-schema";
const INTERNAL_FIELD_SUFFIXES = ["enabled_in_config", "raw_mask"]; const INTERNAL_FIELD_SUFFIXES = ["enabled_in_config", "raw_mask"];
@ -34,6 +38,36 @@ export function normalizeConfigValue(value: unknown): JsonValue {
return stripInternalFields(value as JsonValue); return stripInternalFields(value as JsonValue);
} }
/**
* Collapse null and empty-object values for override comparisons so
* semantically equivalent shapes match. The schema may default `mask: None`
* while the runtime camera config carries `mask: {}` both mean "no
* masks", so collapsing them here keeps the equality check honest. We
* keep this off the public `normalizeConfigValue` so save-flow code paths
* (which serialize form data) aren't affected.
*/
function collapseEmpty(value: JsonValue): JsonValue {
if (Array.isArray(value)) {
return value.map(collapseEmpty);
}
if (isJsonObject(value)) {
const cleaned: JsonObject = {};
for (const [key, val] of Object.entries(value as JsonObject)) {
if (val === null || val === undefined) continue;
const collapsed = collapseEmpty(val as JsonValue);
if (
isJsonObject(collapsed) &&
Object.keys(collapsed as JsonObject).length === 0
) {
continue;
}
cleaned[key] = collapsed;
}
return cleaned;
}
return value;
}
export interface OverrideStatus { export interface OverrideStatus {
/** Whether the field is overridden from global */ /** Whether the field is overridden from global */
isOverridden: boolean; isOverridden: boolean;
@ -96,6 +130,7 @@ export function useConfigOverride({
sectionPath, sectionPath,
compareFields, compareFields,
}: UseConfigOverrideOptions) { }: UseConfigOverrideOptions) {
const { data: schema } = useSWR<RJSFSchema>("config/schema.json");
return useMemo(() => { return useMemo(() => {
if (!config) { if (!config) {
return { return {
@ -153,15 +188,29 @@ export function useConfigOverride({
sectionPath, sectionPath,
); );
const normalizedGlobalValue = normalizeConfigValue(globalValue); // Use the effective baseline (schema defaults when the global section
// is unset, e.g. motion). Without this, sections omitted from the global
// YAML would always read as "overridden" because the raw global value is
// null while every camera has populated defaults.
const normalizedGlobalValue = getEffectiveGlobalBaseline(
config,
sectionPath,
compareFields,
schema,
);
const normalizedCameraValue = normalizeConfigValue(cameraValue); const normalizedCameraValue = normalizeConfigValue(cameraValue);
// Collapse empty/null values for comparison so semantically equivalent
// shapes (e.g. schema default `mask: null` vs runtime `mask: {}`) match.
const collapsedGlobal = collapseEmpty(normalizedGlobalValue);
const collapsedCamera = collapseEmpty(normalizedCameraValue);
const comparisonGlobal = compareFields const comparisonGlobal = compareFields
? pickFields(normalizedGlobalValue, compareFields) ? pickFields(collapsedGlobal, compareFields)
: normalizedGlobalValue; : collapsedGlobal;
const comparisonCamera = compareFields const comparisonCamera = compareFields
? pickFields(normalizedCameraValue, compareFields) ? pickFields(collapsedCamera, compareFields)
: normalizedCameraValue; : collapsedCamera;
// Check if the entire section is overridden // Check if the entire section is overridden
const isOverridden = compareFields const isOverridden = compareFields
@ -176,7 +225,10 @@ export function useConfigOverride({
const cameraFieldValue = get(normalizedCameraValue, fieldPath); const cameraFieldValue = get(normalizedCameraValue, fieldPath);
return { return {
isOverridden: !isEqual(globalFieldValue, cameraFieldValue), isOverridden: !isEqual(
collapseEmpty(globalFieldValue as JsonValue),
collapseEmpty(cameraFieldValue as JsonValue),
),
globalValue: globalFieldValue, globalValue: globalFieldValue,
cameraValue: cameraFieldValue, cameraValue: cameraFieldValue,
}; };
@ -199,7 +251,7 @@ export function useConfigOverride({
getFieldOverride, getFieldOverride,
resetToGlobal, resetToGlobal,
}; };
}, [config, cameraName, sectionPath, compareFields]); }, [config, cameraName, sectionPath, compareFields, schema]);
} }
/** /**
@ -252,6 +304,7 @@ export function useAllCameraOverrides(
config: FrigateConfig | undefined, config: FrigateConfig | undefined,
cameraName: string | undefined, cameraName: string | undefined,
) { ) {
const { data: schema } = useSWR<RJSFSchema>("config/schema.json");
return useMemo(() => { return useMemo(() => {
if (!config || !cameraName) { if (!config || !cameraName) {
return []; return [];
@ -265,17 +318,24 @@ export function useAllCameraOverrides(
const overriddenSections: string[] = []; const overriddenSections: string[] = [];
for (const { key, compareFields } of OVERRIDABLE_SECTIONS) { for (const { key, compareFields } of OVERRIDABLE_SECTIONS) {
const globalValue = normalizeConfigValue(get(config, key)); const globalValue = getEffectiveGlobalBaseline(
config,
key,
compareFields,
schema,
);
const cameraValue = normalizeConfigValue( const cameraValue = normalizeConfigValue(
getBaseCameraSectionValue(config, cameraName, key), getBaseCameraSectionValue(config, cameraName, key),
); );
const collapsedGlobal = collapseEmpty(globalValue);
const collapsedCamera = collapseEmpty(cameraValue);
const comparisonGlobal = compareFields const comparisonGlobal = compareFields
? pickFields(globalValue, compareFields) ? pickFields(collapsedGlobal, compareFields)
: globalValue; : collapsedGlobal;
const comparisonCamera = compareFields const comparisonCamera = compareFields
? pickFields(cameraValue, compareFields) ? pickFields(collapsedCamera, compareFields)
: cameraValue; : collapsedCamera;
if ( if (
compareFields && compareFields.length === 0 compareFields && compareFields.length === 0
@ -287,7 +347,7 @@ export function useAllCameraOverrides(
} }
return overriddenSections; return overriddenSections;
}, [config, cameraName]); }, [config, cameraName, schema]);
} }
export interface FieldDelta { export interface FieldDelta {
@ -386,14 +446,40 @@ function isPathAllowed(path: string, compareFields?: string[]): boolean {
} }
/** /**
* Some Frigate sections (notably `motion`) are dumped by the backend with * Resolve the effective global baseline used for override comparisons.
* `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 * - When the global section is explicitly set, return it (normalized).
* gets defaults applied at runtime. To still detect cross-camera differences * - Otherwise prefer the camera-level schema defaults so a camera that
* in those sections we synthesize a baseline by taking the modal (most common) * diverges from the implicit Pydantic default registers as overriding
* value at each leaf path across cameras cameras whose value diverges from * even with a single camera in the deployment. (Sections like `motion`
* the modal are treated as overriding. * are dumped with `exclude_unset=True`, so the API returns null whenever
* the user hasn't written the section globally.)
* - Fall back to a modal-across-cameras synthetic baseline when the schema
* hasn't loaded yet or the section isn't in it.
*/ */
function getEffectiveGlobalBaseline(
config: FrigateConfig,
sectionPath: string,
compareFields?: string[],
schema?: RJSFSchema,
): JsonValue {
const rawGlobalValue = get(config, sectionPath);
if (rawGlobalValue != null) {
return normalizeConfigValue(rawGlobalValue);
}
if (schema) {
const sectionSchema = extractSectionSchema(schema, sectionPath, "camera");
if (sectionSchema) {
const defaults = applySchemaDefaults(sectionSchema, {});
return normalizeConfigValue(defaults as JsonValue);
}
}
const cameraSectionValues = Object.keys(config.cameras ?? {}).map((name) =>
normalizeConfigValue(getBaseCameraSectionValue(config, name, sectionPath)),
);
return deriveSyntheticGlobalValue(cameraSectionValues, compareFields);
}
function deriveSyntheticGlobalValue( function deriveSyntheticGlobalValue(
cameraSectionValues: JsonValue[], cameraSectionValues: JsonValue[],
compareFields?: string[], compareFields?: string[],
@ -461,6 +547,7 @@ export function useCamerasOverridingSection(
config: FrigateConfig | undefined, config: FrigateConfig | undefined,
sectionPath: string, sectionPath: string,
): CameraOverrideEntry[] { ): CameraOverrideEntry[] {
const { data: schema } = useSWR<RJSFSchema>("config/schema.json");
return useMemo(() => { return useMemo(() => {
if (!config?.cameras || !sectionPath) { if (!config?.cameras || !sectionPath) {
return []; return [];
@ -476,11 +563,9 @@ export function useCamerasOverridingSection(
), ),
); );
const rawGlobalValue = get(config, sectionPath); const globalValue = collapseEmpty(
const globalValue: JsonValue = getEffectiveGlobalBaseline(config, sectionPath, compareFields, schema),
rawGlobalValue == null );
? deriveSyntheticGlobalValue(cameraSectionValues, compareFields)
: normalizeConfigValue(rawGlobalValue);
const entries: CameraOverrideEntry[] = []; const entries: CameraOverrideEntry[] = [];
for (let idx = 0; idx < cameraNames.length; idx += 1) { for (let idx = 0; idx < cameraNames.length; idx += 1) {
@ -489,7 +574,7 @@ export function useCamerasOverridingSection(
const deltasByPath = new Map<string, FieldDelta>(); const deltasByPath = new Map<string, FieldDelta>();
// 1. Camera-level overrides (uses base_config when a profile is active) // 1. Camera-level overrides (uses base_config when a profile is active)
const cameraValue = cameraSectionValues[idx]; const cameraValue = collapseEmpty(cameraSectionValues[idx]);
for (const delta of collectFieldDeltas( for (const delta of collectFieldDeltas(
globalValue, globalValue,
cameraValue, cameraValue,
@ -536,5 +621,5 @@ export function useCamerasOverridingSection(
} }
return entries; return entries;
}, [config, sectionPath]); }, [config, sectionPath, schema]);
} }

View File

@ -24,7 +24,7 @@ const getSchemaDefinitions = (schema: RJSFSchema): Record<string, RJSFSchema> =>
* Extracts and resolves a section schema from the full config schema * Extracts and resolves a section schema from the full config schema
* Uses caching to avoid repeated expensive resolution * Uses caching to avoid repeated expensive resolution
*/ */
function extractSectionSchema( export function extractSectionSchema(
schema: RJSFSchema, schema: RJSFSchema,
sectionPath: string, sectionPath: string,
level: "global" | "camera", level: "global" | "camera",