Compare commits

...

16 Commits

Author SHA1 Message Date
Josh Hawkins
cf9a4a9407 frigate plus submission fix
still show frigate+ section if snapshot has already been submitted and run optimistic update, local state was being overridden
2025-11-07 13:52:02 -06:00
Josh Hawkins
06e5852743 check for snapshot and clip in actions menu 2025-11-07 13:19:34 -06:00
Josh Hawkins
b0f9fefd97 don't display submit to plus if object doesn't have a snapshot 2025-11-07 13:17:36 -06:00
Nicolas Mowen
c2203ead61 Fix incorrect object classification crop 2025-11-07 10:54:03 -07:00
Josh Hawkins
1918e9682b don't smart capitalize friendly names 2025-11-07 10:16:29 -06:00
Josh Hawkins
fefb264e55 ensure header stays on top of video controls 2025-11-07 10:06:55 -06:00
Josh Hawkins
63b2384d87 remove extra flex div causing overflow 2025-11-07 10:03:40 -06:00
Josh Hawkins
d76335096a fix hashing function to avoid collisions 2025-11-07 09:54:05 -06:00
Nicolas Mowen
ab3ded38e6 Use thread lock for openvino to avoid concurrent requests with JinaV2 2025-11-07 08:46:49 -07:00
Josh Hawkins
2376bcaf97 fix mobilepage in tracked object details 2025-11-07 09:33:59 -06:00
Nicolas Mowen
6a27c47808 Update NPU models and docs 2025-11-07 08:16:22 -07:00
Josh Hawkins
7703cfbfee improve zone capitalization 2025-11-07 08:23:50 -06:00
Josh Hawkins
25e8c2a051 show id field when editing zone 2025-11-07 08:08:57 -06:00
GuoQing Liu
ef19332fe5
Add zones friend name (#20761)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* feat: add zones friendly name

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

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

* docs: update zones docs

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

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

* Add friendly_name option for zone configuration

Added optional friendly name for zones in configuration.

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

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

* docs: remove emoji tips

* revert: revert zones doc ui tips

* Update docs/docs/configuration/zones.md

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

* Update docs/docs/configuration/zones.md

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

* Update docs/docs/configuration/zones.md

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

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

* chore: lint fix

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

* refactor: add formatList

---------

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
2025-11-07 08:02:06 -06:00
Josh Hawkins
530b69b877
Miscellaneous fixes (#20833)
* remove frigate+ icon from explore grid footer

* add margin

* pointer cursor on event menu items in detail stream

* don't show submit to plus for non-objects and if plus is disabled

* tweak spacing in annotation settings popover

* Fix deletion of classification images and library

* Ensure after creating a class that things are correct

* Fix dialog getting stuck

* Only show the genai summary popup on mobile when timeline is open

* fix audio transcription embedding

* spacing

* hide x icon on restart sheet to prevent closure issues

* prevent x overflow in detail stream on mobile safari

* ensure name is valid for search effect trigger

* add trigger to detail actions menu

* move find similar to actions menu

* Use a column layout for MobilePageContent in PlatformAwareSheet

 This is so the header is outside the scrolling area and the content can grow/scroll independently. This now matches the way it's done in classification

* Skip azure execution provider

* add optional ref to always scroll to top

the more filters in explore was not scrolled to the top on open due to the use of framer motion

* fix title classes on desktop

---------

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
2025-11-07 06:53:27 -07:00
Artem Vladimirov
a15399fed5
fix: add pluralization (classification model) (#20838)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
Co-authored-by: Artem Vladimirov <a.vladimirov@small.kz>
2025-11-07 05:40:48 -07:00
60 changed files with 756 additions and 470 deletions

View File

@ -5,7 +5,7 @@ title: Enrichments
# Enrichments
Some of Frigate's enrichments can use a discrete GPU / NPU for accelerated processing.
Some of Frigate's enrichments can use a discrete GPU or integrated GPU for accelerated processing.
## Requirements
@ -18,8 +18,10 @@ Object detection and enrichments (like Semantic Search, Face Recognition, and Li
- **Intel**
- 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 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.

View File

@ -261,6 +261,8 @@ OpenVINO is supported on 6th Gen Intel platforms (Skylake) and newer. It will al
:::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:
```yaml
@ -283,7 +285,7 @@ detectors:
| [RF-DETR](#rf-detr) | ✅ | ✅ | Requires XE iGPU or Arc |
| [YOLO-NAS](#yolo-nas) | ✅ | ✅ | |
| [MobileNet v2](#ssdlite-mobilenet-v2) | ✅ | ✅ | Fast and lightweight model, less accurate than larger models |
| [YOLOX](#yolox) | ✅ | ? | |
| [YOLOX](#yolox) | ✅ | ? | |
| [D-FINE](#d-fine) | ❌ | ❌ | |
#### SSDLite MobileNet v2

View File

@ -810,6 +810,8 @@ cameras:
# NOTE: This must be different than any camera names, but can match with another zone on another
# camera.
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.
# 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

View File

@ -78,7 +78,7 @@ Switching between V1 and V2 requires reindexing your embeddings. The embeddings
### GPU Acceleration
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.
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.
```yaml
semantic_search:
@ -90,7 +90,7 @@ semantic_search:
:::info
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.
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.
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.

View File

@ -27,6 +27,7 @@ cameras:
- entire_yard
zones:
entire_yard:
friendly_name: Entire yard # You can use characters from any language text
coordinates: ...
```
@ -44,8 +45,10 @@ cameras:
- edge_yard
zones:
edge_yard:
friendly_name: Edge yard # You can use characters from any language text
coordinates: ...
inner_yard:
friendly_name: Inner yard # You can use characters from any language text
coordinates: ...
```
@ -59,6 +62,7 @@ cameras:
- entire_yard
zones:
entire_yard:
friendly_name: Entire yard
coordinates: ...
```
@ -82,6 +86,7 @@ cameras:
Only car objects can trigger the `front_yard_street` zone and only person can trigger the `entire_yard`. Objects will be tracked for any `person` that enter anywhere in the yard, and for cars only if they enter the street.
### 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.

View File

@ -662,8 +662,11 @@ def delete_classification_dataset_images(
if os.path.isfile(file_path):
os.unlink(file_path)
if os.path.exists(folder) and not os.listdir(folder):
os.rmdir(folder)
return JSONResponse(
content=({"success": True, "message": "Successfully deleted faces."}),
content=({"success": True, "message": "Successfully deleted images."}),
status_code=200,
)
@ -723,7 +726,7 @@ def categorize_classification_image(request: Request, name: str, body: dict = No
os.unlink(training_file)
return JSONResponse(
content=({"success": True, "message": "Successfully deleted faces."}),
content=({"success": True, "message": "Successfully categorized image."}),
status_code=200,
)
@ -761,7 +764,7 @@ def delete_classification_train_images(request: Request, name: str, body: dict =
os.unlink(file_path)
return JSONResponse(
content=({"success": True, "message": "Successfully deleted faces."}),
content=({"success": True, "message": "Successfully deleted images."}),
status_code=200,
)

View File

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

View File

@ -9,7 +9,6 @@ from typing import Optional
from faster_whisper import WhisperModel
from peewee import DoesNotExist
from frigate.comms.embeddings_updater import EmbeddingsRequestEnum
from frigate.comms.inter_process import InterProcessRequestor
from frigate.config import FrigateConfig
from frigate.const import (
@ -32,11 +31,13 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
self,
config: FrigateConfig,
requestor: InterProcessRequestor,
embeddings,
metrics: DataProcessorMetrics,
):
super().__init__(config, metrics, None)
self.config = config
self.requestor = requestor
self.embeddings = embeddings
self.recognizer = None
self.transcription_lock = threading.Lock()
self.transcription_thread = None
@ -128,10 +129,7 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
)
# Embed the description
self.requestor.send_data(
EmbeddingsRequestEnum.embed_description.value,
{"id": event_id, "description": transcription},
)
self.embeddings.embed_description(event_id, transcription)
except DoesNotExist:
logger.debug("No recording found for audio transcription post-processing")

View File

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

View File

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

View File

@ -226,7 +226,9 @@ class EmbeddingMaintainer(threading.Thread):
for c in self.config.cameras.values()
):
self.post_processors.append(
AudioTranscriptionPostProcessor(self.config, self.requestor, metrics)
AudioTranscriptionPostProcessor(
self.config, self.requestor, self.embeddings, metrics
)
)
semantic_trigger_processor: SemanticTriggerProcessor | None = None

View File

@ -369,6 +369,10 @@ def get_ort_providers(
"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:
providers.append(provider)
options.append({})

View File

@ -40,7 +40,8 @@
"deleteModel": {
"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.",
"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_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_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": {
"title": "Edit Classification Model",
@ -50,11 +51,13 @@
},
"deleteDatasetImages": {
"title": "Delete Dataset Images",
"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_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_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": {
"title": "Delete Train Images",
"desc": "Are you sure you want to delete {{count}} images? This action cannot be undone."
"desc_one": "Are you sure you want to delete {{count}} image? This action cannot be undone.",
"desc_other": "Are you sure you want to delete {{count}} images? This action cannot be undone."
},
"renameCategory": {
"title": "Rename Class",

View File

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

@ -14,7 +14,6 @@ type SearchThumbnailProps = {
findSimilar: () => void;
refreshResults: () => void;
showTrackingDetails: () => void;
showSnapshot: () => void;
addTrigger: () => void;
};
@ -24,7 +23,6 @@ export default function SearchThumbnailFooter({
findSimilar,
refreshResults,
showTrackingDetails,
showSnapshot,
addTrigger,
}: SearchThumbnailProps) {
const { t } = useTranslation(["views/search"]);
@ -62,7 +60,6 @@ export default function SearchThumbnailFooter({
findSimilar={findSimilar}
refreshResults={refreshResults}
showTrackingDetails={showTrackingDetails}
showSnapshot={showSnapshot}
addTrigger={addTrigger}
/>
</div>

View File

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

View File

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

View File

@ -1,29 +1,39 @@
import { Switch } from "../ui/switch";
import { Label } from "../ui/label";
import { CameraNameLabel } from "../camera/CameraNameLabel";
import { CameraNameLabel, ZoneNameLabel } from "../camera/FriendlyNameLabel";
type FilterSwitchProps = {
label: string;
disabled?: boolean;
isChecked: boolean;
isCameraName?: boolean;
type?: string;
extraValue?: string;
onCheckedChange: (checked: boolean) => void;
};
export default function FilterSwitch({
label,
disabled = false,
isChecked,
isCameraName = false,
type = "",
extraValue = "",
onCheckedChange,
}: FilterSwitchProps) {
return (
<div className="flex items-center justify-between gap-1">
{isCameraName ? (
{type === "camera" ? (
<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" : ""}`}
htmlFor={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
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) => (
<FilterSwitch
key={item}
label={item.replaceAll("_", " ")}
label={item}
type={"zone"}
isChecked={filter.zones?.includes(item) ?? false}
onCheckedChange={(isChecked) => {
if (isChecked) {

View File

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

View File

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

View File

@ -6,10 +6,7 @@ import { toast } from "sonner";
import axios from "axios";
import { LuCamera, LuDownload, LuTrash2 } from "react-icons/lu";
import { FiMoreVertical } from "react-icons/fi";
import { FaArrowsRotate } from "react-icons/fa6";
import { MdImageSearch } from "react-icons/md";
import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon";
import { isMobileOnly } from "react-device-detect";
import { buttonVariants } from "@/components/ui/button";
import {
ContextMenu,
@ -33,23 +30,18 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import useSWR from "swr";
import { Trans, useTranslation } from "react-i18next";
import { BsFillLightningFill } from "react-icons/bs";
import BlurredIconButton from "../button/BlurredIconButton";
import { PiPath } from "react-icons/pi";
type SearchResultActionsProps = {
searchResult: SearchResult;
findSimilar: () => void;
refreshResults: () => void;
showTrackingDetails: () => void;
showSnapshot: () => void;
addTrigger: () => void;
isContextMenu?: boolean;
children?: ReactNode;
@ -60,7 +52,6 @@ export default function SearchResultActions({
findSimilar,
refreshResults,
showTrackingDetails,
showSnapshot,
addTrigger,
isContextMenu = false,
children,
@ -129,7 +120,7 @@ export default function SearchResultActions({
aria-label={t("itemMenu.viewTrackingDetails.aria")}
onClick={showTrackingDetails}
>
<FaArrowsRotate className="mr-2 size-4" />
<PiPath className="mr-2 size-4" />
<span>{t("itemMenu.viewTrackingDetails.label")}</span>
</MenuItem>
)}
@ -152,18 +143,14 @@ export default function SearchResultActions({
<span>{t("itemMenu.addTrigger.label")}</span>
</MenuItem>
)}
{isMobileOnly &&
config?.plus?.enabled &&
searchResult.has_snapshot &&
searchResult.end_time &&
searchResult.data.type == "object" &&
!searchResult.plus_id && (
{config?.semantic_search?.enabled &&
searchResult.data.type == "object" && (
<MenuItem
aria-label={t("itemMenu.submitToPlus.aria")}
onClick={showSnapshot}
aria-label={t("itemMenu.findSimilar.aria")}
onClick={findSimilar}
>
<FrigatePlusIcon className="mr-2 size-4 cursor-pointer text-primary" />
<span>{t("itemMenu.submitToPlus.label")}</span>
<MdImageSearch className="mr-2 size-4" />
<span>{t("itemMenu.findSimilar.label")}</span>
</MenuItem>
)}
<MenuItem
@ -211,44 +198,6 @@ export default function SearchResultActions({
</ContextMenu>
) : (
<>
{config?.semantic_search?.enabled &&
searchResult.data.type == "object" && (
<Tooltip>
<TooltipTrigger asChild>
<BlurredIconButton
onClick={findSimilar}
aria-label={t("itemMenu.findSimilar.aria")}
>
<MdImageSearch className="size-5" />
</BlurredIconButton>
</TooltipTrigger>
<TooltipContent>
{t("itemMenu.findSimilar.label")}
</TooltipContent>
</Tooltip>
)}
{!isMobileOnly &&
config?.plus?.enabled &&
searchResult.has_snapshot &&
searchResult.end_time &&
searchResult.data.type == "object" &&
!searchResult.plus_id && (
<Tooltip>
<TooltipTrigger asChild>
<BlurredIconButton
onClick={showSnapshot}
aria-label={t("itemMenu.submitToPlus.aria")}
>
<FrigatePlusIcon className="size-5" />
</BlurredIconButton>
</TooltipTrigger>
<TooltipContent>
{t("itemMenu.submitToPlus.label")}
</TooltipContent>
</Tooltip>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<BlurredIconButton aria-label={t("itemMenu.more.aria")}>

View File

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

View File

@ -97,14 +97,12 @@ export default function ClassificationSelectionDialog({
return (
<div className={className ?? "flex"}>
{newClass && (
<TextEntryDialog
open={true}
setOpen={setNewClass}
title={t("createCategory.new")}
onSave={(newCat) => onCategorizeImage(newCat)}
/>
)}
<TextEntryDialog
open={newClass}
setOpen={setNewClass}
title={t("createCategory.new")}
onSave={(newCat) => onCategorizeImage(newCat)}
/>
<Tooltip>
<Selector>

View File

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

View File

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

View File

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

View File

@ -12,6 +12,7 @@ import { TooltipPortal } from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
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
const TOLERANCE = 0.01;
@ -114,6 +115,10 @@ export default function ObjectTrackOverlay({
{ revalidateOnFocus: false },
);
const getZonesFriendlyNames = (zones: string[], config: FrigateConfig) => {
return zones?.map((zone) => resolveZoneName(config, zone)) ?? [];
};
const timelineResults = useMemo(() => {
// Group timeline entries by source_id
if (!timelineData) return selectedObjectIds.map(() => []);
@ -127,8 +132,19 @@ export default function ObjectTrackOverlay({
}
// Return timeline arrays in the same order as selectedObjectIds
return selectedObjectIds.map((id) => grouped[id] || []);
}, [selectedObjectIds, timelineData]);
return selectedObjectIds.map((id) => {
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(
() => ({

View File

@ -141,50 +141,52 @@ export function AnnotationSettingsPane({
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-1 flex-col space-y-6"
className="flex flex-1 flex-col space-y-3"
>
<FormField
control={form.control}
name="annotationOffset"
render={({ field }) => (
<FormItem className="flex flex-row items-start justify-between space-x-2">
<div className="flex flex-col gap-1">
<FormLabel>
{t("trackingDetails.annotationSettings.offset.label")}
</FormLabel>
<FormDescription>
<Trans ns="views/explore">
trackingDetails.annotationSettings.offset.millisecondsToOffset
</Trans>
<FormMessage />
<div className="mt-2">
{t("trackingDetails.annotationSettings.offset.tips")}
<div className="mt-2 flex items-center text-primary">
<Link
to={getLocaleDocUrl("configuration/reference")}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
<>
<FormItem className="flex flex-row items-start justify-between space-x-2">
<div className="flex flex-col gap-1">
<FormLabel>
{t("trackingDetails.annotationSettings.offset.label")}
</FormLabel>
<FormDescription>
<Trans ns="views/explore">
trackingDetails.annotationSettings.offset.millisecondsToOffset
</Trans>
<FormMessage />
</FormDescription>
</div>
<div className="flex flex-col gap-3">
<div className="min-w-24">
<FormControl>
<Input
className="text-md w-full border border-input bg-background p-2 text-center hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
placeholder="0"
{...field}
/>
</FormControl>
</div>
</FormDescription>
</div>
<div className="flex flex-col gap-3">
<div className="min-w-24">
<FormControl>
<Input
className="text-md w-full border border-input bg-background p-2 text-center hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
placeholder="0"
{...field}
/>
</FormControl>
</div>
</FormItem>
<div className="mt-1 text-sm text-secondary-foreground">
{t("trackingDetails.annotationSettings.offset.tips")}
<div className="mt-2 flex items-center text-primary-variant">
<Link
to={getLocaleDocUrl("configuration/reference")}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</div>
</FormItem>
</>
)}
/>

View File

@ -55,29 +55,32 @@ export default function DetailActionsMenu({
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<a
className="w-full"
href={`${baseUrl}api/events/${search.id}/snapshot.jpg?bbox=1`}
download={`${search.camera}_${search.label}.jpg`}
>
<div className="flex cursor-pointer items-center gap-2">
<span>{t("itemMenu.downloadSnapshot.label")}</span>
</div>
</a>
</DropdownMenuItem>
<DropdownMenuItem>
<a
className="w-full"
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>
</a>
</DropdownMenuItem>
{search.has_snapshot && (
<DropdownMenuItem>
<a
className="w-full"
href={`${baseUrl}api/events/${search.id}/snapshot.jpg?bbox=1`}
download={`${search.camera}_${search.label}.jpg`}
>
<div className="flex cursor-pointer items-center gap-2">
<span>{t("itemMenu.downloadSnapshot.label")}</span>
</div>
</a>
</DropdownMenuItem>
)}
{search.has_clip && (
<DropdownMenuItem>
<a
className="w-full"
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>
</a>
</DropdownMenuItem>
)}
{config?.semantic_search.enabled &&
setSimilarity != undefined &&
@ -111,6 +114,23 @@ export default function DetailActionsMenu({
</div>
</DropdownMenuItem>
)}
{config?.semantic_search.enabled && search.data.type == "object" && (
<DropdownMenuItem
onClick={() => {
setIsOpen(false);
setTimeout(() => {
navigate(
`/settings?page=triggers&camera=${search.camera}&event_id=${search.id}`,
);
}, 0);
}}
>
<div className="flex cursor-pointer items-center gap-2">
<span>{t("itemMenu.addTrigger.label")}</span>
</div>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenu>

View File

@ -8,6 +8,9 @@ import {
import { TooltipPortal } from "@radix-ui/react-tooltip";
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
import { useTranslation } from "react-i18next";
import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
import { FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr";
type ObjectPathProps = {
positions?: Position[];
@ -42,16 +45,31 @@ export function ObjectPath({
visible = true,
}: ObjectPathProps) {
const { t } = useTranslation(["views/explore"]);
const { data: config } = useSWR<FrigateConfig>("config");
const getAbsolutePositions = useCallback(() => {
if (!imgRef.current || !positions) return [];
const imgRect = imgRef.current.getBoundingClientRect();
return positions.map((pos) => ({
x: pos.x * imgRect.width,
y: pos.y * imgRect.height,
timestamp: pos.timestamp,
lifecycle_item: pos.lifecycle_item,
}));
}, [positions, imgRef]);
return positions.map((pos) => {
return {
x: pos.x * imgRect.width,
y: pos.y * imgRect.height,
timestamp: pos.timestamp,
lifecycle_item: pos.lifecycle_item?.data?.zones
? {
...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[]) => {
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 { useIsAdmin } from "@/hooks/use-is-admin";
import { getTranslatedLabel } from "@/utils/i18n";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
import { DialogPortal } from "@radix-ui/react-dialog";
import { useDetailStream } from "@/context/detail-stream-context";
import { PiSlidersHorizontalBold } from "react-icons/pi";
@ -306,7 +306,7 @@ function DialogContentComponent({
if (page === "tracking_details") {
return (
<TrackingDetails
className={cn("size-full", !isDesktop && "flex flex-col gap-4")}
className={cn(isDesktop ? "size-full" : "flex flex-col gap-4")}
event={search as unknown as Event}
tabs={
isDesktop ? (
@ -584,7 +584,7 @@ export default function SearchDetailDialog({
"scrollbar-container overflow-y-auto",
isDesktop &&
"max-h-[95dvh] sm:max-w-xl md:max-w-4xl lg:max-w-[70%]",
isMobile && "px-4",
isMobile && "flex h-full flex-col px-4",
)}
onInteractOutside={(e) => {
if (isPopoverOpen) {
@ -596,7 +596,7 @@ export default function SearchDetailDialog({
}
}}
>
<Header>
<Header className={cn(!isDesktop && "top-0 z-[60] mb-0")}>
<Title>{t("trackedObjectDetails")}</Title>
<Description className="sr-only">
{t("trackedObjectDetails")}
@ -1078,12 +1078,31 @@ function ObjectDetailsTab({
});
setState("submitted");
setSearch({
...search,
plus_id: "new_upload",
});
mutate(
(key) =>
typeof key === "string" &&
(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, setSearch],
[search, mutate],
);
const popoverContainerRef = useRef<HTMLDivElement | null>(null);
@ -1242,106 +1261,110 @@ function ObjectDetailsTab({
</div>
</div>
<div
className={cn(
"my-2 flex w-full flex-col justify-between gap-1.5",
state == "submitted" && "flex-row",
)}
>
<div className="text-sm text-primary/40">
<div className="flex flex-row items-center gap-1">
{t("explore.plus.submitToPlus.label", {
ns: "components/dialog",
})}
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">Info</span>
</div>
</PopoverTrigger>
<PopoverContent
container={popoverContainerRef.current}
className="w-80 text-xs"
>
{t("explore.plus.submitToPlus.desc", {
{search.data.type === "object" &&
config?.plus?.enabled &&
search.has_snapshot && (
<div
className={cn(
"my-2 flex w-full flex-col justify-between gap-1.5",
state == "submitted" && "flex-row",
)}
>
<div className="text-sm text-primary/40">
<div className="flex flex-row items-center gap-1">
{t("explore.plus.submitToPlus.label", {
ns: "components/dialog",
})}
</PopoverContent>
</Popover>
</div>
</div>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">Info</span>
</div>
</PopoverTrigger>
<PopoverContent
container={popoverContainerRef.current}
className="w-80 text-xs"
>
{t("explore.plus.submitToPlus.desc", {
ns: "components/dialog",
})}
</PopoverContent>
</Popover>
</div>
</div>
<div className="flex flex-row items-center justify-between gap-2 text-sm">
{state == "reviewing" && (
<>
<div>
{i18n.language === "en" ? (
// English with a/an logic plus label
<>
{/^[aeiou]/i.test(search?.label || "") ? (
<Trans
ns="components/dialog"
values={{ label: search?.label }}
>
explore.plus.review.question.ask_an
</Trans>
<div className="flex flex-row items-center justify-between gap-2 text-sm">
{state == "reviewing" && (
<>
<div>
{i18n.language === "en" ? (
// English with a/an logic plus label
<>
{/^[aeiou]/i.test(search?.label || "") ? (
<Trans
ns="components/dialog"
values={{ label: search?.label }}
>
explore.plus.review.question.ask_an
</Trans>
) : (
<Trans
ns="components/dialog"
values={{ label: search?.label }}
>
explore.plus.review.question.ask_a
</Trans>
)}
</>
) : (
// For other languages
<Trans
ns="components/dialog"
values={{ label: search?.label }}
values={{
untranslatedLabel: search?.label,
translatedLabel: getTranslatedLabel(search?.label),
}}
>
explore.plus.review.question.ask_a
explore.plus.review.question.ask_full
</Trans>
)}
</>
) : (
// For other languages
<Trans
ns="components/dialog"
values={{
untranslatedLabel: search?.label,
translatedLabel: getTranslatedLabel(search?.label),
}}
>
explore.plus.review.question.ask_full
</Trans>
)}
</div>
<div className="flex max-w-xl flex-row gap-2">
<Button
className="flex-1 bg-success"
aria-label={t("button.yes", { ns: "common" })}
onClick={() => {
setState("uploading");
onSubmitToPlus(false);
}}
>
{t("button.yes", { ns: "common" })}
</Button>
<Button
className="flex-1 text-white"
aria-label={t("button.no", { ns: "common" })}
variant="destructive"
onClick={() => {
setState("uploading");
onSubmitToPlus(true);
}}
>
{t("button.no", { ns: "common" })}
</Button>
</div>
</>
)}
{state == "uploading" && <ActivityIndicator />}
{state == "submitted" && (
<div className="flex flex-row items-center justify-center gap-2">
<FaCheckCircle className="size-4 text-success" />
{t("explore.plus.review.state.submitted")}
</div>
<div className="flex max-w-xl flex-row gap-2">
<Button
className="flex-1 bg-success"
aria-label={t("button.yes", { ns: "common" })}
onClick={() => {
setState("uploading");
onSubmitToPlus(false);
}}
>
{t("button.yes", { ns: "common" })}
</Button>
<Button
className="flex-1 text-white"
aria-label={t("button.no", { ns: "common" })}
variant="destructive"
onClick={() => {
setState("uploading");
onSubmitToPlus(true);
}}
>
{t("button.no", { ns: "common" })}
</Button>
</div>
</>
)}
{state == "uploading" && <ActivityIndicator />}
{state == "submitted" && (
<div className="flex flex-row items-center justify-center gap-2">
<FaCheckCircle className="size-4 text-success" />
{t("explore.plus.review.state.submitted")}
</div>
)}
</div>
)}
</div>
</div>
</div>
)}
<div className="flex flex-col gap-1.5">
{config?.cameras[search.camera].objects.genai.enabled &&
!search.end_time &&

View File

@ -23,6 +23,7 @@ import { Link, useNavigate } from "react-router-dom";
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
import { useTranslation } from "react-i18next";
import { getTranslatedLabel } from "@/utils/i18n";
import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
import { Badge } from "@/components/ui/badge";
import { HiDotsHorizontal } from "react-icons/hi";
import axios from "axios";
@ -73,6 +74,12 @@ export function TrackingDetails({
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
// lifecycle rows and overlays follow image-mode seeks. Otherwise fall
// back to currentTime used for video mode.
@ -345,7 +352,8 @@ export function TrackingDetails({
className={cn(
isDesktop
? "flex size-full justify-evenly gap-4 overflow-hidden"
: "flex size-full flex-col gap-2",
: "flex flex-col gap-2",
!isDesktop && cameraAspect === "tall" && "size-full",
className,
)}
>
@ -457,7 +465,7 @@ export function TrackingDetails({
>
{config?.cameras[event.camera]?.onvif.autotracking
.enabled_in_config && (
<div className="mb-2 text-sm text-danger">
<div className="mb-2 ml-3 text-sm text-danger">
{t("trackingDetails.autoTrackingTips")}
</div>
)}
@ -712,8 +720,13 @@ function LifecycleIconRow({
backgroundColor: `rgb(${color})`,
}}
/>
<span className="smart-capitalize">
{zone.replaceAll("_", " ")}
<span
className={cn(
item.data?.zones_friendly_names?.[zidx] === zone &&
"smart-capitalize",
)}
>
{item.data?.zones_friendly_names?.[zidx]}
</span>
</Badge>
);

View File

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

View File

@ -98,7 +98,11 @@ export default function RestartDialog({
open={restartingSheetOpen}
onOpenChange={() => setRestartingSheetOpen(false)}
>
<SheetContent side="top" onInteractOutside={(e) => e.preventDefault()}>
<SheetContent
side="top"
onInteractOutside={(e) => e.preventDefault()}
className="[&>button:first-of-type]:hidden"
>
<div className="flex flex-col items-center">
<ActivityIndicator />
<SheetHeader className="mt-5 text-center">

View File

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

View File

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

View File

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

View File

@ -34,6 +34,7 @@ import { Link } from "react-router-dom";
import { LuExternalLink } from "react-icons/lu";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { getTranslatedLabel } from "@/utils/i18n";
import NameAndIdFields from "../input/NameAndIdFields";
type ZoneEditPaneProps = {
polygons?: Polygon[];
@ -146,15 +147,37 @@ export default function ZoneEditPane({
"masksAndZones.form.zoneName.error.mustNotContainPeriod",
),
},
)
.refine((value: string) => /^[a-zA-Z0-9_-]+$/.test(value), {
message: t("masksAndZones.form.zoneName.error.hasIllegalCharacter"),
})
.refine((value: string) => /[a-zA-Z]/.test(value), {
),
friendly_name: z
.string()
.min(2, {
message: t(
"masksAndZones.form.zoneName.error.mustHaveAtLeastOneLetter",
"masksAndZones.form.zoneName.error.mustBeAtLeastTwoCharacters",
),
}),
})
.refine(
(value: string) => {
return !cameras.map((cam) => cam.name).includes(value);
},
{
message: t(
"masksAndZones.form.zoneName.error.mustNotBeSameWithCamera",
),
},
)
.refine(
(value: string) => {
const otherPolygonNames =
polygons
?.filter((_, index) => index !== activePolygonIndex)
.map((polygon) => polygon.name) || [];
return !otherPolygonNames.includes(value);
},
{
message: t("masksAndZones.form.zoneName.error.alreadyExists"),
},
),
inertia: z.coerce
.number()
.min(1, {
@ -247,6 +270,7 @@ export default function ZoneEditPane({
mode: "onBlur",
defaultValues: {
name: polygon?.name ?? "",
friendly_name: polygon?.friendly_name ?? polygon?.name ?? "",
inertia:
polygon?.camera &&
polygon?.name &&
@ -286,6 +310,7 @@ export default function ZoneEditPane({
async (
{
name: zoneName,
friendly_name,
inertia,
loitering_time,
objects: form_objects,
@ -415,9 +440,14 @@ export default function ZoneEditPane({
}
}
let friendlyNameQuery = "";
if (friendly_name) {
friendlyNameQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.friendly_name=${encodeURIComponent(friendly_name)}`;
}
axios
.put(
`config/set?cameras.${polygon?.camera}.zones.${zoneName}.coordinates=${coordinates}${inertiaQuery}${loiteringTimeQuery}${speedThresholdQuery}${distancesQuery}${objectQueries}${alertQueries}${detectionQueries}`,
`config/set?cameras.${polygon?.camera}.zones.${zoneName}.coordinates=${coordinates}${inertiaQuery}${loiteringTimeQuery}${speedThresholdQuery}${distancesQuery}${objectQueries}${friendlyNameQuery}${alertQueries}${detectionQueries}`,
{
requires_restart: 0,
update_topic: `config/cameras/${polygon.camera}/zones`,
@ -427,7 +457,7 @@ export default function ZoneEditPane({
if (res.status === 200) {
toast.success(
t("masksAndZones.zones.toast.success", {
zoneName,
zoneName: friendly_name || zoneName,
}),
{
position: "top-center",
@ -541,26 +571,17 @@ export default function ZoneEditPane({
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-2 space-y-6">
<FormField
<NameAndIdFields
type="zone"
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("masksAndZones.zones.name.title")}</FormLabel>
<FormControl>
<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>
)}
nameField="friendly_name"
idField="name"
idVisible={(polygon && polygon.name.length > 0) ?? false}
nameLabel={t("masksAndZones.zones.name.title")}
nameDescription={t("masksAndZones.zones.name.tips")}
placeholderName={t("masksAndZones.zones.name.inputPlaceHolder")}
/>
<Separator className="my-2 flex bg-secondary" />
<FormField
control={form.control}

View File

@ -26,6 +26,7 @@ import { Link } from "react-router-dom";
import { Switch } from "@/components/ui/switch";
import { usePersistence } from "@/hooks/use-persistence";
import { isDesktop } from "react-device-detect";
import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
import { PiSlidersHorizontalBold } from "react-icons/pi";
import { MdAutoAwesome } from "react-icons/md";
@ -192,7 +193,7 @@ export default function DetailStream({
<div className="relative flex h-full flex-col">
<div
ref={scrollRef}
className="scrollbar-container flex-1 overflow-y-auto pb-14"
className="scrollbar-container flex-1 overflow-y-auto overflow-x-hidden pb-14"
>
<div className="space-y-4 py-2">
{reviewItems?.length === 0 ? (
@ -793,17 +794,28 @@ function ObjectTimeline({
},
]);
const { data: config } = useSWR<FrigateConfig>("config");
const timeline = useMemo(() => {
if (!fullTimeline) {
return fullTimeline;
}
return fullTimeline.filter(
(t) =>
t.timestamp >= review.start_time &&
(review.end_time == undefined || t.timestamp <= review.end_time),
);
}, [fullTimeline, review]);
return fullTimeline
.filter(
(t) =>
t.timestamp >= review.start_time &&
(review.end_time == undefined || t.timestamp <= review.end_time),
)
.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)) {
return <ActivityIndicator className="ml-2 size-3" />;
@ -811,7 +823,7 @@ function ObjectTimeline({
if (!timeline || timeline.length === 0) {
return (
<div className="py-2 text-sm text-muted-foreground">
<div className="ml-8 text-sm text-muted-foreground">
{t("detail.noObjectDetailData")}
</div>
);

View File

@ -55,20 +55,24 @@ export default function EventMenu({
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent>
<DropdownMenuItem onSelect={handleObjectSelect}>
<DropdownMenuItem
className="cursor-pointer"
onSelect={handleObjectSelect}
>
{isSelected
? t("itemMenu.hideObjectDetails.label")
: t("itemMenu.showObjectDetails.label")}
</DropdownMenuItem>
<DropdownMenuSeparator className="my-0.5" />
<DropdownMenuItem
className="cursor-pointer"
onSelect={() => {
navigate(`/explore?event_id=${event.id}`);
}}
>
{t("details.item.button.viewInExplore")}
</DropdownMenuItem>
<DropdownMenuItem asChild>
<DropdownMenuItem className="cursor-pointer" asChild>
<a
download
href={
@ -86,6 +90,7 @@ export default function EventMenu({
event.data.type == "object" &&
config?.plus?.enabled && (
<DropdownMenuItem
className="cursor-pointer"
onSelect={() => {
setIsOpen(false);
onOpenUpload?.(event);
@ -97,6 +102,7 @@ export default function EventMenu({
{event.has_snapshot && config?.semantic_search?.enabled && (
<DropdownMenuItem
className="cursor-pointer"
onSelect={() => {
if (onOpenSimilarity) onOpenSimilarity(event);
else

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 { useTranslation } from "react-i18next";
import TriggerView from "@/views/settings/TriggerView";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
import {
Sidebar,
SidebarContent,
@ -650,7 +650,7 @@ function CameraSelectButton({
key={item.name}
isChecked={item.name === selectedCamera}
label={item.name}
isCameraName={true}
type={"camera"}
onCheckedChange={(isChecked) => {
if (isChecked && (isEnabled || isCameraSettingsPage)) {
setSelectedCamera(item.name);

View File

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

View File

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

View File

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

View File

@ -1,25 +1,7 @@
import { TrackingDetailsSequence } from "@/types/timeline";
import { t } from "i18next";
import { getTranslatedLabel } from "./i18n";
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],
});
}
import { capitalizeFirstLetter, formatList } from "./stringUtil";
export function getLifecycleItemDescription(
lifecycleItem: TrackingDetailsSequence,
@ -42,7 +24,9 @@ export function getLifecycleItemDescription(
return t("trackingDetails.lifecycleItemDesc.entered_zone", {
ns: "views/explore",
label,
zones: formatZonesList(lifecycleItem.data.zones),
zones: formatList(
lifecycleItem.data.zones_friendly_names ?? lifecycleItem.data.zones,
),
});
case "active":
return t("trackingDetails.lifecycleItemDesc.active", {

View File

@ -1,3 +1,5 @@
import { t } from "i18next";
export const capitalizeFirstLetter = (text: string): string => {
return text.charAt(0).toUpperCase() + text.slice(1);
};
@ -19,20 +21,30 @@ export const capitalizeAll = (text: string): string => {
* @returns A valid camera identifier (lowercase, alphanumeric, max 8 chars)
*/
export function generateFixedHash(name: string, prefix: string = "id"): string {
// Safely encode Unicode as UTF-8 bytes
// Use the full UTF-8 bytes of the name and compute an FNV-1a 32-bit hash.
// This is deterministic, fast, works with Unicode and avoids collisions from
// simple truncation of base64 output.
const utf8Bytes = new TextEncoder().encode(name);
// Convert to base64 manually
let binary = "";
for (const byte of utf8Bytes) {
binary += String.fromCharCode(byte);
// FNV-1a 32-bit hash algorithm
let hash = 0x811c9dc5; // FNV offset basis
for (let i = 0; i < utf8Bytes.length; i++) {
hash ^= utf8Bytes[i];
// Multiply by FNV prime (0x01000193) with 32-bit overflow
hash = (hash >>> 0) * 0x01000193;
// Ensure 32-bit unsigned integer
hash >>>= 0;
}
const base64 = btoa(binary);
// Strip out non-alphanumeric characters and truncate
const cleanHash = base64.replace(/[^a-zA-Z0-9]/g, "").substring(0, 8);
// Convert to an 8-character lowercase hex string
const hashHex = (hash >>> 0).toString(16).padStart(8, "0").toLowerCase();
return `${prefix}_${cleanHash.toLowerCase()}`;
// Ensure the first character is a letter to avoid an identifier that's purely
// 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}`;
}
/**
@ -45,3 +57,29 @@ export function generateFixedHash(name: string, prefix: string = "id"): string {
export function isValidId(name: string): boolean {
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

@ -118,6 +118,11 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
const [trainFilter, setTrainFilter] = useApiFilter<TrainFilter>();
const refreshAll = useCallback(() => {
refreshTrain();
refreshDataset();
}, [refreshTrain, refreshDataset]);
// image multiselect
const [selectedImages, setSelectedImages] = useState<string[]>([]);
@ -183,11 +188,12 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
);
const onDelete = useCallback(
(ids: string[], isName: boolean = false) => {
(ids: string[], isName: boolean = false, category?: string) => {
const targetCategory = category || pageToggle;
const api =
pageToggle == "train"
targetCategory == "train"
? `/classification/${model.name}/train/delete`
: `/classification/${model.name}/dataset/${pageToggle}/delete`;
: `/classification/${model.name}/dataset/${targetCategory}/delete`;
axios
.post(api, { ids })
@ -408,7 +414,7 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
trainImages={trainImages || []}
trainFilter={trainFilter}
selectedImages={selectedImages}
onRefresh={refreshTrain}
onRefresh={refreshAll}
onClickImages={onClickImages}
onDelete={onDelete}
/>
@ -432,7 +438,7 @@ type LibrarySelectorProps = {
dataset: { [id: string]: string[] };
trainImages: string[];
setPageToggle: (toggle: string) => void;
onDelete: (ids: string[], isName: boolean) => void;
onDelete: (ids: string[], isName: boolean, category?: string) => void;
onRename: (old_name: string, new_name: string) => void;
};
function LibrarySelector({
@ -448,7 +454,7 @@ function LibrarySelector({
// data
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [renameClass, setRenameFace] = useState<string | null>(null);
const [renameClass, setRenameClass] = useState<string | null>(null);
const pageTitle = useMemo(() => {
if (pageToggle != "train") {
return pageToggle;
@ -463,12 +469,12 @@ function LibrarySelector({
// interaction
const handleDeleteFace = useCallback(
const handleDeleteCategory = useCallback(
(name: string) => {
// Get all image IDs for this face
// Get all image IDs for this category
const imageIds = dataset?.[name] || [];
onDelete(imageIds, true);
onDelete(imageIds, true, name);
setPageToggle("train");
},
[dataset, onDelete, setPageToggle],
@ -476,7 +482,7 @@ function LibrarySelector({
const handleSetOpen = useCallback(
(open: boolean) => {
setRenameFace(open ? renameClass : null);
setRenameClass(open ? renameClass : null);
},
[renameClass],
);
@ -503,7 +509,7 @@ function LibrarySelector({
className="text-white"
onClick={() => {
if (confirmDelete) {
handleDeleteFace(confirmDelete);
handleDeleteCategory(confirmDelete);
setConfirmDelete(null);
}
}}
@ -521,7 +527,7 @@ function LibrarySelector({
description={t("renameCategory.desc", { name: renameClass })}
onSave={(newName) => {
onRename(renameClass!, newName);
setRenameFace(null);
setRenameClass(null);
}}
defaultValue={renameClass || ""}
regexPattern={/^[\p{L}\p{N}\s'_-]{1,50}$/u}
@ -588,7 +594,7 @@ function LibrarySelector({
className="size-7 lg:opacity-0 lg:transition-opacity lg:group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
setRenameFace(id);
setRenameClass(id);
}}
>
<LuPencil className="size-4 text-primary" />

View File

@ -236,10 +236,6 @@ function ExploreThumbnailImage({
onSelectSearch(event, false, "tracking_details");
};
const handleShowSnapshot = () => {
onSelectSearch(event, false, "snapshot");
};
const handleAddTrigger = () => {
navigate(
`/settings?page=triggers&camera=${event.camera}&event_id=${event.id}`,
@ -252,7 +248,6 @@ function ExploreThumbnailImage({
findSimilar={handleFindSimilar}
refreshResults={mutate}
showTrackingDetails={handleShowTrackingDetails}
showSnapshot={handleShowSnapshot}
addTrigger={handleAddTrigger}
isContextMenu={true}
>

View File

@ -63,7 +63,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
import { DetailStreamProvider } from "@/context/detail-stream-context";
import { GenAISummaryDialog } from "@/components/overlay/chip/GenAISummaryChip";
@ -985,7 +985,7 @@ function Timeline({
),
)}
>
{isMobile && (
{isMobile && timelineType == "timeline" && (
<GenAISummaryDialog review={activeReviewItem} onOpen={onAnalysisOpen} />
)}

View File

@ -688,9 +688,6 @@ export default function SearchView({
showTrackingDetails={() =>
onSelectSearch(value, false, "tracking_details")
}
showSnapshot={() =>
onSelectSearch(value, false, "snapshot")
}
addTrigger={() => {
if (
config?.semantic_search.enabled &&

View File

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

View File

@ -18,7 +18,7 @@ import {
} from "@/components/ui/select";
import { IoMdArrowRoundBack } from "react-icons/io";
import { isDesktop } from "react-device-detect";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
import { Switch } from "@/components/ui/switch";
import { Trans } from "react-i18next";
import { Separator } from "@/components/ui/separator";

View File

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

View File

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

View File

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

View File

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

View File

@ -403,7 +403,8 @@ export default function TriggerView({
setShowCreate(true);
setSelectedTrigger({
enabled: true,
name: "",
name: eventId,
friendly_name: "",
type: "thumbnail",
data: eventId,
threshold: 0.5,

View File

@ -13,7 +13,7 @@ import {
} from "@/components/ui/tooltip";
import useSWR from "swr";
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";
type CameraMetricsProps = {