mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-11 14:31:14 +03:00
add ability to set camera's webui_url from camera management pane
This commit is contained in:
parent
1cad5a78df
commit
2c147d835d
@ -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": {
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user