diff --git a/web/src/components/config-form/ConfigForm.tsx b/web/src/components/config-form/ConfigForm.tsx index 4597939de..2945468dc 100644 --- a/web/src/components/config-form/ConfigForm.tsx +++ b/web/src/components/config-form/ConfigForm.tsx @@ -16,11 +16,11 @@ export interface ConfigFormProps { /** JSON Schema for the form */ schema: RJSFSchema; /** Current form data */ - formData?: Record; + formData?: unknown; /** Called when form data changes */ - onChange?: (data: Record) => void; + onChange?: (data: unknown) => void; /** Called when form is submitted */ - onSubmit?: (data: Record) => void; + onSubmit?: (data: unknown) => void; /** Called when form has errors on submit */ onError?: (errors: unknown[]) => void; /** Additional uiSchema overrides */ diff --git a/web/src/components/config-form/sections/AudioTranscriptionSection.tsx b/web/src/components/config-form/sections/AudioTranscriptionSection.tsx new file mode 100644 index 000000000..cdd6951d4 --- /dev/null +++ b/web/src/components/config-form/sections/AudioTranscriptionSection.tsx @@ -0,0 +1,17 @@ +// Audio Transcription Section Component +// Global and camera-level audio transcription settings + +import { createConfigSection } from "./BaseSection"; + +export const AudioTranscriptionSection = createConfigSection({ + sectionPath: "audio_transcription", + i18nNamespace: "config/audio_transcription", + defaultConfig: { + fieldOrder: ["enabled", "language", "device", "model_size", "live_enabled"], + hiddenFields: ["enabled_in_config"], + advancedFields: ["language", "device", "model_size"], + overrideFields: ["enabled", "live_enabled"], + }, +}); + +export default AudioTranscriptionSection; diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index 2a07c2857..5d024b775 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -1,7 +1,7 @@ // Base Section Component for config form sections // Used as a foundation for reusable section components -import { useMemo, useCallback, useState } from "react"; +import { useMemo, useCallback, useState, useEffect, useRef } from "react"; import useSWR from "swr"; import axios from "axios"; import { toast } from "sonner"; @@ -43,6 +43,8 @@ export interface SectionConfig { hiddenFields?: string[]; /** Fields to show in advanced section */ advancedFields?: string[]; + /** Fields to compare for override detection */ + overrideFields?: string[]; /** Additional uiSchema overrides */ uiSchema?: UiSchema; } @@ -98,6 +100,17 @@ export function createConfigSection({ 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", }; const ConfigSection = function ConfigSection({ @@ -120,6 +133,8 @@ export function createConfigSection({ unknown > | null>(null); const [isSaving, setIsSaving] = useState(false); + const [formKey, setFormKey] = useState(0); + const isResettingRef = useRef(false); const updateTopic = level === "camera" && cameraName @@ -143,6 +158,7 @@ export function createConfigSection({ config, cameraName: level === "camera" ? cameraName : undefined, sectionPath, + compareFields: sectionConfig.overrideFields, }); // Get current form data @@ -193,6 +209,18 @@ export function createConfigSection({ return applySchemaDefaults(sectionSchema, {}); }, [sectionSchema]); + // Clear pendingData whenever formData changes (e.g., from server refresh) + // This prevents RJSF's initial onChange call from being treated as a user edit + useEffect(() => { + setPendingData(null); + }, [formData]); + + useEffect(() => { + if (isResettingRef.current) { + isResettingRef.current = false; + } + }, [formKey]); + const buildOverrides = useCallback( ( current: unknown, @@ -266,8 +294,18 @@ export function createConfigSection({ // Handle form data change const handleChange = useCallback( - (data: Record) => { - const sanitizedData = sanitizeSectionData(data); + (data: unknown) => { + if (isResettingRef.current) { + setPendingData(null); + return; + } + if (!data || typeof data !== "object") { + setPendingData(null); + return; + } + const sanitizedData = sanitizeSectionData( + data as Record, + ); if (isEqual(formData, sanitizedData)) { setPendingData(null); return; @@ -277,6 +315,12 @@ export function createConfigSection({ [formData, sanitizeSectionData], ); + const handleReset = useCallback(() => { + isResettingRef.current = true; + setPendingData(null); + setFormKey((prev) => prev + 1); + }, []); + // Handle save button click const handleSave = useCallback(async () => { if (!pendingData) return; @@ -417,6 +461,7 @@ export function createConfigSection({ const sectionContent = (
)}
- +
+ {hasChanges && ( + + )} + +
); diff --git a/web/src/components/config-form/sections/BirdseyeSection.tsx b/web/src/components/config-form/sections/BirdseyeSection.tsx new file mode 100644 index 000000000..fc14d4c2c --- /dev/null +++ b/web/src/components/config-form/sections/BirdseyeSection.tsx @@ -0,0 +1,17 @@ +// Birdseye Section Component +// Camera-level birdseye settings + +import { createConfigSection } from "./BaseSection"; + +export const BirdseyeSection = createConfigSection({ + sectionPath: "birdseye", + i18nNamespace: "config/birdseye", + defaultConfig: { + fieldOrder: ["enabled", "mode", "order"], + hiddenFields: [], + advancedFields: [], + overrideFields: ["enabled", "mode"], + }, +}); + +export default BirdseyeSection; diff --git a/web/src/components/config-form/sections/CameraMqttSection.tsx b/web/src/components/config-form/sections/CameraMqttSection.tsx new file mode 100644 index 000000000..75b90313f --- /dev/null +++ b/web/src/components/config-form/sections/CameraMqttSection.tsx @@ -0,0 +1,25 @@ +// Camera MQTT Section Component +// Camera-specific MQTT image publishing settings + +import { createConfigSection } from "./BaseSection"; + +export const CameraMqttSection = createConfigSection({ + sectionPath: "mqtt", + i18nNamespace: "config/camera_mqtt", + defaultConfig: { + fieldOrder: [ + "enabled", + "timestamp", + "bounding_box", + "crop", + "height", + "required_zones", + "quality", + ], + hiddenFields: [], + advancedFields: ["height", "quality"], + overrideFields: [], + }, +}); + +export default CameraMqttSection; diff --git a/web/src/components/config-form/sections/CameraUiSection.tsx b/web/src/components/config-form/sections/CameraUiSection.tsx new file mode 100644 index 000000000..2e980b25a --- /dev/null +++ b/web/src/components/config-form/sections/CameraUiSection.tsx @@ -0,0 +1,17 @@ +// Camera UI Section Component +// Camera UI display settings + +import { createConfigSection } from "./BaseSection"; + +export const CameraUiSection = createConfigSection({ + sectionPath: "ui", + i18nNamespace: "config/camera_ui", + defaultConfig: { + fieldOrder: ["dashboard", "order"], + hiddenFields: [], + advancedFields: [], + overrideFields: [], + }, +}); + +export default CameraUiSection; diff --git a/web/src/components/config-form/sections/FaceRecognitionSection.tsx b/web/src/components/config-form/sections/FaceRecognitionSection.tsx new file mode 100644 index 000000000..70819cbbe --- /dev/null +++ b/web/src/components/config-form/sections/FaceRecognitionSection.tsx @@ -0,0 +1,17 @@ +// Face Recognition Section Component +// Camera-level face recognition settings + +import { createConfigSection } from "./BaseSection"; + +export const FaceRecognitionSection = createConfigSection({ + sectionPath: "face_recognition", + i18nNamespace: "config/face_recognition", + defaultConfig: { + fieldOrder: ["enabled", "min_area"], + hiddenFields: [], + advancedFields: ["min_area"], + overrideFields: ["enabled", "min_area"], + }, +}); + +export default FaceRecognitionSection; diff --git a/web/src/components/config-form/sections/FfmpegSection.tsx b/web/src/components/config-form/sections/FfmpegSection.tsx new file mode 100644 index 000000000..0b78a0645 --- /dev/null +++ b/web/src/components/config-form/sections/FfmpegSection.tsx @@ -0,0 +1,44 @@ +// FFmpeg Section Component +// Global and camera-level FFmpeg settings + +import { createConfigSection } from "./BaseSection"; + +export const FfmpegSection = createConfigSection({ + sectionPath: "ffmpeg", + i18nNamespace: "config/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", + ], + }, +}); + +export default FfmpegSection; diff --git a/web/src/components/config-form/sections/LprSection.tsx b/web/src/components/config-form/sections/LprSection.tsx new file mode 100644 index 000000000..d34bc6565 --- /dev/null +++ b/web/src/components/config-form/sections/LprSection.tsx @@ -0,0 +1,17 @@ +// License Plate Recognition Section Component +// Camera-level LPR settings + +import { createConfigSection } from "./BaseSection"; + +export const LprSection = createConfigSection({ + sectionPath: "lpr", + i18nNamespace: "config/lpr", + defaultConfig: { + fieldOrder: ["enabled", "expire_time", "min_area", "enhancement"], + hiddenFields: [], + advancedFields: ["expire_time", "min_area", "enhancement"], + overrideFields: ["enabled", "min_area", "enhancement"], + }, +}); + +export default LprSection; diff --git a/web/src/components/config-form/sections/OnvifSection.tsx b/web/src/components/config-form/sections/OnvifSection.tsx new file mode 100644 index 000000000..61069f394 --- /dev/null +++ b/web/src/components/config-form/sections/OnvifSection.tsx @@ -0,0 +1,25 @@ +// ONVIF Section Component +// Camera-level ONVIF and autotracking settings + +import { createConfigSection } from "./BaseSection"; + +export const OnvifSection = createConfigSection({ + sectionPath: "onvif", + i18nNamespace: "config/onvif", + defaultConfig: { + fieldOrder: [ + "host", + "port", + "user", + "password", + "tls_insecure", + "autotracking", + "ignore_time_mismatch", + ], + hiddenFields: ["autotracking.enabled_in_config"], + advancedFields: ["tls_insecure", "autotracking", "ignore_time_mismatch"], + overrideFields: [], + }, +}); + +export default OnvifSection; diff --git a/web/src/components/config-form/sections/SemanticSearchSection.tsx b/web/src/components/config-form/sections/SemanticSearchSection.tsx new file mode 100644 index 000000000..c8afdd71a --- /dev/null +++ b/web/src/components/config-form/sections/SemanticSearchSection.tsx @@ -0,0 +1,17 @@ +// Semantic Search Section Component +// Camera-level semantic search trigger settings + +import { createConfigSection } from "./BaseSection"; + +export const SemanticSearchSection = createConfigSection({ + sectionPath: "semantic_search", + i18nNamespace: "config/semantic_search", + defaultConfig: { + fieldOrder: ["triggers"], + hiddenFields: [], + advancedFields: [], + overrideFields: [], + }, +}); + +export default SemanticSearchSection; diff --git a/web/src/components/config-form/sections/index.ts b/web/src/components/config-form/sections/index.ts index 3a16ca73a..e34a2d370 100644 --- a/web/src/components/config-form/sections/index.ts +++ b/web/src/components/config-form/sections/index.ts @@ -15,6 +15,15 @@ export { MotionSection } from "./MotionSection"; export { ObjectsSection } from "./ObjectsSection"; export { ReviewSection } from "./ReviewSection"; export { AudioSection } from "./AudioSection"; +export { AudioTranscriptionSection } from "./AudioTranscriptionSection"; +export { BirdseyeSection } from "./BirdseyeSection"; +export { CameraMqttSection } from "./CameraMqttSection"; +export { CameraUiSection } from "./CameraUiSection"; +export { FaceRecognitionSection } from "./FaceRecognitionSection"; +export { FfmpegSection } from "./FfmpegSection"; +export { LprSection } from "./LprSection"; export { NotificationsSection } from "./NotificationsSection"; +export { OnvifSection } from "./OnvifSection"; export { LiveSection } from "./LiveSection"; +export { SemanticSearchSection } from "./SemanticSearchSection"; export { TimestampSection } from "./TimestampSection"; diff --git a/web/src/hooks/use-config-override.ts b/web/src/hooks/use-config-override.ts index c6fc41975..0cfeb6d49 100644 --- a/web/src/hooks/use-config-override.ts +++ b/web/src/hooks/use-config-override.ts @@ -2,6 +2,7 @@ import { useMemo } from "react"; import isEqual from "lodash/isEqual"; import get from "lodash/get"; +import set from "lodash/set"; import type { FrigateConfig } from "@/types/frigateConfig"; const INTERNAL_FIELD_SUFFIXES = ["enabled_in_config", "raw_mask"]; @@ -46,6 +47,24 @@ export interface UseConfigOverrideOptions { cameraName?: string; /** Config section path (e.g., "detect", "record.events") */ sectionPath: string; + /** Optional list of field paths to compare for overrides */ + compareFields?: string[]; +} + +function pickFields(value: unknown, fields: string[]): Record { + if (!fields || fields.length === 0) { + return {}; + } + + const result: Record = {}; + fields.forEach((path) => { + if (!path) return; + const fieldValue = get(value as Record, path); + if (fieldValue !== undefined) { + set(result, path, fieldValue); + } + }); + return result; } /** @@ -72,6 +91,7 @@ export function useConfigOverride({ config, cameraName, sectionPath, + compareFields, }: UseConfigOverrideOptions) { return useMemo(() => { if (!config) { @@ -127,8 +147,17 @@ export function useConfigOverride({ const normalizedGlobalValue = normalizeConfigValue(globalValue); const normalizedCameraValue = normalizeConfigValue(cameraValue); + const comparisonGlobal = compareFields + ? pickFields(normalizedGlobalValue, compareFields) + : normalizedGlobalValue; + const comparisonCamera = compareFields + ? pickFields(normalizedCameraValue, compareFields) + : normalizedCameraValue; + // Check if the entire section is overridden - const isOverridden = !isEqual(normalizedGlobalValue, normalizedCameraValue); + const isOverridden = compareFields + ? compareFields.length > 0 && !isEqual(comparisonGlobal, comparisonCamera) + : !isEqual(comparisonGlobal, comparisonCamera); /** * Get override status for a specific field within the section @@ -161,7 +190,7 @@ export function useConfigOverride({ getFieldOverride, resetToGlobal, }; - }, [config, cameraName, sectionPath]); + }, [config, cameraName, sectionPath, compareFields]); } /** @@ -184,25 +213,62 @@ export function useAllCameraOverrides( const overriddenSections: string[] = []; // Check each section that can be overridden - const sectionsToCheck = [ - "detect", - "record", - "snapshots", - "motion", - "objects", - "review", - "audio", - "notifications", - "live", - "timestamp_style", + const sectionsToCheck: Array<{ + key: string; + compareFields?: string[]; + }> = [ + { key: "detect" }, + { key: "record" }, + { key: "snapshots" }, + { key: "motion" }, + { key: "objects" }, + { key: "review" }, + { key: "audio" }, + { key: "notifications" }, + { key: "live" }, + { key: "timestamp_style" }, + { + key: "audio_transcription", + compareFields: ["enabled", "live_enabled"], + }, + { key: "birdseye", compareFields: ["enabled", "mode"] }, + { key: "face_recognition", compareFields: ["enabled", "min_area"] }, + { + key: "ffmpeg", + compareFields: [ + "path", + "global_args", + "hwaccel_args", + "input_args", + "output_args", + "retry_interval", + "apple_compatibility", + "gpu", + ], + }, + { + key: "lpr", + compareFields: ["enabled", "min_area", "enhancement"], + }, ]; - for (const section of sectionsToCheck) { - const globalValue = normalizeConfigValue(get(config, section)); - const cameraValue = normalizeConfigValue(get(cameraConfig, section)); + for (const { key, compareFields } of sectionsToCheck) { + const globalValue = normalizeConfigValue(get(config, key)); + const cameraValue = normalizeConfigValue(get(cameraConfig, key)); - if (!isEqual(globalValue, cameraValue)) { - overriddenSections.push(section); + const comparisonGlobal = compareFields + ? pickFields(globalValue, compareFields) + : globalValue; + const comparisonCamera = compareFields + ? pickFields(cameraValue, compareFields) + : cameraValue; + + if ( + compareFields && compareFields.length === 0 + ? false + : !isEqual(comparisonGlobal, comparisonCamera) + ) { + overriddenSections.push(key); } } diff --git a/web/src/views/settings/CameraConfigView.tsx b/web/src/views/settings/CameraConfigView.tsx index eaab5201b..9591269e7 100644 --- a/web/src/views/settings/CameraConfigView.tsx +++ b/web/src/views/settings/CameraConfigView.tsx @@ -11,8 +11,17 @@ 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 { BirdseyeSection } from "@/components/config-form/sections/BirdseyeSection"; +import { CameraMqttSection } from "@/components/config-form/sections/CameraMqttSection"; +import { CameraUiSection } from "@/components/config-form/sections/CameraUiSection"; +import { FaceRecognitionSection } from "@/components/config-form/sections/FaceRecognitionSection"; +import { FfmpegSection } from "@/components/config-form/sections/FfmpegSection"; +import { LprSection } from "@/components/config-form/sections/LprSection"; import { NotificationsSection } from "@/components/config-form/sections/NotificationsSection"; +import { OnvifSection } from "@/components/config-form/sections/OnvifSection"; import { LiveSection } from "@/components/config-form/sections/LiveSection"; +import { SemanticSearchSection } from "@/components/config-form/sections/SemanticSearchSection"; import { TimestampSection } from "@/components/config-form/sections/TimestampSection"; import { useAllCameraOverrides } from "@/hooks/use-config-override"; import type { FrigateConfig } from "@/types/frigateConfig"; @@ -179,8 +188,17 @@ const CameraConfigContent = memo(function CameraConfigContent({ "config/objects", "config/review", "config/audio", + "config/audio_transcription", + "config/birdseye", + "config/camera_mqtt", + "config/camera_ui", + "config/face_recognition", + "config/ffmpeg", + "config/lpr", "config/notifications", + "config/onvif", "config/live", + "config/semantic_search", "config/timestamp_style", "views/settings", "common", @@ -200,12 +218,23 @@ const CameraConfigContent = memo(function CameraConfigContent({ ); } - const sections = [ + const sections: Array<{ + key: string; + i18nNamespace: string; + component: typeof DetectSection; + showOverrideIndicator?: boolean; + }> = [ { key: "detect", i18nNamespace: "config/detect", component: DetectSection, }, + { + key: "ffmpeg", + i18nNamespace: "config/ffmpeg", + component: FfmpegSection, + showOverrideIndicator: true, + }, { key: "record", i18nNamespace: "config/record", @@ -232,12 +261,60 @@ const CameraConfigContent = memo(function CameraConfigContent({ component: ReviewSection, }, { key: "audio", i18nNamespace: "config/audio", component: AudioSection }, + { + key: "audio_transcription", + i18nNamespace: "config/audio_transcription", + component: AudioTranscriptionSection, + showOverrideIndicator: true, + }, { key: "notifications", i18nNamespace: "config/notifications", component: NotificationsSection, }, { key: "live", i18nNamespace: "config/live", component: LiveSection }, + { + key: "birdseye", + i18nNamespace: "config/birdseye", + component: BirdseyeSection, + showOverrideIndicator: true, + }, + { + key: "face_recognition", + i18nNamespace: "config/face_recognition", + component: FaceRecognitionSection, + showOverrideIndicator: true, + }, + { + key: "lpr", + i18nNamespace: "config/lpr", + component: LprSection, + showOverrideIndicator: true, + }, + { + key: "semantic_search", + i18nNamespace: "config/semantic_search", + component: SemanticSearchSection, + showOverrideIndicator: false, + }, + { + key: "mqtt", + i18nNamespace: "config/camera_mqtt", + component: CameraMqttSection, + showOverrideIndicator: false, + }, + { + key: "onvif", + i18nNamespace: "config/onvif", + component: OnvifSection, + showOverrideIndicator: false, + }, + { + key: "ui", + i18nNamespace: "config/camera_ui", + component: CameraUiSection, + showOverrideIndicator: false, + }, { key: "timestamp_style", i18nNamespace: "config/timestamp_style", @@ -273,9 +350,9 @@ const CameraConfigContent = memo(function CameraConfigContent({ {sectionLabel} {isOverridden && ( - {t("button.modified", { + {t("button.overridden", { ns: "common", - defaultValue: "Modified", + defaultValue: "Overridden", })} )} @@ -298,7 +375,7 @@ const CameraConfigContent = memo(function CameraConfigContent({ diff --git a/web/src/views/settings/GlobalConfigView.tsx b/web/src/views/settings/GlobalConfigView.tsx index 7d68684b6..454a1b459 100644 --- a/web/src/views/settings/GlobalConfigView.tsx +++ b/web/src/views/settings/GlobalConfigView.tsx @@ -79,6 +79,7 @@ const globalSectionConfigs: Record< "topic_prefix", "client_id", "stats_interval", + "qos", "tls_ca_certs", "tls_client_cert", "tls_client_key", @@ -86,6 +87,7 @@ const globalSectionConfigs: Record< ], advancedFields: [ "stats_interval", + "qos", "tls_ca_certs", "tls_client_cert", "tls_client_key", @@ -102,17 +104,71 @@ const globalSectionConfigs: Record< 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", + "admin_first_time_login", + ], + advancedFields: [ + "cookie_name", + "cookie_secure", + "session_length", + "refresh_time", + "failed_login_rate_limit", + "trusted_proxies", + "hash_iterations", + "roles", + "admin_first_time_login", ], - advancedFields: ["failed_login_rate_limit", "trusted_proxies"], }, tls: { i18nNamespace: "config/tls", fieldOrder: ["enabled", "cert", "key"], advancedFields: [], }, + networking: { + i18nNamespace: "config/networking", + fieldOrder: ["ipv6"], + advancedFields: [], + }, + proxy: { + i18nNamespace: "config/proxy", + fieldOrder: [ + "header_map", + "logout_url", + "auth_secret", + "default_role", + "separator", + ], + advancedFields: ["header_map", "auth_secret", "separator"], + }, + ui: { + i18nNamespace: "config/ui", + fieldOrder: [ + "timezone", + "time_format", + "date_style", + "time_style", + "unit_system", + ], + advancedFields: [], + }, + logger: { + i18nNamespace: "config/logger", + fieldOrder: ["default", "logs"], + advancedFields: ["logs"], + }, + environment_vars: { + i18nNamespace: "config/environment_vars", + fieldOrder: [], + advancedFields: [], + }, telemetry: { i18nNamespace: "config/telemetry", fieldOrder: ["network_interfaces", "stats", "version_check"], @@ -129,39 +185,191 @@ const globalSectionConfigs: Record< "mode", "layout", "inactivity_threshold", + "idle_heartbeat_fps", ], advancedFields: ["width", "height", "quality", "inactivity_threshold"], }, + ffmpeg: { + i18nNamespace: "config/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", + ], + }, + detectors: { + i18nNamespace: "config/detectors", + fieldOrder: [], + advancedFields: [], + }, + model: { + i18nNamespace: "config/model", + fieldOrder: [ + "path", + "labelmap_path", + "width", + "height", + "input_pixel_format", + "input_tensor", + "input_dtype", + "model_type", + "labelmap", + "attributes_map", + ], + advancedFields: [ + "labelmap", + "attributes_map", + "input_pixel_format", + "input_tensor", + "input_dtype", + "model_type", + ], + }, + genai: { + i18nNamespace: "config/genai", + fieldOrder: [ + "provider", + "api_key", + "base_url", + "model", + "provider_options", + "runtime_options", + ], + advancedFields: ["base_url", "provider_options", "runtime_options"], + }, + classification: { + i18nNamespace: "config/classification", + fieldOrder: ["bird", "custom"], + advancedFields: [], + }, semantic_search: { i18nNamespace: "config/semantic_search", - fieldOrder: ["enabled", "reindex", "model_size"], - advancedFields: ["reindex"], + fieldOrder: ["enabled", "reindex", "model", "model_size", "device"], + advancedFields: ["reindex", "device"], + }, + audio_transcription: { + i18nNamespace: "config/audio_transcription", + fieldOrder: ["enabled", "language", "device", "model_size", "live_enabled"], + advancedFields: ["language", "device", "model_size"], }, face_recognition: { i18nNamespace: "config/face_recognition", - fieldOrder: ["enabled", "threshold", "min_area", "model_size"], - advancedFields: ["threshold", "min_area"], + 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: { i18nNamespace: "config/lpr", fieldOrder: [ "enabled", - "threshold", - "min_area", - "min_ratio", - "max_ratio", "model_size", + "detection_threshold", + "min_area", + "recognition_threshold", + "min_plate_length", + "format", + "match_distance", + "known_plates", + "enhancement", + "debug_save_plates", + "device", + "replace_rules", ], - advancedFields: ["threshold", "min_area", "min_ratio", "max_ratio"], + advancedFields: [ + "detection_threshold", + "recognition_threshold", + "min_plate_length", + "format", + "match_distance", + "known_plates", + "enhancement", + "debug_save_plates", + "device", + "replace_rules", + ], + }, + go2rtc: { + i18nNamespace: "config/go2rtc", + fieldOrder: [], + advancedFields: [], + }, + camera_groups: { + i18nNamespace: "config/camera_groups", + fieldOrder: ["cameras", "icon", "order"], + advancedFields: [], + }, + safe_mode: { + i18nNamespace: "config/safe_mode", + fieldOrder: [], + advancedFields: [], + }, + version: { + i18nNamespace: "config/version", + fieldOrder: [], + advancedFields: [], }, }; // System sections (global only) -const systemSections = ["database", "tls", "auth", "telemetry", "birdseye"]; +const systemSections = [ + "database", + "tls", + "auth", + "networking", + "proxy", + "ui", + "logger", + "environment_vars", + "telemetry", + "birdseye", + "ffmpeg", + "detectors", + "model", + "classification", + "go2rtc", + "camera_groups", + "safe_mode", + "version", +]; // Integration sections (global only) const integrationSections = [ "mqtt", + "audio_transcription", + "genai", "semantic_search", "face_recognition", "lpr", @@ -186,18 +394,12 @@ const GlobalConfigSection = memo(function GlobalConfigSection({ "views/settings", "common", ]); - const [pendingData, setPendingData] = useState | null>(null); + const [pendingData, setPendingData] = useState(null); const [isSaving, setIsSaving] = useState(false); - const formData = useMemo((): Record => { - if (!config) return {} as Record; - const value = (config as unknown as Record)[sectionKey]; - return ( - (value as Record) || ({} as Record) - ); + const formData = useMemo((): unknown => { + if (!config) return {}; + return (config as unknown as Record)[sectionKey]; }, [config, sectionKey]); const hasChanges = useMemo(() => { @@ -205,7 +407,7 @@ const GlobalConfigSection = memo(function GlobalConfigSection({ return !isEqual(formData, pendingData); }, [formData, pendingData]); - const handleChange = useCallback((data: Record) => { + const handleChange = useCallback((data: unknown) => { setPendingData(data); }, []); @@ -300,14 +502,29 @@ export default function GlobalConfigView() { "config/live", "config/timestamp_style", "config/mqtt", + "config/audio_transcription", "config/database", "config/auth", "config/tls", + "config/networking", + "config/proxy", + "config/ui", + "config/logger", + "config/environment_vars", "config/telemetry", "config/birdseye", + "config/ffmpeg", + "config/detectors", + "config/model", + "config/genai", + "config/classification", "config/semantic_search", "config/face_recognition", "config/lpr", + "config/go2rtc", + "config/camera_groups", + "config/safe_mode", + "config/version", "common", ]); const [activeTab, setActiveTab] = useState("shared");