Compare commits

..

16 Commits

Author SHA1 Message Date
Josh Hawkins
ab43a33d19 move find similar to actions menu 2025-11-06 21:24:11 -06:00
Josh Hawkins
f3c0c8b61a add trigger to detail actions menu 2025-11-06 18:58:22 -06:00
Josh Hawkins
19e36fc56b ensure name is valid for search effect trigger 2025-11-06 18:54:18 -06:00
Josh Hawkins
6258b90e91 prevent x overflow in detail stream on mobile safari 2025-11-06 18:46:02 -06:00
Josh Hawkins
1d94b24cfe hide x icon on restart sheet to prevent closure issues 2025-11-06 18:02:56 -06:00
Josh Hawkins
294e04adb1 spacing 2025-11-06 17:53:43 -06:00
Josh Hawkins
d4af7f0966 fix audio transcription embedding 2025-11-06 17:38:34 -06:00
Nicolas Mowen
8ddf3ac72e Only show the genai summary popup on mobile when timeline is open 2025-11-06 16:15:38 -07:00
Nicolas Mowen
5b7fe10caf Fix dialog getting stuck 2025-11-06 16:03:36 -07:00
Nicolas Mowen
d8e32b8724 Ensure after creating a class that things are correct 2025-11-06 15:57:42 -07:00
Nicolas Mowen
ca91d3d82b Fix deletion of classification images and library 2025-11-06 15:55:05 -07:00
Josh Hawkins
88b6bd7535 tweak spacing in annotation settings popover 2025-11-06 16:29:27 -06:00
Josh Hawkins
9f73145d63 don't show submit to plus for non-objects and if plus is disabled 2025-11-06 16:25:50 -06:00
Josh Hawkins
f2e3579030 pointer cursor on event menu items in detail stream 2025-11-06 16:22:35 -06:00
Josh Hawkins
796a12bf10 add margin 2025-11-06 16:16:41 -06:00
Josh Hawkins
aa6cd1ec05 remove frigate+ icon from explore grid footer 2025-11-06 16:16:02 -06:00
47 changed files with 239 additions and 546 deletions

View File

