refactor sections and overrides

This commit is contained in:
Josh Hawkins 2026-01-31 12:35:23 -06:00
parent 43c7193a3a
commit e09928a7f0
40 changed files with 1264 additions and 1041 deletions

View File

@ -1069,15 +1069,15 @@
},
"language": {
"label": "Transcription language",
"description": "Language code used for transcription/translation (for example 'en' for English)."
"description": "Language code used for transcription/translation (for example 'en' for English). See https://whisper-api.com/docs/languages/ for supported language codes."
},
"device": {
"label": "Transcription device",
"description": "Device key (CPU/GPU) to run the transcription model on."
"description": "Device key (CPU/GPU) to run the transcription model on. Only NVIDIA CUDA GPUs are currently supported for transcription."
},
"model_size": {
"label": "Model size",
"description": "Model size to use for transcription; the small model runs on CPU, large model requires a GPU."
"description": "Model size to use for offline audio event transcription."
},
"live_enabled": {
"label": "Live transcription",

View File

@ -18,7 +18,9 @@
"menu": {
"ui": "UI",
"globalConfig": "Global Config",
"mqtt": "MQTT",
"cameraConfig": "Camera Config",
"cameraMqtt": "Camera MQTT",
"enrichments": "Enrichments",
"cameraManagement": "Management",
"cameraReview": "Review",

View File

@ -0,0 +1,837 @@
/*
sectionConfigs.ts section configuration overrides
Purpose:
- Centralize UI configuration hints for each config section (field ordering,
grouping, hidden/advanced fields, uiSchema overrides, and overrideFields).
Shape:
- Each section key maps to an object with optional `base`, `global`, and
`camera` entries where each is a `SectionConfig` (or partial):
{
base?: SectionConfig; // common defaults (typically camera-level)
global?: Partial<SectionConfig>; // overrides for global-level UI
camera?: Partial<SectionConfig>; // overrides for camera-level UI
}
Merge rules (used by getSectionConfig):
- `base` is the canonical default and is merged with level-specific overrides.
- Arrays (e.g., `fieldOrder`, `advancedFields`, etc.) in overrides **replace**
the `base` arrays (they are not concatenated).
- `uiSchema` in an override **replaces** the base `uiSchema` rather than deep-merging
(this keeps widget overrides explicit per level).
- Other object properties are deep-merged using lodash.mergeWith with custom
behavior for arrays and `uiSchema` as described.
Example `ffmpeg`:
- `base` (camera defaults) may include `inputs` and a `fieldOrder` that shows
`"inputs"` first.
- `global` override can replace `fieldOrder` with a different ordering
(e.g., omit `inputs` and show `path` first). Calling
`getSectionConfig("ffmpeg", "global")` will return the merged config
where `fieldOrder` comes from `global` (not concatenated with `base`).
*/
import mergeWith from "lodash/mergeWith";
import type { SectionConfig } from "./sections/BaseSection";
export type SectionConfigOverrides = {
base?: SectionConfig;
global?: Partial<SectionConfig>;
camera?: Partial<SectionConfig>;
};
const sectionConfigs: Record<string, SectionConfigOverrides> = {
detect: {
base: {
fieldOrder: [
"enabled",
"fps",
"width",
"height",
"min_initialized",
"max_disappeared",
"annotation_offset",
"stationary",
],
fieldGroups: {
resolution: ["enabled", "width", "height"],
tracking: ["min_initialized", "max_disappeared"],
},
hiddenFields: ["enabled_in_config"],
advancedFields: [
"min_initialized",
"max_disappeared",
"annotation_offset",
"stationary",
],
},
},
record: {
base: {
fieldOrder: [
"enabled",
"expire_interval",
"continuous",
"motion",
"alerts",
"detections",
"preview",
"export",
],
fieldGroups: {
retention: ["enabled", "continuous", "motion"],
events: ["alerts", "detections"],
},
hiddenFields: ["enabled_in_config", "sync_recordings"],
advancedFields: ["expire_interval", "preview", "export"],
},
},
snapshots: {
base: {
fieldOrder: [
"enabled",
"bounding_box",
"crop",
"quality",
"timestamp",
"retain",
],
fieldGroups: {
display: ["enabled", "bounding_box", "crop", "quality", "timestamp"],
},
hiddenFields: ["enabled_in_config"],
advancedFields: ["quality", "retain"],
uiSchema: {
required_zones: {
"ui:widget": "zoneNames",
"ui:options": {
suppressMultiSchema: true,
},
},
},
},
},
motion: {
base: {
fieldOrder: [
"enabled",
"threshold",
"lightning_threshold",
"improve_contrast",
"contour_area",
"delta_alpha",
"frame_alpha",
"frame_height",
"mask",
"mqtt_off_delay",
],
fieldGroups: {
sensitivity: ["enabled", "threshold", "contour_area"],
algorithm: ["improve_contrast", "delta_alpha", "frame_alpha"],
},
hiddenFields: ["enabled_in_config", "mask", "raw_mask"],
advancedFields: [
"lightning_threshold",
"delta_alpha",
"frame_alpha",
"frame_height",
"mqtt_off_delay",
],
},
},
objects: {
base: {
fieldOrder: ["track", "alert", "detect", "filters"],
fieldGroups: {
tracking: ["track", "alert", "detect"],
filtering: ["filters"],
},
hiddenFields: [
"enabled_in_config",
"mask",
"raw_mask",
"genai.enabled_in_config",
"filters.*.mask",
"filters.*.raw_mask",
],
advancedFields: ["filters"],
uiSchema: {
"filters.*.min_area": {
"ui:options": {
suppressMultiSchema: true,
},
},
"filters.*.max_area": {
"ui:options": {
suppressMultiSchema: true,
},
},
track: {
"ui:widget": "objectLabels",
"ui:options": {
suppressMultiSchema: true,
},
},
genai: {
objects: {
"ui:widget": "objectLabels",
"ui:options": {
suppressMultiSchema: true,
},
},
required_zones: {
"ui:widget": "zoneNames",
"ui:options": {
suppressMultiSchema: true,
},
},
enabled_in_config: {
"ui:widget": "hidden",
},
},
},
},
},
review: {
base: {
fieldOrder: ["alerts", "detections", "genai"],
fieldGroups: {},
hiddenFields: [
"enabled_in_config",
"alerts.labels",
"alerts.enabled_in_config",
"alerts.required_zones",
"detections.labels",
"detections.enabled_in_config",
"detections.required_zones",
"genai.enabled_in_config",
],
advancedFields: [],
uiSchema: {
genai: {
additional_concerns: {
"ui:widget": "textarea",
},
activity_context_prompt: {
"ui:widget": "textarea",
},
},
},
},
},
audio: {
base: {
fieldOrder: [
"enabled",
"listen",
"filters",
"min_volume",
"max_not_heard",
"num_threads",
],
fieldGroups: {
detection: ["enabled", "listen", "filters"],
sensitivity: ["min_volume", "max_not_heard"],
},
hiddenFields: ["enabled_in_config"],
advancedFields: ["min_volume", "max_not_heard", "num_threads"],
uiSchema: {
listen: {
"ui:widget": "audioLabels",
},
},
},
},
live: {
base: {
fieldOrder: ["stream_name", "height", "quality"],
fieldGroups: {},
hiddenFields: ["enabled_in_config"],
advancedFields: ["quality"],
},
},
timestamp_style: {
base: {
fieldOrder: ["position", "format", "color", "thickness"],
hiddenFields: ["effect", "enabled_in_config"],
advancedFields: [],
},
},
notifications: {
base: {
fieldOrder: ["enabled", "email"],
fieldGroups: {},
hiddenFields: ["enabled_in_config"],
advancedFields: [],
},
},
onvif: {
base: {
fieldOrder: [
"host",
"port",
"user",
"password",
"tls_insecure",
"ignore_time_mismatch",
"autotracking",
],
hiddenFields: [
"autotracking.enabled_in_config",
"autotracking.movement_weights",
],
advancedFields: ["tls_insecure", "ignore_time_mismatch"],
overrideFields: [],
uiSchema: {
autotracking: {
required_zones: {
"ui:widget": "zoneNames",
},
track: {
"ui:widget": "objectLabels",
},
},
},
},
},
ffmpeg: {
base: {
fieldOrder: [
"inputs",
"path",
"global_args",
"hwaccel_args",
"input_args",
"output_args",
"retry_interval",
"apple_compatibility",
"gpu",
],
hiddenFields: [],
advancedFields: [
"global_args",
"hwaccel_args",
"input_args",
"output_args",
"retry_interval",
"apple_compatibility",
"gpu",
],
overrideFields: [
"path",
"global_args",
"hwaccel_args",
"input_args",
"output_args",
"retry_interval",
"apple_compatibility",
"gpu",
],
uiSchema: {
global_args: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
hwaccel_args: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
input_args: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
output_args: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
detect: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
record: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
items: {
detect: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
record: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
},
},
inputs: {
items: {
global_args: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
hwaccel_args: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
input_args: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
output_args: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
items: {
detect: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
record: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
},
},
},
},
},
},
global: {
fieldOrder: [
"path",
"global_args",
"hwaccel_args",
"input_args",
"output_args",
"retry_interval",
"apple_compatibility",
"gpu",
],
advancedFields: [
"global_args",
"hwaccel_args",
"input_args",
"output_args",
"retry_interval",
"apple_compatibility",
"gpu",
],
uiSchema: {
global_args: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
hwaccel_args: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
input_args: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
output_args: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
detect: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
record: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
},
},
},
},
audio_transcription: {
base: {
fieldOrder: [
"enabled",
"language",
"device",
"model_size",
"live_enabled",
],
hiddenFields: ["enabled_in_config"],
advancedFields: ["language", "device", "model_size"],
overrideFields: ["enabled", "live_enabled"],
},
global: {
fieldOrder: [
"enabled",
"language",
"device",
"model_size",
"live_enabled",
],
advancedFields: ["language", "device", "model_size"],
},
},
birdseye: {
base: {
fieldOrder: ["enabled", "mode", "order"],
hiddenFields: [],
advancedFields: [],
overrideFields: ["enabled", "mode"],
},
global: {
fieldOrder: [
"enabled",
"restream",
"width",
"height",
"quality",
"mode",
"layout",
"inactivity_threshold",
"idle_heartbeat_fps",
],
advancedFields: ["width", "height", "quality", "inactivity_threshold"],
},
},
face_recognition: {
base: {
fieldOrder: ["enabled", "min_area"],
hiddenFields: [],
advancedFields: ["min_area"],
overrideFields: ["enabled", "min_area"],
},
global: {
fieldOrder: [
"enabled",
"model_size",
"unknown_score",
"detection_threshold",
"recognition_threshold",
"min_area",
"min_faces",
"save_attempts",
"blur_confidence_filter",
"device",
],
advancedFields: [
"unknown_score",
"detection_threshold",
"recognition_threshold",
"min_area",
"min_faces",
"save_attempts",
"blur_confidence_filter",
"device",
],
},
},
lpr: {
base: {
fieldOrder: ["enabled", "expire_time", "min_area", "enhancement"],
hiddenFields: [],
advancedFields: ["expire_time", "min_area", "enhancement"],
overrideFields: ["enabled", "min_area", "enhancement"],
},
global: {
fieldOrder: [
"enabled",
"model_size",
"detection_threshold",
"min_area",
"recognition_threshold",
"min_plate_length",
"format",
"match_distance",
"known_plates",
"enhancement",
"debug_save_plates",
"device",
"replace_rules",
],
advancedFields: [
"detection_threshold",
"recognition_threshold",
"min_plate_length",
"format",
"match_distance",
"known_plates",
"enhancement",
"debug_save_plates",
"device",
"replace_rules",
],
},
},
semantic_search: {
base: {
fieldOrder: ["triggers"],
hiddenFields: [],
advancedFields: [],
overrideFields: [],
},
global: {
fieldOrder: ["enabled", "reindex", "model", "model_size", "device"],
advancedFields: ["reindex", "device"],
},
},
mqtt: {
base: {
fieldOrder: [
"enabled",
"timestamp",
"bounding_box",
"crop",
"height",
"required_zones",
"quality",
],
hiddenFields: [],
advancedFields: ["height", "quality"],
overrideFields: [],
uiSchema: {
required_zones: {
"ui:widget": "zoneNames",
},
},
},
global: {
fieldOrder: [
"enabled",
"host",
"port",
"user",
"password",
"topic_prefix",
"client_id",
"stats_interval",
"qos",
"tls_ca_certs",
"tls_client_cert",
"tls_client_key",
"tls_insecure",
],
advancedFields: [
"stats_interval",
"qos",
"tls_ca_certs",
"tls_client_cert",
"tls_client_key",
"tls_insecure",
],
liveValidate: true,
uiSchema: {},
},
},
ui: {
base: {
fieldOrder: ["dashboard", "order"],
hiddenFields: [],
advancedFields: [],
overrideFields: [],
},
global: {
fieldOrder: [
"timezone",
"time_format",
"date_style",
"time_style",
"unit_system",
],
advancedFields: [],
},
},
database: {
base: {
fieldOrder: ["path"],
advancedFields: [],
},
},
auth: {
base: {
fieldOrder: [
"enabled",
"reset_admin_password",
"cookie_name",
"cookie_secure",
"session_length",
"refresh_time",
"native_oauth_url",
"failed_login_rate_limit",
"trusted_proxies",
"hash_iterations",
"roles",
],
hiddenFields: ["admin_first_time_login"],
advancedFields: [
"cookie_name",
"cookie_secure",
"session_length",
"refresh_time",
"failed_login_rate_limit",
"trusted_proxies",
"hash_iterations",
"roles",
],
uiSchema: {
reset_admin_password: {
"ui:widget": "switch",
},
},
},
},
tls: {
base: {
fieldOrder: ["enabled", "cert", "key"],
advancedFields: [],
},
},
networking: {
base: {
fieldOrder: ["ipv6"],
advancedFields: [],
},
},
proxy: {
base: {
fieldOrder: [
"header_map",
"logout_url",
"auth_secret",
"default_role",
"separator",
],
advancedFields: ["header_map", "auth_secret", "separator"],
liveValidate: true,
},
},
logger: {
base: {
fieldOrder: ["default", "logs"],
advancedFields: ["logs"],
},
},
environment_vars: {
base: {
fieldOrder: [],
advancedFields: [],
},
},
telemetry: {
base: {
fieldOrder: ["network_interfaces", "stats", "version_check"],
advancedFields: [],
},
},
detectors: {
base: {
fieldOrder: [],
advancedFields: [],
},
},
model: {
base: {
fieldOrder: [
"path",
"labelmap_path",
"width",
"height",
"input_pixel_format",
"input_tensor",
"input_dtype",
"model_type",
],
advancedFields: [
"input_pixel_format",
"input_tensor",
"input_dtype",
"model_type",
],
hiddenFields: ["labelmap", "attributes_map"],
},
},
genai: {
base: {
fieldOrder: [
"provider",
"api_key",
"base_url",
"model",
"provider_options",
"runtime_options",
],
advancedFields: ["base_url", "provider_options", "runtime_options"],
hiddenFields: ["genai.enabled_in_config"],
},
},
classification: {
base: {
hiddenFields: ["custom"],
advancedFields: [],
},
},
};
const mergeSectionConfig = (
base: SectionConfig | undefined,
overrides: Partial<SectionConfig> | undefined,
): SectionConfig =>
mergeWith({}, base ?? {}, overrides ?? {}, (objValue, srcValue, key) => {
if (Array.isArray(objValue) || Array.isArray(srcValue)) {
return srcValue ?? objValue;
}
if (key === "uiSchema" && srcValue !== undefined) {
return srcValue;
}
return undefined;
});
export function getSectionConfig(
sectionKey: string,
level: "global" | "camera",
): SectionConfig {
const entry = sectionConfigs[sectionKey];
if (!entry) {
return {};
}
const overrides = level === "global" ? entry.global : entry.camera;
return mergeSectionConfig(entry.base, overrides);
}

