add global sections, camera config overrides, and reset button

This commit is contained in:
Josh Hawkins 2026-01-25 10:33:57 -06:00
parent 260237bf1a
commit 7692c89234
15 changed files with 682 additions and 60 deletions

View File

@ -16,11 +16,11 @@ export interface ConfigFormProps {
/** JSON Schema for the form */ /** JSON Schema for the form */
schema: RJSFSchema; schema: RJSFSchema;
/** Current form data */ /** Current form data */
formData?: Record<string, unknown>; formData?: unknown;
/** Called when form data changes */ /** Called when form data changes */
onChange?: (data: Record<string, unknown>) => void; onChange?: (data: unknown) => void;
/** Called when form is submitted */ /** Called when form is submitted */
onSubmit?: (data: Record<string, unknown>) => void; onSubmit?: (data: unknown) => void;
/** Called when form has errors on submit */ /** Called when form has errors on submit */
onError?: (errors: unknown[]) => void; onError?: (errors: unknown[]) => void;
/** Additional uiSchema overrides */ /** Additional uiSchema overrides */

View File

@ -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;

View File

@ -1,7 +1,7 @@
// Base Section Component for config form sections // Base Section Component for config form sections
// Used as a foundation for reusable section components // 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 useSWR from "swr";
import axios from "axios"; import axios from "axios";
import { toast } from "sonner"; import { toast } from "sonner";
@ -43,6 +43,8 @@ export interface SectionConfig {
hiddenFields?: string[]; hiddenFields?: string[];
/** Fields to show in advanced section */ /** Fields to show in advanced section */
advancedFields?: string[]; advancedFields?: string[];
/** Fields to compare for override detection */
overrideFields?: string[];
/** Additional uiSchema overrides */ /** Additional uiSchema overrides */
uiSchema?: UiSchema; uiSchema?: UiSchema;
} }
@ -98,6 +100,17 @@ export function createConfigSection({
review: "review", review: "review",
audio: "audio", audio: "audio",
notifications: "notifications", 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({ const ConfigSection = function ConfigSection({
@ -120,6 +133,8 @@ export function createConfigSection({
unknown unknown
> | null>(null); > | null>(null);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [formKey, setFormKey] = useState(0);
const isResettingRef = useRef(false);
const updateTopic = const updateTopic =
level === "camera" && cameraName level === "camera" && cameraName
@ -143,6 +158,7 @@ export function createConfigSection({
config, config,
cameraName: level === "camera" ? cameraName : undefined, cameraName: level === "camera" ? cameraName : undefined,
sectionPath, sectionPath,
compareFields: sectionConfig.overrideFields,
}); });
// Get current form data // Get current form data
@ -193,6 +209,18 @@ export function createConfigSection({
return applySchemaDefaults(sectionSchema, {}); return applySchemaDefaults(sectionSchema, {});
}, [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( const buildOverrides = useCallback(
( (
current: unknown, current: unknown,
@ -266,8 +294,18 @@ export function createConfigSection({
// Handle form data change // Handle form data change
const handleChange = useCallback( const handleChange = useCallback(
(data: Record<string, unknown>) => { (data: unknown) => {
const sanitizedData = sanitizeSectionData(data); if (isResettingRef.current) {
setPendingData(null);
return;
}
if (!data || typeof data !== "object") {
setPendingData(null);
return;
}
const sanitizedData = sanitizeSectionData(
data as Record<string, unknown>,
);
if (isEqual(formData, sanitizedData)) { if (isEqual(formData, sanitizedData)) {
setPendingData(null); setPendingData(null);
return; return;
@ -277,6 +315,12 @@ export function createConfigSection({
[formData, sanitizeSectionData], [formData, sanitizeSectionData],
); );
const handleReset = useCallback(() => {
isResettingRef.current = true;
setPendingData(null);
setFormKey((prev) => prev + 1);
}, []);
// Handle save button click // Handle save button click
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
if (!pendingData) return; if (!pendingData) return;
@ -417,6 +461,7 @@ export function createConfigSection({
const sectionContent = ( const sectionContent = (
<div className="space-y-6"> <div className="space-y-6">
<ConfigForm <ConfigForm
key={formKey}
schema={sectionSchema} schema={sectionSchema}
formData={pendingData || formData} formData={pendingData || formData}
onChange={handleChange} onChange={handleChange}
@ -454,16 +499,28 @@ export function createConfigSection({
</span> </span>
)} )}
</div> </div>
<Button <div className="flex items-center gap-2">
onClick={handleSave} {hasChanges && (
disabled={!hasChanges || isSaving || disabled} <Button
className="gap-2" onClick={handleReset}
> variant="outline"
<LuSave className="h-4 w-4" /> disabled={isSaving || disabled}
{isSaving className="gap-2"
? t("saving", { ns: "common", defaultValue: "Saving..." }) >
: t("save", { ns: "common", defaultValue: "Save" })} {t("reset", { ns: "common", defaultValue: "Reset" })}
</Button> </Button>
)}
<Button
onClick={handleSave}
disabled={!hasChanges || isSaving || disabled}
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>
</div> </div>
); );

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -15,6 +15,15 @@ export { MotionSection } from "./MotionSection";
export { ObjectsSection } from "./ObjectsSection"; export { ObjectsSection } from "./ObjectsSection";
export { ReviewSection } from "./ReviewSection"; export { ReviewSection } from "./ReviewSection";
export { AudioSection } from "./AudioSection"; 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 { NotificationsSection } from "./NotificationsSection";
export { OnvifSection } from "./OnvifSection";
export { LiveSection } from "./LiveSection"; export { LiveSection } from "./LiveSection";
export { SemanticSearchSection } from "./SemanticSearchSection";
export { TimestampSection } from "./TimestampSection"; export { TimestampSection } from "./TimestampSection";

View File

@ -2,6 +2,7 @@
import { useMemo } from "react"; import { useMemo } from "react";
import isEqual from "lodash/isEqual"; import isEqual from "lodash/isEqual";
import get from "lodash/get"; import get from "lodash/get";
import set from "lodash/set";
import type { FrigateConfig } from "@/types/frigateConfig"; import type { FrigateConfig } from "@/types/frigateConfig";
const INTERNAL_FIELD_SUFFIXES = ["enabled_in_config", "raw_mask"]; const INTERNAL_FIELD_SUFFIXES = ["enabled_in_config", "raw_mask"];
@ -46,6 +47,24 @@ export interface UseConfigOverrideOptions {
cameraName?: string; cameraName?: string;
/** Config section path (e.g., "detect", "record.events") */ /** Config section path (e.g., "detect", "record.events") */
sectionPath: string; sectionPath: string;
/** Optional list of field paths to compare for overrides */
compareFields?: string[];
}
function pickFields(value: unknown, fields: string[]): Record<string, unknown> {
if (!fields || fields.length === 0) {
return {};
}
const result: Record<string, unknown> = {};
fields.forEach((path) => {
if (!path) return;
const fieldValue = get(value as Record<string, unknown>, path);
if (fieldValue !== undefined) {
set(result, path, fieldValue);
}
});
return result;
} }
/** /**
@ -72,6 +91,7 @@ export function useConfigOverride({
config, config,
cameraName, cameraName,
sectionPath, sectionPath,
compareFields,
}: UseConfigOverrideOptions) { }: UseConfigOverrideOptions) {
return useMemo(() => { return useMemo(() => {
if (!config) { if (!config) {
@ -127,8 +147,17 @@ export function useConfigOverride({
const normalizedGlobalValue = normalizeConfigValue(globalValue); const normalizedGlobalValue = normalizeConfigValue(globalValue);
const normalizedCameraValue = normalizeConfigValue(cameraValue); 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 // 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 * Get override status for a specific field within the section
@ -161,7 +190,7 @@ export function useConfigOverride({
getFieldOverride, getFieldOverride,
resetToGlobal, resetToGlobal,
}; };
}, [config, cameraName, sectionPath]); }, [config, cameraName, sectionPath, compareFields]);
} }
/** /**
@ -184,25 +213,62 @@ export function useAllCameraOverrides(
const overriddenSections: string[] = []; const overriddenSections: string[] = [];
// Check each section that can be overridden // Check each section that can be overridden
const sectionsToCheck = [ const sectionsToCheck: Array<{
"detect", key: string;
"record", compareFields?: string[];
"snapshots", }> = [
"motion", { key: "detect" },
"objects", { key: "record" },
"review", { key: "snapshots" },
"audio", { key: "motion" },
"notifications", { key: "objects" },
"live", { key: "review" },
"timestamp_style", { 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) { for (const { key, compareFields } of sectionsToCheck) {
const globalValue = normalizeConfigValue(get(config, section)); const globalValue = normalizeConfigValue(get(config, key));
const cameraValue = normalizeConfigValue(get(cameraConfig, section)); const cameraValue = normalizeConfigValue(get(cameraConfig, key));
if (!isEqual(globalValue, cameraValue)) { const comparisonGlobal = compareFields
overriddenSections.push(section); ? pickFields(globalValue, compareFields)
: globalValue;
const comparisonCamera = compareFields
? pickFields(cameraValue, compareFields)
: cameraValue;
if (
compareFields && compareFields.length === 0
? false
: !isEqual(comparisonGlobal, comparisonCamera)
) {
overriddenSections.push(key);
} }
} }

View File

@ -11,8 +11,17 @@ import { MotionSection } from "@/components/config-form/sections/MotionSection";
import { ObjectsSection } from "@/components/config-form/sections/ObjectsSection"; import { ObjectsSection } from "@/components/config-form/sections/ObjectsSection";
import { ReviewSection } from "@/components/config-form/sections/ReviewSection"; import { ReviewSection } from "@/components/config-form/sections/ReviewSection";
import { AudioSection } from "@/components/config-form/sections/AudioSection"; 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 { NotificationsSection } from "@/components/config-form/sections/NotificationsSection";
import { OnvifSection } from "@/components/config-form/sections/OnvifSection";
import { LiveSection } from "@/components/config-form/sections/LiveSection"; 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 { TimestampSection } from "@/components/config-form/sections/TimestampSection";
import { useAllCameraOverrides } from "@/hooks/use-config-override"; import { useAllCameraOverrides } from "@/hooks/use-config-override";
import type { FrigateConfig } from "@/types/frigateConfig"; import type { FrigateConfig } from "@/types/frigateConfig";
@ -179,8 +188,17 @@ const CameraConfigContent = memo(function CameraConfigContent({
"config/objects", "config/objects",
"config/review", "config/review",
"config/audio", "config/audio",
"config/audio_transcription",
"config/birdseye",
"config/camera_mqtt",
"config/camera_ui",
"config/face_recognition",
"config/ffmpeg",
"config/lpr",
"config/notifications", "config/notifications",
"config/onvif",
"config/live", "config/live",
"config/semantic_search",
"config/timestamp_style", "config/timestamp_style",
"views/settings", "views/settings",
"common", "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", key: "detect",
i18nNamespace: "config/detect", i18nNamespace: "config/detect",
component: DetectSection, component: DetectSection,
}, },
{
key: "ffmpeg",
i18nNamespace: "config/ffmpeg",
component: FfmpegSection,
showOverrideIndicator: true,
},
{ {
key: "record", key: "record",
i18nNamespace: "config/record", i18nNamespace: "config/record",
@ -232,12 +261,60 @@ const CameraConfigContent = memo(function CameraConfigContent({
component: ReviewSection, component: ReviewSection,
}, },
{ key: "audio", i18nNamespace: "config/audio", component: AudioSection }, { key: "audio", i18nNamespace: "config/audio", component: AudioSection },
{
key: "audio_transcription",
i18nNamespace: "config/audio_transcription",
component: AudioTranscriptionSection,
showOverrideIndicator: true,
},
{ {
key: "notifications", key: "notifications",
i18nNamespace: "config/notifications", i18nNamespace: "config/notifications",
component: NotificationsSection, component: NotificationsSection,
}, },
{ key: "live", i18nNamespace: "config/live", component: LiveSection }, { 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", key: "timestamp_style",
i18nNamespace: "config/timestamp_style", i18nNamespace: "config/timestamp_style",
@ -273,9 +350,9 @@ const CameraConfigContent = memo(function CameraConfigContent({
<span>{sectionLabel}</span> <span>{sectionLabel}</span>
{isOverridden && ( {isOverridden && (
<Badge variant="secondary" className="h-5 px-1.5 text-xs"> <Badge variant="secondary" className="h-5 px-1.5 text-xs">
{t("button.modified", { {t("button.overridden", {
ns: "common", ns: "common",
defaultValue: "Modified", defaultValue: "Overridden",
})} })}
</Badge> </Badge>
)} )}
@ -298,7 +375,7 @@ const CameraConfigContent = memo(function CameraConfigContent({
<SectionComponent <SectionComponent
level="camera" level="camera"
cameraName={cameraName} cameraName={cameraName}
showOverrideIndicator showOverrideIndicator={section.showOverrideIndicator !== false}
onSave={onSave} onSave={onSave}
showTitle={true} showTitle={true}
/> />

View File

@ -79,6 +79,7 @@ const globalSectionConfigs: Record<
"topic_prefix", "topic_prefix",
"client_id", "client_id",
"stats_interval", "stats_interval",
"qos",
"tls_ca_certs", "tls_ca_certs",
"tls_client_cert", "tls_client_cert",
"tls_client_key", "tls_client_key",
@ -86,6 +87,7 @@ const globalSectionConfigs: Record<
], ],
advancedFields: [ advancedFields: [
"stats_interval", "stats_interval",
"qos",
"tls_ca_certs", "tls_ca_certs",
"tls_client_cert", "tls_client_cert",
"tls_client_key", "tls_client_key",
@ -102,17 +104,71 @@ const globalSectionConfigs: Record<
fieldOrder: [ fieldOrder: [
"enabled", "enabled",
"reset_admin_password", "reset_admin_password",
"cookie_name",
"cookie_secure",
"session_length",
"refresh_time",
"native_oauth_url", "native_oauth_url",
"failed_login_rate_limit", "failed_login_rate_limit",
"trusted_proxies", "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: { tls: {
i18nNamespace: "config/tls", i18nNamespace: "config/tls",
fieldOrder: ["enabled", "cert", "key"], fieldOrder: ["enabled", "cert", "key"],
advancedFields: [], 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: { telemetry: {
i18nNamespace: "config/telemetry", i18nNamespace: "config/telemetry",
fieldOrder: ["network_interfaces", "stats", "version_check"], fieldOrder: ["network_interfaces", "stats", "version_check"],
@ -129,39 +185,191 @@ const globalSectionConfigs: Record<
"mode", "mode",
"layout", "layout",
"inactivity_threshold", "inactivity_threshold",
"idle_heartbeat_fps",
], ],
advancedFields: ["width", "height", "quality", "inactivity_threshold"], 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: { semantic_search: {
i18nNamespace: "config/semantic_search", i18nNamespace: "config/semantic_search",
fieldOrder: ["enabled", "reindex", "model_size"], fieldOrder: ["enabled", "reindex", "model", "model_size", "device"],
advancedFields: ["reindex"], advancedFields: ["reindex", "device"],
},
audio_transcription: {
i18nNamespace: "config/audio_transcription",
fieldOrder: ["enabled", "language", "device", "model_size", "live_enabled"],
advancedFields: ["language", "device", "model_size"],
}, },
face_recognition: { face_recognition: {
i18nNamespace: "config/face_recognition", i18nNamespace: "config/face_recognition",
fieldOrder: ["enabled", "threshold", "min_area", "model_size"], fieldOrder: [
advancedFields: ["threshold", "min_area"], "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: { lpr: {
i18nNamespace: "config/lpr", i18nNamespace: "config/lpr",
fieldOrder: [ fieldOrder: [
"enabled", "enabled",
"threshold",
"min_area",
"min_ratio",
"max_ratio",
"model_size", "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) // 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) // Integration sections (global only)
const integrationSections = [ const integrationSections = [
"mqtt", "mqtt",
"audio_transcription",
"genai",
"semantic_search", "semantic_search",
"face_recognition", "face_recognition",
"lpr", "lpr",
@ -186,18 +394,12 @@ const GlobalConfigSection = memo(function GlobalConfigSection({
"views/settings", "views/settings",
"common", "common",
]); ]);
const [pendingData, setPendingData] = useState<Record< const [pendingData, setPendingData] = useState<unknown | null>(null);
string,
unknown
> | null>(null);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const formData = useMemo((): Record<string, unknown> => { const formData = useMemo((): unknown => {
if (!config) return {} as Record<string, unknown>; if (!config) return {};
const value = (config as unknown as Record<string, unknown>)[sectionKey]; return (config as unknown as Record<string, unknown>)[sectionKey];
return (
(value as Record<string, unknown>) || ({} as Record<string, unknown>)
);
}, [config, sectionKey]); }, [config, sectionKey]);
const hasChanges = useMemo(() => { const hasChanges = useMemo(() => {
@ -205,7 +407,7 @@ const GlobalConfigSection = memo(function GlobalConfigSection({
return !isEqual(formData, pendingData); return !isEqual(formData, pendingData);
}, [formData, pendingData]); }, [formData, pendingData]);
const handleChange = useCallback((data: Record<string, unknown>) => { const handleChange = useCallback((data: unknown) => {
setPendingData(data); setPendingData(data);
}, []); }, []);
@ -300,14 +502,29 @@ export default function GlobalConfigView() {
"config/live", "config/live",
"config/timestamp_style", "config/timestamp_style",
"config/mqtt", "config/mqtt",
"config/audio_transcription",
"config/database", "config/database",
"config/auth", "config/auth",
"config/tls", "config/tls",
"config/networking",
"config/proxy",
"config/ui",
"config/logger",
"config/environment_vars",
"config/telemetry", "config/telemetry",
"config/birdseye", "config/birdseye",
"config/ffmpeg",
"config/detectors",
"config/model",
"config/genai",
"config/classification",
"config/semantic_search", "config/semantic_search",
"config/face_recognition", "config/face_recognition",
"config/lpr", "config/lpr",
"config/go2rtc",
"config/camera_groups",
"config/safe_mode",
"config/version",
"common", "common",
]); ]);
const [activeTab, setActiveTab] = useState("shared"); const [activeTab, setActiveTab] = useState("shared");