Files
frigate/web/src/utils/configUtil.ts
T
Josh HawkinsandGitHub 3d08bbe520
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
Miscellaneous fixes (#24402)
* check for a valid frame before using its shape

With the camera offline, no preview frame, and `camera-error.jpg` missing, `latest_frame` read `frame.shape` before its `frame is None` check, so it raised `AttributeError` and answered 500 with a traceback instead of the intended "Unable to get valid frame". The check now runs first.

* fix the has_clip self-heal for events with no recordings

`vod_event` looked for a `(body, 404)` tuple, but `vod_ts` returns a `JSONResponse`, so the check never matched and an old event whose recordings are gone kept offering a clip that can't play. It now checks the response status code.

* return 403 for a snapshot or thumbnail on another camera

The broad `except Exception` handlers in `event_snapshot` and `event_thumbnail` caught the `HTTPException` from `require_camera_access`, so a restricted user asking for another camera's snapshot got a 404 instead of a 403, and for an object still being tracked the snapshot was rendered before the check ran. Both endpoints now look up the event and check access in their own block, the way the other endpoints do, so a denial propagates.

* find DST transitions to the second

`get_dst_transitions` probed the offset once every 24 hours from the start time and reported a change at the first probe after it, up to a day late, so events, review items and recordings near a transition were grouped into days with the old offset. A transition after the last daily probe wasn't found at all. The end of the range is probed too now, and a probe that sees the offset change bisects the interval to the second of the transition.

* don't run page shortcuts for keys a dialog already handled

Radix dismisses a dialog on Escape from a capture-phase keydown listener and calls `preventDefault()` without stopping propagation, so `useKeyboardListener` still ran the page's Escape shortcut: cancelling the delete dialog in the face library or a classification model also cleared the whole selection. Keys another shortcut hook handled still get through, since their listener order changes with every render.

* fix train image filtering for a class with a dash

The backend writes a class with a `-` as `_` in train file names, since it splits those names on `-`, while a dataset folder keeps the dash. Filtering the Train grid by `half-open` compared it with `half_open` and hid every attempt. Both sides are normalized the same way now.

* don't edit a chat message while a reply streams

The edit button stayed active while a reply streamed. `submitConversation` returns early while loading, but the message bubble still closed its editor, so the edit was silently lost. The edit button is hidden while a reply streams, and an editor that's already open keeps its draft with send disabled until the reply ends.

* fix restart failing under non-root

restart_frigate() called psutil.Process(1).terminate() to signal s6-svscan, but s6-svscan runs as root while frigate runs as uid 1000, so the call raised AccessDenied. That exception escaped every caller: the UI restart button dropped its websocket client, MQTT restart and Save & Restart just logged and did nothing, and the watchdog crashed its own monitoring thread on a dead detector. This catches AccessDenied and falls through to the existing SIGINT branch, which exits the process for s6 to restart it.

* show runtime overrides in the settings form

The settings form read a camera section's saved config value, but its dependent warnings (audio transcription requiring audio detection, snapshots requiring detect, etc.) read the live config instead. A runtime toggle from the live view, MQTT, or an active profile can turn a section off without touching yaml, and that override persists across restarts, so the Enable switch showed on while the warning said the feature wasn't enabled. This adds an "Overridden (Live)" badge to any field whose live value differs from what's saved, and swaps the affected warnings to runtime-specific wording when a runtime override is the actual cause instead of the config.

* fix mobile overflowing icons in system due to new health pane

* fix genai settings keeping a stale model and dropping roles after save

Switching a GenAI entry's provider left the previous provider's model selected, so saving wrote a model the new provider doesn't serve. llama.cpp can't find that model in `/v1/models`, so the backend reported every capability as false for the entry, and once the save refetched `genai/models` the roles widget stripped `transcribe` from the form on its own. The section showed unsaved changes right after saving, and saving again would have dropped the role. Switching provider now clears the model, and the roles widget only strips a role for a model or provider picked in the form, since the entry-level capability flags only describe the saved model. A selected role stays visible when the provider can't confirm it, so it can still be switched off. The llama.cpp model list also no longer repeats a model whose alias matches its id, which is what `--alias` produces.

* close onvif sessions on shutdown

`OnvifController.close()` only stopped its event loop, so the aiohttp sessions each `ONVIFCamera` holds and the `_poll_config_updates` task were left to be garbage collected during interpreter shutdown, when their warnings can no longer be logged. Every restart ended with a run of `Unclosed client session` and `Task was destroyed but it is pending!` logging errors, which only became visible once restart started exiting the process itself under non-root. `close()` now closes each camera's client and cancels the tasks on the loop before stopping it.

* fixes

* fixes
2026-09-18 07:33:23 -06:00

914 lines
28 KiB
TypeScript

// Shared config save utilities.
//
// Provides the core per-section save logic (buildOverrides, sanitize, restart
// detection, update-topic resolution) used by both the individual per-section
// Save button in BaseSection and the global "Save All" coordinator in Settings.
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;
}
// ---------------------------------------------------------------------------
// cameraUpdateTopicMap — maps config section paths to MQTT/WS update topics
// ---------------------------------------------------------------------------
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",
};
// Sections where global config serves as the default for per-camera config.
// Global updates to these sections are fanned out to all cameras via wildcard.
export const globalCameraDefaultSections = new Set([
"detect",
"objects",
"motion",
"record",
"snapshots",
"review",
"audio",
"notifications",
"ffmpeg",
]);
// ---------------------------------------------------------------------------
// Profile helpers
// ---------------------------------------------------------------------------
/**
* 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),
);
}
// mergeWith customizer that replaces arrays wholesale instead of merging them
// positionally by index. Used when the source value is meant to fully replace
// the destination (e.g. profile overrides, section config overrides), so an
// empty source array correctly clears the destination array.
const replaceArraysCustomizer = (objValue: unknown, srcValue: unknown) => {
if (Array.isArray(objValue) || Array.isArray(srcValue)) {
return srcValue !== undefined ? srcValue : objValue;
}
return undefined;
};
// Merge profile overrides on top of base config values. Matches the backend's
// deep_merge(overrides, base_data) semantics: arrays are replaced wholesale by
// the profile's value rather than merged positionally, so an empty array in a
// profile clears the base array instead of leaving stale entries behind.
export function mergeProfileOverrides<T extends object>(
baseValue: T,
profileOverrides: object,
): T {
return mergeWith(
cloneDeep(baseValue),
cloneDeep(profileOverrides),
replaceArraysCustomizer,
) as T;
}
/** Sections that can appear inside a camera profile definition. */
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 };
}
// ---------------------------------------------------------------------------
// buildOverrides — pure recursive diff of current vs stored config & defaults
// ---------------------------------------------------------------------------
// Recursively compare `current` (pending form data) against `base` (persisted
// config) and `defaults` (schema defaults) to produce a minimal overrides
// payload.
//
// - Returns `undefined` when the value matches `base` (or `defaults` when
// `base` is absent), indicating no override is needed.
// - For objects, recurses per-key; deleted keys (present in `base` but absent
// in `current`) are represented as `""`.
// - For arrays, returns the full array when it differs.
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;
}
// Same-length arrays get compared element by element rather than by
// identity, so an item carrying an explicit null where the base simply
// omits the key does not read as a change. `/api/config` serializes with
// exclude_none, so every nullable field a form materializes would
// otherwise look edited the moment the page opens.
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;
}
// ---------------------------------------------------------------------------
// flattenOverrides — turn an overrides object into a list of leaf paths
// ---------------------------------------------------------------------------
// Walks a nested overrides value and produces a flat list of `{ path, value }`
// entries, one per leaf. Used by save/preview UIs to enumerate the individual
// fields that will be changed.
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]),
);
}
// ---------------------------------------------------------------------------
// sanitizeSectionData — normalize config values and strip hidden fields
// ---------------------------------------------------------------------------
// Normalize raw config data (strip internal fields) and remove any paths
// listed in `hiddenFields` so they are not included in override computation.
// lodash `unset` treats `*` as a literal key. This helper expands wildcard
// segments so that e.g. `"filters.*.mask"` unsets `filters.<each key>.mask`.
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;
}
// ---------------------------------------------------------------------------
// buildConfigDataForPath — convert dotted path to nested config_data payload
// ---------------------------------------------------------------------------
// Converts a dotted path (e.g. "cameras.front_door.detect") and a value into
// a properly nested config_data object (e.g. { cameras: { front_door: { detect: value } } }).
// This ensures the backend's flatten_config_data function can correctly distinguish
// between path separators (dots in the path) and literal dots in keys
// (e.g. "frigate.foo.bar" in logger.logs).
export function buildConfigDataForPath(
path: string,
value: unknown,
): Record<string, unknown> {
const configData: Record<string, unknown> = {};
set(configData, path, value);
return configData;
}
// ---------------------------------------------------------------------------
// requiresRestartForOverrides — determine whether a restart is needed
// ---------------------------------------------------------------------------
// Check whether the given overrides include fields that require a Frigate
// restart. When `restartRequired` is `undefined` the caller's default is
// used; an empty array means "never restart"; otherwise the function checks
// if any of the listed field paths are present in the overrides object.
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,
);
}
// ---------------------------------------------------------------------------
// SectionSavePayload — data produced by prepareSectionSavePayload
// ---------------------------------------------------------------------------
// Ready-to-PUT payload for a single config section.
export interface SectionSavePayload {
basePath: string;
sanitizedOverrides: JsonObject;
updateTopic: string | undefined;
needsRestart: boolean;
pendingDataKey: string;
}
// ---------------------------------------------------------------------------
// extractSectionSchema — resolve a section schema from the full config schema
// ---------------------------------------------------------------------------
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);
}
// ---------------------------------------------------------------------------
// prepareSectionSavePayload — build the PUT payload for a single section
// ---------------------------------------------------------------------------
// Given a pending-data key (e.g. `"detect"` or `"front_door::detect"`), its
// dirty form data, the current stored config, and the full JSON Schema,
// produce a `SectionSavePayload` that can be sent directly to
// `PUT config/set`. Returns `null` when there are no effective overrides.
export function prepareSectionSavePayload(opts: {
pendingDataKey: string;
pendingData: unknown;
config: FrigateConfig;
fullSchema: RJSFSchema;
}): SectionSavePayload | null {
const { pendingDataKey, pendingData, config, fullSchema } = opts;
if (!pendingData) return null;
// Parse pendingDataKey → sectionPath, level, cameraName
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";
}
// Detect profile-encoded section paths (e.g., "profiles.armed.detect")
const profileInfo = parseProfileFromSectionPath(sectionPath);
const schemaSection = profileInfo.actualSection;
// Resolve section config using the actual section name (not the profile path)
const sectionConfig = getSectionConfig(schemaSection, level);
// Resolve section schema using the actual section name
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,
);
// Compute rawFormData (the current stored value for this section)
// For profiles, merge base camera config with profile overrides (matching
// what BaseSection displays in the form) so the diff only contains actual
// user changes, not every field from the merged view.
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 {
// Use base (pre-profile) value so the diff matches what the form shows
rawSectionValue = getBaseCameraSectionValue(
config,
cameraName,
sectionPath,
);
}
} else {
rawSectionValue = get(config, sectionPath);
}
const rawFormData =
rawSectionValue === undefined || rawSectionValue === null
? {}
: rawSectionValue;
// For profile sections, also hide restart-required fields to match
// effectiveHiddenFields in BaseSection (prevents spurious deletion markers
// for fields that are hidden from the form during profile editing).
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;
// Sanitize raw form data
const rawData = sanitizeSectionData(
rawFormData as ConfigSectionData,
hiddenFieldsForSanitize,
);
// Compute schema defaults
const schemaDefaults = modifiedSchema
? applySchemaDefaults(modifiedSchema, {})
: {};
const effectiveDefaults = getEffectiveDefaultsForSection(
schemaSection,
level,
modifiedSchema ?? undefined,
schemaDefaults,
);
// Build overrides
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;
}
// Compute basePath
const basePath =
level === "camera" && cameraName
? `cameras.${cameraName}.${sectionPath}`
: sectionPath;
// Compute updateTopic — profile definitions don't trigger hot-reload
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}`;
}
// Restart detection — profile definitions never need restart
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;
}