mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-21 03:41:55 +03:00
restructure camera enable/disable pane
This commit is contained in:
parent
ec44398b1c
commit
497084ada1
@ -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.<br /> <em>Note: This does not disable go2rtc restreams.</em><br /><br />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.<br /><br /><strong>On</strong>: streams are processed normally.<br /><strong>Off</strong>: temporarily pauses processing. Does not persist across Frigate restarts.<br /><strong>Disabled</strong>: stops processing and saves the change to your configuration. A restart is required to re-enable a disabled camera.<br /><br /><em>Note: Disabling does not affect go2rtc restreams.</em><br /><br />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",
|
||||
|
||||
@ -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({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{enabledCameras.length > 0 && (
|
||||
{(enabledCameras.length > 0 || disabledCameras.length > 0) && (
|
||||
<SettingsGroupCard
|
||||
title={
|
||||
<Trans ns="views/settings">
|
||||
@ -285,83 +285,66 @@ export default function CameraManagementView({
|
||||
>
|
||||
<div className={SPLIT_ROW_CLASS_NAME}>
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
className="cursor-pointer"
|
||||
htmlFor={"enabled-cameras-switch"}
|
||||
>
|
||||
{t("cameraManagement.streams.enableLabel")}
|
||||
<p className="hidden text-sm text-muted-foreground md:block">
|
||||
<Trans ns="views/settings">
|
||||
cameraManagement.streams.enableDesc
|
||||
</Trans>
|
||||
</p>
|
||||
</Label>
|
||||
<Label>{t("cameraManagement.streams.label")}</Label>
|
||||
<p className="hidden text-sm text-muted-foreground md:block">
|
||||
<Trans ns="views/settings">
|
||||
cameraManagement.streams.description
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<div className="max-w-md space-y-1.5">
|
||||
<Reorder.Group
|
||||
as="div"
|
||||
axis="y"
|
||||
values={orderedCameras}
|
||||
onReorder={setOrderedCameras}
|
||||
className="space-y-2 rounded-lg bg-secondary p-4"
|
||||
>
|
||||
{orderedCameras.map((camera) => (
|
||||
<EnabledCameraRow
|
||||
key={camera}
|
||||
camera={camera}
|
||||
onConfigChanged={updateConfig}
|
||||
onDragEnd={handleReorderDragEnd}
|
||||
/>
|
||||
))}
|
||||
</Reorder.Group>
|
||||
<div className="space-y-3 rounded-lg bg-secondary p-4">
|
||||
{orderedCameras.length > 0 && (
|
||||
<Reorder.Group
|
||||
as="div"
|
||||
axis="y"
|
||||
values={orderedCameras}
|
||||
onReorder={setOrderedCameras}
|
||||
className="space-y-2"
|
||||
>
|
||||
{orderedCameras.map((camera) => (
|
||||
<ActiveCameraRow
|
||||
key={camera}
|
||||
camera={camera}
|
||||
onConfigChanged={updateConfig}
|
||||
onDragEnd={handleReorderDragEnd}
|
||||
setRestartDialogOpen={setRestartDialogOpen}
|
||||
/>
|
||||
))}
|
||||
</Reorder.Group>
|
||||
)}
|
||||
{orderedCameras.length > 0 &&
|
||||
disabledCameras.length > 0 && (
|
||||
<div className="border-t border-border/40" />
|
||||
)}
|
||||
{disabledCameras.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{t(
|
||||
"cameraManagement.streams.disabledSubheading",
|
||||
)}
|
||||
</p>
|
||||
{disabledCameras.map((camera) => (
|
||||
<DisabledCameraRow
|
||||
key={camera}
|
||||
camera={camera}
|
||||
onConfigChanged={updateConfig}
|
||||
setRestartDialogOpen={setRestartDialogOpen}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ReorderSaveStatusIndicator
|
||||
status={reorderSaveStatus}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground md:hidden">
|
||||
<Trans ns="views/settings">
|
||||
cameraManagement.streams.enableDesc
|
||||
cameraManagement.streams.description
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
{disabledCameras.length > 0 && (
|
||||
<div className={SPLIT_ROW_CLASS_NAME}>
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
className="cursor-pointer"
|
||||
htmlFor={"disabled-cameras-switch"}
|
||||
>
|
||||
{t("cameraManagement.streams.disableLabel")}
|
||||
<RestartRequiredIndicator className="ml-1" />
|
||||
</Label>
|
||||
<p className="hidden text-sm text-muted-foreground md:block">
|
||||
{t("cameraManagement.streams.disableDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={`${CONTROL_COLUMN_CLASS_NAME} space-y-1.5`}
|
||||
>
|
||||
<div className="max-w-md space-y-2 rounded-lg bg-secondary p-4">
|
||||
{disabledCameras.map((camera) => (
|
||||
<div
|
||||
key={camera}
|
||||
className="flex flex-row items-center justify-between"
|
||||
>
|
||||
<CameraNameLabel camera={camera} />
|
||||
<CameraConfigEnableSwitch
|
||||
cameraName={camera}
|
||||
onConfigChanged={updateConfig}
|
||||
setRestartDialogOpen={setRestartDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground md:hidden">
|
||||
{t("cameraManagement.streams.disableDesc")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SettingsGroupCard>
|
||||
)}
|
||||
|
||||
@ -468,17 +451,19 @@ function ReorderSaveStatusIndicator({
|
||||
);
|
||||
}
|
||||
|
||||
type EnabledCameraRowProps = {
|
||||
type ActiveCameraRowProps = {
|
||||
camera: string;
|
||||
onConfigChanged: () => Promise<unknown>;
|
||||
onDragEnd: () => void;
|
||||
setRestartDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
<CameraEnableSwitch cameraName={camera} />
|
||||
<CameraStatusSelect
|
||||
cameraName={camera}
|
||||
isDisabledInConfig={false}
|
||||
onConfigChanged={onConfigChanged}
|
||||
setRestartDialogOpen={setRestartDialogOpen}
|
||||
/>
|
||||
</Reorder.Item>
|
||||
);
|
||||
}
|
||||
|
||||
type CameraEnableSwitchProps = {
|
||||
cameraName: string;
|
||||
type DisabledCameraRowProps = {
|
||||
camera: string;
|
||||
onConfigChanged: () => Promise<unknown>;
|
||||
setRestartDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
function CameraEnableSwitch({ cameraName }: CameraEnableSwitchProps) {
|
||||
const { payload: enabledState, send: sendEnabled } =
|
||||
useEnabledState(cameraName);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
const isChecked =
|
||||
enabledState === "ON" || enabledState === "OFF"
|
||||
? enabledState === "ON"
|
||||
: (config?.cameras?.[cameraName]?.enabled ?? false);
|
||||
|
||||
function DisabledCameraRow({
|
||||
camera,
|
||||
onConfigChanged,
|
||||
setRestartDialogOpen,
|
||||
}: DisabledCameraRowProps) {
|
||||
return (
|
||||
<div className="flex flex-row items-center">
|
||||
<Switch
|
||||
id={`camera-enabled-${cameraName}`}
|
||||
checked={isChecked}
|
||||
onCheckedChange={(isChecked) => {
|
||||
sendEnabled(isChecked ? "ON" : "OFF");
|
||||
}}
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<CameraNameLabel camera={camera} className="text-muted-foreground" />
|
||||
<CameraDetailsEditor
|
||||
cameraName={camera}
|
||||
onConfigChanged={onConfigChanged}
|
||||
/>
|
||||
</div>
|
||||
<CameraStatusSelect
|
||||
cameraName={camera}
|
||||
isDisabledInConfig={true}
|
||||
onConfigChanged={onConfigChanged}
|
||||
setRestartDialogOpen={setRestartDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type CameraStatus = "on" | "off" | "disabled";
|
||||
|
||||
type CameraStatusSelectProps = {
|
||||
cameraName: string;
|
||||
isDisabledInConfig: boolean;
|
||||
onConfigChanged: () => Promise<unknown>;
|
||||
setRestartDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
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: (
|
||||
<a onClick={() => setRestartDialogOpen(true)}>
|
||||
<Button>
|
||||
{t("restart.button", { ns: "components/dialog" })}
|
||||
</Button>
|
||||
</a>
|
||||
),
|
||||
},
|
||||
);
|
||||
} 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 (
|
||||
<div className="flex h-7 w-[110px] flex-row items-center justify-end">
|
||||
<ActivityIndicator className="size-4" size={16} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Select value={currentStatus} onValueChange={handleChange}>
|
||||
<SelectTrigger className="h-7 w-[110px] text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="on">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{t("cameraManagement.streams.status.on")}
|
||||
{isDisabledInConfig && (
|
||||
<LuRefreshCcw
|
||||
className="size-3 text-muted-foreground"
|
||||
aria-label={restartLabel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
{!isDisabledInConfig && (
|
||||
<SelectItem value="off">
|
||||
{t("cameraManagement.streams.status.off")}
|
||||
</SelectItem>
|
||||
)}
|
||||
<SelectItem value="disabled">
|
||||
{t("cameraManagement.streams.status.disabled")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
type CameraDetailsEditorProps = {
|
||||
cameraName: string;
|
||||
onConfigChanged: () => Promise<unknown>;
|
||||
@ -783,97 +956,6 @@ function CameraDetailsEditor({
|
||||
);
|
||||
}
|
||||
|
||||
type CameraConfigEnableSwitchProps = {
|
||||
cameraName: string;
|
||||
setRestartDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
onConfigChanged: () => Promise<unknown>;
|
||||
};
|
||||
|
||||
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: (
|
||||
<a onClick={() => setRestartDialogOpen(true)}>
|
||||
<Button>
|
||||
{t("restart.button", { ns: "components/dialog" })}
|
||||
</Button>
|
||||
</a>
|
||||
),
|
||||
},
|
||||
);
|
||||
} 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 (
|
||||
<div className="flex flex-row items-center">
|
||||
{isSaving ? (
|
||||
<ActivityIndicator className="h-5 w-8" size={16} />
|
||||
) : (
|
||||
<Switch
|
||||
id={`camera-enabled-${cameraName}`}
|
||||
checked={false}
|
||||
onCheckedChange={onCheckedChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type CameraTypeSectionProps = {
|
||||
cameras: string[];
|
||||
config: FrigateConfig | undefined;
|
||||
@ -1231,12 +1313,12 @@ function ProfileCameraEnableSection({
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="enabled">
|
||||
{t("cameraManagement.profiles.enabled", {
|
||||
{t("cameraManagement.profiles.on", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="disabled">
|
||||
{t("cameraManagement.profiles.disabled", {
|
||||
{t("cameraManagement.profiles.off", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</SelectItem>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user