View File

@ -2,30 +2,11 @@
// Reusable for both global and camera-level audio settings
import { createConfigSection } from "./BaseSection";
import { getSectionConfig } from "../sectionConfigs";
export const AudioSection = createConfigSection({
sectionPath: "audio",
defaultConfig: {
fieldOrder: [
"enabled",
"listen",
"filters",
"min_volume",
"max_not_heard",
"num_threads",
],
fieldGroups: {
detection: ["enabled", "listen", "filters"],
sensitivity: ["min_volume", "max_not_heard"],
},
hiddenFields: ["enabled_in_config"],
advancedFields: ["min_volume", "max_not_heard", "num_threads"],
uiSchema: {
listen: {
"ui:widget": "audioLabels",
},
},
},
defaultConfig: getSectionConfig("audio", "camera"),
});
export default AudioSection;

View File

@ -2,15 +2,11 @@
// Global and camera-level audio transcription settings
import { createConfigSection } from "./BaseSection";
import { getSectionConfig } from "../sectionConfigs";
export const AudioTranscriptionSection = createConfigSection({
sectionPath: "audio_transcription",
defaultConfig: {
fieldOrder: ["enabled", "language", "device", "model_size", "live_enabled"],
hiddenFields: ["enabled_in_config"],
advancedFields: ["language", "device", "model_size"],
overrideFields: ["enabled", "live_enabled"],
},
defaultConfig: getSectionConfig("audio_transcription", "camera"),
});
export default AudioTranscriptionSection;

