From 497084ada1facfc4f86636f25f706da24c7cf364 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 24 May 2026 15:05:33 -0500 Subject: [PATCH] restructure camera enable/disable pane --- web/public/locales/en/views/settings.json | 25 +- .../views/settings/CameraManagementView.tsx | 450 +++++++++++------- 2 files changed, 281 insertions(+), 194 deletions(-) diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 11fcd92123..78d41ed590 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -457,7 +457,7 @@ }, "cameraManagement": { "title": "Manage Cameras", - "description": "Add, edit, and delete cameras, control which cameras are enabled, and configure per-profile and camera type overrides. To configure streams, detection, motion, and other camera-specific settings, choose the specific section under Camera Configuration.", + "description": "Add, edit, and delete cameras, control the state of each camera, and configure per-profile and camera type overrides. To configure streams, detection, motion, and other camera-specific settings, choose the specific section under Camera Configuration.", "addCamera": "Add New Camera", "deleteCamera": "Delete Camera", "deleteCameraDialog": { @@ -475,12 +475,17 @@ "selectCamera": "Select a Camera", "backToSettings": "Back to Camera Settings", "streams": { - "title": "Enable / Disable Cameras", - "enableLabel": "Enabled cameras", - "enableDesc": "Temporarily disable an enabled camera until Frigate restarts. Disabling a camera completely stops Frigate's processing of this camera's streams. Detection, recording, and debugging will be unavailable.
Note: This does not disable go2rtc restreams.

Drag the handle to reorder the cameras as they appear in the UI. The order of enabled cameras will be reflected throughout the UI including the Live dashboard and camera selection dropdowns.", - "disableLabel": "Disabled cameras", - "disableDesc": "Enable a camera that is currently not visible in the UI and disabled in the configuration. A restart of Frigate is required after enabling.", - "enableSuccess": "Enabled {{cameraName}} in configuration. Restart Frigate to apply the changes.", + "title": "Camera State", + "label": "Camera state", + "description": "Set the operating state for each camera.

On: streams are processed normally.
Off: temporarily pauses processing. Does not persist across Frigate restarts.
Disabled: stops processing and saves the change to your configuration. A restart is required to re-enable a disabled camera.

Note: Disabling does not affect go2rtc restreams.

Drag the handle to reorder active cameras as they appear throughout the UI, including the Live dashboard and camera selection dropdowns.", + "disabledSubheading": "Disabled in configuration", + "status": { + "on": "On", + "off": "Off", + "disabled": "Disabled" + }, + "enableSuccess": "Enabled {{cameraName}}. Restart Frigate to apply.", + "disableSuccess": "Disabled {{cameraName}} and saved to configuration.", "reorderHandle": "Drag to reorder", "saving": "Saving…", "saved": "Saved", @@ -527,10 +532,10 @@ "profiles": { "title": "Profile Camera Overrides", "selectLabel": "Select profile", - "description": "Configure which cameras are enabled or disabled when a profile is activated. Cameras set to \"Inherit\" keep their base enabled state.", + "description": "Configure which cameras are turned on or off when a profile is activated. Cameras set to \"Inherit\" keep their default state.", "inherit": "Inherit", - "enabled": "Enabled", - "disabled": "Disabled" + "on": "On", + "off": "Off" }, "cameraType": { "title": "Camera Type", diff --git a/web/src/views/settings/CameraManagementView.tsx b/web/src/views/settings/CameraManagementView.tsx index 212b32389a..def5902c6e 100644 --- a/web/src/views/settings/CameraManagementView.tsx +++ b/web/src/views/settings/CameraManagementView.tsx @@ -20,6 +20,7 @@ import { LuGripVertical, LuPencil, LuPlus, + LuRefreshCcw, LuTrash2, } from "react-icons/lu"; import { Reorder, useDragControls } from "framer-motion"; @@ -28,7 +29,6 @@ import { Link } from "react-router-dom"; import { useDocDomain } from "@/hooks/use-doc-domain"; 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"; @@ -275,7 +275,7 @@ export default function CameraManagementView({ )} - {enabledCameras.length > 0 && ( + {(enabledCameras.length > 0 || disabledCameras.length > 0) && ( @@ -285,83 +285,66 @@ export default function CameraManagementView({ >
- + +

+ + cameraManagement.streams.description + +

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

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

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

- cameraManagement.streams.enableDesc + cameraManagement.streams.description

- {disabledCameras.length > 0 && ( -
-
- -

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

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

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

-
-
- )} )} @@ -468,17 +451,19 @@ function ReorderSaveStatusIndicator({ ); } -type EnabledCameraRowProps = { +type ActiveCameraRowProps = { camera: string; onConfigChanged: () => Promise; onDragEnd: () => void; + setRestartDialogOpen: React.Dispatch>; }; -function EnabledCameraRow({ +function ActiveCameraRow({ camera, onConfigChanged, onDragEnd, -}: EnabledCameraRowProps) { + setRestartDialogOpen, +}: ActiveCameraRowProps) { const { t } = useTranslation(["views/settings"]); const controls = useDragControls(); @@ -506,38 +491,226 @@ function EnabledCameraRow({ onConfigChanged={onConfigChanged} />
- + ); } -type CameraEnableSwitchProps = { - cameraName: string; +type DisabledCameraRowProps = { + camera: string; + onConfigChanged: () => Promise; + setRestartDialogOpen: React.Dispatch>; }; -function CameraEnableSwitch({ cameraName }: CameraEnableSwitchProps) { - const { payload: enabledState, send: sendEnabled } = - useEnabledState(cameraName); - const { data: config } = useSWR("config"); - - const isChecked = - enabledState === "ON" || enabledState === "OFF" - ? enabledState === "ON" - : (config?.cameras?.[cameraName]?.enabled ?? false); - +function DisabledCameraRow({ + camera, + onConfigChanged, + setRestartDialogOpen, +}: DisabledCameraRowProps) { return ( -
- { - sendEnabled(isChecked ? "ON" : "OFF"); - }} +
+
+ + +
+
); } +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 [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(); + 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, + t, + ], + ); + + if (isSaving) { + return ( +
+ +
+ ); + } + + return ( + + ); +} + type CameraDetailsEditorProps = { cameraName: string; onConfigChanged: () => Promise; @@ -783,97 +956,6 @@ function CameraDetailsEditor({ ); } -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 CameraTypeSectionProps = { cameras: string[]; config: FrigateConfig | undefined; @@ -1231,12 +1313,12 @@ function ProfileCameraEnableSection({ })} - {t("cameraManagement.profiles.enabled", { + {t("cameraManagement.profiles.on", { ns: "views/settings", })} - {t("cameraManagement.profiles.disabled", { + {t("cameraManagement.profiles.off", { ns: "views/settings", })}