import { DropdownMenu, DropdownMenuContent, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer"; import { Button } from "@/components/ui/button"; import { useCallback, useEffect, useMemo, useState } from "react"; import useOptimisticState from "@/hooks/use-optimistic-state"; import { isMobile } from "react-device-detect"; import { FaVideo } from "react-icons/fa"; import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; import useSWR from "swr"; import FilterSwitch from "@/components/filter/FilterSwitch"; import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter"; import { PolygonType } from "@/types/canvas"; import CameraReviewSettingsView from "@/views/settings/CameraReviewSettingsView"; import CameraManagementView from "@/views/settings/CameraManagementView"; import MotionTunerView from "@/views/settings/MotionTunerView"; import MasksAndZonesView from "@/views/settings/MasksAndZonesView"; import UsersView from "@/views/settings/UsersView"; import RolesView from "@/views/settings/RolesView"; import NotificationView from "@/views/settings/NotificationsSettingsView"; import UiSettingsView from "@/views/settings/UiSettingsView"; import FrigatePlusSettingsView from "@/views/settings/FrigatePlusSettingsView"; import MaintenanceSettingsView from "@/views/settings/MaintenanceSettingsView"; import { SingleSectionPage, type SettingsPageProps, } from "@/views/settings/SingleSectionPage"; import { useSearchEffect } from "@/hooks/use-overlay-state"; import { useNavigate, useSearchParams } from "react-router-dom"; import { useInitialCameraState } from "@/api/ws"; import { useIsAdmin } from "@/hooks/use-is-admin"; import { useTranslation } from "react-i18next"; import TriggerView from "@/views/settings/TriggerView"; import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; import { Sidebar, SidebarContent, SidebarGroup, SidebarGroupLabel, SidebarInset, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, SidebarProvider, } from "@/components/ui/sidebar"; import { cn } from "@/lib/utils"; import Heading from "@/components/ui/heading"; import { LuChevronRight } from "react-icons/lu"; import Logo from "@/components/Logo"; import { MobilePage, MobilePageContent, MobilePageHeader, MobilePageTitle, } from "@/components/mobile/MobilePage"; const allSettingsViews = [ "profileSettings", "globalDetect", "globalRecording", "globalSnapshots", "globalMotion", "globalObjects", "globalReview", "globalAudioEvents", "globalLivePlayback", "globalTimestampStyle", "systemDatabase", "systemTls", "systemAuthentication", "systemNetworking", "systemProxy", "systemUi", "systemLogging", "systemEnvironmentVariables", "systemTelemetry", "systemBirdseye", "systemFfmpeg", "systemDetectorHardware", "systemDetectionModel", "systemMqtt", "integrationSemanticSearch", "integrationGenerativeAi", "integrationFaceRecognition", "integrationLpr", "integrationObjectClassification", "integrationAudioTranscription", "cameraDetect", "cameraFfmpeg", "cameraRecording", "cameraSnapshots", "cameraMotion", "cameraObjects", "cameraConfigReview", "cameraAudioEvents", "cameraAudioTranscription", "cameraNotifications", "cameraLivePlayback", "cameraBirdseye", "cameraFaceRecognition", "cameraLpr", "cameraMqttConfig", "cameraOnvif", "cameraUi", "cameraTimestampStyle", "cameraManagement", "cameraReview", "masksAndZones", "motionTuner", "enrichments", "triggers", "debug", "users", "roles", "notifications", "frigateplus", "maintenance", ] as const; type SettingsType = (typeof allSettingsViews)[number]; const createSectionPage = ( sectionKey: string, level: "global" | "camera", options?: { showOverrideIndicator?: boolean }, ) => { return (props: SettingsPageProps) => ( ); }; const GlobalDetectSettingsPage = createSectionPage("detect", "global"); const GlobalRecordingSettingsPage = createSectionPage("record", "global"); const GlobalSnapshotsSettingsPage = createSectionPage("snapshots", "global"); const GlobalMotionSettingsPage = createSectionPage("motion", "global"); const GlobalObjectsSettingsPage = createSectionPage("objects", "global"); const GlobalReviewSettingsPage = createSectionPage("review", "global"); const GlobalAudioEventsSettingsPage = createSectionPage("audio", "global"); const GlobalLivePlaybackSettingsPage = createSectionPage("live", "global"); const GlobalTimestampStyleSettingsPage = createSectionPage( "timestamp_style", "global", ); const SystemDatabaseSettingsPage = createSectionPage("database", "global"); const SystemTlsSettingsPage = createSectionPage("tls", "global"); const SystemAuthenticationSettingsPage = createSectionPage("auth", "global"); const SystemNetworkingSettingsPage = createSectionPage("networking", "global"); const SystemProxySettingsPage = createSectionPage("proxy", "global"); const SystemUiSettingsPage = createSectionPage("ui", "global"); const SystemLoggingSettingsPage = createSectionPage("logger", "global"); const SystemEnvironmentVariablesSettingsPage = createSectionPage( "environment_vars", "global", ); const SystemTelemetrySettingsPage = createSectionPage("telemetry", "global"); const SystemBirdseyeSettingsPage = createSectionPage("birdseye", "global"); const SystemFfmpegSettingsPage = createSectionPage("ffmpeg", "global"); const SystemDetectorHardwareSettingsPage = createSectionPage( "detectors", "global", ); const SystemDetectionModelSettingsPage = createSectionPage("model", "global"); const SystemMqttSettingsPage = createSectionPage("mqtt", "global"); const IntegrationSemanticSearchSettingsPage = createSectionPage( "semantic_search", "global", ); const IntegrationGenerativeAiSettingsPage = createSectionPage( "genai", "global", ); const IntegrationFaceRecognitionSettingsPage = createSectionPage( "face_recognition", "global", ); const IntegrationLprSettingsPage = createSectionPage("lpr", "global"); const IntegrationObjectClassificationSettingsPage = createSectionPage( "classification", "global", ); const IntegrationAudioTranscriptionSettingsPage = createSectionPage( "audio_transcription", "global", ); const CameraDetectSettingsPage = createSectionPage("detect", "camera"); const CameraFfmpegSettingsPage = createSectionPage("ffmpeg", "camera"); const CameraRecordingSettingsPage = createSectionPage("record", "camera"); const CameraSnapshotsSettingsPage = createSectionPage("snapshots", "camera"); const CameraMotionSettingsPage = createSectionPage("motion", "camera"); const CameraObjectsSettingsPage = createSectionPage("objects", "camera"); const CameraConfigReviewSettingsPage = createSectionPage("review", "camera"); const CameraAudioEventsSettingsPage = createSectionPage("audio", "camera"); const CameraAudioTranscriptionSettingsPage = createSectionPage( "audio_transcription", "camera", ); const CameraNotificationsSettingsPage = createSectionPage( "notifications", "camera", ); const CameraLivePlaybackSettingsPage = createSectionPage("live", "camera"); const CameraBirdseyeSettingsPage = createSectionPage("birdseye", "camera"); const CameraFaceRecognitionSettingsPage = createSectionPage( "face_recognition", "camera", ); const CameraLprSettingsPage = createSectionPage("lpr", "camera"); const CameraMqttConfigSettingsPage = createSectionPage("mqtt", "camera", { showOverrideIndicator: false, }); const CameraOnvifSettingsPage = createSectionPage("onvif", "camera", { showOverrideIndicator: false, }); const CameraUiSettingsPage = createSectionPage("ui", "camera", { showOverrideIndicator: false, }); const CameraTimestampStyleSettingsPage = createSectionPage( "timestamp_style", "camera", ); const settingsGroups = [ { label: "general", items: [{ key: "profileSettings", component: UiSettingsView }], }, { label: "globalConfig", items: [ { key: "globalDetect", component: GlobalDetectSettingsPage }, { key: "globalRecording", component: GlobalRecordingSettingsPage }, { key: "globalSnapshots", component: GlobalSnapshotsSettingsPage }, { key: "globalMotion", component: GlobalMotionSettingsPage }, { key: "globalObjects", component: GlobalObjectsSettingsPage }, { key: "globalReview", component: GlobalReviewSettingsPage }, { key: "globalAudioEvents", component: GlobalAudioEventsSettingsPage }, { key: "globalLivePlayback", component: GlobalLivePlaybackSettingsPage, }, { key: "globalTimestampStyle", component: GlobalTimestampStyleSettingsPage, }, ], }, { label: "cameras", items: [ { key: "cameraDetect", component: CameraDetectSettingsPage }, { key: "cameraFfmpeg", component: CameraFfmpegSettingsPage }, { key: "cameraRecording", component: CameraRecordingSettingsPage }, { key: "cameraSnapshots", component: CameraSnapshotsSettingsPage }, { key: "cameraMotion", component: CameraMotionSettingsPage }, { key: "cameraObjects", component: CameraObjectsSettingsPage }, { key: "cameraConfigReview", component: CameraConfigReviewSettingsPage }, { key: "cameraAudioEvents", component: CameraAudioEventsSettingsPage }, { key: "cameraAudioTranscription", component: CameraAudioTranscriptionSettingsPage, }, { key: "cameraNotifications", component: CameraNotificationsSettingsPage, }, { key: "cameraLivePlayback", component: CameraLivePlaybackSettingsPage, }, { key: "cameraBirdseye", component: CameraBirdseyeSettingsPage }, { key: "cameraFaceRecognition", component: CameraFaceRecognitionSettingsPage, }, { key: "cameraLpr", component: CameraLprSettingsPage }, { key: "cameraMqttConfig", component: CameraMqttConfigSettingsPage }, { key: "cameraOnvif", component: CameraOnvifSettingsPage }, { key: "cameraUi", component: CameraUiSettingsPage }, { key: "cameraTimestampStyle", component: CameraTimestampStyleSettingsPage, }, { key: "cameraManagement", component: CameraManagementView }, { key: "cameraReview", component: CameraReviewSettingsView }, { key: "masksAndZones", component: MasksAndZonesView }, { key: "motionTuner", component: MotionTunerView }, ], }, { label: "enrichments", items: [ { key: "integrationSemanticSearch", component: IntegrationSemanticSearchSettingsPage, }, { key: "integrationGenerativeAi", component: IntegrationGenerativeAiSettingsPage, }, { key: "integrationFaceRecognition", component: IntegrationFaceRecognitionSettingsPage, }, { key: "integrationLpr", component: IntegrationLprSettingsPage }, { key: "integrationObjectClassification", component: IntegrationObjectClassificationSettingsPage, }, { key: "integrationAudioTranscription", component: IntegrationAudioTranscriptionSettingsPage, }, ], }, { label: "system", items: [ { key: "systemDatabase", component: SystemDatabaseSettingsPage }, { key: "systemMqtt", component: SystemMqttSettingsPage }, { key: "systemTls", component: SystemTlsSettingsPage }, { key: "systemAuthentication", component: SystemAuthenticationSettingsPage, }, { key: "systemNetworking", component: SystemNetworkingSettingsPage }, { key: "systemProxy", component: SystemProxySettingsPage }, { key: "systemUi", component: SystemUiSettingsPage }, { key: "systemLogging", component: SystemLoggingSettingsPage }, { key: "systemEnvironmentVariables", component: SystemEnvironmentVariablesSettingsPage, }, { key: "systemTelemetry", component: SystemTelemetrySettingsPage }, { key: "systemBirdseye", component: SystemBirdseyeSettingsPage }, { key: "systemFfmpeg", component: SystemFfmpegSettingsPage }, { key: "systemDetectorHardware", component: SystemDetectorHardwareSettingsPage, }, { key: "systemDetectionModel", component: SystemDetectionModelSettingsPage, }, ], }, { label: "users", items: [ { key: "users", component: UsersView }, { key: "roles", component: RolesView }, ], }, { label: "notifications", items: [ { key: "notifications", component: NotificationView }, { key: "triggers", component: TriggerView }, ], }, { label: "frigateplus", items: [{ key: "frigateplus", component: FrigatePlusSettingsView }], }, { label: "maintenance", items: [{ key: "maintenance", component: MaintenanceSettingsView }], }, ]; const CAMERA_SELECT_BUTTON_PAGES = [ "debug", "cameraDetect", "cameraFfmpeg", "cameraRecording", "cameraSnapshots", "cameraMotion", "cameraObjects", "cameraConfigReview", "cameraAudioEvents", "cameraAudioTranscription", "cameraNotifications", "cameraLivePlayback", "cameraBirdseye", "cameraFaceRecognition", "cameraLpr", "cameraMqttConfig", "cameraOnvif", "cameraUi", "cameraTimestampStyle", "cameraReview", "masksAndZones", "motionTuner", "triggers", ]; const ALLOWED_VIEWS_FOR_VIEWER = ["ui", "debug", "notifications"]; const getCurrentComponent = (page: SettingsType) => { for (const group of settingsGroups) { for (const item of group.items) { if (item.key === page) { return item.component; } } } return null; }; function MobileMenuItem({ item, onSelect, onClose, className, }: { item: { key: string }; onSelect: (key: string) => void; onClose?: () => void; className?: string; }) { const { t } = useTranslation(["views/settings"]); return (
{ onSelect(item.key); onClose?.(); }} >
{t("menu." + item.key)}
); } export default function Settings() { const { t } = useTranslation(["views/settings"]); const [page, setPage] = useState("profileSettings"); const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100); const [contentMobileOpen, setContentMobileOpen] = useState(false); const { data: config } = useSWR("config"); const [searchParams] = useSearchParams(); // auth and roles const isAdmin = useIsAdmin(); const visibleSettingsViews = !isAdmin ? ALLOWED_VIEWS_FOR_VIEWER : allSettingsViews; // TODO: confirm leave page const [unsavedChanges, setUnsavedChanges] = useState(false); const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false); const navigate = useNavigate(); const cameras = useMemo(() => { if (!config) { return []; } return Object.values(config.cameras) .filter((conf) => conf.ui.dashboard && conf.enabled_in_config) .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order); }, [config]); const [selectedCamera, setSelectedCamera] = useState(""); const { payload: allCameraStates } = useInitialCameraState( cameras.length > 0 ? cameras[0].name : "", true, ); const cameraEnabledStates = useMemo(() => { const states: Record = {}; if (allCameraStates) { Object.entries(allCameraStates).forEach(([camName, state]) => { states[camName] = state.config?.enabled ?? false; }); } // fallback to config if ws data isn’t available yet cameras.forEach((cam) => { if (!(cam.name in states)) { states[cam.name] = cam.enabled; } }); return states; }, [allCameraStates, cameras]); const [filterZoneMask, setFilterZoneMask] = useState(); const handleDialog = useCallback( (save: boolean) => { if (unsavedChanges && save) { // TODO } setConfirmationDialogOpen(false); setUnsavedChanges(false); }, [unsavedChanges], ); useEffect(() => { if (cameras.length > 0) { if (!selectedCamera) { // Set to first enabled camera initially if no selection const firstEnabledCamera = cameras.find((cam) => cameraEnabledStates[cam.name]) || cameras[0]; setSelectedCamera(firstEnabledCamera.name); } else if ( !cameraEnabledStates[selectedCamera] && pageToggle !== "cameraReview" ) { // Switch to first enabled camera if current one is disabled, unless on "camera settings" page const firstEnabledCamera = cameras.find((cam) => cameraEnabledStates[cam.name]) || cameras[0]; if (firstEnabledCamera.name !== selectedCamera) { setSelectedCamera(firstEnabledCamera.name); } } } }, [cameras, selectedCamera, cameraEnabledStates, pageToggle]); useSearchEffect("page", (page: string) => { if (allSettingsViews.includes(page as SettingsType)) { // Restrict viewer to UI settings if ( !isAdmin && !ALLOWED_VIEWS_FOR_VIEWER.includes(page as SettingsType) ) { setPageToggle("profileSettings"); } else { setPageToggle(page as SettingsType); } if (isMobile) { setContentMobileOpen(true); } } // don't clear url params if we're creating a new object mask return !(searchParams.has("object_mask") || searchParams.has("event_id")); }); useSearchEffect("camera", (camera: string) => { const cameraNames = cameras.map((c) => c.name); if (cameraNames.includes(camera)) { setSelectedCamera(camera); if (isMobile) { setContentMobileOpen(true); } } // don't clear url params if we're creating a new object mask or trigger return !(searchParams.has("object_mask") || searchParams.has("event_id")); }); useEffect(() => { if (!contentMobileOpen) { document.title = t("documentTitle.default"); } }, [t, contentMobileOpen]); if (isMobile) { return ( <> {!contentMobileOpen && (

