UI tweaks (#23304)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions

* restructure camera enable/disable pane

* remove obsolete camera edit form

* change terminology to off/on instead of disabled/enabled

* docs

* move menu options and add current camera name badge

* docs

* tweaks
This commit is contained in:
Josh Hawkins
2026-05-24 14:59:56 -06:00
committed by GitHub
parent ec44398b1c
commit 7e0e0635b8
17 changed files with 419 additions and 1083 deletions
+312 -282
View File
@@ -11,7 +11,6 @@ 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 {
@@ -20,15 +19,13 @@ import {
LuGripVertical,
LuPencil,
LuPlus,
LuRefreshCcw,
LuTrash2,
} from "react-icons/lu";
import { Reorder, useDragControls } from "framer-motion";
import { IoMdArrowRoundBack } from "react-icons/io";
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";
@@ -78,12 +75,10 @@ const REORDER_SAVED_INDICATOR_MS = 1500;
type ReorderSaveStatus = "idle" | "saving" | "saved";
type CameraManagementViewProps = {
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
profileState?: ProfileState;
};
export default function CameraManagementView({
setUnsavedChanges,
profileState,
}: CameraManagementViewProps) {
const { t } = useTranslation(["views/settings", "common"]);
@@ -91,12 +86,6 @@ export default function CameraManagementView({
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
const [viewMode, setViewMode] = useState<"settings" | "add" | "edit">(
"settings",
); // Control view state
const [editCameraName, setEditCameraName] = useState<string | undefined>(
undefined,
); // Track camera being edited
const [showWizard, setShowWizard] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
@@ -226,14 +215,6 @@ export default function CameraManagementView({
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 (
<>
<Toaster
@@ -244,174 +225,124 @@ export default function CameraManagementView({
/>
<div className="flex size-full space-y-6">
<div className="scrollbar-container flex-1 overflow-y-auto pb-2">
{viewMode === "settings" ? (
<>
<Heading as="h4" className="mb-2">
{t("cameraManagement.title")}
</Heading>
<p className="mb-6 max-w-5xl text-sm text-muted-foreground">
{t("cameraManagement.description")}
</p>
<Heading as="h4" className="mb-2">
{t("cameraManagement.title")}
</Heading>
<p className="mb-6 max-w-5xl text-sm text-muted-foreground">
{t("cameraManagement.description")}
</p>
<div className="w-full max-w-5xl space-y-6">
<div className="flex gap-2">
<Button
variant="select"
onClick={() => setShowWizard(true)}
className="mb-2 flex max-w-48 items-center gap-2"
>
<LuPlus className="h-4 w-4" />
{t("cameraManagement.addCamera")}
</Button>
{enabledCameras.length + disabledCameras.length > 0 && (
<Button
variant="destructive"
onClick={() => setShowDeleteDialog(true)}
className="mb-2 flex max-w-48 items-center gap-2 text-white"
>
<LuTrash2 className="h-4 w-4" />
{t("cameraManagement.deleteCamera")}
</Button>
)}
</div>
<div className="w-full max-w-5xl space-y-6">
<div className="flex gap-2">
<Button
variant="select"
onClick={() => setShowWizard(true)}
className="mb-2 flex max-w-48 items-center gap-2"
>
<LuPlus className="h-4 w-4" />
{t("cameraManagement.addCamera")}
</Button>
{enabledCameras.length + disabledCameras.length > 0 && (
<Button
variant="destructive"
onClick={() => setShowDeleteDialog(true)}
className="mb-2 flex max-w-48 items-center gap-2 text-white"
>
<LuTrash2 className="h-4 w-4" />
{t("cameraManagement.deleteCamera")}
</Button>
)}
</div>
{enabledCameras.length > 0 && (
<SettingsGroupCard
title={
{(enabledCameras.length > 0 || disabledCameras.length > 0) && (
<SettingsGroupCard
title={
<Trans ns="views/settings">
cameraManagement.streams.title
</Trans>
}
>
<div className={SPLIT_ROW_CLASS_NAME}>
<div className="space-y-1.5">
<Label>{t("cameraManagement.streams.label")}</Label>
<p className="hidden text-sm text-muted-foreground md:block">
<Trans ns="views/settings">
cameraManagement.streams.title
cameraManagement.streams.description
</Trans>
}
>
<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>
</div>
<div className="max-w-md space-y-1.5">
</p>
</div>
<div className="max-w-md space-y-1.5">
<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 rounded-lg bg-secondary p-4"
className="space-y-2"
>
{orderedCameras.map((camera) => (
<EnabledCameraRow
<ActiveCameraRow
key={camera}
camera={camera}
onConfigChanged={updateConfig}
onDragEnd={handleReorderDragEnd}
setRestartDialogOpen={setRestartDialogOpen}
/>
))}
</Reorder.Group>
<ReorderSaveStatusIndicator
status={reorderSaveStatus}
/>
</div>
<p className="text-sm text-muted-foreground md:hidden">
<Trans ns="views/settings">
cameraManagement.streams.enableDesc
</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")}
)}
{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
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>
)}
{profileState &&
profileState.allProfileNames.length > 0 &&
enabledCameras.length > 0 && (
<ProfileCameraEnableSection
profileState={profileState}
cameras={enabledCameras}
config={config}
onConfigChanged={updateConfig}
/>
)}
{config?.lpr?.enabled && allCameras.length > 0 && (
<CameraTypeSection
cameras={allCameras}
config={config}
onConfigChanged={updateConfig}
setRestartDialogOpen={setRestartDialogOpen}
/>
)}
</div>
</>
) : (
<>
<div className="mb-4 flex items-center gap-2">
<Button
className={`flex items-center gap-2.5 rounded-lg`}
aria-label={t("label.back", { ns: "common" })}
size="sm"
onClick={handleBack}
>
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
{isDesktop && (
<div className="text-primary">
{t("button.back", { ns: "common" })}
)}
</div>
)}
</Button>
</div>
<div className="md:max-w-5xl">
<CameraEditForm
cameraName={viewMode === "edit" ? editCameraName : undefined}
onSave={handleBack}
onCancel={handleBack}
<ReorderSaveStatusIndicator status={reorderSaveStatus} />
</div>
<p className="text-sm text-muted-foreground md:hidden">
<Trans ns="views/settings">
cameraManagement.streams.description
</Trans>
</p>
</div>
</SettingsGroupCard>
)}
{profileState &&
profileState.allProfileNames.length > 0 &&
enabledCameras.length > 0 && (
<ProfileCameraEnableSection
profileState={profileState}
cameras={enabledCameras}
config={config}
onConfigChanged={updateConfig}
/>
</div>
</>
)}
)}
{config?.lpr?.enabled && allCameras.length > 0 && (
<CameraTypeSection
cameras={allCameras}
config={config}
onConfigChanged={updateConfig}
setRestartDialogOpen={setRestartDialogOpen}
/>
)}
</div>
</div>
</div>
@@ -468,17 +399,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 +439,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 +904,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 +1261,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>