add ability to set camera's webui_url from camera management pane

This commit is contained in:
Josh Hawkins 2026-05-21 09:40:10 -05:00
parent 1cad5a78df
commit 2c147d835d
2 changed files with 205 additions and 34 deletions

View File

@ -484,11 +484,15 @@
"reorderHandle": "Drag to reorder", "reorderHandle": "Drag to reorder",
"saving": "Saving…", "saving": "Saving…",
"saved": "Saved", "saved": "Saved",
"friendlyName": { "details": {
"edit": "Edit camera display name", "edit": "Edit camera details",
"title": "Edit Display Name", "title": "Edit Camera Details",
"description": "Set the friendly name shown for this camera throughout the Frigate UI. Leave blank to use the camera ID.", "description": "Update the display name and external URL used for this camera throughout the Frigate UI.",
"rename": "Rename" "friendlyNameLabel": "Display Name",
"friendlyNameHelp": "Friendly name shown for this camera throughout the Frigate UI. Leave blank to use the camera ID.",
"webuiUrlLabel": "Camera Web UI URL",
"webuiUrlHelp": "URL to visit the camera's web UI directly from the Debug view. Leave blank to disable the link.",
"webuiUrlInvalid": "Must be a valid URL (e.g., https://example.com)."
} }
}, },
"cameraConfig": { "cameraConfig": {

View File

@ -36,7 +36,15 @@ 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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@ -53,6 +61,17 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const REORDER_SAVED_INDICATOR_MS = 1500; const REORDER_SAVED_INDICATOR_MS = 1500;
@ -482,7 +501,7 @@ function EnabledCameraRow({
<LuGripVertical className="size-4" /> <LuGripVertical className="size-4" />
</button> </button>
<CameraNameLabel camera={camera} /> <CameraNameLabel camera={camera} />
<CameraFriendlyNameEditor <CameraDetailsEditor
cameraName={camera} cameraName={camera}
onConfigChanged={onConfigChanged} onConfigChanged={onConfigChanged}
/> />
@ -519,25 +538,91 @@ function CameraEnableSwitch({ cameraName }: CameraEnableSwitchProps) {
); );
} }
type CameraFriendlyNameEditorProps = { type CameraDetailsEditorProps = {
cameraName: string; cameraName: string;
onConfigChanged: () => Promise<unknown>; onConfigChanged: () => Promise<unknown>;
}; };
function CameraFriendlyNameEditor({ type CameraDetailsFormValues = {
friendlyName: string;
webuiUrl: string;
};
function CameraDetailsEditor({
cameraName, cameraName,
onConfigChanged, onConfigChanged,
}: CameraFriendlyNameEditorProps) { }: CameraDetailsEditorProps) {
const { t } = useTranslation(["views/settings", "common"]); const { t } = useTranslation(["views/settings", "common"]);
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const currentFriendlyName = config?.cameras?.[cameraName]?.friendly_name; const currentFriendlyName = config?.cameras?.[cameraName]?.friendly_name;
const currentWebuiUrl = config?.cameras?.[cameraName]?.webui_url;
const onSave = useCallback( const formSchema = useMemo(
async (text: string) => { () =>
z.object({
friendlyName: z.string(),
webuiUrl: z.string().refine(
(val) => {
const trimmed = val.trim();
if (!trimmed) return true;
try {
new URL(trimmed);
return true;
} catch {
return false;
}
},
{
message: t("cameraManagement.streams.details.webuiUrlInvalid", {
ns: "views/settings",
}),
},
),
}),
[t],
);
const form = useForm<CameraDetailsFormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
friendlyName: currentFriendlyName ?? "",
webuiUrl: currentWebuiUrl ?? "",
},
});
// Reset form values from config whenever the dialog is opened.
useEffect(() => {
if (open) {
form.reset({
friendlyName: currentFriendlyName ?? "",
webuiUrl: currentWebuiUrl ?? "",
});
}
}, [open, currentFriendlyName, currentWebuiUrl, form]);
const onSubmit = useCallback(
async (values: CameraDetailsFormValues) => {
if (isSaving) return; if (isSaving) return;
// only send fields the user actually changed
const newFriendly = values.friendlyName.trim() || null;
const newWebui = values.webuiUrl.trim() || null;
const cameraUpdate: Record<string, string | null> = {};
if (newFriendly !== (currentFriendlyName ?? null)) {
cameraUpdate.friendly_name = newFriendly;
}
if (newWebui !== (currentWebuiUrl ?? null)) {
cameraUpdate.webui_url = newWebui;
}
if (Object.keys(cameraUpdate).length === 0) {
setOpen(false);
return;
}
setIsSaving(true); setIsSaving(true);
try { try {
@ -545,9 +630,7 @@ function CameraFriendlyNameEditor({
requires_restart: 0, requires_restart: 0,
config_data: { config_data: {
cameras: { cameras: {
[cameraName]: { [cameraName]: cameraUpdate,
friendly_name: text.trim() || null,
},
}, },
}, },
}); });
@ -573,10 +656,17 @@ function CameraFriendlyNameEditor({
setIsSaving(false); setIsSaving(false);
} }
}, },
[cameraName, isSaving, onConfigChanged, t], [
cameraName,
currentFriendlyName,
currentWebuiUrl,
isSaving,
onConfigChanged,
t,
],
); );
const renameLabel = t("cameraManagement.streams.friendlyName.rename", { const editLabel = t("cameraManagement.streams.details.edit", {
ns: "views/settings", ns: "views/settings",
}); });
@ -588,30 +678,107 @@ function CameraFriendlyNameEditor({
variant="ghost" variant="ghost"
size="icon" size="icon"
className="size-7" className="size-7"
aria-label={renameLabel} aria-label={editLabel}
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
disabled={isSaving} disabled={isSaving}
> >
<LuPencil className="size-3.5" /> <LuPencil className="size-3.5" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>{renameLabel}</TooltipContent> <TooltipContent>{editLabel}</TooltipContent>
</Tooltip> </Tooltip>
<TextEntryDialog <Dialog open={open} onOpenChange={setOpen}>
open={open} <DialogContent>
setOpen={setOpen} <DialogHeader>
title={t("cameraManagement.streams.friendlyName.title", { <DialogTitle>
ns: "views/settings", {t("cameraManagement.streams.details.title", {
})} ns: "views/settings",
description={t("cameraManagement.streams.friendlyName.description", { })}
ns: "views/settings", </DialogTitle>
})} <DialogDescription>
defaultValue={currentFriendlyName ?? ""} {t("cameraManagement.streams.details.description", {
placeholder={currentFriendlyName ? undefined : cameraName} ns: "views/settings",
allowEmpty })}
isSaving={isSaving} </DialogDescription>
onSave={onSave} </DialogHeader>
/> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="friendlyName"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("cameraManagement.streams.details.friendlyNameLabel", {
ns: "views/settings",
})}
</FormLabel>
<FormControl>
<Input
{...field}
placeholder={cameraName}
disabled={isSaving}
autoFocus
/>
</FormControl>
<p className="text-xs text-muted-foreground">
{t("cameraManagement.streams.details.friendlyNameHelp", {
ns: "views/settings",
})}
</p>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="webuiUrl"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("cameraManagement.streams.details.webuiUrlLabel", {
ns: "views/settings",
})}
</FormLabel>
<FormControl>
<Input
{...field}
placeholder="https://"
disabled={isSaving}
/>
</FormControl>
<p className="text-xs text-muted-foreground">
{t("cameraManagement.streams.details.webuiUrlHelp", {
ns: "views/settings",
})}
</p>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="pt-2">
<Button
type="button"
disabled={isSaving}
onClick={() => setOpen(false)}
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button variant="select" type="submit" disabled={isSaving}>
{isSaving ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator className="size-4" />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
</> </>
); );
} }