View File

@ -0,0 +1,12 @@
// Auth Section Component
// Global authentication configuration settings
import { createConfigSection } from "./BaseSection";
import { getSectionConfig } from "../sectionConfigs";
export const AuthSection = createConfigSection({
sectionPath: "auth",
defaultConfig: getSectionConfig("auth", "global"),
});
export default AuthSection;

View File

@ -2,15 +2,11 @@
// Camera-level birdseye settings
import { createConfigSection } from "./BaseSection";
import { getSectionConfig } from "../sectionConfigs";
export const BirdseyeSection = createConfigSection({
sectionPath: "birdseye",
defaultConfig: {
fieldOrder: ["enabled", "mode", "order"],
hiddenFields: [],
advancedFields: [],
overrideFields: ["enabled", "mode"],
},
defaultConfig: getSectionConfig("birdseye", "camera"),
});
export default BirdseyeSection;

View File

@ -2,28 +2,11 @@
// Camera-specific MQTT image publishing settings
import { createConfigSection } from "./BaseSection";
import { getSectionConfig } from "../sectionConfigs";
export const CameraMqttSection = createConfigSection({
sectionPath: "mqtt",
defaultConfig: {
fieldOrder: [
"enabled",
"timestamp",
"bounding_box",
"crop",
"height",
"required_zones",
"quality",
],
hiddenFields: [],
advancedFields: ["height", "quality"],
overrideFields: [],
uiSchema: {
required_zones: {
"ui:widget": "zoneNames",
},
},
},
defaultConfig: getSectionConfig("mqtt", "camera"),
});
export default CameraMqttSection;

View File

@ -2,15 +2,11 @@
// Camera UI display settings
import { createConfigSection } from "./BaseSection";
import { getSectionConfig } from "../sectionConfigs";
export const CameraUiSection = createConfigSection({
sectionPath: "ui",
defaultConfig: {
fieldOrder: ["dashboard", "order"],
hiddenFields: [],
advancedFields: [],
overrideFields: [],
},
defaultConfig: getSectionConfig("ui", "camera"),
});
export default CameraUiSection;

View File

@ -0,0 +1,12 @@
// Classification Section Component
// Global classification configuration settings
import { createConfigSection } from "./BaseSection";
import { getSectionConfig } from "../sectionConfigs";
export const ClassificationSection = createConfigSection({
sectionPath: "classification",
defaultConfig: getSectionConfig("classification", "global"),
});
export default ClassificationSection;

View File

@ -0,0 +1,12 @@
// Database Section Component
// Global database configuration settings
import { createConfigSection } from "./BaseSection";
import { getSectionConfig } from "../sectionConfigs";
export const DatabaseSection = createConfigSection({
sectionPath: "database",
defaultConfig: getSectionConfig("database", "global"),
});
export default DatabaseSection;

View File

@ -2,32 +2,11 @@
// Reusable for both global and camera-level detect settings
import { createConfigSection } from "./BaseSection";
import { getSectionConfig } from "../sectionConfigs";
export const DetectSection = createConfigSection({
sectionPath: "detect",
defaultConfig: {
fieldOrder: [
"enabled",
"fps",
"width",
"height",
"min_initialized",
"max_disappeared",
"annotation_offset",
"stationary",
],
fieldGroups: {
resolution: ["enabled", "width", "height"],
tracking: ["min_initialized", "max_disappeared"],
},
hiddenFields: ["enabled_in_config"],
advancedFields: [
"min_initialized",
"max_disappeared",
"annotation_offset",
"stationary",
],
},
defaultConfig: getSectionConfig("detect", "camera"),
});
export default DetectSection;

View File

@ -0,0 +1,12 @@
// Detectors Section Component
// Global detectors configuration settings
import { createConfigSection } from "./BaseSection";
import { getSectionConfig } from "../sectionConfigs";
export const DetectorsSection = createConfigSection({
sectionPath: "detectors",
defaultConfig: getSectionConfig("detectors", "global"),
});
export default DetectorsSection;

View File

@ -0,0 +1,12 @@
// Environment Variables Section Component
// Global environment variables configuration settings
import { createConfigSection } from "./BaseSection";
import { getSectionConfig } from "../sectionConfigs";
export const EnvironmentVarsSection = createConfigSection({
sectionPath: "environment_vars",
defaultConfig: getSectionConfig("environment_vars", "global"),
});
export default EnvironmentVarsSection;

View File

@ -2,15 +2,11 @@
// Camera-level face recognition settings
import { createConfigSection } from "./BaseSection";
import { getSectionConfig } from "../sectionConfigs";
export const FaceRecognitionSection = createConfigSection({
sectionPath: "face_recognition",
defaultConfig: {
fieldOrder: ["enabled", "min_area"],
hiddenFields: [],
advancedFields: ["min_area"],
overrideFields: ["enabled", "min_area"],
},
defaultConfig: getSectionConfig("face_recognition", "camera"),
});
export default FaceRecognitionSection;

View File