{t("menu.settings", { ns: "common" })}

{settingsGroups.map((group) => { const filteredItems = group.items.filter((item) => visibleSettingsViews.includes(item.key as SettingsType), ); if (filteredItems.length === 0) return null; return (
{filteredItems.length > 1 && (

{t("menu." + group.label)}

)} {filteredItems.map((item) => ( { if ( !isAdmin && !ALLOWED_VIEWS_FOR_VIEWER.includes( key as SettingsType, ) ) { setPageToggle("profileSettings"); } else { setPageToggle(key as SettingsType); } setContentMobileOpen(true); }} /> ))}
); })}
)} navigate(-1)} actions={ CAMERA_SELECT_BUTTON_PAGES.includes(pageToggle) ? (
{pageToggle == "masksAndZones" && ( )}
) : undefined } > {t("menu." + page)}
{(() => { const CurrentComponent = getCurrentComponent(page); if (!CurrentComponent) return null; return ( ); })()}
{confirmationDialogOpen && ( setConfirmationDialogOpen(false)} > {t("dialog.unsavedChanges.title")} {t("dialog.unsavedChanges.desc")} handleDialog(false)}> {t("button.cancel", { ns: "common" })} handleDialog(true)}> {t("button.save", { ns: "common" })} )} ); } return (
{t("menu.settings", { ns: "common" })} {CAMERA_SELECT_BUTTON_PAGES.includes(page) && (
{pageToggle == "masksAndZones" && ( )}
)}
{settingsGroups.map((group) => { const filteredItems = group.items.filter((item) => visibleSettingsViews.includes(item.key as SettingsType), ); if (filteredItems.length === 0) return null; return ( {filteredItems.length === 1 ? ( { if ( !isAdmin && !ALLOWED_VIEWS_FOR_VIEWER.includes( filteredItems[0].key as SettingsType, ) ) { setPageToggle("profileSettings"); } else { setPageToggle( filteredItems[0].key as SettingsType, ); } }} >
{t("menu." + filteredItems[0].key)}
) : ( <> pageToggle === item.key, ) ? "text-primary" : "text-sidebar-foreground/80", )} >
{t("menu." + group.label)}
{filteredItems.map((item) => ( { if ( !isAdmin && !ALLOWED_VIEWS_FOR_VIEWER.includes( item.key as SettingsType, ) ) { setPageToggle("profileSettings"); } else { setPageToggle(item.key as SettingsType); } }} >
{t("menu." + item.key)}
))}
)}
); })}
{(() => { const CurrentComponent = getCurrentComponent(page); if (!CurrentComponent) return null; return ( ); })()}
{confirmationDialogOpen && ( setConfirmationDialogOpen(false)} > {t("dialog.unsavedChanges.title")} {t("dialog.unsavedChanges.desc")} handleDialog(false)}> {t("button.cancel", { ns: "common" })} handleDialog(true)}> {t("button.save", { ns: "common" })} )}
); } type CameraSelectButtonProps = { allCameras: CameraConfig[]; selectedCamera: string; setSelectedCamera: React.Dispatch>; cameraEnabledStates: Record; currentPage: SettingsType; }; function CameraSelectButton({ allCameras, selectedCamera, setSelectedCamera, cameraEnabledStates, currentPage, }: CameraSelectButtonProps) { const { t } = useTranslation(["views/settings"]); const [open, setOpen] = useState(false); if (!allCameras.length) { return null; } const trigger = ( ); const content = ( <> {isMobile && ( <> {t("cameraSetting.camera")} )}
{allCameras.map((item) => { const isEnabled = cameraEnabledStates[item.name]; const isCameraSettingsPage = currentPage === "cameraReview"; return ( { if (isChecked && (isEnabled || isCameraSettingsPage)) { setSelectedCamera(item.name); setOpen(false); } }} disabled={!isEnabled && !isCameraSettingsPage} /> ); })}
); if (isMobile) { return ( { if (!open) { setSelectedCamera(selectedCamera); } setOpen(open); }} > {trigger} {content} ); } return ( { if (!open) { setSelectedCamera(selectedCamera); } setOpen(open); }} > {trigger} {content} ); }