import get from "lodash/get";
import cloneDeep from "lodash/cloneDeep";
import unset from "lodash/unset";
import isEqual from "lodash/isEqual";
import mergeWith from "lodash/mergeWith";
import set from "lodash/set";
import { isJsonObject } from "@/lib/utils";
import { REDACTED_CREDENTIAL_SENTINEL } from "@/lib/const";
import { applySchemaDefaults } from "@/lib/config-schema";
import { applyConfiguredToggles } from "@/utils/runtimeOverrides";
import { normalizeConfigValue } from "@/hooks/use-config-override";
import {
modifySchemaForSection,
getEffectiveDefaultsForSection,
sanitizeOverridesForSection,
} from "@/components/config-form/sections/section-special-cases";
import type { RJSFSchema } from "@rjsf/utils";
import type { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import type {
ConfigSectionData,
HiddenFieldContext,
JsonObject,
JsonValue,
} from "@/types/configForm";
import type { SectionConfig } from "../components/config-form/sections/BaseSection";
import { sectionConfigs } from "../components/config-form/sectionConfigs";
* Recursively strip any key whose value is the redaction sentinel from a
* config_data payload. Use just before sending to /config/set so untouched
* credential placeholder fields don't clobber the saved YAML value. Mutates
* and returns the input.
*/
export function stripRedactedCredentials<T>(value: T): T {
if (Array.isArray(value)) {
for (const item of value) {
stripRedactedCredentials(item);
}
return value;
}
if (value && typeof value === "object") {
const obj = value as Record<string, unknown>;
for (const key of Object.keys(obj)) {
const v = obj[key];
if (v === REDACTED_CREDENTIAL_SENTINEL) {
delete obj[key];
} else if (v && typeof v === "object") {
stripRedactedCredentials(v);
}
}
}
return value;
}
export const cameraUpdateTopicMap: Record<string, string> = {
detect: "detect",
record: "record",
snapshots: "snapshots",
motion: "motion",
objects: "objects",
review: "review",
audio: "audio",
notifications: "notifications",
live: "live",
timestamp_style: "timestamp_style",
audio_transcription: "audio_transcription",
birdseye: "birdseye",
face_recognition: "face_recognition",
ffmpeg: "ffmpeg",
lpr: "lpr",
semantic_search: "semantic_search",
mqtt: "mqtt",
onvif: "onvif",
ui: "ui",
zones: "zones",
};
export const globalCameraDefaultSections = new Set([
"detect",
"objects",
"motion",
"record",
"snapshots",
"review",
"audio",
"notifications",
"ffmpeg",
]);
* Get the saved-config value for a camera section, which is what the settings
* form edits.
*
* Two things move the top-level (effective) value away from yaml. A profile
* merges its overrides into it, and the API then populates `base_config` with
* the originals. Runtime toggles from the live view, MQTT, or Home Assistant
* change it in place, and `applyConfiguredToggles` puts those fields back.
*/
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 applyConfiguredToggles(
cam,
sectionPath,
base !== undefined ? base : get(cam, sectionPath),
);
}
const replaceArraysCustomizer = (objValue: unknown, srcValue: unknown) => {
if (Array.isArray(objValue) || Array.isArray(srcValue)) {
return srcValue !== undefined ? srcValue : objValue;
}
return undefined;
};
export function mergeProfileOverrides<T extends object>(
baseValue: T,
profileOverrides: object,
): T {
return mergeWith(
cloneDeep(baseValue),
cloneDeep(profileOverrides),
replaceArraysCustomizer,
) as T;
}
export const PROFILE_ELIGIBLE_SECTIONS = new Set([
"audio",
"birdseye",
"detect",
"face_recognition",
"lpr",
"motion",
"notifications",
"objects",
"record",
"review",
"snapshots",
]);
* Parse a section path that may encode a profile reference.
*
* Examples:
* "detect" → { isProfile: false, actualSection: "detect" }
* "profiles.armed.detect" → { isProfile: true, profileName: "armed", actualSection: "detect" }
*/
export function parseProfileFromSectionPath(sectionPath: string): {
isProfile: boolean;
profileName?: string;
actualSection: string;
} {
const match = sectionPath.match(/^profiles\.([^.]+)\.(.+)$/);
if (match) {
return { isProfile: true, profileName: match[1], actualSection: match[2] };
}
return { isProfile: false, actualSection: sectionPath };
}
export function buildOverrides(
current: unknown,
base: unknown,
defaults: unknown,
): unknown | undefined {
if (current === null || current === undefined || current === "") {
return undefined;
}
if (Array.isArray(current)) {
if (
current.length === 0 &&
(base === undefined || base === null) &&
(defaults === undefined || defaults === null)
) {
return undefined;
}
if (
(base === undefined &&
defaults !== undefined &&
isEqual(current, defaults)) ||
isEqual(current, base)
) {
return undefined;
}
if (Array.isArray(base) && base.length === current.length) {
const baseItems = base;
const defaultItems = Array.isArray(defaults) ? defaults : undefined;
const unchanged = current.every(
(item, index) =>
buildOverrides(item, baseItems[index], defaultItems?.[index]) ===
undefined,
);
if (unchanged) {
return undefined;
}
}
return current;
}
if (isJsonObject(current)) {
const currentObj = current;
const baseObj = isJsonObject(base) ? base : undefined;
const defaultsObj = isJsonObject(defaults) ? defaults : undefined;
const result: JsonObject = {};
for (const [key, value] of Object.entries(currentObj)) {
if (
(value === undefined || value === null) &&
baseObj &&
baseObj[key] !== undefined &&
baseObj[key] !== null
) {
result[key] = "";
continue;
}
const overrideValue = buildOverrides(
value,
baseObj ? baseObj[key] : undefined,
defaultsObj ? defaultsObj[key] : undefined,
);
if (overrideValue !== undefined) {
result[key] = overrideValue as JsonValue;
}
}
if (baseObj) {
for (const [key, baseValue] of Object.entries(baseObj)) {
if (Object.prototype.hasOwnProperty.call(currentObj, key)) {
continue;
}
if (baseValue === undefined) {
continue;
}
result[key] = "";
}
}
return Object.keys(result).length > 0 ? result : undefined;
}
if (
base === undefined &&
defaults !== undefined &&
isEqual(current, defaults)
) {
return undefined;
}
if (isEqual(current, base)) {
return undefined;
}
return current;
}
export function flattenOverrides(
value: JsonValue | undefined,
path: string[] = [],
): Array<{ path: string; value: JsonValue }> {
if (value === undefined) return [];
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return [{ path: path.join("."), value }];
}
const entries = Object.entries(value);
if (entries.length === 0) {
return [{ path: path.join("."), value: {} }];
}
return entries.flatMap(([key, entryValue]) =>
flattenOverrides(entryValue, [...path, key]),
);
}
export function unsetWithWildcard(
obj: Record<string, unknown>,
path: string,
): void {
if (!path.includes("*")) {
unset(obj, path);
return;
}
const segments = path.split(".");
const starIndex = segments.indexOf("*");
const prefix = segments.slice(0, starIndex).join(".");
const suffix = segments.slice(starIndex + 1).join(".");
const parent = prefix ? get(obj, prefix) : obj;
if (parent && typeof parent === "object") {
for (const key of Object.keys(parent as Record<string, unknown>)) {
const fullPath = suffix ? `${key}.${suffix}` : key;
unsetWithWildcard(parent as Record<string, unknown>, fullPath);
}
}
}
export function sanitizeSectionData(
data: ConfigSectionData,
hiddenFields?: string[],
): ConfigSectionData {
const normalized = normalizeConfigValue(data) as ConfigSectionData;
if (!hiddenFields || hiddenFields.length === 0) {
return normalized;
}
const cleaned = cloneDeep(normalized) as ConfigSectionData;
hiddenFields.forEach((path) => {
if (!path) return;
unsetWithWildcard(cleaned as Record<string, unknown>, path);
});
return cleaned;
}
export function buildConfigDataForPath(
path: string,
value: unknown,
): Record<string, unknown> {
const configData: Record<string, unknown> = {};
set(configData, path, value);
return configData;
}
function hasMatchAtPath(value: unknown, pathSegments: string[]): boolean {
if (pathSegments.length === 0) {
return value !== undefined;
}
if (value === undefined || value === null) {
return false;
}
const [segment, ...rest] = pathSegments;
if (segment === "*") {
if (Array.isArray(value)) {
return value.some((item) => hasMatchAtPath(item, rest));
}
if (isJsonObject(value)) {
return Object.values(value).some((item) => hasMatchAtPath(item, rest));
}
return false;
}
if (Array.isArray(value)) {
const index = Number(segment);
if (!Number.isInteger(index)) {
return false;
}
return hasMatchAtPath(value[index], rest);
}
if (isJsonObject(value)) {
return hasMatchAtPath(value[segment], rest);
}
return false;
}
export function requiresRestartForOverrides(
overrides: unknown,
restartRequired: string[] | undefined,
defaultRequiresRestart: boolean = true,
): boolean {
if (restartRequired === undefined) {
return defaultRequiresRestart;
}
if (restartRequired.length === 0) {
return false;
}
if (!overrides || typeof overrides !== "object") {
return false;
}
return restartRequired.some((path) => {
if (!path) {
return false;
}
if (!path.includes("*")) {
return get(overrides as JsonObject, path) !== undefined;
}
return hasMatchAtPath(overrides, path.split("."));
});
}
export function requiresRestartForFieldPath(
fieldPath: Array<string | number>,
restartRequired: string[] | undefined,
defaultRequiresRestart: boolean = true,
): boolean {
if (restartRequired === undefined) {
return defaultRequiresRestart;
}
if (restartRequired.length === 0) {
return false;
}
if (fieldPath.length === 0) {
return false;
}
const probe: Record<string, unknown> = {};
set(
probe,
fieldPath.map((segment) => String(segment)),
true,
);
return requiresRestartForOverrides(
probe,
restartRequired,
defaultRequiresRestart,
);
}
export interface SectionSavePayload {
basePath: string;
sanitizedOverrides: JsonObject;
updateTopic: string | undefined;
needsRestart: boolean;
pendingDataKey: string;
}
import { resolveAndCleanSchema } from "@/lib/config-schema";
import { getAllAttributes } from "@/utils/modelUtil";
type SchemaWithDefinitions = RJSFSchema & {
$defs?: Record<string, RJSFSchema>;
definitions?: Record<string, RJSFSchema>;
properties?: Record<string, RJSFSchema>;
};
function getSchemaDefinitions(schema: RJSFSchema): Record<string, RJSFSchema> {
return (
(schema as SchemaWithDefinitions).$defs ||
(schema as SchemaWithDefinitions).definitions ||
{}
);
}
function extractSectionSchema(
schema: RJSFSchema,
sectionPath: string,
level: "global" | "camera",
): RJSFSchema | null {
const defs = getSchemaDefinitions(schema);
const schemaObj = schema as SchemaWithDefinitions;
let sectionDef: RJSFSchema | null = null;
if (level === "camera") {
const cameraConfigDef = defs.CameraConfig;
if (cameraConfigDef?.properties) {
const sectionProp = cameraConfigDef.properties[sectionPath];
if (sectionProp && typeof sectionProp === "object") {
if ("$ref" in sectionProp && typeof sectionProp.$ref === "string") {
const refPath = sectionProp.$ref
.replace(/^#\/\$defs\//, "")
.replace(/^#\/definitions\//, "");
sectionDef = defs[refPath] || null;
} else {
sectionDef = sectionProp;
}
}
}
} else {
if (schemaObj.properties) {
const sectionProp = schemaObj.properties[sectionPath];
if (sectionProp && typeof sectionProp === "object") {
if ("$ref" in sectionProp && typeof sectionProp.$ref === "string") {
const refPath = sectionProp.$ref
.replace(/^#\/\$defs\//, "")
.replace(/^#\/definitions\//, "");
sectionDef = defs[refPath] || null;
} else {
sectionDef = sectionProp;
}
}
}
}
if (!sectionDef) return null;
const schemaWithDefs: RJSFSchema = { ...sectionDef, $defs: defs };
return resolveAndCleanSchema(schemaWithDefs);
}
export function prepareSectionSavePayload(opts: {
pendingDataKey: string;
pendingData: unknown;
config: FrigateConfig;
fullSchema: RJSFSchema;
}): SectionSavePayload | null {
const { pendingDataKey, pendingData, config, fullSchema } = opts;
if (!pendingData) return null;
let sectionPath: string;
let level: "global" | "camera";
let cameraName: string | undefined;
if (pendingDataKey.includes("::")) {
const idx = pendingDataKey.indexOf("::");
cameraName = pendingDataKey.slice(0, idx);
sectionPath = pendingDataKey.slice(idx + 2);
level = "camera";
} else {
sectionPath = pendingDataKey;
level = "global";
}
const profileInfo = parseProfileFromSectionPath(sectionPath);
const schemaSection = profileInfo.actualSection;
const sectionConfig = getSectionConfig(schemaSection, level);
const sectionSchema = extractSectionSchema(fullSchema, schemaSection, level);
if (!sectionSchema) return null;
const modifiedSchema = modifySchemaForSection(
schemaSection,
level,
sectionSchema,
config
? {
fullConfig: config,
fullCameraConfig:
level === "camera" && cameraName
? config.cameras?.[cameraName]
: undefined,
level,
cameraName,
}
: undefined,
);
let rawSectionValue: unknown;
if (level === "camera" && cameraName) {
if (profileInfo.isProfile) {
const baseValue = getBaseCameraSectionValue(
config,
cameraName,
profileInfo.actualSection,
);
const profileOverrides = get(config.cameras?.[cameraName], sectionPath);
if (
profileOverrides &&
typeof profileOverrides === "object" &&
baseValue &&
typeof baseValue === "object"
) {
rawSectionValue = mergeProfileOverrides(
baseValue as object,
profileOverrides as object,
);
} else {
rawSectionValue = baseValue;
}
} else {
rawSectionValue = getBaseCameraSectionValue(
config,
cameraName,
sectionPath,
);
}
} else {
rawSectionValue = get(config, sectionPath);
}
const rawFormData =
rawSectionValue === undefined || rawSectionValue === null
? {}
: rawSectionValue;
const resolvedHidden = resolveHiddenFieldEntries(sectionConfig.hiddenFields, {
fullConfig: config,
fullCameraConfig:
level === "camera" && cameraName
? config.cameras?.[cameraName]
: undefined,
level,
cameraName,
formData: rawFormData as ConfigSectionData,
});
const hiddenFieldsForSanitize =
profileInfo.isProfile && sectionConfig.restartRequired?.length
? [...new Set([...resolvedHidden, ...sectionConfig.restartRequired])]
: resolvedHidden;
const rawData = sanitizeSectionData(
rawFormData as ConfigSectionData,
hiddenFieldsForSanitize,
);
const schemaDefaults = modifiedSchema
? applySchemaDefaults(modifiedSchema, {})
: {};
const effectiveDefaults = getEffectiveDefaultsForSection(
schemaSection,
level,
modifiedSchema ?? undefined,
schemaDefaults,
);
const overrides = buildOverrides(pendingData, rawData, effectiveDefaults);
const sanitizedOverrides = sanitizeOverridesForSection(
schemaSection,
level,
overrides,
);
if (
!sanitizedOverrides ||
typeof sanitizedOverrides !== "object" ||
Object.keys(sanitizedOverrides as JsonObject).length === 0
) {
return null;
}
const basePath =
level === "camera" && cameraName
? `cameras.${cameraName}.${sectionPath}`
: sectionPath;
let updateTopic: string | undefined;
if (profileInfo.isProfile) {
updateTopic = undefined;
} else if (level === "camera" && cameraName) {
const topic = cameraUpdateTopicMap[sectionPath];
updateTopic = topic ? `config/cameras/${cameraName}/${topic}` : undefined;
} else if (globalCameraDefaultSections.has(sectionPath)) {
const topic = cameraUpdateTopicMap[sectionPath];
updateTopic = topic ? `config/cameras/*/${topic}` : `config/${sectionPath}`;
} else {
updateTopic = `config/${sectionPath}`;
}
const needsRestart = profileInfo.isProfile
? false
: requiresRestartForOverrides(
sanitizedOverrides,
sectionConfig.restartRequired,
true,
);
return {
basePath,
sanitizedOverrides: sanitizedOverrides as JsonObject,
updateTopic,
needsRestart,
pendingDataKey,
};
}
const mergeSectionConfig = (
base: SectionConfig | undefined,
overrides: Partial<SectionConfig> | undefined,
): SectionConfig =>
mergeWith({}, base ?? {}, overrides ?? {}, (objValue, srcValue, key) => {
const arrayResult = replaceArraysCustomizer(objValue, srcValue);
if (arrayResult !== undefined) return arrayResult;
if (key === "uiSchema") {
if (objValue && srcValue) {
return mergeWith({}, objValue, srcValue, replaceArraysCustomizer);
}
return srcValue ?? objValue;
}
return undefined;
});
export function getSectionConfig(
sectionKey: string,
level: "global" | "camera" | "replay",
): SectionConfig {
const entry = sectionConfigs[sectionKey];
if (!entry) {
return {};
}
const overrides =
level === "global"
? entry.global
: level === "replay"
? entry.replay
: entry.camera;
return mergeSectionConfig(entry.base, overrides);
}
* Resolve the effective attribute label set at a given scope. At camera
* (and replay) scope on a dedicated LPR camera (`camera.type === "lpr"`),
* `license_plate` is treated as a regular tracked object — not an
* attribute — to match the backend's per-camera carve-out in
* `frigate/video/detect.py`. Returns the full attribute list at global
* scope and for non-LPR cameras.
*/
export function getEffectiveAttributeLabels(
fullConfig: FrigateConfig | undefined,
fullCameraConfig: CameraConfig | undefined,
level: "global" | "camera" | "replay" | undefined,
): string[] {
const all = getAllAttributes(fullConfig);
if (level !== "global" && fullCameraConfig?.type === "lpr") {
return all.filter((attr) => attr !== "license_plate");
}
return all;
}
* Build a `HiddenFieldContext` for the common case where a callsite has
* `config`, an optional `cameraName`, and a level, but no per-section
* saved form data to thread through. Resolvers that don't read `formData`
* (which is most of them) just fall through to `fullCameraConfig` /
* `fullConfig`.
*/
export function buildHiddenFieldContext(
config: FrigateConfig | undefined,
level: "global" | "camera" | "replay",
cameraName?: string,
): HiddenFieldContext | undefined {
if (!config) return undefined;
return {
fullConfig: config,
fullCameraConfig:
level !== "global" && cameraName
? config.cameras?.[cameraName]
: undefined,
level,
cameraName,
};
}
* Resolve the effective hidden-field patterns for a section. Each entry in
* `hiddenFields` is either a literal pattern or a function that produces
* patterns from the loaded config and scope (e.g. `filters.<attr>` for each
* `model.all_attributes` entry on the objects section, gated by the
* effective `objects.track` list at the current scope).
*/
export function getEffectiveHiddenFields(
sectionKey: string,
level: "global" | "camera" | "replay",
ctx: HiddenFieldContext | undefined,
): string[] {
return resolveHiddenFieldEntries(
getSectionConfig(sectionKey, level).hiddenFields,
ctx,
);
}
export function resolveHiddenFieldEntries(
entries: SectionConfig["hiddenFields"] | undefined,
ctx: HiddenFieldContext | undefined,
): string[] {
if (!entries || entries.length === 0) return [];
const result: string[] = [];
for (const entry of entries) {
if (typeof entry === "function") {
if (ctx) result.push(...entry(ctx));
} else {
result.push(entry);
}
}
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;
}