diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index a19b70cec4..805eb65e8e 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1674,6 +1674,17 @@ "refresh": "Refresh models", "probeFailed": "Failed to probe models", "fetchedModels": "Successfully fetched model list" + }, + "ptzPresets": { + "placeholder": "Select or enter a preset...", + "search": "Search or enter a preset...", + "noPresets": "No presets available", + "available": "Camera presets", + "useCustom": "Use \"{{value}}\"" + }, + "defaultRole": { + "admin": "Admin", + "viewer": "Viewer" } }, "globalConfig": { @@ -1763,7 +1774,7 @@ "addStream": "Add stream", "addStreamDesc": "Enter a name for the new stream. This name will be used to reference the stream in your camera configuration.", "addUrl": "Add URL", - "streamNumber": "Stream {{index}}", + "sourceNumber": "Source {{index}}", "streamName": "Stream name", "streamNamePlaceholder": "e.g., front_door", "streamUrlPlaceholder": "e.g., rtsp://user:pass@192.168.1.100/stream", @@ -1928,6 +1939,9 @@ }, "semanticSearch": { "jinav2SmallModelSize": "The 'small' size with the Jina V2 model has high RAM and inference cost. The 'large' model with a discrete GPU is recommended." + }, + "onvif": { + "autotrackingNoZones": "Autotracking requires at least one zone. Define a zone for this camera in Masks / Zones, then set it as a required zone below." } } } diff --git a/web/src/components/config-form/section-configs/onvif.ts b/web/src/components/config-form/section-configs/onvif.ts index edb62ff7fe..a031c992c0 100644 --- a/web/src/components/config-form/section-configs/onvif.ts +++ b/web/src/components/config-form/section-configs/onvif.ts @@ -25,6 +25,24 @@ const onvif: SectionConfigOverrides = { advancedFields: ["tls_insecure", "ignore_time_mismatch"], overrideFields: [], restartRequired: ["autotracking.calibrate_on_startup"], + fieldMessages: [ + { + key: "autotracking-no-zones", + field: "autotracking.required_zones", + messageKey: "configMessages.onvif.autotrackingNoZones", + severity: "error", + position: "before", + condition: (ctx) => { + if (ctx.level !== "camera") return false; + const zones = ctx.fullCameraConfig?.zones; + return ( + !zones || + typeof zones !== "object" || + Object.keys(zones).length === 0 + ); + }, + }, + ], uiSchema: { host: { "ui:options": { size: "sm" }, @@ -39,11 +57,16 @@ const onvif: SectionConfigOverrides = { required_zones: { "ui:widget": "zoneNames", }, + return_preset: { + "ui:options": { size: "sm" }, + "ui:widget": "ptzPresets", + }, track: { "ui:widget": "objectLabels", }, zooming: { "ui:options": { + size: "xs", enumI18nPrefix: "onvif.autotracking.zooming", }, }, diff --git a/web/src/components/config-form/section-configs/proxy.ts b/web/src/components/config-form/section-configs/proxy.ts index 08e05b6b86..897aef63e0 100644 --- a/web/src/components/config-form/section-configs/proxy.ts +++ b/web/src/components/config-form/section-configs/proxy.ts @@ -21,6 +21,10 @@ const proxy: SectionConfigOverrides = { "ui:widget": "password", "ui:options": { size: "md" }, }, + default_role: { + "ui:widget": "defaultRole", + "ui:options": { size: "sm" }, + }, header_map: { "ui:after": { render: "ProxyRoleMap" }, }, diff --git a/web/src/components/config-form/theme/frigateTheme.ts b/web/src/components/config-form/theme/frigateTheme.ts index 40f3f76c93..109c44eb9a 100644 --- a/web/src/components/config-form/theme/frigateTheme.ts +++ b/web/src/components/config-form/theme/frigateTheme.ts @@ -33,6 +33,8 @@ import { OptionalFieldWidget } from "./widgets/OptionalFieldWidget"; import { SemanticSearchModelWidget } from "./widgets/SemanticSearchModelWidget"; import { SemanticSearchModelSizeWidget } from "./widgets/SemanticSearchModelSizeWidget"; import { OnvifProfileWidget } from "./widgets/OnvifProfileWidget"; +import { PTZPresetsWidget } from "./widgets/PTZPresetsWidget"; +import { DefaultRoleWidget } from "./widgets/DefaultRoleWidget"; import { FieldTemplate } from "./templates/FieldTemplate"; import { ObjectFieldTemplate } from "./templates/ObjectFieldTemplate"; @@ -90,6 +92,8 @@ export const frigateTheme: FrigateTheme = { semanticSearchModel: SemanticSearchModelWidget, semanticSearchModelSize: SemanticSearchModelSizeWidget, onvifProfile: OnvifProfileWidget, + ptzPresets: PTZPresetsWidget, + defaultRole: DefaultRoleWidget, }, templates: { FieldTemplate: FieldTemplate as React.ComponentType, diff --git a/web/src/components/config-form/theme/widgets/DefaultRoleWidget.tsx b/web/src/components/config-form/theme/widgets/DefaultRoleWidget.tsx new file mode 100644 index 0000000000..a8925784a7 --- /dev/null +++ b/web/src/components/config-form/theme/widgets/DefaultRoleWidget.tsx @@ -0,0 +1,56 @@ +import { useMemo } from "react"; +import type { WidgetProps } from "@rjsf/utils"; +import { useTranslation } from "react-i18next"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { ConfigFormContext } from "@/types/configForm"; +import { getSizedFieldClassName } from "../utils"; + +const BUILT_IN_ROLES = ["admin", "viewer"]; + +export function DefaultRoleWidget(props: WidgetProps) { + const { id, value, disabled, readonly, onChange, schema, options, registry } = + props; + const { t } = useTranslation(["views/settings"]); + + const fieldClassName = getSizedFieldClassName(options, "sm"); + + const formContext = registry?.formContext as ConfigFormContext | undefined; + const roles = useMemo(() => { + const configured = Object.keys(formContext?.fullConfig?.auth?.roles ?? {}); + // Keep admin/viewer first, then any custom roles in config order. + const custom = configured.filter((r) => !BUILT_IN_ROLES.includes(r)); + return [...BUILT_IN_ROLES, ...custom]; + }, [formContext]); + + const selectedValue = typeof value === "string" && value ? value : "viewer"; + + const getLabel = (role: string) => + BUILT_IN_ROLES.includes(role) ? t(`configForm.defaultRole.${role}`) : role; + + return ( + + ); +} + +export default DefaultRoleWidget; diff --git a/web/src/components/config-form/theme/widgets/PTZPresetsWidget.tsx b/web/src/components/config-form/theme/widgets/PTZPresetsWidget.tsx new file mode 100644 index 0000000000..95e323b8ac --- /dev/null +++ b/web/src/components/config-form/theme/widgets/PTZPresetsWidget.tsx @@ -0,0 +1,151 @@ +// Combobox widget for ONVIF PTZ preset fields (e.g. autotracking.return_preset). +// Fetches the camera's PTZ presets and shows them in a dropdown, while still +// allowing a typed custom value so existing presets that the camera does not +// report (such as "home") are preserved. +import { useState, useMemo } from "react"; +import type { WidgetProps } from "@rjsf/utils"; +import { useTranslation } from "react-i18next"; +import useSWR from "swr"; +import { Check, ChevronsUpDown, Plus } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import type { ConfigFormContext } from "@/types/configForm"; +import type { CameraPtzInfo } from "@/types/ptz"; +import { getSizedFieldClassName } from "../utils"; + +export function PTZPresetsWidget(props: WidgetProps) { + const { id, value, disabled, readonly, onChange, options, registry } = props; + const { t } = useTranslation(["views/settings"]); + const [open, setOpen] = useState(false); + const [searchValue, setSearchValue] = useState(""); + + const fieldClassName = getSizedFieldClassName(options, "md"); + + const formContext = registry?.formContext as ConfigFormContext | undefined; + const cameraName = formContext?.cameraName; + const isCameraLevel = formContext?.level === "camera"; + const hasOnvifHost = !!formContext?.fullCameraConfig?.onvif?.host; + + const { data: ptzInfo } = useSWR( + isCameraLevel && cameraName && hasOnvifHost + ? `${cameraName}/ptz/info` + : null, + { + // ONVIF may not be initialized yet when the settings page loads, + // so retry until presets become available + refreshInterval: (data) => + data?.presets && data.presets.length > 0 ? 0 : 5000, + }, + ); + + const presets = useMemo(() => ptzInfo?.presets ?? [], [ptzInfo]); + + const trimmedSearch = searchValue.trim(); + const matchesPreset = useMemo( + () => presets.some((p) => p.toLowerCase() === trimmedSearch.toLowerCase()), + [presets, trimmedSearch], + ); + const showCustomOption = trimmedSearch.length > 0 && !matchesPreset; + + const commit = (next: string) => { + onChange(next); + setSearchValue(""); + setOpen(false); + }; + + const currentLabel = typeof value === "string" && value ? value : undefined; + + return ( + { + setOpen(next); + if (!next) setSearchValue(""); + }} + > + + + + + + { + if (e.key === "Enter" && showCustomOption) { + e.preventDefault(); + commit(trimmedSearch); + } + }} + /> + + {showCustomOption && ( + + commit(trimmedSearch)} + > + + {t("configForm.ptzPresets.useCustom", { + value: trimmedSearch, + })} + + + )} + {presets.length > 0 ? ( + + {presets.map((preset) => ( + commit(preset)} + > + + {preset} + + ))} + + ) : !showCustomOption ? ( +
+ {t("configForm.ptzPresets.noPresets")} +
+ ) : null} +
+
+
+
+ ); +}