import Heading from "@/components/ui/heading"; import { useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react"; import { CONTROL_COLUMN_CLASS_NAME, SettingsGroupCard, SPLIT_ROW_CLASS_NAME, } from "@/components/card/SettingsGroupCard"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import useSWR from "swr"; import { FrigateConfig } from "@/types/frigateConfig"; import { useTranslation } from "react-i18next"; import CameraWizardDialog from "@/components/settings/CameraWizardDialog"; import DeleteCameraDialog from "@/components/overlay/dialog/DeleteCameraDialog"; import { LuCheck, LuCopy, LuExternalLink, LuGripVertical, LuPencil, LuPlus, LuRefreshCcw, LuTrash2, } from "react-icons/lu"; import CloneCameraDialog from "@/components/settings/CloneCameraDialog"; import { Reorder, useDragControls } from "framer-motion"; import { Link } from "react-router-dom"; import { useDocDomain } from "@/hooks/use-doc-domain"; import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; import { Trans } from "react-i18next"; import { useEnabledState, useRestart } from "@/api/ws"; import { Label } from "@/components/ui/label"; import axios from "axios"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import RestartDialog from "@/components/overlay/dialog/RestartDialog"; import RestartRequiredIndicator from "@/components/indicators/RestartRequiredIndicator"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import type { ProfileState } from "@/types/profile"; import { getProfileColor } from "@/utils/profileColors"; import { isReplayCamera } from "@/utils/cameraUtil"; import { StatusBarMessagesContext } from "@/context/statusbar-provider"; import { cn } from "@/lib/utils"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; const REORDER_SAVED_INDICATOR_MS = 1500; type ReorderSaveStatus = "idle" | "saving" | "saved"; type CameraManagementViewProps = { profileState?: ProfileState; }; export default function CameraManagementView({ profileState, }: CameraManagementViewProps) { const { t } = useTranslation(["views/settings", "common"]); const { data: config, mutate: updateConfig } = useSWR("config"); const [showWizard, setShowWizard] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showCloneDialog, setShowCloneDialog] = useState(false); // State for restart dialog when enabling a disabled camera const [restartDialogOpen, setRestartDialogOpen] = useState(false); const { send: sendRestart } = useRestart(); const enabledCameras = useMemo(() => { if (config) { return Object.keys(config.cameras) .filter( (camera) => config.cameras[camera].enabled_in_config && !isReplayCamera(camera), ) .sort((a, b) => { const orderA = config.cameras[a].ui?.order ?? 0; const orderB = config.cameras[b].ui?.order ?? 0; if (orderA !== orderB) return orderA - orderB; return a.localeCompare(b); }); } return []; }, [config]); // Diverges from config during a drag and while the save is in flight. const [orderedCameras, setOrderedCameras] = useState(enabledCameras); const orderedCamerasRef = useRef(orderedCameras); useEffect(() => { orderedCamerasRef.current = orderedCameras; }, [orderedCameras]); useEffect(() => { setOrderedCameras((prev) => { if ( prev.length === enabledCameras.length && prev.every((cam, i) => cam === enabledCameras[i]) ) { return prev; } return enabledCameras; }); }, [enabledCameras]); const [reorderSaveStatus, setReorderSaveStatus] = useState("idle"); const reorderSavedTimerRef = useRef | null>( null, ); useEffect(() => { return () => { if (reorderSavedTimerRef.current) { clearTimeout(reorderSavedTimerRef.current); } }; }, []); const handleReorderDragEnd = useCallback(async () => { const current = orderedCamerasRef.current; if ( current.length === enabledCameras.length && current.every((cam, i) => cam === enabledCameras[i]) ) { return; } const cameraUpdates: Record = {}; current.forEach((cam, i) => { cameraUpdates[cam] = { ui: { order: i * 10 } }; }); if (reorderSavedTimerRef.current) { clearTimeout(reorderSavedTimerRef.current); reorderSavedTimerRef.current = null; } setReorderSaveStatus("saving"); try { await axios.put("config/set", { requires_restart: 0, config_data: { cameras: cameraUpdates }, }); await updateConfig(); setReorderSaveStatus("saved"); reorderSavedTimerRef.current = setTimeout(() => { setReorderSaveStatus("idle"); reorderSavedTimerRef.current = null; }, REORDER_SAVED_INDICATOR_MS); } catch (error) { setOrderedCameras(enabledCameras); setReorderSaveStatus("idle"); const errorMessage = axios.isAxiosError(error) && (error.response?.data?.message || error.response?.data?.detail) ? error.response?.data?.message || error.response?.data?.detail : t("toast.save.error.noMessage", { ns: "common" }); toast.error(t("toast.save.error.title", { errorMessage, ns: "common" }), { position: "top-center", }); } }, [enabledCameras, updateConfig, t]); const disabledCameras = useMemo(() => { if (config) { return Object.keys(config.cameras) .filter( (camera) => !config.cameras[camera].enabled_in_config && !isReplayCamera(camera), ) .sort(); } return []; }, [config]); const allCameras = useMemo(() => { if (config) { return Object.keys(config.cameras) .filter((camera) => !isReplayCamera(camera)) .sort(); } return []; }, [config]); useEffect(() => { document.title = t("documentTitle.cameraManagement"); }, [t]); return ( <>
{t("cameraManagement.title")}

{t("cameraManagement.description")}

{enabledCameras.length + disabledCameras.length > 0 && ( )}
{enabledCameras.length + disabledCameras.length > 0 && (
{t("cameraManagement.clone.sectionTitle")}

{t("cameraManagement.clone.sectionDescription")}

)} {(enabledCameras.length > 0 || disabledCameras.length > 0) && ( cameraManagement.streams.title } >

cameraManagement.streams.description

{orderedCameras.length > 0 && ( {orderedCameras.map((camera) => ( ))} )} {orderedCameras.length > 0 && disabledCameras.length > 0 && (
)} {disabledCameras.length > 0 && (

{t("cameraManagement.streams.disabledSubheading")}

{disabledCameras.map((camera) => ( ))}
)}

cameraManagement.streams.description

)} {profileState && profileState.allProfileNames.length > 0 && enabledCameras.length > 0 && ( )} {config?.lpr?.enabled && allCameras.length > 0 && ( )}
setShowWizard(false)} /> setShowDeleteDialog(false)} onDeleted={() => { setShowDeleteDialog(false); updateConfig(); }} /> setRestartDialogOpen(false)} onRestart={() => sendRestart("restart")} /> setShowCloneDialog(false)} /> ); } type ReorderSaveStatusIndicatorProps = { status: ReorderSaveStatus; }; function ReorderSaveStatusIndicator({ status, }: ReorderSaveStatusIndicatorProps) { const { t } = useTranslation(["views/settings"]); return (
{status === "saving" && ( {t("cameraManagement.streams.saving")} )} {status === "saved" && ( {t("cameraManagement.streams.saved")} )}
); } type ActiveCameraRowProps = { camera: string; onConfigChanged: () => Promise; onDragEnd: () => void; setRestartDialogOpen: React.Dispatch>; }; function ActiveCameraRow({ camera, onConfigChanged, onDragEnd, setRestartDialogOpen, }: ActiveCameraRowProps) { const { t } = useTranslation(["views/settings"]); const controls = useDragControls(); return (
); } type DisabledCameraRowProps = { camera: string; onConfigChanged: () => Promise; setRestartDialogOpen: React.Dispatch>; }; function DisabledCameraRow({ camera, onConfigChanged, setRestartDialogOpen, }: DisabledCameraRowProps) { return (
); } type CameraStatus = "on" | "off" | "disabled"; type CameraStatusSelectProps = { cameraName: string; isDisabledInConfig: boolean; onConfigChanged: () => Promise; setRestartDialogOpen: React.Dispatch>; }; function CameraStatusSelect({ cameraName, isDisabledInConfig, onConfigChanged, setRestartDialogOpen, }: CameraStatusSelectProps) { const { t } = useTranslation([ "views/settings", "components/dialog", "common", ]); const { payload: enabledState, send: sendEnabled } = useEnabledState(cameraName); const statusBar = useContext(StatusBarMessagesContext); const [isSaving, setIsSaving] = useState(false); const currentStatus: CameraStatus = isDisabledInConfig ? "disabled" : enabledState === "OFF" ? "off" : "on"; const restartLabel = t("configForm.restartRequiredField", { ns: "views/settings", defaultValue: "Restart required", }); const handleChange = useCallback( async (newStatus: string) => { if (newStatus === currentStatus || isSaving) { return; } if (newStatus === "on" && !isDisabledInConfig) { sendEnabled("ON"); return; } if (newStatus === "off" && !isDisabledInConfig) { sendEnabled("OFF"); return; } if (newStatus === "on" && isDisabledInConfig) { setIsSaving(true); try { await axios.put("config/set", { requires_restart: 1, config_data: { cameras: { [cameraName]: { enabled: true } }, }, }); await onConfigChanged(); toast.success( t("cameraManagement.streams.enableSuccess", { ns: "views/settings", cameraName, }), { position: "top-center", action: ( setRestartDialogOpen(true)}> ), }, ); } catch (error) { const errorMessage = axios.isAxiosError(error) && (error.response?.data?.message || error.response?.data?.detail) ? error.response?.data?.message || error.response?.data?.detail : t("toast.save.error.noMessage", { ns: "common" }); toast.error( t("toast.save.error.title", { errorMessage, ns: "common" }), { position: "top-center" }, ); } finally { setIsSaving(false); } return; } if (newStatus === "disabled" && !isDisabledInConfig) { setIsSaving(true); try { // Stop runtime processing immediately before persisting the // disable so the camera stops working without waiting for // a restart. The config write below makes the change durable. sendEnabled("OFF"); await axios.put("config/set", { requires_restart: 0, config_data: { cameras: { [cameraName]: { enabled: false } }, }, }); await onConfigChanged(); statusBar?.addMessage( "config_restart_required", t("configForm.restartRequiredFooter", { ns: "views/settings" }), undefined, "config_restart_required", ); toast.success( t("cameraManagement.streams.disableSuccess", { ns: "views/settings", cameraName, }), { position: "top-center" }, ); } catch (error) { const errorMessage = axios.isAxiosError(error) && (error.response?.data?.message || error.response?.data?.detail) ? error.response?.data?.message || error.response?.data?.detail : t("toast.save.error.noMessage", { ns: "common" }); toast.error( t("toast.save.error.title", { errorMessage, ns: "common" }), { position: "top-center" }, ); } finally { setIsSaving(false); } return; } }, [ cameraName, currentStatus, isDisabledInConfig, isSaving, onConfigChanged, sendEnabled, setRestartDialogOpen, statusBar, t, ], ); if (isSaving) { return (
); } return ( ); } type CameraDetailsEditorProps = { cameraName: string; onConfigChanged: () => Promise; }; type CameraDetailsFormValues = { friendlyName: string; webuiUrl: string; }; function CameraDetailsEditor({ cameraName, onConfigChanged, }: CameraDetailsEditorProps) { const { t } = useTranslation(["views/settings", "common"]); const { data: config } = useSWR("config"); const [open, setOpen] = useState(false); const [isSaving, setIsSaving] = useState(false); const currentFriendlyName = config?.cameras?.[cameraName]?.friendly_name; const currentWebuiUrl = config?.cameras?.[cameraName]?.webui_url; const formSchema = useMemo( () => z.object({ friendlyName: z.string(), webuiUrl: z.string().refine( (val) => { const trimmed = val.trim(); if (!trimmed) return true; try { new URL(trimmed); return true; } catch { return false; } }, { message: t("cameraManagement.streams.details.webuiUrlInvalid", { ns: "views/settings", }), }, ), }), [t], ); const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { friendlyName: currentFriendlyName ?? "", webuiUrl: currentWebuiUrl ?? "", }, }); // Reset form values from config whenever the dialog is opened. useEffect(() => { if (open) { form.reset({ friendlyName: currentFriendlyName ?? "", webuiUrl: currentWebuiUrl ?? "", }); } }, [open, currentFriendlyName, currentWebuiUrl, form]); const onSubmit = useCallback( async (values: CameraDetailsFormValues) => { if (isSaving) return; // only send fields the user actually changed const newFriendly = values.friendlyName.trim() || null; const newWebui = values.webuiUrl.trim() || null; const cameraUpdate: Record = {}; if (newFriendly !== (currentFriendlyName ?? null)) { cameraUpdate.friendly_name = newFriendly; } if (newWebui !== (currentWebuiUrl ?? null)) { cameraUpdate.webui_url = newWebui; } if (Object.keys(cameraUpdate).length === 0) { setOpen(false); return; } setIsSaving(true); try { await axios.put("config/set", { requires_restart: 0, config_data: { cameras: { [cameraName]: cameraUpdate, }, }, }); await onConfigChanged(); setOpen(false); toast.success(t("toast.save.success", { ns: "common" }), { position: "top-center", }); } catch (error) { const errorMessage = axios.isAxiosError(error) && (error.response?.data?.message || error.response?.data?.detail) ? error.response?.data?.message || error.response?.data?.detail : t("toast.save.error.noMessage", { ns: "common" }); toast.error( t("toast.save.error.title", { errorMessage, ns: "common" }), { position: "top-center" }, ); } finally { setIsSaving(false); } }, [ cameraName, currentFriendlyName, currentWebuiUrl, isSaving, onConfigChanged, t, ], ); const editLabel = t("cameraManagement.streams.details.edit", { ns: "views/settings", }); return ( <> {editLabel} {t("cameraManagement.streams.details.title", { ns: "views/settings", })} {t("cameraManagement.streams.details.description", { ns: "views/settings", })}
( {t("cameraManagement.streams.details.friendlyNameLabel", { ns: "views/settings", })}

{t("cameraManagement.streams.details.friendlyNameHelp", { ns: "views/settings", })}

)} /> ( {t("cameraManagement.streams.details.webuiUrlLabel", { ns: "views/settings", })}

{t("cameraManagement.streams.details.webuiUrlHelp", { ns: "views/settings", })}

)} />
); } type CameraTypeSectionProps = { cameras: string[]; config: FrigateConfig | undefined; onConfigChanged: () => Promise; setRestartDialogOpen: React.Dispatch>; }; function CameraTypeSection({ cameras, config, onConfigChanged, setRestartDialogOpen, }: CameraTypeSectionProps) { const { t } = useTranslation([ "views/settings", "common", "components/dialog", ]); const { getLocaleDocUrl } = useDocDomain(); const [savingCamera, setSavingCamera] = useState(null); // Optimistic local state: the parsed config API doesn't reflect type // changes until Frigate restarts, so we track saved values locally. const [localOverrides, setLocalOverrides] = useState>( {}, ); const handleTypeChange = useCallback( async (camera: string, value: string) => { setSavingCamera(camera); try { const typeValue = value === "lpr" ? "lpr" : null; await axios.put("config/set", { requires_restart: 1, config_data: { cameras: { [camera]: { type: typeValue, }, }, }, }); await onConfigChanged(); setLocalOverrides((prev) => ({ ...prev, [camera]: value, })); toast.success( t("cameraManagement.cameraType.saveSuccess", { ns: "views/settings", cameraName: camera, }), { position: "top-center", action: ( setRestartDialogOpen(true)}> ), }, ); } catch (error) { const errorMessage = axios.isAxiosError(error) && (error.response?.data?.message || error.response?.data?.detail) ? error.response?.data?.message || error.response?.data?.detail : t("toast.save.error.noMessage", { ns: "common" }); toast.error( t("toast.save.error.title", { errorMessage, ns: "common" }), { position: "top-center" }, ); } finally { setSavingCamera(null); } }, [onConfigChanged, setRestartDialogOpen, t], ); const getCameraType = useCallback( (camera: string): string => { const localValue = localOverrides[camera]; if (localValue) return localValue; const type = config?.cameras?.[camera]?.type; return type === "lpr" ? "lpr" : "normal"; }, [config, localOverrides], ); return (

{t("cameraManagement.cameraType.description", { ns: "views/settings", })}

{t("readTheDocumentation", { ns: "common" })}
{cameras.map((camera) => { const currentType = getCameraType(camera); const isSaving = savingCamera === camera; return (
{isSaving ? ( ) : ( )}
); })}

{t("cameraManagement.cameraType.description", { ns: "views/settings", })}

{t("readTheDocumentation", { ns: "common" })}
); } type ProfileCameraEnableSectionProps = { profileState: ProfileState; cameras: string[]; config: FrigateConfig | undefined; onConfigChanged: () => Promise; }; function ProfileCameraEnableSection({ profileState, cameras, config, onConfigChanged, }: ProfileCameraEnableSectionProps) { const { t } = useTranslation(["views/settings", "common"]); const [selectedProfile, setSelectedProfile] = useState( profileState.allProfileNames[0] ?? "", ); const [savingCamera, setSavingCamera] = useState(null); // Optimistic local state: the parsed config API doesn't reflect profile // enabled changes until Frigate restarts, so we track saved values locally. const [localOverrides, setLocalOverrides] = useState< Record> >({}); const handleEnabledChange = useCallback( async (camera: string, value: string) => { setSavingCamera(camera); try { const enabledValue = value === "enabled" ? true : value === "disabled" ? false : null; const configData = enabledValue === null ? { cameras: { [camera]: { profiles: { [selectedProfile]: { enabled: "" } }, }, }, } : { cameras: { [camera]: { profiles: { [selectedProfile]: { enabled: enabledValue } }, }, }, }; await axios.put("config/set", { requires_restart: 0, config_data: configData, }); await onConfigChanged(); setLocalOverrides((prev) => ({ ...prev, [selectedProfile]: { ...prev[selectedProfile], [camera]: value, }, })); toast.success(t("toast.save.success", { ns: "common" }), { position: "top-center", }); } catch { toast.error(t("toast.save.error.title", { ns: "common" }), { position: "top-center", }); } finally { setSavingCamera(null); } }, [selectedProfile, onConfigChanged, t], ); const getEnabledState = useCallback( (camera: string): string => { // Check optimistic local state first const localValue = localOverrides[selectedProfile]?.[camera]; if (localValue) return localValue; const profileData = config?.cameras?.[camera]?.profiles?.[selectedProfile]; if (!profileData || profileData.enabled === undefined) return "inherit"; return profileData.enabled ? "enabled" : "disabled"; }, [config, selectedProfile, localOverrides], ); if (!selectedProfile) return null; return (

{t("cameraManagement.profiles.description", { ns: "views/settings", })}

{cameras.map((camera) => { const state = getEnabledState(camera); const isSaving = savingCamera === camera; return (
{isSaving ? ( ) : ( )}
); })}
); }