Add zones friend name (#20761)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions

* feat: add zones friendly name

* fix: fix the issue where the input field was empty when there was no friendly_name

* chore: fix the issue where the friendly name would replace spaces with underscores

* docs: update zones docs

* Update web/src/components/settings/ZoneEditPane.tsx

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>

* Add friendly_name option for zone configuration

Added optional friendly name for zones in configuration.

* fix: fix the logical error in the null/empty check for the polygons parameter

* fix: remove the toast name for zones will use the friendly_name instead

* docs: remove emoji tips

* revert: revert zones doc ui tips

* Update docs/docs/configuration/zones.md

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>

* Update docs/docs/configuration/zones.md

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>

* Update docs/docs/configuration/zones.md

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>

* feat: add friendly zone names to tracking details and lifecycle item descriptions

* chore: lint fix

* refactor: add friendly zone names to timeline entries and clean up unused code

* refactor: add formatList

---------

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
This commit is contained in:
GuoQing Liu 2025-11-07 22:02:06 +08:00 committed by GitHub
parent 530b69b877
commit ef19332fe5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 314 additions and 126 deletions

View File

@ -810,6 +810,8 @@ cameras:
# NOTE: This must be different than any camera names, but can match with another zone on another # NOTE: This must be different than any camera names, but can match with another zone on another
# camera. # camera.
front_steps: front_steps:
# Optional: A friendly name or descriptive text for the zones
friendly_name: ""
# Required: List of x,y coordinates to define the polygon of the zone. # Required: List of x,y coordinates to define the polygon of the zone.
# NOTE: Presence in a zone is evaluated only based on the bottom center of the objects bounding box. # NOTE: Presence in a zone is evaluated only based on the bottom center of the objects bounding box.
coordinates: 0.033,0.306,0.324,0.138,0.439,0.185,0.042,0.428 coordinates: 0.033,0.306,0.324,0.138,0.439,0.185,0.042,0.428

View File

@ -27,6 +27,7 @@ cameras:
- entire_yard - entire_yard
zones: zones:
entire_yard: entire_yard:
friendly_name: Entire yard # You can use characters from any language text
coordinates: ... coordinates: ...
``` ```
@ -44,8 +45,10 @@ cameras:
- edge_yard - edge_yard
zones: zones:
edge_yard: edge_yard:
friendly_name: Edge yard # You can use characters from any language text
coordinates: ... coordinates: ...
inner_yard: inner_yard:
friendly_name: Inner yard # You can use characters from any language text
coordinates: ... coordinates: ...
``` ```
@ -59,6 +62,7 @@ cameras:
- entire_yard - entire_yard
zones: zones:
entire_yard: entire_yard:
friendly_name: Entire yard
coordinates: ... coordinates: ...
``` ```
@ -82,6 +86,7 @@ cameras:
Only car objects can trigger the `front_yard_street` zone and only person can trigger the `entire_yard`. Objects will be tracked for any `person` that enter anywhere in the yard, and for cars only if they enter the street. Only car objects can trigger the `front_yard_street` zone and only person can trigger the `entire_yard`. Objects will be tracked for any `person` that enter anywhere in the yard, and for cars only if they enter the street.
### Zone Loitering ### Zone Loitering
Sometimes objects are expected to be passing through a zone, but an object loitering in an area is unexpected. Zones can be configured to have a minimum loitering time after which the object will be considered in the zone. Sometimes objects are expected to be passing through a zone, but an object loitering in an area is unexpected. Zones can be configured to have a minimum loitering time after which the object will be considered in the zone.

View File

@ -13,6 +13,9 @@ logger = logging.getLogger(__name__)
class ZoneConfig(BaseModel): class ZoneConfig(BaseModel):
friendly_name: Optional[str] = Field(
None, title="Zone friendly name used in the Frigate UI."
)
filters: dict[str, FilterConfig] = Field( filters: dict[str, FilterConfig] = Field(
default_factory=dict, title="Zone filters." default_factory=dict, title="Zone filters."
) )

View File

@ -2,12 +2,19 @@ import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label"; import * as LabelPrimitive from "@radix-ui/react-label";
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name"; import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
import { CameraConfig } from "@/types/frigateConfig"; import { CameraConfig } from "@/types/frigateConfig";
import { useZoneFriendlyName } from "@/hooks/use-zone-friendly-name";
interface CameraNameLabelProps interface CameraNameLabelProps
extends React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> { extends React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> {
camera?: string | CameraConfig; camera?: string | CameraConfig;
} }
interface ZoneNameLabelProps
extends React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> {
zone: string;
camera?: string;
}
const CameraNameLabel = React.forwardRef< const CameraNameLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>, React.ElementRef<typeof LabelPrimitive.Root>,
CameraNameLabelProps CameraNameLabelProps
@ -21,4 +28,17 @@ const CameraNameLabel = React.forwardRef<
}); });
CameraNameLabel.displayName = LabelPrimitive.Root.displayName; CameraNameLabel.displayName = LabelPrimitive.Root.displayName;
export { CameraNameLabel }; const ZoneNameLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
ZoneNameLabelProps
>(({ className, zone, camera, ...props }, ref) => {
const displayName = useZoneFriendlyName(zone, camera);
return (
<LabelPrimitive.Root ref={ref} className={className} {...props}>
{displayName}
</LabelPrimitive.Root>
);
});
ZoneNameLabel.displayName = LabelPrimitive.Root.displayName;
export { CameraNameLabel, ZoneNameLabel };