@ -5,7 +5,7 @@ title: Enrichments
# Enrichments # Enrichments
Some of Frigate's enrichments can use a discrete GPU or integrated GPU for accelerated processing. Some of Frigate's enrichments can use a discrete GPU / NPU for accelerated processing.
## Requirements ## Requirements
@ -18,10 +18,8 @@ Object detection and enrichments (like Semantic Search, Face Recognition, and Li
- **Intel** - **Intel**
- OpenVINO will automatically be detected and used for enrichments in the default Frigate image. - OpenVINO will automatically be detected and used for enrichments in the default Frigate image.
- **Note:** Intel NPUs have limited model support for enrichments. GPU is recommended for enrichments when available.
- **Nvidia** - **Nvidia**
- Nvidia GPUs will automatically be detected and used for enrichments in the `-tensorrt` Frigate image. - Nvidia GPUs will automatically be detected and used for enrichments in the `-tensorrt` Frigate image.
- Jetson devices will automatically be detected and used for enrichments in the `-tensorrt-jp6` Frigate image. - Jetson devices will automatically be detected and used for enrichments in the `-tensorrt-jp6` Frigate image.

View File

@ -261,8 +261,6 @@ OpenVINO is supported on 6th Gen Intel platforms (Skylake) and newer. It will al
:::tip :::tip
**NPU + GPU Systems:** If you have both NPU and GPU available (Intel Core Ultra processors), use NPU for object detection and GPU for enrichments (semantic search, face recognition, etc.) for best performance and compatibility.
When using many cameras one detector may not be enough to keep up. Multiple detectors can be defined assuming GPU resources are available. An example configuration would be: When using many cameras one detector may not be enough to keep up. Multiple detectors can be defined assuming GPU resources are available. An example configuration would be:
```yaml ```yaml
@ -285,7 +283,7 @@ detectors:
| [RF-DETR](#rf-detr) | ✅ | ✅ | Requires XE iGPU or Arc | | [RF-DETR](#rf-detr) | ✅ | ✅ | Requires XE iGPU or Arc |
| [YOLO-NAS](#yolo-nas) | ✅ | ✅ | | | [YOLO-NAS](#yolo-nas) | ✅ | ✅ | |
| [MobileNet v2](#ssdlite-mobilenet-v2) | ✅ | ✅ | Fast and lightweight model, less accurate than larger models | | [MobileNet v2](#ssdlite-mobilenet-v2) | ✅ | ✅ | Fast and lightweight model, less accurate than larger models |
| [YOLOX](#yolox) | ✅ | ? | | | [YOLOX](#yolox) | ✅ | ? | |
| [D-FINE](#d-fine) | ❌ | ❌ | | | [D-FINE](#d-fine) | ❌ | ❌ | |
#### SSDLite MobileNet v2 #### SSDLite MobileNet v2

View File

@ -810,8 +810,6 @@ 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

@ -78,7 +78,7 @@ Switching between V1 and V2 requires reindexing your embeddings. The embeddings
### GPU Acceleration ### GPU Acceleration
The CLIP models are downloaded in ONNX format, and the `large` model can be accelerated using GPU hardware, when available. This depends on the Docker build that is used. You can also target a specific device in a multi-GPU installation. The CLIP models are downloaded in ONNX format, and the `large` model can be accelerated using GPU / NPU hardware, when available. This depends on the Docker build that is used. You can also target a specific device in a multi-GPU installation.
```yaml ```yaml
semantic_search: semantic_search:
@ -90,7 +90,7 @@ semantic_search:
:::info :::info
If the correct build is used for your GPU / NPU and the `large` model is configured, then the GPU will be detected and used automatically. If the correct build is used for your GPU / NPU and the `large` model is configured, then the GPU / NPU will be detected and used automatically.
Specify the `device` option to target a specific GPU in a multi-GPU system (see [onnxruntime's provider options](https://onnxruntime.ai/docs/execution-providers/)). Specify the `device` option to target a specific GPU in a multi-GPU system (see [onnxruntime's provider options](https://onnxruntime.ai/docs/execution-providers/)).
If you do not specify a device, the first available GPU will be used. If you do not specify a device, the first available GPU will be used.

View File

@ -27,7 +27,6 @@ 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: ...
``` ```
@ -45,10 +44,8 @@ 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: ...
``` ```
@ -62,7 +59,6 @@ cameras:
- entire_yard - entire_yard
zones: zones:
entire_yard: entire_yard:
friendly_name: Entire yard
coordinates: ... coordinates: ...
``` ```
@ -86,7 +82,6 @@ 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,9 +13,6 @@ 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

@ -418,8 +418,8 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
obj_data["box"][2], obj_data["box"][2],
obj_data["box"][3], obj_data["box"][3],
max( max(
obj_data["box"][2] - obj_data["box"][0], obj_data["box"][1] - obj_data["box"][0],
obj_data["box"][3] - obj_data["box"][1], obj_data["box"][3] - obj_data["box"][2],
), ),
1.0, 1.0,
) )

View File

@ -3,7 +3,6 @@
import logging import logging
import os import os
import platform import platform
import threading
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Any from typing import Any
@ -162,12 +161,12 @@ class CudaGraphRunner(BaseModelRunner):
""" """
@staticmethod @staticmethod
def is_model_supported(model_type: str) -> bool: def is_complex_model(model_type: str) -> bool:
# Import here to avoid circular imports # Import here to avoid circular imports
from frigate.detectors.detector_config import ModelTypeEnum from frigate.detectors.detector_config import ModelTypeEnum
from frigate.embeddings.types import EnrichmentModelTypeEnum from frigate.embeddings.types import EnrichmentModelTypeEnum
return model_type not in [ return model_type in [
ModelTypeEnum.yolonas.value, ModelTypeEnum.yolonas.value,
EnrichmentModelTypeEnum.paddleocr.value, EnrichmentModelTypeEnum.paddleocr.value,
EnrichmentModelTypeEnum.jina_v1.value, EnrichmentModelTypeEnum.jina_v1.value,
@ -240,30 +239,9 @@ class OpenVINOModelRunner(BaseModelRunner):
EnrichmentModelTypeEnum.jina_v2.value, EnrichmentModelTypeEnum.jina_v2.value,
] ]
@staticmethod
def is_model_npu_supported(model_type: str) -> bool:
# Import here to avoid circular imports
from frigate.embeddings.types import EnrichmentModelTypeEnum
return model_type not in [
EnrichmentModelTypeEnum.paddleocr.value,
EnrichmentModelTypeEnum.jina_v1.value,
EnrichmentModelTypeEnum.jina_v2.value,
EnrichmentModelTypeEnum.arcface.value,
]
def __init__(self, model_path: str, device: str, model_type: str, **kwargs): def __init__(self, model_path: str, device: str, model_type: str, **kwargs):
self.model_path = model_path self.model_path = model_path
self.device = device self.device = device
if device == "NPU" and not OpenVINOModelRunner.is_model_npu_supported(
model_type
):
logger.warning(
f"OpenVINO model {model_type} is not supported on NPU, using GPU instead"
)
device = "GPU"
self.complex_model = OpenVINOModelRunner.is_complex_model(model_type) self.complex_model = OpenVINOModelRunner.is_complex_model(model_type)
if not os.path.isfile(model_path): if not os.path.isfile(model_path):
@ -291,10 +269,6 @@ class OpenVINOModelRunner(BaseModelRunner):
self.infer_request = self.compiled_model.create_infer_request() self.infer_request = self.compiled_model.create_infer_request()
self.input_tensor: ov.Tensor | None = None self.input_tensor: ov.Tensor | None = None
# Thread lock to prevent concurrent inference (needed for JinaV2 which shares
# one runner between text and vision embeddings called from different threads)
self._inference_lock = threading.Lock()
if not self.complex_model: if not self.complex_model:
try: try:
input_shape = self.compiled_model.inputs[0].get_shape() input_shape = self.compiled_model.inputs[0].get_shape()
@ -338,70 +312,67 @@ class OpenVINOModelRunner(BaseModelRunner):
Returns: Returns:
List of output tensors List of output tensors
""" """
# Lock prevents concurrent access to infer_request # Handle single input case for backward compatibility
# Needed for JinaV2: genai thread (text) + embeddings thread (vision) if (
with self._inference_lock: len(inputs) == 1
# Handle single input case for backward compatibility and len(self.compiled_model.inputs) == 1
if ( and self.input_tensor is not None
len(inputs) == 1 ):
and len(self.compiled_model.inputs) == 1 # Single input case - use the pre-allocated tensor for efficiency
and self.input_tensor is not None input_data = list(inputs.values())[0]
): np.copyto(self.input_tensor.data, input_data)
# Single input case - use the pre-allocated tensor for efficiency self.infer_request.infer(self.input_tensor)
input_data = list(inputs.values())[0] else:
np.copyto(self.input_tensor.data, input_data) if self.complex_model:
self.infer_request.infer(self.input_tensor) try:
else: # This ensures the model starts with a clean state for each sequence
if self.complex_model: # Important for RNN models like PaddleOCR recognition
try: self.infer_request.reset_state()
# This ensures the model starts with a clean state for each sequence except Exception:
# Important for RNN models like PaddleOCR recognition # this will raise an exception for models with AUTO set as the device
self.infer_request.reset_state() pass
except Exception:
# this will raise an exception for models with AUTO set as the device
pass
# Multiple inputs case - set each input by name # Multiple inputs case - set each input by name
for input_name, input_data in inputs.items(): for input_name, input_data in inputs.items():
# Find the input by name and its index # Find the input by name and its index
input_port = None input_port = None
input_index = None input_index = None
for idx, port in enumerate(self.compiled_model.inputs): for idx, port in enumerate(self.compiled_model.inputs):
if port.get_any_name() == input_name: if port.get_any_name() == input_name:
input_port = port input_port = port
input_index = idx input_index = idx
break break
if input_port is None: if input_port is None:
raise ValueError(f"Input '{input_name}' not found in model") raise ValueError(f"Input '{input_name}' not found in model")
# Create tensor with the correct element type # Create tensor with the correct element type
input_element_type = input_port.get_element_type() input_element_type = input_port.get_element_type()
# Ensure input data matches the expected dtype to prevent type mismatches # Ensure input data matches the expected dtype to prevent type mismatches
# that can occur with models like Jina-CLIP v2 running on OpenVINO # that can occur with models like Jina-CLIP v2 running on OpenVINO
expected_dtype = input_element_type.to_dtype() expected_dtype = input_element_type.to_dtype()
if input_data.dtype != expected_dtype: if input_data.dtype != expected_dtype:
logger.debug( logger.debug(
f"Converting input '{input_name}' from {input_data.dtype} to {expected_dtype}" f"Converting input '{input_name}' from {input_data.dtype} to {expected_dtype}"
) )
input_data = input_data.astype(expected_dtype) input_data = input_data.astype(expected_dtype)
input_tensor = ov.Tensor(input_element_type, input_data.shape) input_tensor = ov.Tensor(input_element_type, input_data.shape)
np.copyto(input_tensor.data, input_data) np.copyto(input_tensor.data, input_data)
# Set the input tensor for the specific port index # Set the input tensor for the specific port index
self.infer_request.set_input_tensor(input_index, input_tensor) self.infer_request.set_input_tensor(input_index, input_tensor)
# Run inference # Run inference
self.infer_request.infer() self.infer_request.infer()
# Get all output tensors # Get all output tensors
outputs = [] outputs = []
for i in range(len(self.compiled_model.outputs)): for i in range(len(self.compiled_model.outputs)):
outputs.append(self.infer_request.get_output_tensor(i).data) outputs.append(self.infer_request.get_output_tensor(i).data)
return outputs return outputs
class RKNNModelRunner(BaseModelRunner): class RKNNModelRunner(BaseModelRunner):
@ -529,7 +500,7 @@ def get_optimized_runner(
return OpenVINOModelRunner(model_path, device, model_type, **kwargs) return OpenVINOModelRunner(model_path, device, model_type, **kwargs)
if ( if (
not CudaGraphRunner.is_model_supported(model_type) not CudaGraphRunner.is_complex_model(model_type)
and providers[0] == "CUDAExecutionProvider" and providers[0] == "CUDAExecutionProvider"
): ):
options[0] = { options[0] = {

View File

@ -369,10 +369,6 @@ def get_ort_providers(
"enable_cpu_mem_arena": False, "enable_cpu_mem_arena": False,
} }
) )
elif provider == "AzureExecutionProvider":
# Skip Azure provider - not typically available on local hardware
# and prevents fallback to OpenVINO when it's the first provider
continue
else: else:
providers.append(provider) providers.append(provider)
options.append({}) options.append({})

View File

@ -40,8 +40,7 @@
"deleteModel": { "deleteModel": {
"title": "Delete Classification Model", "title": "Delete Classification Model",
"single": "Are you sure you want to delete {{name}}? This will permanently delete all associated data including images and training data. This action cannot be undone.", "single": "Are you sure you want to delete {{name}}? This will permanently delete all associated data including images and training data. This action cannot be undone.",
"desc_one": "Are you sure you want to delete {{count}} model? This will permanently delete all associated data including images and training data. This action cannot be undone.", "desc": "Are you sure you want to delete {{count}} model(s)? This will permanently delete all associated data including images and training data. This action cannot be undone."
"desc_other": "Are you sure you want to delete {{count}} models? This will permanently delete all associated data including images and training data. This action cannot be undone."
}, },
"edit": { "edit": {
"title": "Edit Classification Model", "title": "Edit Classification Model",
@ -51,13 +50,11 @@
}, },
"deleteDatasetImages": { "deleteDatasetImages": {
"title": "Delete Dataset Images", "title": "Delete Dataset Images",
"desc_one": "Are you sure you want to delete {{count}} image from {{dataset}}? This action cannot be undone and will require re-training the model.", "desc": "Are you sure you want to delete {{count}} images from {{dataset}}? This action cannot be undone and will require re-training the model."
"desc_other": "Are you sure you want to delete {{count}} images from {{dataset}}? This action cannot be undone and will require re-training the model."
}, },
"deleteTrainImages": { "deleteTrainImages": {
"title": "Delete Train Images", "title": "Delete Train Images",
"desc_one": "Are you sure you want to delete {{count}} image? This action cannot be undone.", "desc": "Are you sure you want to delete {{count}} images? This action cannot be undone."
"desc_other": "Are you sure you want to delete {{count}} images? This action cannot be undone."
}, },
"renameCategory": { "renameCategory": {
"title": "Rename Class", "title": "Rename Class",

View File

@ -2,19 +2,12 @@ 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
@ -28,17 +21,4 @@ const CameraNameLabel = React.forwardRef<
}); });
CameraNameLabel.displayName = LabelPrimitive.Root.displayName; CameraNameLabel.displayName = LabelPrimitive.Root.displayName;
const ZoneNameLabel = React.forwardRef< export { CameraNameLabel };
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/FriendlyNameLabel"; import { CameraNameLabel } from "../camera/CameraNameLabel";
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}
type={"camera"} isCameraName={true}
disabled={ disabled={
mainCamera !== undefined && mainCamera !== undefined &&
currentCameras !== undefined && currentCameras !== undefined &&

View File

@ -1,39 +1,29 @@
import { Switch } from "../ui/switch"; import { Switch } from "../ui/switch";
import { Label } from "../ui/label"; import { Label } from "../ui/label";
import { CameraNameLabel, ZoneNameLabel } from "../camera/FriendlyNameLabel"; import { CameraNameLabel } from "../camera/CameraNameLabel";
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,
type = "", isCameraName = false,
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">
{type === "camera" ? ( {isCameraName ? (
<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,8 +550,7 @@ export function GeneralFilterContent({
{allZones.map((item) => ( {allZones.map((item) => (
<FilterSwitch <FilterSwitch
key={item} key={item}
label={item} label={item.replaceAll("_", " ")}
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, ZoneNameLabel } from "../camera/FriendlyNameLabel"; import { CameraNameLabel } from "../camera/CameraNameLabel";
type InputWithTagsProps = { type InputWithTagsProps = {
inputFocused: boolean; inputFocused: boolean;
@ -831,8 +831,6 @@ 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("_", " ")
)} )}
@ -936,11 +934,6 @@ export default function InputWithTags({
<CameraNameLabel camera={suggestion} /> <CameraNameLabel camera={suggestion} />
{")"} {")"}
</> </>
) : currentFilterType === "zones" ? (
<>
{suggestion} {" ("} <ZoneNameLabel zone={suggestion} />
{")"}
</>
) : ( ) : (
suggestion suggestion
) )
@ -950,8 +943,6 @@ 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/FriendlyNameLabel"; import { CameraNameLabel } from "../camera/CameraNameLabel";
type LiveContextMenuProps = { type LiveContextMenuProps = {
className?: string; className?: string;

View File

@ -4,7 +4,6 @@ import {
useEffect, useEffect,
useState, useState,
useCallback, useCallback,
useRef,
} from "react"; } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
@ -122,20 +121,17 @@ export function MobilePagePortal({
type MobilePageContentProps = { type MobilePageContentProps = {
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
scrollerRef?: React.RefObject<HTMLDivElement>;
}; };
export function MobilePageContent({ export function MobilePageContent({
children, children,
className, className,
scrollerRef,
}: MobilePageContentProps) { }: MobilePageContentProps) {
const context = useContext(MobilePageContext); const context = useContext(MobilePageContext);
if (!context) if (!context)
throw new Error("MobilePageContent must be used within MobilePage"); throw new Error("MobilePageContent must be used within MobilePage");
const [isVisible, setIsVisible] = useState(context.open); const [isVisible, setIsVisible] = useState(context.open);
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => { useEffect(() => {
if (context.open) { if (context.open) {
@ -144,27 +140,15 @@ export function MobilePageContent({
}, [context.open]); }, [context.open]);
const handleAnimationComplete = () => { const handleAnimationComplete = () => {
if (context.open) { if (!context.open) {
// After opening animation completes, ensure scroller is at the top
if (scrollerRef?.current) {
scrollerRef.current.scrollTop = 0;
}
} else {
setIsVisible(false); setIsVisible(false);
} }
}; };
useEffect(() => {
if (context.open && scrollerRef?.current) {
scrollerRef.current.scrollTop = 0;
}
}, [context.open, scrollerRef]);
return ( return (
<AnimatePresence> <AnimatePresence>
{isVisible && ( {isVisible && (
<motion.div <motion.div
ref={containerRef}
className={cn( className={cn(
"fixed inset-0 z-50 mb-12 bg-background", "fixed inset-0 z-50 mb-12 bg-background",
isPWA && "mb-16", isPWA && "mb-16",

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/FriendlyNameLabel"; import { CameraNameLabel } from "../camera/CameraNameLabel";
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/FriendlyNameLabel"; import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
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/FriendlyNameLabel"; import { CameraNameLabel } from "../camera/CameraNameLabel";
type MobileCameraDrawerProps = { type MobileCameraDrawerProps = {
allCameras: string[]; allCameras: string[];

View File

@ -12,7 +12,6 @@ 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;
@ -115,10 +114,6 @@ 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(() => []);
@ -132,19 +127,8 @@ 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) => { return selectedObjectIds.map((id) => grouped[id] || []);
const entries = grouped[id] || []; }, [selectedObjectIds, timelineData]);
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

@ -55,32 +55,29 @@ export default function DetailActionsMenu({
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuPortal> <DropdownMenuPortal>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
{search.has_snapshot && ( <DropdownMenuItem>
<DropdownMenuItem> <a
<a className="w-full"
className="w-full" href={`${baseUrl}api/events/${search.id}/snapshot.jpg?bbox=1`}
href={`${baseUrl}api/events/${search.id}/snapshot.jpg?bbox=1`} download={`${search.camera}_${search.label}.jpg`}
download={`${search.camera}_${search.label}.jpg`} >
> <div className="flex cursor-pointer items-center gap-2">
<div className="flex cursor-pointer items-center gap-2"> <span>{t("itemMenu.downloadSnapshot.label")}</span>
<span>{t("itemMenu.downloadSnapshot.label")}</span> </div>
</div> </a>
</a> </DropdownMenuItem>
</DropdownMenuItem>
)} <DropdownMenuItem>
{search.has_clip && ( <a
<DropdownMenuItem> className="w-full"
<a href={`${baseUrl}api/${search.camera}/${clipTimeRange}/clip.mp4`}
className="w-full" download
href={`${baseUrl}api/${search.camera}/${clipTimeRange}/clip.mp4`} >
download <div className="flex cursor-pointer items-center gap-2">
> <span>{t("itemMenu.downloadVideo.label")}</span>
<div className="flex cursor-pointer items-center gap-2"> </div>
<span>{t("itemMenu.downloadVideo.label")}</span> </a>
</div> </DropdownMenuItem>
</a>
</DropdownMenuItem>
)}
{config?.semantic_search.enabled && {config?.semantic_search.enabled &&
setSimilarity != undefined && setSimilarity != undefined &&

View File

@ -8,9 +8,6 @@ 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[];
@ -45,31 +42,16 @@ 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) => ({
return { x: pos.x * imgRect.width,
x: pos.x * imgRect.width, y: pos.y * imgRect.height,
y: pos.y * imgRect.height, timestamp: pos.timestamp,
timestamp: pos.timestamp, lifecycle_item: pos.lifecycle_item,
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/FriendlyNameLabel"; import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
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";
@ -306,7 +306,7 @@ function DialogContentComponent({
if (page === "tracking_details") { if (page === "tracking_details") {
return ( return (
<TrackingDetails <TrackingDetails
className={cn(isDesktop ? "size-full" : "flex flex-col gap-4")} className={cn("size-full", !isDesktop && "flex flex-col gap-4")}
event={search as unknown as Event} event={search as unknown as Event}
tabs={ tabs={
isDesktop ? ( isDesktop ? (
@ -584,7 +584,7 @@ export default function SearchDetailDialog({
"scrollbar-container overflow-y-auto", "scrollbar-container overflow-y-auto",
isDesktop && isDesktop &&
"max-h-[95dvh] sm:max-w-xl md:max-w-4xl lg:max-w-[70%]", "max-h-[95dvh] sm:max-w-xl md:max-w-4xl lg:max-w-[70%]",
isMobile && "flex h-full flex-col px-4", isMobile && "px-4",
)} )}
onInteractOutside={(e) => { onInteractOutside={(e) => {
if (isPopoverOpen) { if (isPopoverOpen) {
@ -596,7 +596,7 @@ export default function SearchDetailDialog({
} }
}} }}
> >
<Header className={cn(!isDesktop && "top-0 z-[60] mb-0")}> <Header>
<Title>{t("trackedObjectDetails")}</Title> <Title>{t("trackedObjectDetails")}</Title>
<Description className="sr-only"> <Description className="sr-only">
{t("trackedObjectDetails")} {t("trackedObjectDetails")}
@ -1078,31 +1078,12 @@ function ObjectDetailsTab({
}); });
setState("submitted"); setState("submitted");
mutate( setSearch({
(key) => ...search,
typeof key === "string" && plus_id: "new_upload",
(key.includes("events") || });
key.includes("events/search") ||
key.includes("events/explore")),
(currentData: SearchResult[][] | SearchResult[] | undefined) => {
if (!currentData) return currentData;
// optimistic update
return currentData
.flat()
.map((event) =>
event.id === search.id
? { ...event, plus_id: "new_upload" }
: event,
);
},
{
optimisticData: true,
rollbackOnError: true,
revalidate: false,
},
);
}, },
[search, mutate], [search, setSearch],
); );
const popoverContainerRef = useRef<HTMLDivElement | null>(null); const popoverContainerRef = useRef<HTMLDivElement | null>(null);
@ -1262,8 +1243,8 @@ function ObjectDetailsTab({
</div> </div>
{search.data.type === "object" && {search.data.type === "object" &&
config?.plus?.enabled && !search.plus_id &&
search.has_snapshot && ( config?.plus?.enabled && (
<div <div
className={cn( className={cn(
"my-2 flex w-full flex-col justify-between gap-1.5", "my-2 flex w-full flex-col justify-between gap-1.5",

View File

@ -23,7 +23,6 @@ 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";
@ -74,12 +73,6 @@ 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.
@ -352,8 +345,7 @@ export function TrackingDetails({
className={cn( className={cn(
isDesktop isDesktop
? "flex size-full justify-evenly gap-4 overflow-hidden" ? "flex size-full justify-evenly gap-4 overflow-hidden"
: "flex flex-col gap-2", : "flex size-full flex-col gap-2",
!isDesktop && cameraAspect === "tall" && "size-full",
className, className,
)} )}
> >
@ -720,13 +712,8 @@ function LifecycleIconRow({
backgroundColor: `rgb(${color})`, backgroundColor: `rgb(${color})`,
}} }}
/> />
<span <span className="smart-capitalize">
className={cn( {zone.replaceAll("_", " ")}
item.data?.zones_friendly_names?.[zidx] === zone &&
"smart-capitalize",
)}
>
{item.data?.zones_friendly_names?.[zidx]}
</span> </span>
</Badge> </Badge>
); );

View File

@ -20,9 +20,7 @@ import {
SheetTitle, SheetTitle,
SheetTrigger, SheetTrigger,
} from "@/components/ui/sheet"; } from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
import { isMobile } from "react-device-detect"; import { isMobile } from "react-device-detect";
import { useRef } from "react";
type PlatformAwareDialogProps = { type PlatformAwareDialogProps = {
trigger: JSX.Element; trigger: JSX.Element;
@ -81,8 +79,6 @@ export function PlatformAwareSheet({
open, open,
onOpenChange, onOpenChange,
}: PlatformAwareSheetProps) { }: PlatformAwareSheetProps) {
const scrollerRef = useRef<HTMLDivElement>(null);
if (isMobile) { if (isMobile) {
return ( return (
<MobilePage open={open} onOpenChange={onOpenChange}> <MobilePage open={open} onOpenChange={onOpenChange}>
@ -90,22 +86,14 @@ export function PlatformAwareSheet({
{trigger} {trigger}
</MobilePageTrigger> </MobilePageTrigger>
<MobilePagePortal> <MobilePagePortal>
<MobilePageContent <MobilePageContent className="h-full overflow-hidden">
className="flex h-full flex-col"
scrollerRef={scrollerRef}
>
<MobilePageHeader <MobilePageHeader
className="mx-2" className="mx-2"
onClose={() => onOpenChange(false)} onClose={() => onOpenChange(false)}
> >
<MobilePageTitle>{title}</MobilePageTitle> <MobilePageTitle>{title}</MobilePageTitle>
</MobilePageHeader> </MobilePageHeader>
<div <div className={contentClassName}>{content}</div>
ref={scrollerRef}
className={cn("flex-1 overflow-y-auto", contentClassName)}
>
{content}
</div>
</MobilePageContent> </MobilePageContent>
</MobilePagePortal> </MobilePagePortal>
</MobilePage> </MobilePage>

View File

@ -230,7 +230,6 @@ export default function SearchFilterDialog({
<PlatformAwareSheet <PlatformAwareSheet
trigger={trigger} trigger={trigger}
title={t("more")} title={t("more")}
titleClassName="mb-5 -mt-3"
content={content} content={content}
contentClassName={cn( contentClassName={cn(
"w-auto lg:min-w-[275px] scrollbar-container h-full overflow-auto px-4", "w-auto lg:min-w-[275px] scrollbar-container h-full overflow-auto px-4",
@ -430,8 +429,7 @@ export function ZoneFilterContent({
{allZones.map((item) => ( {allZones.map((item) => (
<FilterSwitch <FilterSwitch
key={item} key={item}
label={item} label={item.replaceAll("_", " ")}
type={"zone"}
isChecked={zones?.includes(item) ?? false} isChecked={zones?.includes(item) ?? false}
onCheckedChange={(isChecked) => { onCheckedChange={(isChecked) => {
if (isChecked) { if (isChecked) {

View File

@ -262,17 +262,13 @@ export function PolygonCanvas({
}; };
useEffect(() => { useEffect(() => {
if (activePolygonIndex === undefined || !polygons?.length) { if (activePolygonIndex === undefined || !polygons) {
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?.friendly_name ?? polygon?.name, name: polygon?.name,
}), }),
{ {
position: "top-center", position: "top-center",
@ -261,9 +261,7 @@ export default function PolygonItem({
}} }}
/> />
)} )}
<p className="cursor-default"> <p className="cursor-default">{polygon.name}</p>
{polygon.friendly_name ?? polygon.name}
</p>
</div> </div>
<AlertDialog <AlertDialog
open={deleteDialogOpen} open={deleteDialogOpen}
@ -280,7 +278,7 @@ export default function PolygonItem({
ns="views/settings" ns="views/settings"
values={{ values={{
type: polygon.type.replace("_", " "), type: polygon.type.replace("_", " "),
name: polygon.friendly_name ?? polygon.name, name: polygon.name,
}} }}
> >
masksAndZones.form.polygonDrawing.delete.desc masksAndZones.form.polygonDrawing.delete.desc

View File

@ -34,7 +34,6 @@ 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[];
@ -147,37 +146,15 @@ export default function ZoneEditPane({
"masksAndZones.form.zoneName.error.mustNotContainPeriod", "masksAndZones.form.zoneName.error.mustNotContainPeriod",
), ),
}, },
),
friendly_name: z
.string()
.min(2, {
message: t(
"masksAndZones.form.zoneName.error.mustBeAtLeastTwoCharacters",
),
})
.refine(
(value: string) => {
return !cameras.map((cam) => cam.name).includes(value);
},
{
message: t(
"masksAndZones.form.zoneName.error.mustNotBeSameWithCamera",
),
},
) )
.refine( .refine((value: string) => /^[a-zA-Z0-9_-]+$/.test(value), {
(value: string) => { message: t("masksAndZones.form.zoneName.error.hasIllegalCharacter"),
const otherPolygonNames = })
polygons .refine((value: string) => /[a-zA-Z]/.test(value), {
?.filter((_, index) => index !== activePolygonIndex) message: t(
.map((polygon) => polygon.name) || []; "masksAndZones.form.zoneName.error.mustHaveAtLeastOneLetter",
),
return !otherPolygonNames.includes(value); }),
},
{
message: t("masksAndZones.form.zoneName.error.alreadyExists"),
},
),
inertia: z.coerce inertia: z.coerce
.number() .number()
.min(1, { .min(1, {
@ -270,7 +247,6 @@ 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 &&
@ -310,7 +286,6 @@ export default function ZoneEditPane({
async ( async (
{ {
name: zoneName, name: zoneName,
friendly_name,
inertia, inertia,
loitering_time, loitering_time,
objects: form_objects, objects: form_objects,
@ -440,14 +415,9 @@ 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}${friendlyNameQuery}${alertQueries}${detectionQueries}`, `config/set?cameras.${polygon?.camera}.zones.${zoneName}.coordinates=${coordinates}${inertiaQuery}${loiteringTimeQuery}${speedThresholdQuery}${distancesQuery}${objectQueries}${alertQueries}${detectionQueries}`,
{ {
requires_restart: 0, requires_restart: 0,
update_topic: `config/cameras/${polygon.camera}/zones`, update_topic: `config/cameras/${polygon.camera}/zones`,
@ -457,7 +427,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: friendly_name || zoneName, zoneName,
}), }),
{ {
position: "top-center", position: "top-center",
@ -571,17 +541,26 @@ 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">
<NameAndIdFields <FormField
type="zone"
control={form.control} control={form.control}
nameField="friendly_name" name="name"
idField="name" render={({ field }) => (
idVisible={(polygon && polygon.name.length > 0) ?? false} <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,7 +26,6 @@ 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";
@ -794,28 +793,17 @@ function ObjectTimeline({
}, },
]); ]);
const { data: config } = useSWR<FrigateConfig>("config");
const timeline = useMemo(() => { const timeline = useMemo(() => {
if (!fullTimeline) { if (!fullTimeline) {
return fullTimeline; return fullTimeline;
} }
return fullTimeline return fullTimeline.filter(
.filter( (t) =>
(t) => t.timestamp >= review.start_time &&
t.timestamp >= review.start_time && (review.end_time == undefined || t.timestamp <= review.end_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

@ -1,41 +0,0 @@
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/FriendlyNameLabel"; import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
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}
type={"camera"} isCameraName={true}
onCheckedChange={(isChecked) => { onCheckedChange={(isChecked) => {
if (isChecked && (isEnabled || isCameraSettingsPage)) { if (isChecked && (isEnabled || isCameraSettingsPage)) {
setSelectedCamera(item.name); setSelectedCamera(item.name);

View File

@ -11,12 +11,10 @@ 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,7 +280,6 @@ export interface CameraConfig {
speed_threshold: number; speed_threshold: number;
objects: string[]; objects: string[];
color: number[]; color: number[];
friendly_name?: string;
}; };
}; };
} }

View File

@ -22,7 +22,6 @@ 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,7 +1,25 @@
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, formatList } from "./stringUtil"; import { capitalizeFirstLetter } 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,
@ -24,9 +42,7 @@ export function getLifecycleItemDescription(
return t("trackingDetails.lifecycleItemDesc.entered_zone", { return t("trackingDetails.lifecycleItemDesc.entered_zone", {
ns: "views/explore", ns: "views/explore",
label, label,
zones: formatList( zones: formatZonesList(lifecycleItem.data.zones),
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,5 +1,3 @@
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);
}; };
@ -21,30 +19,20 @@ export const capitalizeAll = (text: string): string => {
* @returns A valid camera identifier (lowercase, alphanumeric, max 8 chars) * @returns A valid camera identifier (lowercase, alphanumeric, max 8 chars)
*/ */
export function generateFixedHash(name: string, prefix: string = "id"): string { export function generateFixedHash(name: string, prefix: string = "id"): string {
// Use the full UTF-8 bytes of the name and compute an FNV-1a 32-bit hash. // Safely encode Unicode as UTF-8 bytes
// This is deterministic, fast, works with Unicode and avoids collisions from
// simple truncation of base64 output.
const utf8Bytes = new TextEncoder().encode(name); const utf8Bytes = new TextEncoder().encode(name);
// FNV-1a 32-bit hash algorithm // Convert to base64 manually
let hash = 0x811c9dc5; // FNV offset basis let binary = "";
for (let i = 0; i < utf8Bytes.length; i++) { for (const byte of utf8Bytes) {
hash ^= utf8Bytes[i]; binary += String.fromCharCode(byte);
// Multiply by FNV prime (0x01000193) with 32-bit overflow
hash = (hash >>> 0) * 0x01000193;
// Ensure 32-bit unsigned integer
hash >>>= 0;
} }
const base64 = btoa(binary);
// Convert to an 8-character lowercase hex string // Strip out non-alphanumeric characters and truncate
const hashHex = (hash >>> 0).toString(16).padStart(8, "0").toLowerCase(); const cleanHash = base64.replace(/[^a-zA-Z0-9]/g, "").substring(0, 8);
// Ensure the first character is a letter to avoid an identifier that's purely return `${prefix}_${cleanHash.toLowerCase()}`;
// numeric (isValidId forbids all-digit IDs). If it starts with a digit,
// replace with 'a'. This is extremely unlikely but a simple safeguard.
const safeHash = /^[0-9]/.test(hashHex[0]) ? `a${hashHex.slice(1)}` : hashHex;
return `${prefix}_${safeHash}`;
} }
/** /**
@ -57,29 +45,3 @@ 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/FriendlyNameLabel"; import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
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/FriendlyNameLabel"; import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
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/FriendlyNameLabel"; import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
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,6 +23,7 @@ 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";
@ -41,8 +42,6 @@ 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;
@ -87,18 +86,11 @@ 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: cameraConfig.zones[name].friendly_name,
objects: zoneData.objects, objects: zoneData.objects,
color: zoneData.color, color: zoneData.color,
})); }));
@ -107,27 +99,27 @@ export default function CameraSettingsView({
const alertsLabels = useMemo(() => { const alertsLabels = useMemo(() => {
return cameraConfig?.review.alerts.labels return cameraConfig?.review.alerts.labels
? formatList( ? cameraConfig.review.alerts.labels
cameraConfig.review.alerts.labels.map((label) => .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
? formatList( ? cameraConfig.review.detections.labels
cameraConfig.review.detections.labels.map((label) => .map((label) =>
getTranslatedLabel( getTranslatedLabel(
label, label,
cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object", cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object",
), ),
), )
) .join(", ")
: ""; : "";
}, [cameraConfig]); }, [cameraConfig]);
@ -533,14 +525,8 @@ export default function CameraSettingsView({
}} }}
/> />
</FormControl> </FormControl>
<FormLabel <FormLabel className="font-normal smart-capitalize">
className={cn( {zone.name.replaceAll("_", " ")}
"font-normal",
!zone.friendly_name &&
"smart-capitalize",
)}
>
{zone.friendly_name || zone.name}
</FormLabel> </FormLabel>
</FormItem> </FormItem>
)} )}
@ -562,11 +548,14 @@ export default function CameraSettingsView({
"cameraReview.reviewClassification.zoneObjectAlertsTips", "cameraReview.reviewClassification.zoneObjectAlertsTips",
{ {
alertsLabels, alertsLabels,
zone: formatList( zone: watchedAlertsZones
watchedAlertsZones.map((zone) => .map((zone) =>
getZoneName(zone), capitalizeFirstLetter(zone).replaceAll(
), "_",
), " ",
),
)
.join(", "),
cameraName: selectCameraName, cameraName: selectCameraName,
}, },
) )
@ -638,14 +627,8 @@ export default function CameraSettingsView({
}} }}
/> />
</FormControl> </FormControl>
<FormLabel <FormLabel className="font-normal smart-capitalize">
className={cn( {zone.name.replaceAll("_", " ")}
"font-normal",
!zone.friendly_name &&
"smart-capitalize",
)}
>
{zone.friendly_name || zone.name}
</FormLabel> </FormLabel>
</FormItem> </FormItem>
)} )}
@ -684,11 +667,14 @@ export default function CameraSettingsView({
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.text" i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.text"
values={{ values={{
detectionsLabels, detectionsLabels,
zone: formatList( zone: watchedDetectionsZones
watchedDetectionsZones.map((zone) => .map((zone) =>
getZoneName(zone), capitalizeFirstLetter(zone).replaceAll(
), "_",
), " ",
),
)
.join(", "),
cameraName: selectCameraName, cameraName: selectCameraName,
}} }}
ns="views/settings" ns="views/settings"
@ -698,11 +684,14 @@ export default function CameraSettingsView({
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.notSelectDetections" i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.notSelectDetections"
values={{ values={{
detectionsLabels, detectionsLabels,
zone: formatList( zone: watchedDetectionsZones
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/FriendlyNameLabel"; import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
type FrigatePlusModel = { type FrigatePlusModel = {
id: string; id: string;

View File

@ -229,7 +229,6 @@ 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/FriendlyNameLabel"; import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
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}
type={"camera"} isCameraName={true}
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/FriendlyNameLabel"; import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
import { resolveCameraName } from "@/hooks/use-camera-friendly-name"; import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
type CameraMetricsProps = { type CameraMetricsProps = {