import Heading from "@/components/ui/heading"; import { useCallback, useEffect, useMemo, useState } from "react"; import { CONTROL_COLUMN_CLASS_NAME, SettingsGroupCard, SPLIT_ROW_CLASS_NAME, } from "@/components/card/SettingsGroupCard"; import { toast } from "sonner"; import { Toaster } from "@/components/ui/sonner"; import { Button } from "@/components/ui/button"; import useSWR from "swr"; import { FrigateConfig } from "@/types/frigateConfig"; import { useTranslation } from "react-i18next"; import CameraEditForm from "@/components/settings/CameraEditForm"; import CameraWizardDialog from "@/components/settings/CameraWizardDialog"; import DeleteCameraDialog from "@/components/overlay/dialog/DeleteCameraDialog"; import { LuPlus, LuTrash2 } from "react-icons/lu"; import { IoMdArrowRoundBack } from "react-icons/io"; import { isDesktop } from "react-device-detect"; import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; import { Switch } from "@/components/ui/switch"; 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 type { ProfileState } from "@/types/profile"; import { getProfileColor } from "@/utils/profileColors"; import { cn } from "@/lib/utils"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; type CameraManagementViewProps = { setUnsavedChanges: React.Dispatch>; profileState?: ProfileState; }; export default function CameraManagementView({ setUnsavedChanges, profileState, }: CameraManagementViewProps) { const { t } = useTranslation(["views/settings"]); const { data: config, mutate: updateConfig } = useSWR("config"); const [viewMode, setViewMode] = useState<"settings" | "add" | "edit">( "settings", ); // Control view state const [editCameraName, setEditCameraName] = useState( undefined, ); // Track camera being edited const [showWizard, setShowWizard] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); // State for restart dialog when enabling a disabled camera const [restartDialogOpen, setRestartDialogOpen] = useState(false); const { send: sendRestart } = useRestart(); // List of cameras for dropdown const enabledCameras = useMemo(() => { if (config) { return Object.keys(config.cameras) .filter((camera) => config.cameras[camera].enabled_in_config) .sort(); } return []; }, [config]); const disabledCameras = useMemo(() => { if (config) { return Object.keys(config.cameras) .filter((camera) => !config.cameras[camera].enabled_in_config) .sort(); } return []; }, [config]); useEffect(() => { document.title = t("documentTitle.cameraManagement"); }, [t]); // Handle back navigation from add/edit form const handleBack = useCallback(() => { setViewMode("settings"); setEditCameraName(undefined); setUnsavedChanges(false); updateConfig(); }, [updateConfig, setUnsavedChanges]); return ( <>
{viewMode === "settings" ? ( <> {t("cameraManagement.title")}
{enabledCameras.length + disabledCameras.length > 0 && ( )}
{enabledCameras.length > 0 && ( cameraManagement.streams.title } >
{enabledCameras.map((camera) => (
))}

cameraManagement.streams.enableDesc

{disabledCameras.length > 0 && (

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

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

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

)}
)} {profileState && profileState.allProfileNames.length > 0 && enabledCameras.length > 0 && ( )}
) : ( <>
)}
setShowWizard(false)} /> setShowDeleteDialog(false)} onDeleted={() => { setShowDeleteDialog(false); updateConfig(); }} /> setRestartDialogOpen(false)} onRestart={() => sendRestart("restart")} /> ); } type CameraEnableSwitchProps = { cameraName: string; }; function CameraEnableSwitch({ cameraName }: CameraEnableSwitchProps) { const { payload: enabledState, send: sendEnabled } = useEnabledState(cameraName); return (
{ sendEnabled(isChecked ? "ON" : "OFF"); }} />
); } type CameraConfigEnableSwitchProps = { cameraName: string; setRestartDialogOpen: React.Dispatch>; onConfigChanged: () => Promise; }; function CameraConfigEnableSwitch({ cameraName, onConfigChanged, setRestartDialogOpen, }: CameraConfigEnableSwitchProps) { const { t } = useTranslation([ "common", "views/settings", "components/dialog", ]); const [isSaving, setIsSaving] = useState(false); const onCheckedChange = useCallback( async (isChecked: boolean) => { if (!isChecked || isSaving) { return; } 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); } }, [cameraName, isSaving, onConfigChanged, setRestartDialogOpen, t], ); return (
{isSaving ? ( ) : ( )}
); } 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", { 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], ); const profileColor = selectedProfile ? getProfileColor(selectedProfile, profileState.allProfileNames) : null; 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 ? ( ) : ( )}
); })}
); }