View File

@ -76,7 +76,7 @@ import { CameraStreamingDialog } from "../settings/CameraStreamingDialog";
import { DialogTrigger } from "@radix-ui/react-dialog"; import { DialogTrigger } from "@radix-ui/react-dialog";
import { useStreamingSettings } from "@/context/streaming-settings-provider"; import { useStreamingSettings } from "@/context/streaming-settings-provider";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { CameraNameLabel } from "../camera/CameraNameLabel"; import { CameraNameLabel } from "../camera/FriendlyNameLabel";
import { useAllowedCameras } from "@/hooks/use-allowed-cameras"; import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
import { useIsCustomRole } from "@/hooks/use-is-custom-role"; import { useIsCustomRole } from "@/hooks/use-is-custom-role";

View File

@ -190,7 +190,7 @@ export function CamerasFilterContent({
key={item} key={item}
isChecked={currentCameras?.includes(item) ?? false} isChecked={currentCameras?.includes(item) ?? false}
label={item} label={item}
isCameraName={true} type={"camera"}
disabled={ disabled={
mainCamera !== undefined && mainCamera !== undefined &&
currentCameras !== undefined && currentCameras !== undefined &&

View File

@ -1,29 +1,39 @@
import { Switch } from "../ui/switch"; import { Switch } from "../ui/switch";
import { Label } from "../ui/label"; import { Label } from "../ui/label";
import { CameraNameLabel } from "../camera/CameraNameLabel"; import { CameraNameLabel, ZoneNameLabel } from "../camera/FriendlyNameLabel";
type FilterSwitchProps = { type FilterSwitchProps = {
label: string; label: string;
disabled?: boolean; disabled?: boolean;
isChecked: boolean; isChecked: boolean;
isCameraName?: boolean; isCameraName?: boolean;
type?: string;
extraValue?: string;
onCheckedChange: (checked: boolean) => void; onCheckedChange: (checked: boolean) => void;
}; };
export default function FilterSwitch({ export default function FilterSwitch({
label, label,
disabled = false, disabled = false,
isChecked, isChecked,
isCameraName = false, type = "",
extraValue = "",
onCheckedChange, onCheckedChange,
}: FilterSwitchProps) { }: FilterSwitchProps) {
return ( return (
<div className="flex items-center justify-between gap-1"> <div className="flex items-center justify-between gap-1">
{isCameraName ? ( {type === "camera" ? (
<CameraNameLabel <CameraNameLabel
className={`mx-2 w-full cursor-pointer text-sm font-medium leading-none text-primary smart-capitalize peer-disabled:cursor-not-allowed peer-disabled:opacity-70 ${disabled ? "text-secondary-foreground" : ""}`} className={`mx-2 w-full cursor-pointer text-sm font-medium leading-none text-primary smart-capitalize peer-disabled:cursor-not-allowed peer-disabled:opacity-70 ${disabled ? "text-secondary-foreground" : ""}`}
htmlFor={label} htmlFor={label}
camera={label} camera={label}
/> />
) : type === "zone" ? (
<ZoneNameLabel
className={`mx-2 w-full cursor-pointer text-sm font-medium leading-none text-primary smart-capitalize peer-disabled:cursor-not-allowed peer-disabled:opacity-70 ${disabled ? "text-secondary-foreground" : ""}`}
htmlFor={label}
camera={extraValue}
zone={label}
/>
) : ( ) : (
<Label <Label
className={`mx-2 w-full cursor-pointer text-primary smart-capitalize ${disabled ? "text-secondary-foreground" : ""}`} className={`mx-2 w-full cursor-pointer text-primary smart-capitalize ${disabled ? "text-secondary-foreground" : ""}`}

View File

@ -550,7 +550,8 @@ export function GeneralFilterContent({
{allZones.map((item) => ( {allZones.map((item) => (
<FilterSwitch <FilterSwitch
key={item} key={item}
label={item.replaceAll("_", " ")} label={item}
type={"zone"}
isChecked={filter.zones?.includes(item) ?? false} isChecked={filter.zones?.includes(item) ?? false}
onCheckedChange={(isChecked) => { onCheckedChange={(isChecked) => {
if (isChecked) { if (isChecked) {

View File

@ -53,7 +53,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
import { MdImageSearch } from "react-icons/md"; import { MdImageSearch } from "react-icons/md";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getTranslatedLabel } from "@/utils/i18n"; import { getTranslatedLabel } from "@/utils/i18n";
import { CameraNameLabel } from "../camera/CameraNameLabel"; import { CameraNameLabel, ZoneNameLabel } from "../camera/FriendlyNameLabel";
type InputWithTagsProps = { type InputWithTagsProps = {
inputFocused: boolean; inputFocused: boolean;
@ -831,6 +831,8 @@ export default function InputWithTags({
getTranslatedLabel(value) getTranslatedLabel(value)
) : filterType === "cameras" ? ( ) : filterType === "cameras" ? (
<CameraNameLabel camera={value} /> <CameraNameLabel camera={value} />
) : filterType === "zones" ? (
<ZoneNameLabel zone={value} />
) : ( ) : (
value.replaceAll("_", " ") value.replaceAll("_", " ")
)} )}
@ -934,6 +936,11 @@ export default function InputWithTags({
<CameraNameLabel camera={suggestion} /> <CameraNameLabel camera={suggestion} />
{")"} {")"}
</> </>
) : currentFilterType === "zones" ? (
<>
{suggestion} {" ("} <ZoneNameLabel zone={suggestion} />
{")"}
</>
) : ( ) : (
suggestion suggestion
) )
@ -943,6 +950,8 @@ export default function InputWithTags({
{currentFilterType ? ( {currentFilterType ? (
currentFilterType === "cameras" ? ( currentFilterType === "cameras" ? (
<CameraNameLabel camera={suggestion} /> <CameraNameLabel camera={suggestion} />
) : currentFilterType === "zones" ? (
<ZoneNameLabel zone={suggestion} />
) : ( ) : (
formatFilterValues(currentFilterType, suggestion) formatFilterValues(currentFilterType, suggestion)
) )

View File

@ -47,7 +47,7 @@ import {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useDateLocale } from "@/hooks/use-date-locale"; import { useDateLocale } from "@/hooks/use-date-locale";
import { useIsAdmin } from "@/hooks/use-is-admin"; import { useIsAdmin } from "@/hooks/use-is-admin";
import { CameraNameLabel } from "../camera/CameraNameLabel"; import { CameraNameLabel } from "../camera/FriendlyNameLabel";
type LiveContextMenuProps = { type LiveContextMenuProps = {
className?: string; className?: string;

View File

@ -25,7 +25,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { CameraNameLabel } from "../camera/CameraNameLabel"; import { CameraNameLabel } from "../camera/FriendlyNameLabel";
import { isDesktop, isMobile } from "react-device-detect"; import { isDesktop, isMobile } from "react-device-detect";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {

View File

@ -24,7 +24,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel"; import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
type EditRoleCamerasOverlayProps = { type EditRoleCamerasOverlayProps = {
show: boolean; show: boolean;

View File

@ -4,7 +4,7 @@ import { Button } from "../ui/button";
import { FaVideo } from "react-icons/fa"; import { FaVideo } from "react-icons/fa";
import { isMobile } from "react-device-detect"; import { isMobile } from "react-device-detect";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CameraNameLabel } from "../camera/CameraNameLabel"; import { CameraNameLabel } from "../camera/FriendlyNameLabel";
type MobileCameraDrawerProps = { type MobileCameraDrawerProps = {
allCameras: string[]; allCameras: string[];

View File

@ -12,6 +12,7 @@ import { TooltipPortal } from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Event } from "@/types/event"; import { Event } from "@/types/event";
import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
// Use a small tolerance (10ms) for browsers with seek precision by-design issues // Use a small tolerance (10ms) for browsers with seek precision by-design issues
const TOLERANCE = 0.01; const TOLERANCE = 0.01;
@ -114,6 +115,10 @@ export default function ObjectTrackOverlay({
{ revalidateOnFocus: false }, { revalidateOnFocus: false },
); );
const getZonesFriendlyNames = (zones: string[], config: FrigateConfig) => {
return zones?.map((zone) => resolveZoneName(config, zone)) ?? [];
};
const timelineResults = useMemo(() => { const timelineResults = useMemo(() => {
// Group timeline entries by source_id // Group timeline entries by source_id
if (!timelineData) return selectedObjectIds.map(() => []); if (!timelineData) return selectedObjectIds.map(() => []);
@ -127,8 +132,19 @@ export default function ObjectTrackOverlay({
} }
// Return timeline arrays in the same order as selectedObjectIds // Return timeline arrays in the same order as selectedObjectIds
return selectedObjectIds.map((id) => grouped[id] || []); return selectedObjectIds.map((id) => {
}, [selectedObjectIds, timelineData]); const entries = grouped[id] || [];
return entries.map((event) => ({
...event,
data: {
...event.data,
zones_friendly_names: config
? getZonesFriendlyNames(event.data?.zones, config)
: [],
},
}));
});
}, [selectedObjectIds, timelineData, config]);
const typeColorMap = useMemo( const typeColorMap = useMemo(
() => ({ () => ({

View File

@ -8,6 +8,9 @@ import {
import { TooltipPortal } from "@radix-ui/react-tooltip"; import { TooltipPortal } from "@radix-ui/react-tooltip";
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil"; import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
import { FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr";
type ObjectPathProps = { type ObjectPathProps = {
positions?: Position[]; positions?: Position[];
@ -42,16 +45,31 @@ export function ObjectPath({
visible = true, visible = true,
}: ObjectPathProps) { }: ObjectPathProps) {
const { t } = useTranslation(["views/explore"]); const { t } = useTranslation(["views/explore"]);
const { data: config } = useSWR<FrigateConfig>("config");
const getAbsolutePositions = useCallback(() => { const getAbsolutePositions = useCallback(() => {
if (!imgRef.current || !positions) return []; if (!imgRef.current || !positions) return [];
const imgRect = imgRef.current.getBoundingClientRect(); const imgRect = imgRef.current.getBoundingClientRect();
return positions.map((pos) => ({ return positions.map((pos) => {
x: pos.x * imgRect.width, return {
y: pos.y * imgRect.height, x: pos.x * imgRect.width,
timestamp: pos.timestamp, y: pos.y * imgRect.height,
lifecycle_item: pos.lifecycle_item, timestamp: pos.timestamp,
})); lifecycle_item: pos.lifecycle_item?.data?.zones
}, [positions, imgRef]); ? {
...pos.lifecycle_item,
data: {
...pos.lifecycle_item?.data,
zones_friendly_names: pos.lifecycle_item?.data.zones.map(
(zone) => {
return resolveZoneName(config, zone);
},
),
},
}
: pos.lifecycle_item,
};
});
}, [imgRef, positions, config]);
const generateStraightPath = useCallback((points: Position[]) => { const generateStraightPath = useCallback((points: Position[]) => {
if (!points || points.length < 2) return ""; if (!points || points.length < 2) return "";

View File

@ -80,7 +80,7 @@ import TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { useIsAdmin } from "@/hooks/use-is-admin"; import { useIsAdmin } from "@/hooks/use-is-admin";
import { getTranslatedLabel } from "@/utils/i18n"; import { getTranslatedLabel } from "@/utils/i18n";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel"; import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
import { DialogPortal } from "@radix-ui/react-dialog"; import { DialogPortal } from "@radix-ui/react-dialog";
import { useDetailStream } from "@/context/detail-stream-context"; import { useDetailStream } from "@/context/detail-stream-context";
import { PiSlidersHorizontalBold } from "react-icons/pi"; import { PiSlidersHorizontalBold } from "react-icons/pi";

View File

@ -23,6 +23,7 @@ import { Link, useNavigate } from "react-router-dom";
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil"; import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getTranslatedLabel } from "@/utils/i18n"; import { getTranslatedLabel } from "@/utils/i18n";
import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { HiDotsHorizontal } from "react-icons/hi"; import { HiDotsHorizontal } from "react-icons/hi";
import axios from "axios"; import axios from "axios";
@ -73,6 +74,12 @@ export function TrackingDetails({
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
eventSequence?.map((event) => {
event.data.zones_friendly_names = event.data?.zones?.map((zone) => {
return resolveZoneName(config, zone);
});
});
// Use manualOverride (set when seeking in image mode) if present so // Use manualOverride (set when seeking in image mode) if present so
// lifecycle rows and overlays follow image-mode seeks. Otherwise fall // lifecycle rows and overlays follow image-mode seeks. Otherwise fall
// back to currentTime used for video mode. // back to currentTime used for video mode.
@ -713,7 +720,8 @@ function LifecycleIconRow({
}} }}
/> />
<span className="smart-capitalize"> <span className="smart-capitalize">
{zone.replaceAll("_", " ")} {item.data?.zones_friendly_names?.[zidx] ??
zone.replaceAll("_", " ")}
</span> </span>
</Badge> </Badge>
); );

View File

@ -430,7 +430,8 @@ export function ZoneFilterContent({
{allZones.map((item) => ( {allZones.map((item) => (
<FilterSwitch <FilterSwitch
key={item} key={item}
label={item.replaceAll("_", " ")} label={item}
type={"zone"}
isChecked={zones?.includes(item) ?? false} isChecked={zones?.includes(item) ?? false}
onCheckedChange={(isChecked) => { onCheckedChange={(isChecked) => {
if (isChecked) { if (isChecked) {

View File

@ -262,13 +262,17 @@ export function PolygonCanvas({
}; };
useEffect(() => { useEffect(() => {
if (activePolygonIndex === undefined || !polygons) { if (activePolygonIndex === undefined || !polygons?.length) {
return; return;
} }
const updatedPolygons = [...polygons]; const updatedPolygons = [...polygons];
const activePolygon = updatedPolygons[activePolygonIndex]; const activePolygon = updatedPolygons[activePolygonIndex];
if (!activePolygon) {
return;
}
// add default points order for already completed polygons // add default points order for already completed polygons
if (!activePolygon.pointsOrder && activePolygon.isFinished) { if (!activePolygon.pointsOrder && activePolygon.isFinished) {
updatedPolygons[activePolygonIndex] = { updatedPolygons[activePolygonIndex] = {

View File

@ -179,7 +179,7 @@ export default function PolygonItem({
if (res.status === 200) { if (res.status === 200) {
toast.success( toast.success(
t("masksAndZones.form.polygonDrawing.delete.success", { t("masksAndZones.form.polygonDrawing.delete.success", {
name: polygon?.name, name: polygon?.friendly_name ?? polygon?.name,
}), }),
{ {
position: "top-center", position: "top-center",
@ -261,7 +261,9 @@ export default function PolygonItem({
}} }}
/> />
)} )}
<p className="cursor-default">{polygon.name}</p> <p className="cursor-default">
{polygon.friendly_name ?? polygon.name}
</p>
</div> </div>
<AlertDialog <AlertDialog
open={deleteDialogOpen} open={deleteDialogOpen}
@ -278,7 +280,7 @@ export default function PolygonItem({
ns="views/settings" ns="views/settings"
values={{ values={{
type: polygon.type.replace("_", " "), type: polygon.type.replace("_", " "),
name: polygon.name, name: polygon.friendly_name ?? polygon.name,
}} }}
> >
masksAndZones.form.polygonDrawing.delete.desc masksAndZones.form.polygonDrawing.delete.desc

View File

@ -34,6 +34,7 @@ import { Link } from "react-router-dom";
import { LuExternalLink } from "react-icons/lu"; import { LuExternalLink } from "react-icons/lu";
import { useDocDomain } from "@/hooks/use-doc-domain"; import { useDocDomain } from "@/hooks/use-doc-domain";
import { getTranslatedLabel } from "@/utils/i18n"; import { getTranslatedLabel } from "@/utils/i18n";
import NameAndIdFields from "../input/NameAndIdFields";
type ZoneEditPaneProps = { type ZoneEditPaneProps = {
polygons?: Polygon[]; polygons?: Polygon[];
@ -146,15 +147,37 @@ export default function ZoneEditPane({
"masksAndZones.form.zoneName.error.mustNotContainPeriod", "masksAndZones.form.zoneName.error.mustNotContainPeriod",
), ),
}, },
) ),
.refine((value: string) => /^[a-zA-Z0-9_-]+$/.test(value), { friendly_name: z
message: t("masksAndZones.form.zoneName.error.hasIllegalCharacter"), .string()
}) .min(2, {
.refine((value: string) => /[a-zA-Z]/.test(value), {
message: t( message: t(
"masksAndZones.form.zoneName.error.mustHaveAtLeastOneLetter", "masksAndZones.form.zoneName.error.mustBeAtLeastTwoCharacters",
), ),
}), })
.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 inertia: z.coerce
.number() .number()
.min(1, { .min(1, {
@ -247,6 +270,7 @@ export default function ZoneEditPane({
mode: "onBlur", mode: "onBlur",
defaultValues: { defaultValues: {
name: polygon?.name ?? "", name: polygon?.name ?? "",
friendly_name: polygon?.friendly_name ?? polygon?.name ?? "",
inertia: inertia:
polygon?.camera && polygon?.camera &&
polygon?.name && polygon?.name &&
@ -286,6 +310,7 @@ export default function ZoneEditPane({
async ( async (
{ {
name: zoneName, name: zoneName,
friendly_name,
inertia, inertia,
loitering_time, loitering_time,
objects: form_objects, objects: form_objects,
@ -415,9 +440,14 @@ export default function ZoneEditPane({
} }
} }
let friendlyNameQuery = "";
if (friendly_name) {
friendlyNameQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.friendly_name=${encodeURIComponent(friendly_name)}`;
}
axios axios
.put( .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, requires_restart: 0,
update_topic: `config/cameras/${polygon.camera}/zones`, update_topic: `config/cameras/${polygon.camera}/zones`,
@ -427,7 +457,7 @@ export default function ZoneEditPane({
if (res.status === 200) { if (res.status === 200) {
toast.success( toast.success(
t("masksAndZones.zones.toast.success", { t("masksAndZones.zones.toast.success", {
zoneName, zoneName: friendly_name || zoneName,
}), }),
{ {
position: "top-center", position: "top-center",
@ -541,26 +571,16 @@ export default function ZoneEditPane({
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-2 space-y-6"> <form onSubmit={form.handleSubmit(onSubmit)} className="mt-2 space-y-6">
<FormField <NameAndIdFields
type="zone"
control={form.control} control={form.control}
name="name" nameField="friendly_name"
render={({ field }) => ( idField="name"
<FormItem> nameLabel={t("masksAndZones.zones.name.title")}
<FormLabel>{t("masksAndZones.zones.name.title")}</FormLabel> nameDescription={t("masksAndZones.zones.name.tips")}
<FormControl> placeholderName={t("masksAndZones.zones.name.inputPlaceHolder")}
<Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
placeholder={t("masksAndZones.zones.name.inputPlaceHolder")}
{...field}
/>
</FormControl>
<FormDescription>
{t("masksAndZones.zones.name.tips")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/> />
<Separator className="my-2 flex bg-secondary" /> <Separator className="my-2 flex bg-secondary" />
<FormField <FormField
control={form.control} control={form.control}

View File

@ -26,6 +26,7 @@ import { Link } from "react-router-dom";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { usePersistence } from "@/hooks/use-persistence"; import { usePersistence } from "@/hooks/use-persistence";
import { isDesktop } from "react-device-detect"; import { isDesktop } from "react-device-detect";
import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
import { PiSlidersHorizontalBold } from "react-icons/pi"; import { PiSlidersHorizontalBold } from "react-icons/pi";
import { MdAutoAwesome } from "react-icons/md"; import { MdAutoAwesome } from "react-icons/md";
@ -793,17 +794,28 @@ function ObjectTimeline({
}, },
]); ]);
const { data: config } = useSWR<FrigateConfig>("config");
const timeline = useMemo(() => { const timeline = useMemo(() => {
if (!fullTimeline) { if (!fullTimeline) {
return fullTimeline; return fullTimeline;
} }
return fullTimeline.filter( return fullTimeline
(t) => .filter(
t.timestamp >= review.start_time && (t) =>
(review.end_time == undefined || t.timestamp <= review.end_time), t.timestamp >= review.start_time &&
); (review.end_time == undefined || t.timestamp <= review.end_time),
}, [fullTimeline, review]); )
.map((event) => ({
...event,
data: {
...event.data,
zones_friendly_names: event.data?.zones?.map((zone) =>
resolveZoneName(config, zone),
),
},
}));
}, [config, fullTimeline, review]);
if (isValidating && (!timeline || timeline.length === 0)) { if (isValidating && (!timeline || timeline.length === 0)) {
return <ActivityIndicator className="ml-2 size-3" />; return <ActivityIndicator className="ml-2 size-3" />;

View File

@ -0,0 +1,41 @@
import { FrigateConfig } from "@/types/frigateConfig";
import { useMemo } from "react";
import useSWR from "swr";
export function resolveZoneName(
config: FrigateConfig | undefined,
zoneId: string,
cameraId?: string,
) {
if (!config) return String(zoneId).replace(/_/g, " ");
if (cameraId) {
const camera = config.cameras?.[String(cameraId)];
const zone = camera?.zones?.[zoneId];
return zone?.friendly_name || String(zoneId).replace(/_/g, " ");
}
for (const camKey in config.cameras) {
if (!Object.prototype.hasOwnProperty.call(config.cameras, camKey)) continue;
const cam = config.cameras[camKey];
if (!cam?.zones) continue;
if (Object.prototype.hasOwnProperty.call(cam.zones, zoneId)) {
const zone = cam.zones[zoneId];
return zone?.friendly_name || String(zoneId).replace(/_/g, " ");
}
}
// Fallback: return a cleaned-up zoneId string
return String(zoneId).replace(/_/g, " ");
}
export function useZoneFriendlyName(zoneId: string, cameraId?: string): string {
const { data: config } = useSWR<FrigateConfig>("config");
const name = useMemo(
() => resolveZoneName(config, zoneId, cameraId),
[config, cameraId, zoneId],
);
return name;
}

View File

@ -42,7 +42,7 @@ import { useInitialCameraState } from "@/api/ws";
import { useIsAdmin } from "@/hooks/use-is-admin"; import { useIsAdmin } from "@/hooks/use-is-admin";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import TriggerView from "@/views/settings/TriggerView"; import TriggerView from "@/views/settings/TriggerView";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel"; import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@ -650,7 +650,7 @@ function CameraSelectButton({
key={item.name} key={item.name}
isChecked={item.name === selectedCamera} isChecked={item.name === selectedCamera}
label={item.name} label={item.name}
isCameraName={true} type={"camera"}
onCheckedChange={(isChecked) => { onCheckedChange={(isChecked) => {
if (isChecked && (isEnabled || isCameraSettingsPage)) { if (isChecked && (isEnabled || isCameraSettingsPage)) {
setSelectedCamera(item.name); setSelectedCamera(item.name);

View File

@ -11,10 +11,12 @@ export type Polygon = {
distances: number[]; distances: number[];
isFinished: boolean; isFinished: boolean;
color: number[]; color: number[];
friendly_name?: string;
}; };
export type ZoneFormValuesType = { export type ZoneFormValuesType = {
name: string; name: string;
friendly_name: string;
inertia: number; inertia: number;
loitering_time: number; loitering_time: number;
isFinished: boolean; isFinished: boolean;

View File

@ -280,6 +280,7 @@ export interface CameraConfig {
speed_threshold: number; speed_threshold: number;
objects: string[]; objects: string[];
color: number[]; color: number[];
friendly_name?: string;
}; };
}; };
} }

View File

@ -22,6 +22,7 @@ export type TrackingDetailsSequence = {
attribute: string; attribute: string;
attribute_box?: [number, number, number, number]; attribute_box?: [number, number, number, number];
zones: string[]; zones: string[];
zones_friendly_names?: string[];
}; };
class_type: LifecycleClassType; class_type: LifecycleClassType;
source_id: string; source_id: string;

View File

@ -1,25 +1,7 @@
import { TrackingDetailsSequence } from "@/types/timeline"; import { TrackingDetailsSequence } from "@/types/timeline";
import { t } from "i18next"; import { t } from "i18next";
import { getTranslatedLabel } from "./i18n"; import { getTranslatedLabel } from "./i18n";
import { capitalizeFirstLetter } from "./stringUtil"; import { capitalizeFirstLetter, formatList } from "./stringUtil";
function formatZonesList(zones: string[]): string {
if (zones.length === 0) return "";
if (zones.length === 1) return zones[0];
if (zones.length === 2) {
return t("list.two", {
0: zones[0],
1: zones[1],
});
}
const separatorWithSpace = t("list.separatorWithSpace", { ns: "common" });
const allButLast = zones.slice(0, -1).join(separatorWithSpace);
return t("list.many", {
items: allButLast,
last: zones[zones.length - 1],
});
}
export function getLifecycleItemDescription( export function getLifecycleItemDescription(
lifecycleItem: TrackingDetailsSequence, lifecycleItem: TrackingDetailsSequence,
@ -42,7 +24,9 @@ export function getLifecycleItemDescription(
return t("trackingDetails.lifecycleItemDesc.entered_zone", { return t("trackingDetails.lifecycleItemDesc.entered_zone", {
ns: "views/explore", ns: "views/explore",
label, label,
zones: formatZonesList(lifecycleItem.data.zones), zones: formatList(
lifecycleItem.data.zones_friendly_names ?? lifecycleItem.data.zones,
),
}); });
case "active": case "active":
return t("trackingDetails.lifecycleItemDesc.active", { return t("trackingDetails.lifecycleItemDesc.active", {

View File

@ -1,3 +1,5 @@
import { t } from "i18next";
export const capitalizeFirstLetter = (text: string): string => { export const capitalizeFirstLetter = (text: string): string => {
return text.charAt(0).toUpperCase() + text.slice(1); return text.charAt(0).toUpperCase() + text.slice(1);
}; };
@ -45,3 +47,29 @@ export function generateFixedHash(name: string, prefix: string = "id"): string {
export function isValidId(name: string): boolean { export function isValidId(name: string): boolean {
return /^[a-zA-Z0-9_-]+$/.test(name) && !/^\d+$/.test(name); return /^[a-zA-Z0-9_-]+$/.test(name) && !/^\d+$/.test(name);
} }
/**
* Formats a list of strings into a human-readable format with proper localization.
* Handles different cases for empty, single-item, two-item, and multi-item lists.
*
* @param item - The array of strings to format
* @returns A formatted string representation of the list
*/
export function formatList(item: string[]): string {
if (item.length === 0) return "";
if (item.length === 1) return item[0];
if (item.length === 2) {
return t("list.two", {
0: item[0],
1: item[1],
ns: "common",
});
}
const separatorWithSpace = t("list.separatorWithSpace", { ns: "common" });
const allButLast = item.slice(0, -1).join(separatorWithSpace);
return t("list.many", {
items: allButLast,
last: item[item.length - 1],
});
}

View File

@ -63,7 +63,7 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel"; import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
import { useAllowedCameras } from "@/hooks/use-allowed-cameras"; import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
import { DetailStreamProvider } from "@/context/detail-stream-context"; import { DetailStreamProvider } from "@/context/detail-stream-context";
import { GenAISummaryDialog } from "@/components/overlay/chip/GenAISummaryChip"; import { GenAISummaryDialog } from "@/components/overlay/chip/GenAISummaryChip";

View File

@ -36,7 +36,7 @@ import EditRoleCamerasDialog from "@/components/overlay/EditRoleCamerasDialog";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import DeleteRoleDialog from "@/components/overlay/DeleteRoleDialog"; import DeleteRoleDialog from "@/components/overlay/DeleteRoleDialog";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel"; import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
type AuthenticationViewProps = { type AuthenticationViewProps = {
section?: "users" | "roles"; section?: "users" | "roles";

View File

@ -18,7 +18,7 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
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/CameraNameLabel"; import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Trans } from "react-i18next"; import { Trans } from "react-i18next";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";

View File

@ -23,7 +23,6 @@ import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import axios from "axios"; import axios from "axios";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { LuExternalLink } from "react-icons/lu"; import { LuExternalLink } from "react-icons/lu";
import { capitalizeFirstLetter } from "@/utils/stringUtil";
import { MdCircle } from "react-icons/md"; import { MdCircle } from "react-icons/md";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
@ -42,6 +41,8 @@ import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
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 { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name"; import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
import { formatList } from "@/utils/stringUtil";
type CameraSettingsViewProps = { type CameraSettingsViewProps = {
selectedCamera: string; selectedCamera: string;
@ -86,40 +87,47 @@ export default function CameraSettingsView({
// zones and labels // zones and labels
const getZoneName = useCallback(
(zoneId: string, cameraId?: string) =>
resolveZoneName(config, zoneId, cameraId),
[config],
);
const zones = useMemo(() => { const zones = useMemo(() => {
if (cameraConfig) { if (cameraConfig) {
return Object.entries(cameraConfig.zones).map(([name, zoneData]) => ({ return Object.entries(cameraConfig.zones).map(([name, zoneData]) => ({
camera: cameraConfig.name, camera: cameraConfig.name,
name, name,
friendly_name: getZoneName(name, cameraConfig.name),
objects: zoneData.objects, objects: zoneData.objects,
color: zoneData.color, color: zoneData.color,
})); }));
} }
}, [cameraConfig]); }, [cameraConfig, getZoneName]);
const alertsLabels = useMemo(() => { const alertsLabels = useMemo(() => {
return cameraConfig?.review.alerts.labels return cameraConfig?.review.alerts.labels
? cameraConfig.review.alerts.labels ? formatList(
.map((label) => cameraConfig.review.alerts.labels.map((label) =>
getTranslatedLabel( getTranslatedLabel(
label, label,
cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object", cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object",
), ),
) ),
.join(", ") )
: ""; : "";
}, [cameraConfig]); }, [cameraConfig]);
const detectionsLabels = useMemo(() => { const detectionsLabels = useMemo(() => {
return cameraConfig?.review.detections.labels return cameraConfig?.review.detections.labels
? cameraConfig.review.detections.labels ? formatList(
.map((label) => cameraConfig.review.detections.labels.map((label) =>
getTranslatedLabel( getTranslatedLabel(
label, label,
cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object", cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object",
), ),
) ),
.join(", ") )
: ""; : "";
}, [cameraConfig]); }, [cameraConfig]);
@ -526,7 +534,7 @@ export default function CameraSettingsView({
/> />
</FormControl> </FormControl>
<FormLabel className="font-normal smart-capitalize"> <FormLabel className="font-normal smart-capitalize">
{zone.name.replaceAll("_", " ")} {zone.friendly_name}
</FormLabel> </FormLabel>
</FormItem> </FormItem>
)} )}
@ -548,14 +556,11 @@ export default function CameraSettingsView({
"cameraReview.reviewClassification.zoneObjectAlertsTips", "cameraReview.reviewClassification.zoneObjectAlertsTips",
{ {
alertsLabels, alertsLabels,
zone: watchedAlertsZones zone: formatList(
.map((zone) => watchedAlertsZones.map((zone) =>
capitalizeFirstLetter(zone).replaceAll( getZoneName(zone),
"_", ),
" ", ),
),
)
.join(", "),
cameraName: selectCameraName, cameraName: selectCameraName,
}, },
) )
@ -628,7 +633,7 @@ export default function CameraSettingsView({
/> />
</FormControl> </FormControl>
<FormLabel className="font-normal smart-capitalize"> <FormLabel className="font-normal smart-capitalize">
{zone.name.replaceAll("_", " ")} {zone.friendly_name}
</FormLabel> </FormLabel>
</FormItem> </FormItem>
)} )}
@ -667,14 +672,11 @@ export default function CameraSettingsView({
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.text" i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.text"
values={{ values={{
detectionsLabels, detectionsLabels,
zone: watchedDetectionsZones zone: formatList(
.map((zone) => watchedDetectionsZones.map((zone) =>
capitalizeFirstLetter(zone).replaceAll( getZoneName(zone),
"_", ),
" ", ),
),
)
.join(", "),
cameraName: selectCameraName, cameraName: selectCameraName,
}} }}
ns="views/settings" ns="views/settings"
@ -684,14 +686,11 @@ export default function CameraSettingsView({
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.notSelectDetections" i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.notSelectDetections"
values={{ values={{
detectionsLabels, detectionsLabels,
zone: watchedDetectionsZones zone: formatList(
.map((zone) => watchedDetectionsZones.map((zone) =>
capitalizeFirstLetter(zone).replaceAll( getZoneName(zone),
"_", ),
" ", ),
),
)
.join(", "),
cameraName: selectCameraName, cameraName: selectCameraName,
}} }}
ns="views/settings" ns="views/settings"

View File

@ -23,7 +23,7 @@ import {
SelectTrigger, SelectTrigger,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { useDocDomain } from "@/hooks/use-doc-domain"; import { useDocDomain } from "@/hooks/use-doc-domain";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel"; import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
type FrigatePlusModel = { type FrigatePlusModel = {
id: string; id: string;

View File

@ -229,6 +229,7 @@ export default function MasksAndZonesView({
typeIndex: index, typeIndex: index,
camera: cameraConfig.name, camera: cameraConfig.name,
name, name,
friendly_name: zoneData.friendly_name,
objects: zoneData.objects, objects: zoneData.objects,
points: interpolatePoints( points: interpolatePoints(
parseCoordinates(zoneData.coordinates), parseCoordinates(zoneData.coordinates),

View File

@ -45,7 +45,7 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { useDateLocale } from "@/hooks/use-date-locale"; import { useDateLocale } from "@/hooks/use-date-locale";
import { useDocDomain } from "@/hooks/use-doc-domain"; 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 { useIsAdmin } from "@/hooks/use-is-admin";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -476,7 +476,7 @@ export default function NotificationView({
<FilterSwitch <FilterSwitch
key={camera.name} key={camera.name}
label={camera.name} label={camera.name}
isCameraName={true} type={"camera"}
isChecked={field.value?.includes( isChecked={field.value?.includes(
camera.name, camera.name,
)} )}

View File

@ -13,7 +13,7 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import useSWR from "swr"; import useSWR from "swr";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel"; import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
import { resolveCameraName } from "@/hooks/use-camera-friendly-name"; import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
type CameraMetricsProps = { type CameraMetricsProps = {