feat: add zones friendly name

This commit is contained in:
ZhaiSoul 2025-11-02 13:30:09 +00:00
parent 1e50d83d06
commit 2339b5f35d
30 changed files with 227 additions and 89 deletions

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;
@ -73,6 +74,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(() => []);
@ -86,8 +91,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,30 @@ 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, if (config && pos.lifecycle_item?.data?.zones) {
y: pos.y * imgRect.height, pos.lifecycle_item = {
timestamp: pos.timestamp, ...pos.lifecycle_item,
lifecycle_item: pos.lifecycle_item, data: {
})); ...pos.lifecycle_item.data,
}, [positions, imgRef]); zones_friendly_names: pos.lifecycle_item.data.zones.map((zone) => {
return resolveZoneName(config, zone);
}),
},
};
}
return {
x: pos.x * imgRect.width,
y: pos.y * imgRect.height,
timestamp: pos.timestamp,
lifecycle_item: 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

@ -77,7 +77,7 @@ import { useIsAdmin } from "@/hooks/use-is-admin";
import FaceSelectionDialog from "../FaceSelectionDialog"; import FaceSelectionDialog from "../FaceSelectionDialog";
import { getTranslatedLabel } from "@/utils/i18n"; import { getTranslatedLabel } from "@/utils/i18n";
import { CgTranscript } from "react-icons/cg"; import { CgTranscript } from "react-icons/cg";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel"; import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
import { PiPath } from "react-icons/pi"; import { PiPath } from "react-icons/pi";
import Heading from "@/components/ui/heading"; import Heading from "@/components/ui/heading";

View File

@ -429,7 +429,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

@ -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,38 @@ 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",
), ),
}), })
.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 inertia: z.coerce
.number() .number()
.min(1, { .min(1, {
@ -247,6 +271,7 @@ export default function ZoneEditPane({
mode: "onBlur", mode: "onBlur",
defaultValues: { defaultValues: {
name: polygon?.name ?? "", name: polygon?.name ?? "",
friendly_name: polygon?.friendly_name ?? "",
inertia: inertia:
polygon?.camera && polygon?.camera &&
polygon?.name && polygon?.name &&
@ -286,6 +311,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 +441,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 +458,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 +572,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

@ -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,
@ -642,7 +642,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

@ -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,7 @@ 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";
type CameraSettingsViewProps = { type CameraSettingsViewProps = {
selectedCamera: string; selectedCamera: string;
@ -86,16 +86,23 @@ 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
@ -526,7 +533,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 +555,9 @@ export default function CameraSettingsView({
"cameraReview.reviewClassification.zoneObjectAlertsTips", "cameraReview.reviewClassification.zoneObjectAlertsTips",
{ {
alertsLabels, alertsLabels,
zone: watchedAlertsZones zone: watchedAlertsZones.map((zone) =>
.map((zone) => getZoneName(zone),
capitalizeFirstLetter(zone).replaceAll( ),
"_",
" ",
),
)
.join(", "),
cameraName: selectCameraName, cameraName: selectCameraName,
}, },
) )
@ -628,7 +630,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 +669,9 @@ export default function CameraSettingsView({
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.text" i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.text"
values={{ values={{
detectionsLabels, detectionsLabels,
zone: watchedDetectionsZones zone: watchedDetectionsZones.map((zone) =>
.map((zone) => getZoneName(zone),
capitalizeFirstLetter(zone).replaceAll( ),
"_",
" ",
),
)
.join(", "),
cameraName: selectCameraName, cameraName: selectCameraName,
}} }}
ns="views/settings" ns="views/settings"
@ -684,14 +681,9 @@ export default function CameraSettingsView({
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.notSelectDetections" i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.notSelectDetections"
values={{ values={{
detectionsLabels, detectionsLabels,
zone: watchedDetectionsZones zone: watchedDetectionsZones.map((zone) =>
.map((zone) => getZoneName(zone),
capitalizeFirstLetter(zone).replaceAll( ),
"_",
" ",
),
)
.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 = {