// Global Configuration View // Main view for configuring global Frigate settings import { useMemo, useCallback, useState, useEffect, useRef } 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"; 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 { NotificationsSection } from "@/components/config-form/sections/NotificationsSection"; import { LiveSection } from "@/components/config-form/sections/LiveSection"; import { TimestampSection } from "@/components/config-form/sections/TimestampSection"; import type { RJSFSchema } from "@rjsf/utils"; 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"; // Shared sections that can be overridden at camera level const sharedSections = [ { key: "detect", i18nNamespace: "config/detect", component: DetectSection }, { key: "record", i18nNamespace: "config/record", component: RecordSection }, { key: "snapshots", i18nNamespace: "config/snapshots", component: SnapshotsSection, }, { key: "motion", i18nNamespace: "config/motion", component: MotionSection }, { key: "objects", i18nNamespace: "config/objects", component: ObjectsSection, }, { key: "review", i18nNamespace: "config/review", component: ReviewSection }, { key: "audio", i18nNamespace: "config/audio", component: AudioSection }, { key: "notifications", i18nNamespace: "config/notifications", component: NotificationsSection, }, { key: "live", i18nNamespace: "config/live", component: LiveSection }, { key: "timestamp_style", i18nNamespace: "config/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; i18nNamespace: string; } > = { mqtt: { i18nNamespace: "config/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", ], }, database: { i18nNamespace: "config/database", fieldOrder: ["path"], advancedFields: [], }, auth: { i18nNamespace: "config/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", "admin_first_time_login", ], }, 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"], liveValidate: true, }, 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"], advancedFields: ["stats"], }, birdseye: { i18nNamespace: "config/birdseye", fieldOrder: [ "enabled", "restream", "width", "height", "quality", "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", "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", "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", "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", ], }, 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", "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", ]; 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 } = useTranslation([ sectionConfig?.i18nNamespace || "common", "views/settings", "common", ]); const [pendingData, setPendingData] = useState(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)[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, }, }); 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 (
{title} {hasChanges && ( {t("modified", { ns: "common", defaultValue: "Modified" })} )}
{hasChanges && ( {t("unsavedChanges", { ns: "views/settings", defaultValue: "You have unsaved changes", })} )}
{hasChanges && ( )}
); } export default function GlobalConfigView() { const { t } = useTranslation([ "views/settings", "config/detect", "config/record", "config/snapshots", "config/motion", "config/objects", "config/review", "config/audio", "config/notifications", "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"); const [activeSection, setActiveSection] = useState("detect"); const { data: config, mutate: refreshConfig } = useSWR("config"); const { data: schema } = useSWR("config/schema.json"); const handleSave = useCallback(() => { refreshConfig(); }, [refreshConfig]); // Get the sections for the current tab const currentSections = useMemo(() => { if (activeTab === "shared") { return sharedSections; } else if (activeTab === "system") { return systemSections.map((key) => ({ key, i18nNamespace: globalSectionConfigs[key].i18nNamespace, component: null, // Uses GlobalConfigSection instead })); } else { return integrationSections.map((key) => ({ key, i18nNamespace: globalSectionConfigs[key].i18nNamespace, component: null, })); } }, [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"); } }, []); if (!config || !schema) { return (
); } return (
{t("configForm.global.title", { defaultValue: "Global Configuration", })}

{t("configForm.global.description", { defaultValue: "Configure global settings that apply to all cameras by default.", })}

{t("configForm.global.tabs.shared", { defaultValue: "Shared Defaults", })} {t("configForm.global.tabs.system", { defaultValue: "System" })} {t("configForm.global.tabs.integrations", { defaultValue: "Integrations", })}
{/* Section Navigation */} {/* Section Content */}
{activeTab === "shared" && ( <> {sharedSections.map((section) => { const SectionComponent = section.component; return (
{t("label", { ns: section.i18nNamespace, defaultValue: section.key.charAt(0).toUpperCase() + section.key.slice(1).replace(/_/g, " "), })}
); })} )} {activeTab === "system" && ( <> {systemSections.map((sectionKey) => { const sectionTitle = t("label", { ns: globalSectionConfigs[sectionKey].i18nNamespace, defaultValue: sectionKey.charAt(0).toUpperCase() + sectionKey.slice(1).replace(/_/g, " "), }); return (
); })} )} {activeTab === "integrations" && ( <> {integrationSections.map((sectionKey) => { const sectionTitle = t("label", { ns: globalSectionConfigs[sectionKey].i18nNamespace, defaultValue: sectionKey.charAt(0).toUpperCase() + sectionKey.slice(1).replace(/_/g, " "), }); return (
); })} )}
); }