allow changing camera friendly_name from camera management pane

This commit is contained in:
Josh Hawkins 2026-04-26 17:00:47 -05:00
parent 61eb365712
commit 9c94640ffb
3 changed files with 138 additions and 6 deletions

View File

@ -457,7 +457,13 @@
"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>", "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>",
"disableLabel": "Disabled cameras", "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.", "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." "enableSuccess": "Enabled {{cameraName}} in configuration. Restart Frigate to apply the changes.",
"friendlyName": {
"edit": "Edit camera display name",
"title": "Edit Display Name",
"description": "Set the friendly name shown for this camera throughout the Frigate UI. Leave blank to use the camera ID.",
"rename": "Rename"
}
}, },
"cameraConfig": { "cameraConfig": {
"add": "Add Camera", "add": "Add Camera",

View File

@ -1,3 +1,4 @@
import ActivityIndicator from "@/components/indicators/activity-indicator";
import TextEntry from "@/components/input/TextEntry"; import TextEntry from "@/components/input/TextEntry";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -19,7 +20,9 @@ type TextEntryDialogProps = {
setOpen: (open: boolean) => void; setOpen: (open: boolean) => void;
onSave: (text: string) => void; onSave: (text: string) => void;
defaultValue?: string; defaultValue?: string;
placeholder?: string;
allowEmpty?: boolean; allowEmpty?: boolean;
isSaving?: boolean;
regexPattern?: RegExp; regexPattern?: RegExp;
regexErrorMessage?: string; regexErrorMessage?: string;
forbiddenPattern?: RegExp; forbiddenPattern?: RegExp;
@ -33,7 +36,9 @@ export default function TextEntryDialog({
setOpen, setOpen,
onSave, onSave,
defaultValue = "", defaultValue = "",
placeholder,
allowEmpty = false, allowEmpty = false,
isSaving = false,
regexPattern, regexPattern,
regexErrorMessage, regexErrorMessage,
forbiddenPattern, forbiddenPattern,
@ -50,6 +55,7 @@ export default function TextEntryDialog({
</DialogHeader> </DialogHeader>
<TextEntry <TextEntry
defaultValue={defaultValue} defaultValue={defaultValue}
placeholder={placeholder}
allowEmpty={allowEmpty} allowEmpty={allowEmpty}
onSave={onSave} onSave={onSave}
regexPattern={regexPattern} regexPattern={regexPattern}
@ -58,11 +64,22 @@ export default function TextEntryDialog({
forbiddenErrorMessage={forbiddenErrorMessage} forbiddenErrorMessage={forbiddenErrorMessage}
> >
<DialogFooter className={cn("pt-4", isMobile && "gap-2")}> <DialogFooter className={cn("pt-4", isMobile && "gap-2")}>
<Button type="button" onClick={() => setOpen(false)}> <Button
type="button"
disabled={isSaving}
onClick={() => setOpen(false)}
>
{t("button.cancel")} {t("button.cancel")}
</Button> </Button>
<Button variant="select" type="submit"> <Button variant="select" type="submit" disabled={isSaving}>
{t("button.save")} {isSaving ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator className="size-4" />
<span>{t("button.saving")}</span>
</div>
) : (
t("button.save")
)}
</Button> </Button>
</DialogFooter> </DialogFooter>
</TextEntry> </TextEntry>

View File

@ -14,7 +14,7 @@ import { useTranslation } from "react-i18next";
import CameraEditForm from "@/components/settings/CameraEditForm"; import CameraEditForm from "@/components/settings/CameraEditForm";
import CameraWizardDialog from "@/components/settings/CameraWizardDialog"; import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
import DeleteCameraDialog from "@/components/overlay/dialog/DeleteCameraDialog"; import DeleteCameraDialog from "@/components/overlay/dialog/DeleteCameraDialog";
import { LuPlus, LuTrash2 } from "react-icons/lu"; import { LuPencil, LuPlus, LuTrash2 } from "react-icons/lu";
import { IoMdArrowRoundBack } from "react-icons/io"; import { IoMdArrowRoundBack } from "react-icons/io";
import { isDesktop } from "react-device-detect"; import { isDesktop } from "react-device-detect";
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
@ -26,6 +26,12 @@ import axios from "axios";
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
import RestartDialog from "@/components/overlay/dialog/RestartDialog"; import RestartDialog from "@/components/overlay/dialog/RestartDialog";
import RestartRequiredIndicator from "@/components/indicators/RestartRequiredIndicator"; import RestartRequiredIndicator from "@/components/indicators/RestartRequiredIndicator";
import TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { ProfileState } from "@/types/profile"; import type { ProfileState } from "@/types/profile";
import { getProfileColor } from "@/utils/profileColors"; import { getProfileColor } from "@/utils/profileColors";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -161,7 +167,13 @@ export default function CameraManagementView({
key={camera} key={camera}
className="flex flex-row items-center justify-between" className="flex flex-row items-center justify-between"
> >
<CameraNameLabel camera={camera} /> <div className="flex items-center gap-1">
<CameraNameLabel camera={camera} />
<CameraFriendlyNameEditor
cameraName={camera}
onConfigChanged={updateConfig}
/>
</div>
<CameraEnableSwitch cameraName={camera} /> <CameraEnableSwitch cameraName={camera} />
</div> </div>
))} ))}
@ -297,6 +309,103 @@ function CameraEnableSwitch({ cameraName }: CameraEnableSwitchProps) {
); );
} }
type CameraFriendlyNameEditorProps = {
cameraName: string;
onConfigChanged: () => Promise<unknown>;
};
function CameraFriendlyNameEditor({
cameraName,
onConfigChanged,
}: CameraFriendlyNameEditorProps) {
const { t } = useTranslation(["views/settings", "common"]);
const { data: config } = useSWR<FrigateConfig>("config");
const [open, setOpen] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const currentFriendlyName = config?.cameras?.[cameraName]?.friendly_name;
const onSave = useCallback(
async (text: string) => {
if (isSaving) return;
setIsSaving(true);
try {
await axios.put("config/set", {
requires_restart: 0,
config_data: {
cameras: {
[cameraName]: {
friendly_name: text.trim() || null,
},
},
},
});
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, isSaving, onConfigChanged, t],
);
const renameLabel = t("cameraManagement.streams.friendlyName.rename", {
ns: "views/settings",
});
return (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7"
aria-label={renameLabel}
onClick={() => setOpen(true)}
disabled={isSaving}
>
<LuPencil className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{renameLabel}</TooltipContent>
</Tooltip>
<TextEntryDialog
open={open}
setOpen={setOpen}
title={t("cameraManagement.streams.friendlyName.title", {
ns: "views/settings",
})}
description={t("cameraManagement.streams.friendlyName.description", {
ns: "views/settings",
})}
defaultValue={currentFriendlyName ?? ""}
placeholder={currentFriendlyName ? undefined : cameraName}
allowEmpty
isSaving={isSaving}
onSave={onSave}
/>
</>
);
}
type CameraConfigEnableSwitchProps = { type CameraConfigEnableSwitchProps = {
cameraName: string; cameraName: string;
setRestartDialogOpen: React.Dispatch<React.SetStateAction<boolean>>; setRestartDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;