@ -2,136 +2,11 @@
// Global and camera-level FFmpeg settings
import { createConfigSection } from "./BaseSection";
import { getSectionConfig } from "../sectionConfigs";
export const FfmpegSection = createConfigSection({
sectionPath: "ffmpeg",
defaultConfig: {
fieldOrder: [
"inputs",
"path",
"global_args",
"hwaccel_args",
"input_args",
"output_args",
"retry_interval",
"apple_compatibility",
"gpu",
],
hiddenFields: [],
advancedFields: [
"global_args",
"hwaccel_args",
"input_args",
"output_args",
"retry_interval",
"apple_compatibility",
"gpu",
],
overrideFields: [
"path",
"global_args",
"hwaccel_args",
"input_args",
"output_args",
"retry_interval",
"apple_compatibility",
"gpu",
],
uiSchema: {
global_args: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
hwaccel_args: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
input_args: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
output_args: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
detect: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
record: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
items: {
detect: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
record: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
},
},
inputs: {
items: {
global_args: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
hwaccel_args: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
input_args: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
output_args: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
items: {
detect: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
record: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
},
},
},
},
},
},
defaultConfig: getSectionConfig("ffmpeg", "camera"),
});
export default FfmpegSection;

View File

@ -0,0 +1,12 @@
// GenAI Section Component
// Global GenAI configuration settings
import { createConfigSection } from "./BaseSection";
import { getSectionConfig } from "../sectionConfigs";
export const GenaiSection = createConfigSection({
sectionPath: "genai",
defaultConfig: getSectionConfig("genai", "global"),
});
export default GenaiSection;

View File

@ -2,15 +2,11 @@
// Reusable for both global and camera-level live settings
import { createConfigSection } from "./BaseSection";
import { getSectionConfig } from "../sectionConfigs";
export const LiveSection = createConfigSection({
sectionPath: "live",
defaultConfig: {
fieldOrder: ["stream_name", "height", "quality"],
fieldGroups: {},
hiddenFields: ["enabled_in_config"],
advancedFields: ["quality"],
},
defaultConfig: getSectionConfig("live", "camera"),
});
export default LiveSection;

View File

@ -0,0 +1,12 @@
// Logger Section Component
// Global logger configuration settings
import { createConfigSection } from "./BaseSection";
import { getSectionConfig } from "../sectionConfigs";
export const LoggerSection = createConfigSection({
sectionPath: "logger",
defaultConfig: getSectionConfig("logger", "global"),
});
export default LoggerSection;

View File

@ -2,15 +2,11 @@
// Camera-level LPR settings
import { createConfigSection } from "./BaseSection";
import { getSectionConfig } from "../sectionConfigs";
export const LprSection = createConfigSection({
sectionPath: "lpr",
defaultConfig: {
fieldOrder: ["enabled", "expire_time", "min_area", "enhancement"],
hiddenFields: [],
advancedFields: ["expire_time", "min_area", "enhancement"],
overrideFields: ["enabled", "min_area", "enhancement"],
},
defaultConfig: getSectionConfig("lpr", "camera"),
});
export default LprSection;

View File

@ -0,0 +1,12 @@
// Model Section Component
// Global model configuration settings
import { createConfigSection } from "./BaseSection";
import { getSectionConfig } from "../sectionConfigs";
export const ModelSection = createConfigSection({
sectionPath: "model",
defaultConfig: getSectionConfig("model", "global"),
});
export default ModelSection;

View File

@ -2,35 +2,11 @@
// Reusable for both global and camera-level motion settings
import { createConfigSection } from "./BaseSection";
import { getSectionConfig } from "../sectionConfigs";
export const MotionSection = createConfigSection({
sectionPath: "motion",
defaultConfig: {
fieldOrder: [
"enabled",
"threshold",
"lightning_threshold",
"improve_contrast",
"contour_area",
"delta_alpha",
"frame_alpha",
"frame_height",
"mask",
"mqtt_off_delay",
],
fieldGroups: {
sensitivity: ["enabled", "threshold", "contour_area"],
algorithm: ["improve_contrast", "delta_alpha", "frame_alpha"],
},
hiddenFields: ["enabled_in_config", "mask", "raw_mask"],
advancedFields: [
"lightning_threshold",
"delta_alpha",
"frame_alpha",
"frame_height",
"mqtt_off_delay",
],
},
defaultConfig: getSectionConfig("motion", "camera"),
});
export default MotionSection;

View File

@ -0,0 +1,12 @@
// MQTT Section Component
// Global MQTT configuration settings
import { createConfigSection } from "./BaseSection";
import { getSectionConfig } from "../sectionConfigs";
export const MqttSection = createConfigSection({
sectionPath: "mqtt",
defaultConfig: getSectionConfig("mqtt", "global"),
});
export default MqttSection;

View File

@ -0,0 +1,12 @@
// Networking Section Component
// Global networking configuration settings
import { createConfigSection } from "./BaseSection";
import { getSectionConfig } from "../sectionConfigs";
export const NetworkingSection = createConfigSection({
sectionPath: "networking",
defaultConfig: getSectionConfig("networking", "global"),
});
export default NetworkingSection;

View File

@ -2,15 +2,11 @@
// Reusable for both global and camera-level notification settings
import { createConfigSection } from "./BaseSection";
import { getSectionConfig } from "../sectionConfigs";
export const NotificationsSection = createConfigSection({
sectionPath: "notifications",
defaultConfig: {
fieldOrder: ["enabled", "email"],
fieldGroups: {},
hiddenFields: ["enabled_in_config"],
advancedFields: [],
},
defaultConfig: getSectionConfig("notifications", "camera"),
});
export default NotificationsSection;

View File

@ -2,60 +2,11 @@
// Reusable for both global and camera-level objects settings
import { createConfigSection } from "./BaseSection";
import { getSectionConfig } from "../sectionConfigs";
export const ObjectsSection = createConfigSection({
sectionPath: "objects",
defaultConfig: {
fieldOrder: ["track", "alert", "detect", "filters"],
fieldGroups: {
tracking: ["track", "alert", "detect"],
filtering: ["filters"],
},
hiddenFields: [
"enabled_in_config",
"mask",
"raw_mask",
"genai.enabled_in_config",
"filters.*.mask",
"filters.*.raw_mask",
],
advancedFields: ["filters"],
uiSchema: {
"filters.*.min_area": {
"ui:options": {
suppressMultiSchema: true,
},
},
"filters.*.max_area": {
"ui:options": {
suppressMultiSchema: true,
},
},
track: {
"ui:widget": "objectLabels",
"ui:options": {
suppressMultiSchema: true,
},
},
genai: {
objects: {
"ui:widget": "objectLabels",
"ui:options": {
suppressMultiSchema: true,
},
},
required_zones: {
"ui:widget": "zoneNames",
"ui:options": {
suppressMultiSchema: true,
},
},
enabled_in_config: {
"ui:widget": "hidden",
},
},
},
},
defaultConfig: getSectionConfig("objects", "camera"),
});
export default ObjectsSection;

View File

@ -2,36 +2,11 @@
// Camera-level ONVIF and autotracking settings
import { createConfigSection } from "./BaseSection";
import { getSectionConfig } from "../sectionConfigs";
export const OnvifSection = createConfigSection({
sectionPath: "onvif",
defaultConfig: {
fieldOrder: [
"host",
"port",
"user",
"password",
"tls_insecure",
"ignore_time_mismatch",
"autotracking",
],
hiddenFields: [
"autotracking.enabled_in_config",
"autotracking.movement_weights",
],
advancedFields: ["tls_insecure", "ignore_time_mismatch"],
overrideFields: [],
uiSchema: {
autotracking: {
required_zones: {
"ui:widget": "zoneNames",
},
track: {
"ui:widget": "objectLabels",
},
},
},
},
defaultConfig: getSectionConfig("onvif", "camera"),
});
export default OnvifSection;

