From 2339b5f35d9927f5ca97b6b7e63fd50085c81f66 Mon Sep 17 00:00:00 2001 From: ZhaiSoul <842607283@qq.com> Date: Sun, 2 Nov 2025 13:30:09 +0000 Subject: [PATCH] feat: add zones friendly name --- frigate/config/camera/zone.py | 3 + ...eraNameLabel.tsx => FriendlyNameLabel.tsx} | 22 +++++- .../components/filter/CameraGroupSelector.tsx | 2 +- .../components/filter/CamerasFilterButton.tsx | 2 +- web/src/components/filter/FilterSwitch.tsx | 16 +++- .../components/filter/ReviewFilterGroup.tsx | 3 +- web/src/components/input/InputWithTags.tsx | 11 ++- web/src/components/menu/LiveContextMenu.tsx | 2 +- .../components/overlay/CreateRoleDialog.tsx | 2 +- .../overlay/EditRoleCamerasDialog.tsx | 2 +- .../components/overlay/MobileCameraDrawer.tsx | 2 +- .../components/overlay/ObjectTrackOverlay.tsx | 20 ++++- .../components/overlay/detail/ObjectPath.tsx | 31 ++++++-- .../overlay/detail/SearchDetailDialog.tsx | 2 +- .../overlay/dialog/SearchFilterDialog.tsx | 3 +- web/src/components/settings/PolygonItem.tsx | 6 +- web/src/components/settings/ZoneEditPane.tsx | 75 ++++++++++++------- web/src/hooks/use-zone-friendly-name.ts | 41 ++++++++++ web/src/pages/Settings.tsx | 4 +- web/src/types/canvas.ts | 2 + web/src/types/frigateConfig.ts | 1 + web/src/types/timeline.ts | 1 + web/src/views/recording/RecordingView.tsx | 2 +- web/src/views/settings/AuthenticationView.tsx | 2 +- .../views/settings/CameraManagementView.tsx | 2 +- web/src/views/settings/CameraSettingsView.tsx | 48 +++++------- .../settings/FrigatePlusSettingsView.tsx | 2 +- web/src/views/settings/MasksAndZonesView.tsx | 1 + .../settings/NotificationsSettingsView.tsx | 4 +- web/src/views/system/CameraMetrics.tsx | 2 +- 30 files changed, 227 insertions(+), 89 deletions(-) rename web/src/components/camera/{CameraNameLabel.tsx => FriendlyNameLabel.tsx} (53%) create mode 100644 web/src/hooks/use-zone-friendly-name.ts diff --git a/frigate/config/camera/zone.py b/frigate/config/camera/zone.py index 3e69240d5..530ba1cf9 100644 --- a/frigate/config/camera/zone.py +++ b/frigate/config/camera/zone.py @@ -13,6 +13,9 @@ logger = logging.getLogger(__name__) class ZoneConfig(BaseModel): + friendly_name: Optional[str] = Field( + None, title="Zone friendly name used in the Frigate UI." + ) filters: dict[str, FilterConfig] = Field( default_factory=dict, title="Zone filters." ) diff --git a/web/src/components/camera/CameraNameLabel.tsx b/web/src/components/camera/FriendlyNameLabel.tsx similarity index 53% rename from web/src/components/camera/CameraNameLabel.tsx rename to web/src/components/camera/FriendlyNameLabel.tsx index ab022f5c8..ca0978852 100644 --- a/web/src/components/camera/CameraNameLabel.tsx +++ b/web/src/components/camera/FriendlyNameLabel.tsx @@ -2,12 +2,19 @@ import * as React from "react"; import * as LabelPrimitive from "@radix-ui/react-label"; import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name"; import { CameraConfig } from "@/types/frigateConfig"; +import { useZoneFriendlyName } from "@/hooks/use-zone-friendly-name"; interface CameraNameLabelProps extends React.ComponentPropsWithoutRef { camera?: string | CameraConfig; } +interface ZoneNameLabelProps + extends React.ComponentPropsWithoutRef { + zone: string; + camera?: string; +} + const CameraNameLabel = React.forwardRef< React.ElementRef, CameraNameLabelProps @@ -21,4 +28,17 @@ const CameraNameLabel = React.forwardRef< }); CameraNameLabel.displayName = LabelPrimitive.Root.displayName; -export { CameraNameLabel }; +const ZoneNameLabel = React.forwardRef< + React.ElementRef, + ZoneNameLabelProps +>(({ className, zone, camera, ...props }, ref) => { + const displayName = useZoneFriendlyName(zone, camera); + return ( + + {displayName} + + ); +}); +ZoneNameLabel.displayName = LabelPrimitive.Root.displayName; + +export { CameraNameLabel, ZoneNameLabel }; diff --git a/web/src/components/filter/CameraGroupSelector.tsx b/web/src/components/filter/CameraGroupSelector.tsx index cd0b118c9..a700981b6 100644 --- a/web/src/components/filter/CameraGroupSelector.tsx +++ b/web/src/components/filter/CameraGroupSelector.tsx @@ -76,7 +76,7 @@ import { CameraStreamingDialog } from "../settings/CameraStreamingDialog"; import { DialogTrigger } from "@radix-ui/react-dialog"; import { useStreamingSettings } from "@/context/streaming-settings-provider"; import { Trans, useTranslation } from "react-i18next"; -import { CameraNameLabel } from "../camera/CameraNameLabel"; +import { CameraNameLabel } from "../camera/FriendlyNameLabel"; import { useAllowedCameras } from "@/hooks/use-allowed-cameras"; import { useIsCustomRole } from "@/hooks/use-is-custom-role"; diff --git a/web/src/components/filter/CamerasFilterButton.tsx b/web/src/components/filter/CamerasFilterButton.tsx index 93b8a8651..baeccf06f 100644 --- a/web/src/components/filter/CamerasFilterButton.tsx +++ b/web/src/components/filter/CamerasFilterButton.tsx @@ -190,7 +190,7 @@ export function CamerasFilterContent({ key={item} isChecked={currentCameras?.includes(item) ?? false} label={item} - isCameraName={true} + type={"camera"} disabled={ mainCamera !== undefined && currentCameras !== undefined && diff --git a/web/src/components/filter/FilterSwitch.tsx b/web/src/components/filter/FilterSwitch.tsx index fa8709d96..d282b9e12 100644 --- a/web/src/components/filter/FilterSwitch.tsx +++ b/web/src/components/filter/FilterSwitch.tsx @@ -1,29 +1,39 @@ import { Switch } from "../ui/switch"; import { Label } from "../ui/label"; -import { CameraNameLabel } from "../camera/CameraNameLabel"; +import { CameraNameLabel, ZoneNameLabel } from "../camera/FriendlyNameLabel"; type FilterSwitchProps = { label: string; disabled?: boolean; isChecked: boolean; isCameraName?: boolean; + type?: string; + extraValue?: string; onCheckedChange: (checked: boolean) => void; }; export default function FilterSwitch({ label, disabled = false, isChecked, - isCameraName = false, + type = "", + extraValue = "", onCheckedChange, }: FilterSwitchProps) { return (
- {isCameraName ? ( + {type === "camera" ? ( + ) : type === "zone" ? ( + ) : (
masksAndZones.form.polygonDrawing.delete.desc diff --git a/web/src/components/settings/ZoneEditPane.tsx b/web/src/components/settings/ZoneEditPane.tsx index 8a1e36a0e..cfbf747ce 100644 --- a/web/src/components/settings/ZoneEditPane.tsx +++ b/web/src/components/settings/ZoneEditPane.tsx @@ -34,6 +34,7 @@ import { Link } from "react-router-dom"; import { LuExternalLink } from "react-icons/lu"; import { useDocDomain } from "@/hooks/use-doc-domain"; import { getTranslatedLabel } from "@/utils/i18n"; +import NameAndIdFields from "../input/NameAndIdFields"; type ZoneEditPaneProps = { polygons?: Polygon[]; @@ -146,15 +147,38 @@ export default function ZoneEditPane({ "masksAndZones.form.zoneName.error.mustNotContainPeriod", ), }, - ) - .refine((value: string) => /^[a-zA-Z0-9_-]+$/.test(value), { - message: t("masksAndZones.form.zoneName.error.hasIllegalCharacter"), - }) - .refine((value: string) => /[a-zA-Z]/.test(value), { + ), + friendly_name: z + .string() + .min(2, { message: t( - "masksAndZones.form.zoneName.error.mustHaveAtLeastOneLetter", + "masksAndZones.form.zoneName.error.mustBeAtLeastTwoCharacters", ), - }), + }) + .transform((val: string) => val.trim().replace(/\s+/g, "_")) + .refine( + (value: string) => { + return !cameras.map((cam) => cam.name).includes(value); + }, + { + message: t( + "masksAndZones.form.zoneName.error.mustNotBeSameWithCamera", + ), + }, + ) + .refine( + (value: string) => { + const otherPolygonNames = + polygons + ?.filter((_, index) => index !== activePolygonIndex) + .map((polygon) => polygon.name) || []; + + return !otherPolygonNames.includes(value); + }, + { + message: t("masksAndZones.form.zoneName.error.alreadyExists"), + }, + ), inertia: z.coerce .number() .min(1, { @@ -247,6 +271,7 @@ export default function ZoneEditPane({ mode: "onBlur", defaultValues: { name: polygon?.name ?? "", + friendly_name: polygon?.friendly_name ?? "", inertia: polygon?.camera && polygon?.name && @@ -286,6 +311,7 @@ export default function ZoneEditPane({ async ( { name: zoneName, + friendly_name, inertia, loitering_time, objects: form_objects, @@ -415,9 +441,14 @@ export default function ZoneEditPane({ } } + let friendlyNameQuery = ""; + if (friendly_name) { + friendlyNameQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.friendly_name=${encodeURIComponent(friendly_name)}`; + } + axios .put( - `config/set?cameras.${polygon?.camera}.zones.${zoneName}.coordinates=${coordinates}${inertiaQuery}${loiteringTimeQuery}${speedThresholdQuery}${distancesQuery}${objectQueries}${alertQueries}${detectionQueries}`, + `config/set?cameras.${polygon?.camera}.zones.${zoneName}.coordinates=${coordinates}${inertiaQuery}${loiteringTimeQuery}${speedThresholdQuery}${distancesQuery}${objectQueries}${friendlyNameQuery}${alertQueries}${detectionQueries}`, { requires_restart: 0, update_topic: `config/cameras/${polygon.camera}/zones`, @@ -427,7 +458,7 @@ export default function ZoneEditPane({ if (res.status === 200) { toast.success( t("masksAndZones.zones.toast.success", { - zoneName, + zoneName: friendly_name || zoneName, }), { position: "top-center", @@ -541,26 +572,16 @@ export default function ZoneEditPane({
- ( - - {t("masksAndZones.zones.name.title")} - - - - - {t("masksAndZones.zones.name.tips")} - - - - )} + nameField="friendly_name" + idField="name" + nameLabel={t("masksAndZones.zones.name.title")} + nameDescription={t("masksAndZones.zones.name.tips")} + placeholderName={t("masksAndZones.zones.name.inputPlaceHolder")} /> + ("config"); + + const name = useMemo( + () => resolveZoneName(config, zoneId, cameraId), + [config, cameraId, zoneId], + ); + + return name; +} diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 844329fc7..ba81730d5 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -42,7 +42,7 @@ import { useInitialCameraState } from "@/api/ws"; import { useIsAdmin } from "@/hooks/use-is-admin"; import { useTranslation } from "react-i18next"; import TriggerView from "@/views/settings/TriggerView"; -import { CameraNameLabel } from "@/components/camera/CameraNameLabel"; +import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; import { Sidebar, SidebarContent, @@ -642,7 +642,7 @@ function CameraSelectButton({ key={item.name} isChecked={item.name === selectedCamera} label={item.name} - isCameraName={true} + type={"camera"} onCheckedChange={(isChecked) => { if (isChecked && (isEnabled || isCameraSettingsPage)) { setSelectedCamera(item.name); diff --git a/web/src/types/canvas.ts b/web/src/types/canvas.ts index 9c1748ce0..a70238834 100644 --- a/web/src/types/canvas.ts +++ b/web/src/types/canvas.ts @@ -11,10 +11,12 @@ export type Polygon = { distances: number[]; isFinished: boolean; color: number[]; + friendly_name?: string; }; export type ZoneFormValuesType = { name: string; + friendly_name: string; inertia: number; loitering_time: number; isFinished: boolean; diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index ffe4cc14d..71aacb13c 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -280,6 +280,7 @@ export interface CameraConfig { speed_threshold: number; objects: string[]; color: number[]; + friendly_name?: string; }; }; } diff --git a/web/src/types/timeline.ts b/web/src/types/timeline.ts index 4551937d9..c8e5f7543 100644 --- a/web/src/types/timeline.ts +++ b/web/src/types/timeline.ts @@ -22,6 +22,7 @@ export type TrackingDetailsSequence = { attribute: string; attribute_box?: [number, number, number, number]; zones: string[]; + zones_friendly_names?: string[]; }; class_type: LifecycleClassType; source_id: string; diff --git a/web/src/views/recording/RecordingView.tsx b/web/src/views/recording/RecordingView.tsx index 00e46411e..cbd5ad41c 100644 --- a/web/src/views/recording/RecordingView.tsx +++ b/web/src/views/recording/RecordingView.tsx @@ -63,7 +63,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { CameraNameLabel } from "@/components/camera/CameraNameLabel"; +import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; import { useAllowedCameras } from "@/hooks/use-allowed-cameras"; import { DetailStreamProvider } from "@/context/detail-stream-context"; import { GenAISummaryDialog } from "@/components/overlay/chip/GenAISummaryChip"; diff --git a/web/src/views/settings/AuthenticationView.tsx b/web/src/views/settings/AuthenticationView.tsx index db5f22555..5c11d8914 100644 --- a/web/src/views/settings/AuthenticationView.tsx +++ b/web/src/views/settings/AuthenticationView.tsx @@ -36,7 +36,7 @@ import EditRoleCamerasDialog from "@/components/overlay/EditRoleCamerasDialog"; import { useTranslation } from "react-i18next"; import DeleteRoleDialog from "@/components/overlay/DeleteRoleDialog"; import { Separator } from "@/components/ui/separator"; -import { CameraNameLabel } from "@/components/camera/CameraNameLabel"; +import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; type AuthenticationViewProps = { section?: "users" | "roles"; diff --git a/web/src/views/settings/CameraManagementView.tsx b/web/src/views/settings/CameraManagementView.tsx index 4db384e0a..52d5879e1 100644 --- a/web/src/views/settings/CameraManagementView.tsx +++ b/web/src/views/settings/CameraManagementView.tsx @@ -18,7 +18,7 @@ import { } from "@/components/ui/select"; import { IoMdArrowRoundBack } from "react-icons/io"; import { isDesktop } from "react-device-detect"; -import { CameraNameLabel } from "@/components/camera/CameraNameLabel"; +import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; import { Switch } from "@/components/ui/switch"; import { Trans } from "react-i18next"; import { Separator } from "@/components/ui/separator"; diff --git a/web/src/views/settings/CameraSettingsView.tsx b/web/src/views/settings/CameraSettingsView.tsx index f42ec84fe..878ea9e98 100644 --- a/web/src/views/settings/CameraSettingsView.tsx +++ b/web/src/views/settings/CameraSettingsView.tsx @@ -23,7 +23,6 @@ import { StatusBarMessagesContext } from "@/context/statusbar-provider"; import axios from "axios"; import { Link } from "react-router-dom"; import { LuExternalLink } from "react-icons/lu"; -import { capitalizeFirstLetter } from "@/utils/stringUtil"; import { MdCircle } from "react-icons/md"; import { cn } from "@/lib/utils"; import { Trans, useTranslation } from "react-i18next"; @@ -42,6 +41,7 @@ import CameraWizardDialog from "@/components/settings/CameraWizardDialog"; import { IoMdArrowRoundBack } from "react-icons/io"; import { isDesktop } from "react-device-detect"; import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name"; +import { resolveZoneName } from "@/hooks/use-zone-friendly-name"; type CameraSettingsViewProps = { selectedCamera: string; @@ -86,16 +86,23 @@ export default function CameraSettingsView({ // zones and labels + const getZoneName = useCallback( + (zoneId: string, cameraId?: string) => + resolveZoneName(config, zoneId, cameraId), + [config], + ); + const zones = useMemo(() => { if (cameraConfig) { return Object.entries(cameraConfig.zones).map(([name, zoneData]) => ({ camera: cameraConfig.name, name, + friendly_name: getZoneName(name, cameraConfig.name), objects: zoneData.objects, color: zoneData.color, })); } - }, [cameraConfig]); + }, [cameraConfig, getZoneName]); const alertsLabels = useMemo(() => { return cameraConfig?.review.alerts.labels @@ -526,7 +533,7 @@ export default function CameraSettingsView({ /> - {zone.name.replaceAll("_", " ")} + {zone.friendly_name} )} @@ -548,14 +555,9 @@ export default function CameraSettingsView({ "cameraReview.reviewClassification.zoneObjectAlertsTips", { alertsLabels, - zone: watchedAlertsZones - .map((zone) => - capitalizeFirstLetter(zone).replaceAll( - "_", - " ", - ), - ) - .join(", "), + zone: watchedAlertsZones.map((zone) => + getZoneName(zone), + ), cameraName: selectCameraName, }, ) @@ -628,7 +630,7 @@ export default function CameraSettingsView({ /> - {zone.name.replaceAll("_", " ")} + {zone.friendly_name} )} @@ -667,14 +669,9 @@ export default function CameraSettingsView({ i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.text" values={{ detectionsLabels, - zone: watchedDetectionsZones - .map((zone) => - capitalizeFirstLetter(zone).replaceAll( - "_", - " ", - ), - ) - .join(", "), + zone: watchedDetectionsZones.map((zone) => + getZoneName(zone), + ), cameraName: selectCameraName, }} ns="views/settings" @@ -684,14 +681,9 @@ export default function CameraSettingsView({ i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.notSelectDetections" values={{ detectionsLabels, - zone: watchedDetectionsZones - .map((zone) => - capitalizeFirstLetter(zone).replaceAll( - "_", - " ", - ), - ) - .join(", "), + zone: watchedDetectionsZones.map((zone) => + getZoneName(zone), + ), cameraName: selectCameraName, }} ns="views/settings" diff --git a/web/src/views/settings/FrigatePlusSettingsView.tsx b/web/src/views/settings/FrigatePlusSettingsView.tsx index 20e248070..52af94354 100644 --- a/web/src/views/settings/FrigatePlusSettingsView.tsx +++ b/web/src/views/settings/FrigatePlusSettingsView.tsx @@ -23,7 +23,7 @@ import { SelectTrigger, } from "@/components/ui/select"; import { useDocDomain } from "@/hooks/use-doc-domain"; -import { CameraNameLabel } from "@/components/camera/CameraNameLabel"; +import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; type FrigatePlusModel = { id: string; diff --git a/web/src/views/settings/MasksAndZonesView.tsx b/web/src/views/settings/MasksAndZonesView.tsx index 956ce3f95..27c542e87 100644 --- a/web/src/views/settings/MasksAndZonesView.tsx +++ b/web/src/views/settings/MasksAndZonesView.tsx @@ -229,6 +229,7 @@ export default function MasksAndZonesView({ typeIndex: index, camera: cameraConfig.name, name, + friendly_name: zoneData.friendly_name, objects: zoneData.objects, points: interpolatePoints( parseCoordinates(zoneData.coordinates), diff --git a/web/src/views/settings/NotificationsSettingsView.tsx b/web/src/views/settings/NotificationsSettingsView.tsx index b3a049418..6280ca6a8 100644 --- a/web/src/views/settings/NotificationsSettingsView.tsx +++ b/web/src/views/settings/NotificationsSettingsView.tsx @@ -45,7 +45,7 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Trans, useTranslation } from "react-i18next"; import { useDateLocale } from "@/hooks/use-date-locale"; import { useDocDomain } from "@/hooks/use-doc-domain"; -import { CameraNameLabel } from "@/components/camera/CameraNameLabel"; +import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; import { useIsAdmin } from "@/hooks/use-is-admin"; import { cn } from "@/lib/utils"; @@ -476,7 +476,7 @@ export default function NotificationView({