View File

@ -0,0 +1,12 @@
// Proxy Section Component
// Global proxy configuration settings
import { createConfigSection } from "./BaseSection";
import { getSectionConfig } from "../sectionConfigs";
export const ProxySection = createConfigSection({
sectionPath: "proxy",
defaultConfig: getSectionConfig("proxy", "global"),
});
export default ProxySection;

View File

@ -2,27 +2,11 @@
// Reusable for both global and camera-level record settings
import { createConfigSection } from "./BaseSection";
import { getSectionConfig } from "../sectionConfigs";
export const RecordSection = createConfigSection({
sectionPath: "record",
defaultConfig: {
fieldOrder: [
"enabled",
"expire_interval",
"continuous",
"motion",
"alerts",
"detections",
"preview",
"export",
],
fieldGroups: {
retention: ["enabled", "continuous", "motion"],
events: ["alerts", "detections"],
},
hiddenFields: ["enabled_in_config", "sync_recordings"],
advancedFields: ["expire_interval", "preview", "export"],
},
defaultConfig: getSectionConfig("record", "camera"),
});
export default RecordSection;

View File

@ -2,34 +2,11 @@
// Reusable for both global and camera-level review settings
import { createConfigSection } from "./BaseSection";
import { getSectionConfig } from "../sectionConfigs";
export const ReviewSection = createConfigSection({
sectionPath: "review",
defaultConfig: {
fieldOrder: ["alerts", "detections", "genai"],
fieldGroups: {},
hiddenFields: [
"enabled_in_config",
"alerts.labels",
"alerts.enabled_in_config",
"alerts.required_zones",
"detections.labels",
"detections.enabled_in_config",
"detections.required_zones",
"genai.enabled_in_config",
],
advancedFields: [],
uiSchema: {
genai: {
additional_concerns: {
"ui:widget": "textarea",
},
activity_context_prompt: {
"ui:widget": "textarea",
},
},
},
},
defaultConfig: getSectionConfig("review", "camera"),
});
export default ReviewSection;

View File

@ -2,15 +2,11 @@
// Camera-level semantic search trigger settings
import { createConfigSection } from "./BaseSection";
import { getSectionConfig } from "../sectionConfigs";
export const SemanticSearchSection = createConfigSection({
sectionPath: "semantic_search",
defaultConfig: {
fieldOrder: ["triggers"],
hiddenFields: [],
advancedFields: [],
overrideFields: [],
},
defaultConfig: getSectionConfig("semantic_search", "camera"),
});
export default SemanticSearchSection;

View File

@ -2,32 +2,11 @@
// Reusable for both global and camera-level snapshots settings
import { createConfigSection } from "./BaseSection";
import { getSectionConfig } from "../sectionConfigs";
export const SnapshotsSection = createConfigSection({
sectionPath: "snapshots",
defaultConfig: {
fieldOrder: [
"enabled",
"bounding_box",
"crop",
"quality",
"timestamp",
"retain",
],
fieldGroups: {
display: ["enabled", "bounding_box", "crop", "quality", "timestamp"],
},
hiddenFields: ["enabled_in_config"],
advancedFields: ["quality", "retain"],
uiSchema: {
required_zones: {
"ui:widget": "zoneNames",
"ui:options": {
suppressMultiSchema: true,
},
},
},
},
defaultConfig: getSectionConfig("snapshots", "camera"),
});
export default SnapshotsSection;

View File

@ -0,0 +1,12 @@
// Telemetry Section Component
// Global telemetry configuration settings
import { createConfigSection } from "./BaseSection";
import { getSectionConfig } from "../sectionConfigs";
export const TelemetrySection = createConfigSection({
sectionPath: "telemetry",
defaultConfig: getSectionConfig("telemetry", "global"),
});
export default TelemetrySection;

View File

@ -2,14 +2,11 @@
// Reusable for both global and camera-level timestamp_style settings
import { createConfigSection } from "./BaseSection";
import { getSectionConfig } from "../sectionConfigs";
export const TimestampSection = createConfigSection({
sectionPath: "timestamp_style",
defaultConfig: {
fieldOrder: ["position", "format", "color", "thickness"],
hiddenFields: ["effect", "enabled_in_config"],
advancedFields: [],
},
defaultConfig: getSectionConfig("timestamp_style", "camera"),
});
export default TimestampSection;

View File

@ -0,0 +1,12 @@
// TLS Section Component
// Global TLS configuration settings
import { createConfigSection } from "./BaseSection";
import { getSectionConfig } from "../sectionConfigs";
export const TlsSection = createConfigSection({
sectionPath: "tls",
defaultConfig: getSectionConfig("tls", "global"),
});
export default TlsSection;

View File

@ -0,0 +1,12 @@
// UI Section Component
// Global UI configuration settings
import { createConfigSection } from "./BaseSection";
import { getSectionConfig } from "../sectionConfigs";
export const UiSection = createConfigSection({
sectionPath: "ui",
defaultConfig: getSectionConfig("ui", "global"),
});
export default UiSection;

View File

@ -17,13 +17,27 @@ export { ReviewSection } from "./ReviewSection";
export { AudioSection } from "./AudioSection";
export { AudioTranscriptionSection } from "./AudioTranscriptionSection";
export { BirdseyeSection } from "./BirdseyeSection";
export { AuthSection } from "./AuthSection";
export { ClassificationSection } from "./ClassificationSection";
export { CameraMqttSection } from "./CameraMqttSection";
export { CameraUiSection } from "./CameraUiSection";
export { DatabaseSection } from "./DatabaseSection";
export { DetectorsSection } from "./DetectorsSection";
export { EnvironmentVarsSection } from "./EnvironmentVarsSection";
export { FaceRecognitionSection } from "./FaceRecognitionSection";
export { FfmpegSection } from "./FfmpegSection";
export { GenaiSection } from "./GenaiSection";
export { LprSection } from "./LprSection";
export { LoggerSection } from "./LoggerSection";
export { NotificationsSection } from "./NotificationsSection";
export { OnvifSection } from "./OnvifSection";
export { LiveSection } from "./LiveSection";
export { ModelSection } from "./ModelSection";
export { MqttSection } from "./MqttSection";
export { NetworkingSection } from "./NetworkingSection";
export { ProxySection } from "./ProxySection";
export { SemanticSearchSection } from "./SemanticSearchSection";
export { TelemetrySection } from "./TelemetrySection";
export { TimestampSection } from "./TimestampSection";
export { TlsSection } from "./TlsSection";
export { UiSection } from "./UiSection";

View File

@ -39,6 +39,7 @@ import FrigatePlusSettingsView from "@/views/settings/FrigatePlusSettingsView";
import MaintenanceSettingsView from "@/views/settings/MaintenanceSettingsView";
import GlobalConfigView from "@/views/settings/GlobalConfigView";
import CameraConfigView from "@/views/settings/CameraConfigView";
import { createSingleSectionPage } from "@/views/settings/SingleSectionPage";
import { useSearchEffect } from "@/hooks/use-overlay-state";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useInitialCameraState } from "@/api/ws";
@ -64,6 +65,10 @@ import { cn } from "@/lib/utils";
import Heading from "@/components/ui/heading";
import { LuChevronRight } from "react-icons/lu";
import Logo from "@/components/Logo";
import {
CameraMqttSection,
MqttSection,
} from "@/components/config-form/sections";
import {
MobilePage,
MobilePageContent,
@ -74,7 +79,9 @@ import {
const allSettingsViews = [
"ui",
"globalConfig",
"mqtt",
"cameraConfig",
"cameraMqtt",
"enrichments",
"cameraManagement",
"cameraReview",
@ -90,18 +97,33 @@ const allSettingsViews = [
] as const;
type SettingsType = (typeof allSettingsViews)[number];
const MqttSettingsPage = createSingleSectionPage({
sectionKey: "mqtt",
level: "global",
SectionComponent: MqttSection,
});
const CameraMqttSettingsPage = createSingleSectionPage({
sectionKey: "mqtt",
level: "camera",
SectionComponent: CameraMqttSection,
showOverrideIndicator: false,
});
const settingsGroups = [
{
label: "general",
items: [
{ key: "ui", component: UiSettingsView },
{ key: "globalConfig", component: GlobalConfigView },
{ key: "mqtt", component: MqttSettingsPage },
],
},
{
label: "cameras",
items: [
{ key: "cameraConfig", component: CameraConfigView },
{ key: "cameraMqtt", component: CameraMqttSettingsPage },
{ key: "cameraManagement", component: CameraManagementView },
{ key: "cameraReview", component: CameraReviewSettingsView },
{ key: "masksAndZones", component: MasksAndZonesView },
@ -139,6 +161,7 @@ const settingsGroups = [
const CAMERA_SELECT_BUTTON_PAGES = [
"debug",
"cameraConfig",
"cameraMqtt",
"cameraReview",
"masksAndZones",
"motionTuner",

View File

@ -1,12 +1,9 @@
// Global Configuration View
// Main view for configuring global Frigate settings
import { useMemo, useCallback, useState, useEffect, useRef } from "react";
import { useMemo, useCallback, useState } from "react";
import useSWR from "swr";
import axios from "axios";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { ConfigForm } from "@/components/config-form/ConfigForm";
import { DetectSection } from "@/components/config-form/sections/DetectSection";
import { RecordSection } from "@/components/config-form/sections/RecordSection";
import { SnapshotsSection } from "@/components/config-form/sections/SnapshotsSection";
@ -14,545 +11,90 @@ import { MotionSection } from "@/components/config-form/sections/MotionSection";
import { ObjectsSection } from "@/components/config-form/sections/ObjectsSection";
import { ReviewSection } from "@/components/config-form/sections/ReviewSection";
import { AudioSection } from "@/components/config-form/sections/AudioSection";
import { AudioTranscriptionSection } from "@/components/config-form/sections/AudioTranscriptionSection";
import { AuthSection } from "@/components/config-form/sections/AuthSection";
import { BirdseyeSection } from "@/components/config-form/sections/BirdseyeSection";
import { ClassificationSection } from "@/components/config-form/sections/ClassificationSection";
import { DatabaseSection } from "@/components/config-form/sections/DatabaseSection";
import { DetectorsSection } from "@/components/config-form/sections/DetectorsSection";
import { EnvironmentVarsSection } from "@/components/config-form/sections/EnvironmentVarsSection";
import { FaceRecognitionSection } from "@/components/config-form/sections/FaceRecognitionSection";
import { FfmpegSection } from "@/components/config-form/sections/FfmpegSection";
import { GenaiSection } from "@/components/config-form/sections/GenaiSection";
import { LiveSection } from "@/components/config-form/sections/LiveSection";
import { LoggerSection } from "@/components/config-form/sections/LoggerSection";
import { LprSection } from "@/components/config-form/sections/LprSection";
import { ModelSection } from "@/components/config-form/sections/ModelSection";
import { MqttSection } from "@/components/config-form/sections/MqttSection";
import { NetworkingSection } from "@/components/config-form/sections/NetworkingSection";
import { ProxySection } from "@/components/config-form/sections/ProxySection";
import { SemanticSearchSection } from "@/components/config-form/sections/SemanticSearchSection";
import { TimestampSection } from "@/components/config-form/sections/TimestampSection";
import type { RJSFSchema } from "@rjsf/utils";
import { TelemetrySection } from "@/components/config-form/sections/TelemetrySection";
import { TlsSection } from "@/components/config-form/sections/TlsSection";
import { UiSection } from "@/components/config-form/sections/UiSection";
import type { FrigateConfig } from "@/types/frigateConfig";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { extractSchemaSection } from "@/lib/config-schema";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import Heading from "@/components/ui/heading";
import { LuSave } from "react-icons/lu";
import isEqual from "lodash/isEqual";
import { cn } from "@/lib/utils";
import { getSectionConfig } from "@/components/config-form/sectionConfigs";
// Shared sections that can be overridden at camera level
const sharedSections = [
{ key: "detect", component: DetectSection },
{ key: "record", component: RecordSection },
{
key: "snapshots",
component: SnapshotsSection,
},
{ key: "snapshots", component: SnapshotsSection },
{ key: "motion", component: MotionSection },
{
key: "objects",
component: ObjectsSection,
},
{ key: "objects", component: ObjectsSection },
{ key: "review", component: ReviewSection },
{ key: "audio", component: AudioSection },
{ key: "live", component: LiveSection },
{
key: "timestamp_style",
component: TimestampSection,
},
{ key: "timestamp_style", component: TimestampSection },
];
// Section configurations for global-only settings (system and integrations)
const globalSectionConfigs: Record<
string,
{
fieldOrder?: string[];
hiddenFields?: string[];
advancedFields?: string[];
liveValidate?: boolean;
uiSchema?: Record<string, unknown>;
}
> = {
mqtt: {
fieldOrder: [
"enabled",
"host",
"port",
"user",
"password",
"topic_prefix",
"client_id",
"stats_interval",
"qos",
"tls_ca_certs",
"tls_client_cert",
"tls_client_key",
"tls_insecure",
],
advancedFields: [
"stats_interval",
"qos",
"tls_ca_certs",
"tls_client_cert",
"tls_client_key",
"tls_insecure",
],
liveValidate: true,
},
database: {
fieldOrder: ["path"],
advancedFields: [],
},
auth: {
fieldOrder: [
"enabled",
"reset_admin_password",
"cookie_name",
"cookie_secure",
"session_length",
"refresh_time",
"native_oauth_url",
"failed_login_rate_limit",
"trusted_proxies",
"hash_iterations",
"roles",
],
hiddenFields: ["admin_first_time_login"],
advancedFields: [
"cookie_name",
"cookie_secure",
"session_length",
"refresh_time",
"failed_login_rate_limit",
"trusted_proxies",
"hash_iterations",
"roles",
],
uiSchema: {
reset_admin_password: {
"ui:widget": "switch",
},
},
},
tls: {
fieldOrder: ["enabled", "cert", "key"],
advancedFields: [],
},
networking: {
fieldOrder: ["ipv6"],
advancedFields: [],
},
proxy: {
fieldOrder: [
"header_map",
"logout_url",
"auth_secret",
"default_role",
"separator",
],
advancedFields: ["header_map", "auth_secret", "separator"],
liveValidate: true,
},
ui: {
fieldOrder: [
"timezone",
"time_format",
"date_style",
"time_style",
"unit_system",
],
advancedFields: [],
},
logger: {
fieldOrder: ["default", "logs"],
advancedFields: ["logs"],
},
environment_vars: {
fieldOrder: [],
advancedFields: [],
},
telemetry: {
fieldOrder: ["network_interfaces", "stats", "version_check"],
advancedFields: [],
},
birdseye: {
fieldOrder: [
"enabled",
"restream",
"width",
"height",
"quality",
"mode",
"layout",
"inactivity_threshold",
"idle_heartbeat_fps",
],
advancedFields: ["width", "height", "quality", "inactivity_threshold"],
},
ffmpeg: {
fieldOrder: [
"path",
"global_args",
"hwaccel_args",
"input_args",
"output_args",
"retry_interval",
"apple_compatibility",
"gpu",
],
advancedFields: [
"global_args",
"hwaccel_args",
"input_args",
"output_args",
"retry_interval",
"apple_compatibility",
"gpu",
],
uiSchema: {
global_args: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
hwaccel_args: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
input_args: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
output_args: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
detect: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
record: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
},
},
},
detectors: {
fieldOrder: [],
advancedFields: [],
},
model: {
fieldOrder: [
"path",
"labelmap_path",
"width",
"height",
"input_pixel_format",
"input_tensor",
"input_dtype",
"model_type",
],
advancedFields: [
"input_pixel_format",
"input_tensor",
"input_dtype",
"model_type",
],
hiddenFields: ["labelmap", "attributes_map"],
},
genai: {
fieldOrder: [
"provider",
"api_key",
"base_url",
"model",
"provider_options",
"runtime_options",
],
advancedFields: ["base_url", "provider_options", "runtime_options"],
hiddenFields: ["genai.enabled_in_config"],
},
classification: {
hiddenFields: ["custom"],
advancedFields: [],
},
semantic_search: {
fieldOrder: ["enabled", "reindex", "model", "model_size", "device"],
advancedFields: ["reindex", "device"],
},
audio_transcription: {
fieldOrder: ["enabled", "language", "device", "model_size", "live_enabled"],
advancedFields: ["language", "device", "model_size"],
},
face_recognition: {
fieldOrder: [
"enabled",
"model_size",
"unknown_score",
"detection_threshold",
"recognition_threshold",
"min_area",
"min_faces",
"save_attempts",
"blur_confidence_filter",
"device",
],
advancedFields: [
"unknown_score",
"detection_threshold",
"recognition_threshold",
"min_area",
"min_faces",
"save_attempts",
"blur_confidence_filter",
"device",
],
},
lpr: {
fieldOrder: [
"enabled",
"model_size",
"detection_threshold",
"min_area",
"recognition_threshold",
"min_plate_length",
"format",
"match_distance",
"known_plates",
"enhancement",
"debug_save_plates",
"device",
"replace_rules",
],
advancedFields: [
"detection_threshold",
"recognition_threshold",
"min_plate_length",
"format",
"match_distance",
"known_plates",
"enhancement",
"debug_save_plates",
"device",
"replace_rules",
],
},
};
// System sections (global only)
const systemSections = [
"database",
"tls",
"auth",
"networking",
"proxy",
"ui",
"logger",
"environment_vars",
"telemetry",
"birdseye",
"ffmpeg",
"detectors",
"model",
{ key: "database", component: DatabaseSection },
{ key: "tls", component: TlsSection },
{ key: "auth", component: AuthSection },
{ key: "networking", component: NetworkingSection },
{ key: "proxy", component: ProxySection },
{ key: "ui", component: UiSection },
{ key: "logger", component: LoggerSection },
{ key: "environment_vars", component: EnvironmentVarsSection },
{ key: "telemetry", component: TelemetrySection },
{ key: "birdseye", component: BirdseyeSection },
{ key: "ffmpeg", component: FfmpegSection },
{ key: "detectors", component: DetectorsSection },
{ key: "model", component: ModelSection },
];
// Integration sections (global only)
const integrationSections = [
"mqtt",
"semantic_search",
"genai",
"face_recognition",
"lpr",
"classification",
"audio_transcription",
{ key: "mqtt", component: MqttSection },
{ key: "semantic_search", component: SemanticSearchSection },
{ key: "genai", component: GenaiSection },
{ key: "face_recognition", component: FaceRecognitionSection },
{ key: "lpr", component: LprSection },
{ key: "classification", component: ClassificationSection },
{ key: "audio_transcription", component: AudioTranscriptionSection },
];
interface GlobalConfigSectionProps {
sectionKey: string;
schema: RJSFSchema | null;
config: FrigateConfig | undefined;
onSave: () => void;
title: string;
}
function GlobalConfigSection({
sectionKey,
schema,
config,
onSave,
title,
}: GlobalConfigSectionProps) {
const sectionConfig = globalSectionConfigs[sectionKey];
const { t, i18n } = useTranslation([
"config/global",
"views/settings",
"common",
]);
const [pendingData, setPendingData] = useState<unknown | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [formKey, setFormKey] = useState(0);
const isResettingRef = useRef(false);
const formData = useMemo((): unknown => {
if (!config) return {};
return (config as unknown as Record<string, unknown>)[sectionKey];
}, [config, sectionKey]);
useEffect(() => {
setPendingData(null);
}, [formData]);
useEffect(() => {
if (isResettingRef.current) {
isResettingRef.current = false;
}
}, [formKey]);
const hasChanges = useMemo(() => {
if (!pendingData) return false;
return !isEqual(formData, pendingData);
}, [formData, pendingData]);
const handleChange = useCallback(
(data: unknown) => {
if (isResettingRef.current) {
setPendingData(null);
return;
}
if (!data || typeof data !== "object") {
setPendingData(null);
return;
}
if (isEqual(formData, data)) {
setPendingData(null);
return;
}
setPendingData(data);
},
[formData],
);
const handleReset = useCallback(() => {
isResettingRef.current = true;
setPendingData(null);
setFormKey((prev) => prev + 1);
}, []);
const handleSave = useCallback(async () => {
if (!pendingData) return;
setIsSaving(true);
try {
// await axios.put("config/set", {
// update_topic: `config/${sectionKey}`,
// config_data: {
// [sectionKey]: pendingData,
// },
// });
// log axios for debugging
console.log("Saved config section", sectionKey, pendingData);
toast.success(
t("toast.success", {
ns: "views/settings",
defaultValue: "Settings saved successfully",
}),
);
setPendingData(null);
onSave();
} catch {
toast.error(
t("toast.error", {
ns: "views/settings",
defaultValue: "Failed to save settings",
}),
);
} finally {
setIsSaving(false);
}
}, [sectionKey, pendingData, t, onSave]);
if (!schema || !sectionConfig) {
return null;
}
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Heading as="h4">{title}</Heading>
{hasChanges && (
<Badge variant="outline" className="text-xs">
{t("modified", { ns: "common", defaultValue: "Modified" })}
</Badge>
)}
</div>
<ConfigForm
key={formKey}
schema={schema}
formData={pendingData || formData}
onChange={handleChange}
fieldOrder={sectionConfig.fieldOrder}
hiddenFields={sectionConfig.hiddenFields}
advancedFields={sectionConfig.advancedFields}
liveValidate={sectionConfig.liveValidate}
uiSchema={sectionConfig.uiSchema}
showSubmit={false}
i18nNamespace="config/global"
formContext={{ level: "global", sectionI18nPrefix: sectionKey }}
disabled={isSaving}
/>
<div className="flex items-center justify-between pt-2">
<div className="flex items-center gap-2">
{hasChanges && (
<span className="text-sm text-muted-foreground">
{t("unsavedChanges", {
ns: "views/settings",
defaultValue: "You have unsaved changes",
})}
</span>
)}
</div>
<div className="flex items-center gap-2">
{hasChanges && (
<Button
onClick={handleReset}
variant="outline"
disabled={isSaving}
className="gap-2"
>
{t("reset", { ns: "common", defaultValue: "Reset" })}
</Button>
)}
<Button
onClick={handleSave}
disabled={!hasChanges || isSaving}
className="gap-2"
>
<LuSave className="h-4 w-4" />
{isSaving
? t("saving", { ns: "common", defaultValue: "Saving..." })
: t("save", { ns: "common", defaultValue: "Save" })}
</Button>
</div>
</div>
</div>
);
}
export default function GlobalConfigView() {
const { t, i18n } = useTranslation([
"views/settings",
"config/global",
"common",
]);
const defaultSharedSection = sharedSections[0]?.key ?? "";
const defaultSystemSection = systemSections[0]?.key ?? "";
const defaultIntegrationSection = integrationSections[0]?.key ?? "";
const [activeTab, setActiveTab] = useState("shared");
const [activeSection, setActiveSection] = useState("detect");
const [activeSection, setActiveSection] = useState(defaultSharedSection);
const { data: config, mutate: refreshConfig } =
useSWR<FrigateConfig>("config");
const { data: schema } = useSWR<RJSFSchema>("config/schema.json");
const handleSave = useCallback(() => {
refreshConfig();
@ -562,32 +104,29 @@ export default function GlobalConfigView() {
const currentSections = useMemo(() => {
if (activeTab === "shared") {
return sharedSections;
} else if (activeTab === "system") {
return systemSections.map((key) => ({
key,
component: null, // Uses GlobalConfigSection instead
}));
} else {
return integrationSections.map((key) => ({
key,
component: null,
}));
}
if (activeTab === "system") {
return systemSections;
}
return integrationSections;
}, [activeTab]);
// Reset active section when tab changes
const handleTabChange = useCallback((tab: string) => {
setActiveTab(tab);
if (tab === "shared") {
setActiveSection("detect");
} else if (tab === "system") {
setActiveSection("database");
} else {
setActiveSection("mqtt");
}
}, []);
const handleTabChange = useCallback(
(tab: string) => {
setActiveTab(tab);
if (tab === "shared") {
setActiveSection(defaultSharedSection);
} else if (tab === "system") {
setActiveSection(defaultSystemSection);
} else {
setActiveSection(defaultIntegrationSection);
}
},
[defaultSharedSection, defaultSystemSection, defaultIntegrationSection],
);
if (!config || !schema) {
if (!config) {
return (
<div className="flex h-full items-center justify-center">
<ActivityIndicator />
@ -666,105 +205,42 @@ export default function GlobalConfigView() {
{/* Section Content */}
<div className="scrollbar-container flex-1 overflow-y-auto pr-4">
{activeTab === "shared" && (
<>
{sharedSections.map((section) => {
const SectionComponent = section.component;
return (
<div
key={section.key}
className={cn(
activeSection === section.key ? "block" : "hidden",
)}
>
<Heading as="h4" className="mb-1">
{t(`${section.key}.label`, {
ns: "config/global",
defaultValue:
section.key.charAt(0).toUpperCase() +
section.key.slice(1).replace(/_/g, " "),
})}
</Heading>
{i18n.exists(`${section.key}.description`, {
{currentSections.map((section) => {
const SectionComponent = section.component;
return (
<div
key={section.key}
className={cn(
activeSection === section.key ? "block" : "hidden",
)}
>
<Heading as="h4" className="mb-1">
{t(`${section.key}.label`, {
ns: "config/global",
defaultValue:
section.key.charAt(0).toUpperCase() +
section.key.slice(1).replace(/_/g, " "),
})}
</Heading>
{i18n.exists(`${section.key}.description`, {
ns: "config/global",
}) && (
<p className="mb-4 text-sm text-muted-foreground">
{t(`${section.key}.description`, {
ns: "config/global",
}) && (
<p className="mb-4 text-sm text-muted-foreground">
{t(`${section.key}.description`, {
ns: "config/global",
})}
</p>
)}
})}
</p>
)}
<SectionComponent
level="global"
onSave={handleSave}
showTitle={false}
/>
</div>
);
})}
</>
)}
{activeTab === "system" && (
<>
{systemSections.map((sectionKey) => {
const sectionTitle = t(`${sectionKey}.label`, {
ns: "config/global",
defaultValue:
sectionKey.charAt(0).toUpperCase() +
sectionKey.slice(1).replace(/_/g, " "),
});
return (
<div
key={sectionKey}
className={cn(
activeSection === sectionKey ? "block" : "hidden",
)}
>
<GlobalConfigSection
sectionKey={sectionKey}
schema={extractSchemaSection(schema, sectionKey)}
config={config}
onSave={handleSave}
title={sectionTitle}
/>
</div>
);
})}
</>
)}
{activeTab === "integrations" && (
<>
{integrationSections.map((sectionKey) => {
const sectionTitle = t(`${sectionKey}.label`, {
ns: "config/global",
defaultValue:
sectionKey.charAt(0).toUpperCase() +
sectionKey.slice(1).replace(/_/g, " "),
});
return (
<div
key={sectionKey}
className={cn(
activeSection === sectionKey ? "block" : "hidden",
)}
>
<GlobalConfigSection
sectionKey={sectionKey}
schema={extractSchemaSection(schema, sectionKey)}
config={config}
onSave={handleSave}
title={sectionTitle}
/>
</div>
);
})}
</>
)}
<SectionComponent
level="global"
onSave={handleSave}
showTitle={false}
sectionConfig={getSectionConfig(section.key, "global")}
/>
</div>
);
})}
</div>
</div>
</Tabs>

View File

@ -0,0 +1,78 @@
import { useTranslation } from "react-i18next";
import Heading from "@/components/ui/heading";
import type {
BaseSectionProps,
SectionConfig,
} from "@/components/config-form/sections";
import type { PolygonType } from "@/types/canvas";
export type SettingsPageProps = {
selectedCamera?: string;
setUnsavedChanges?: React.Dispatch<React.SetStateAction<boolean>>;
selectedZoneMask?: PolygonType[];
};
export type SingleSectionPageOptions = {
sectionKey: string;
level: "global" | "camera";
SectionComponent: React.ComponentType<BaseSectionProps>;
sectionConfig?: SectionConfig;
requiresRestart?: boolean;
showOverrideIndicator?: boolean;
};
export function createSingleSectionPage({
sectionKey,
level,
SectionComponent,
sectionConfig,
requiresRestart,
showOverrideIndicator = true,
}: SingleSectionPageOptions) {
return function SingleSectionPage({
selectedCamera,
setUnsavedChanges,
}: SettingsPageProps) {
const sectionNamespace =
level === "camera" ? "config/cameras" : "config/global";
const { t, i18n } = useTranslation([
sectionNamespace,
"views/settings",
"common",
]);
if (level === "camera" && !selectedCamera) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
{t("configForm.camera.noCameras", { ns: "views/settings" })}
</div>
);
}
return (
<div className="flex size-full flex-col pr-2">
<div className="mb-4">
<Heading as="h2">
{t(`${sectionKey}.label`, { ns: sectionNamespace })}
</Heading>
{i18n.exists(`${sectionKey}.description`, {
ns: sectionNamespace,
}) && (
<p className="text-sm text-muted-foreground">
{t(`${sectionKey}.description`, { ns: sectionNamespace })}
</p>
)}
</div>
<SectionComponent
level={level}
cameraName={level === "camera" ? selectedCamera : undefined}
showOverrideIndicator={showOverrideIndicator}
onSave={() => setUnsavedChanges?.(false)}
showTitle={false}
sectionConfig={sectionConfig}
requiresRestart={requiresRestart}
/>
</div>
);
};
}