mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-03 06:50:58 +00:00
Compare commits
21 Commits
8f858fc412
...
2a2fed8bfd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a2fed8bfd | ||
|
|
543632d591 | ||
|
|
be51408862 | ||
|
|
0c535f2a1e | ||
|
|
c5722213b2 | ||
|
|
54c0b62ca0 | ||
|
|
985f8cf680 | ||
|
|
d1acd4803f | ||
|
|
c6bb3e4ff2 | ||
|
|
4ad1a003b3 | ||
|
|
98586e01f6 | ||
|
|
29f51f3cb8 | ||
|
|
f693b373ef | ||
|
|
350e5b11c4 | ||
|
|
60f5da90fd | ||
|
|
ebba7d1422 | ||
|
|
d6d87a6eeb | ||
|
|
f8d5ba006e | ||
|
|
a335e224be | ||
|
|
9f5f3eb2b1 | ||
|
|
9b4507aa61 |
@ -12,7 +12,7 @@
|
||||
|
||||
A complete and local NVR designed for [Home Assistant](https://www.home-assistant.io) with AI object detection. Uses OpenCV and Tensorflow to perform realtime object detection locally for IP cameras.
|
||||
|
||||
Use of a GPU or AI accelerator is highly recommended. AI accelerators will outperform even the best CPUs with very little overhead. See Frigate's supported [object detectors](https://docs.frigate.video/configuration/object_detectors/).
|
||||
Use of a GPU or AI accelerator such as a [Google Coral](https://coral.ai/products/) or [Hailo](https://hailo.ai/) is highly recommended. AI accelerators will outperform even the best CPUs with very little overhead.
|
||||
|
||||
- Tight integration with Home Assistant via a [custom component](https://github.com/blakeblackshear/frigate-hass-integration)
|
||||
- Designed to minimize resource use and maximize performance by only looking for objects when and where it is necessary
|
||||
|
||||
@ -3,18 +3,18 @@ id: license_plate_recognition
|
||||
title: License Plate Recognition (LPR)
|
||||
---
|
||||
|
||||
Frigate can recognize license plates on vehicles and automatically add the detected characters to the `recognized_license_plate` field or a [known](#matching) name as a `sub_label` to tracked objects of type `car` or `motorcycle`. A common use case may be to read the license plates of cars pulling into a driveway or cars passing by on a street.
|
||||
Frigate can recognize license plates on vehicles and automatically add the detected characters to the `recognized_license_plate` field or a known name as a `sub_label` to tracked objects of type `car` or `motorcycle`. A common use case may be to read the license plates of cars pulling into a driveway or cars passing by on a street.
|
||||
|
||||
LPR works best when the license plate is clearly visible to the camera. For moving vehicles, Frigate continuously refines the recognition process, keeping the most confident result. When a vehicle becomes stationary, LPR continues to run for a short time after to attempt recognition.
|
||||
|
||||
When a plate is recognized, the details are:
|
||||
|
||||
- Added as a `sub_label` (if [known](#matching)) or the `recognized_license_plate` field (if unknown) to a tracked object.
|
||||
- Viewable in the Details pane in Review/History.
|
||||
- Added as a `sub_label` (if known) or the `recognized_license_plate` field (if unknown) to a tracked object.
|
||||
- Viewable in the Review Item Details pane in Review (sub labels).
|
||||
- Viewable in the Tracked Object Details pane in Explore (sub labels and recognized license plates).
|
||||
- Filterable through the More Filters menu in Explore.
|
||||
- Published via the `frigate/events` MQTT topic as a `sub_label` ([known](#matching)) or `recognized_license_plate` (unknown) for the `car` or `motorcycle` tracked object.
|
||||
- Published via the `frigate/tracked_object_update` MQTT topic with `name` (if [known](#matching)) and `plate`.
|
||||
- Published via the `frigate/events` MQTT topic as a `sub_label` (known) or `recognized_license_plate` (unknown) for the `car` or `motorcycle` tracked object.
|
||||
- Published via the `frigate/tracked_object_update` MQTT topic with `name` (if known) and `plate`.
|
||||
|
||||
## Model Requirements
|
||||
|
||||
@ -31,7 +31,6 @@ In the default mode, Frigate's LPR needs to first detect a `car` or `motorcycle`
|
||||
## Minimum System Requirements
|
||||
|
||||
License plate recognition works by running AI models locally on your system. The YOLOv9 plate detector model and the OCR models ([PaddleOCR](https://github.com/PaddlePaddle/PaddleOCR)) are relatively lightweight and can run on your CPU or GPU, depending on your configuration. At least 4GB of RAM is required.
|
||||
|
||||
## Configuration
|
||||
|
||||
License plate recognition is disabled by default. Enable it in your config file:
|
||||
@ -74,8 +73,8 @@ Fine-tune the LPR feature using these optional parameters at the global level of
|
||||
- Default: `small`
|
||||
- This can be `small` or `large`.
|
||||
- The `small` model is fast and identifies groups of Latin and Chinese characters.
|
||||
- The `large` model identifies Latin characters only, and uses an enhanced text detector to find characters on multi-line plates. It is significantly slower than the `small` model.
|
||||
- If your country or region does not use multi-line plates, you should use the `small` model as performance is much better for single-line plates.
|
||||
- The `large` model identifies Latin characters only, but uses an enhanced text detector and is more capable at finding characters on multi-line plates. It is significantly slower than the `small` model. Note that using the `large` model does not improve _text recognition_, but it may improve _text detection_.
|
||||
- For most users, the `small` model is recommended.
|
||||
|
||||
### Recognition
|
||||
|
||||
@ -178,7 +177,7 @@ lpr:
|
||||
|
||||
:::note
|
||||
|
||||
If a camera is configured to detect `car` or `motorcycle` but you don't want Frigate to run LPR for that camera, disable LPR at the camera level:
|
||||
If you want to detect cars on cameras but don't want to use resources to run LPR on those cars, you should disable LPR for those specific cameras.
|
||||
|
||||
```yaml
|
||||
cameras:
|
||||
@ -306,7 +305,7 @@ With this setup:
|
||||
- Review items will always be classified as a `detection`.
|
||||
- Snapshots will always be saved.
|
||||
- Zones and object masks are **not** used.
|
||||
- The `frigate/events` MQTT topic will **not** publish tracked object updates with the license plate bounding box and score, though `frigate/reviews` will publish if recordings are enabled. If a plate is recognized as a [known](#matching) plate, publishing will occur with an updated `sub_label` field. If characters are recognized, publishing will occur with an updated `recognized_license_plate` field.
|
||||
- The `frigate/events` MQTT topic will **not** publish tracked object updates with the license plate bounding box and score, though `frigate/reviews` will publish if recordings are enabled. If a plate is recognized as a known plate, publishing will occur with an updated `sub_label` field. If characters are recognized, publishing will occur with an updated `recognized_license_plate` field.
|
||||
- License plate snapshots are saved at the highest-scoring moment and appear in Explore.
|
||||
- Debug view will not show `license_plate` bounding boxes.
|
||||
|
||||
|
||||
@ -141,7 +141,7 @@ Triggers are best configured through the Frigate UI.
|
||||
Check the `Add Attribute` box to add the trigger's internal ID (e.g., "red_car_alert") to a data attribute on the tracked object that can be processed via the API or MQTT.
|
||||
5. Save the trigger to update the configuration and store the embedding in the database.
|
||||
|
||||
When a trigger fires, the UI highlights the trigger with a blue dot for 3 seconds for easy identification. Additionally, the UI will show the last date/time and tracked object ID that activated your trigger. The last triggered timestamp is not saved to the database or persisted through restarts of Frigate.
|
||||
When a trigger fires, the UI highlights the trigger with a blue dot for 3 seconds for easy identification.
|
||||
|
||||
### Usage and Best Practices
|
||||
|
||||
|
||||
@ -56,7 +56,7 @@ services:
|
||||
volumes:
|
||||
- /path/to/your/config:/config
|
||||
- /path/to/your/storage:/media/frigate
|
||||
- type: tmpfs # Recommended: 1GB of memory
|
||||
- type: tmpfs # Optional: 1GB of memory, reduces SSD/SD Card wear
|
||||
target: /tmp/cache
|
||||
tmpfs:
|
||||
size: 1000000000
|
||||
@ -310,7 +310,7 @@ services:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- /path/to/your/config:/config
|
||||
- /path/to/your/storage:/media/frigate
|
||||
- type: tmpfs # Recommended: 1GB of memory
|
||||
- type: tmpfs # Optional: 1GB of memory, reduces SSD/SD Card wear
|
||||
target: /tmp/cache
|
||||
tmpfs:
|
||||
size: 1000000000
|
||||
|
||||
@ -179,36 +179,6 @@ def config(request: Request):
|
||||
return JSONResponse(content=config)
|
||||
|
||||
|
||||
@router.get("/config/raw_paths", dependencies=[Depends(require_role(["admin"]))])
|
||||
def config_raw_paths(request: Request):
|
||||
"""Admin-only endpoint that returns camera paths and go2rtc streams without credential masking."""
|
||||
config_obj: FrigateConfig = request.app.frigate_config
|
||||
|
||||
raw_paths = {"cameras": {}, "go2rtc": {"streams": {}}}
|
||||
|
||||
# Extract raw camera ffmpeg input paths
|
||||
for camera_name, camera in config_obj.cameras.items():
|
||||
raw_paths["cameras"][camera_name] = {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{"path": input.path, "roles": input.roles}
|
||||
for input in camera.ffmpeg.inputs
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
# Extract raw go2rtc stream URLs
|
||||
go2rtc_config = config_obj.go2rtc.model_dump(
|
||||
mode="json", warnings="none", exclude_none=True
|
||||
)
|
||||
for stream_name, stream in go2rtc_config.get("streams", {}).items():
|
||||
if stream is None:
|
||||
continue
|
||||
raw_paths["go2rtc"]["streams"][stream_name] = stream
|
||||
|
||||
return JSONResponse(content=raw_paths)
|
||||
|
||||
|
||||
@router.get("/config/raw")
|
||||
def config_raw():
|
||||
config_file = find_config_file()
|
||||
|
||||
@ -1781,8 +1781,9 @@ def create_trigger_embedding(
|
||||
logger.debug(
|
||||
f"Writing thumbnail for trigger with data {body.data} in {camera_name}."
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
except Exception as e:
|
||||
logger.error(e.with_traceback())
|
||||
logger.error(
|
||||
f"Failed to write thumbnail for trigger with data {body.data} in {camera_name}"
|
||||
)
|
||||
|
||||
@ -1806,8 +1807,8 @@ def create_trigger_embedding(
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
except Exception:
|
||||
logger.exception("Error creating trigger embedding")
|
||||
except Exception as e:
|
||||
logger.error(e.with_traceback())
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
@ -1916,8 +1917,9 @@ def update_trigger_embedding(
|
||||
logger.debug(
|
||||
f"Deleted thumbnail for trigger with data {trigger.data} in {camera_name}."
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
except Exception as e:
|
||||
logger.error(e.with_traceback())
|
||||
logger.error(
|
||||
f"Failed to delete thumbnail for trigger with data {trigger.data} in {camera_name}"
|
||||
)
|
||||
|
||||
@ -1956,8 +1958,9 @@ def update_trigger_embedding(
|
||||
logger.debug(
|
||||
f"Writing thumbnail for trigger with data {body.data} in {camera_name}."
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
except Exception as e:
|
||||
logger.error(e.with_traceback())
|
||||
logger.error(
|
||||
f"Failed to write thumbnail for trigger with data {body.data} in {camera_name}"
|
||||
)
|
||||
|
||||
@ -1969,8 +1972,8 @@ def update_trigger_embedding(
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
except Exception:
|
||||
logger.exception("Error updating trigger embedding")
|
||||
except Exception as e:
|
||||
logger.error(e.with_traceback())
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
@ -2030,8 +2033,9 @@ def delete_trigger_embedding(
|
||||
logger.debug(
|
||||
f"Deleted thumbnail for trigger with data {trigger.data} in {camera_name}."
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
except Exception as e:
|
||||
logger.error(e.with_traceback())
|
||||
logger.error(
|
||||
f"Failed to delete thumbnail for trigger with data {trigger.data} in {camera_name}"
|
||||
)
|
||||
|
||||
@ -2043,8 +2047,8 @@ def delete_trigger_embedding(
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
except Exception:
|
||||
logger.exception("Error deleting trigger embedding")
|
||||
except Exception as e:
|
||||
logger.error(e.with_traceback())
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
|
||||
@ -762,15 +762,6 @@ async def recording_clip(
|
||||
.order_by(Recordings.start_time.asc())
|
||||
)
|
||||
|
||||
if recordings.count() == 0:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "No recordings found for the specified time range",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
file_name = sanitize_filename(f"playlist_{camera_name}_{start_ts}-{end_ts}.txt")
|
||||
file_path = os.path.join(CACHE_DIR, file_name)
|
||||
with open(file_path, "w") as file:
|
||||
|
||||
@ -136,7 +136,6 @@ class CameraMaintainer(threading.Thread):
|
||||
self.ptz_metrics[name],
|
||||
self.region_grids[name],
|
||||
self.stop_event,
|
||||
self.config.logger,
|
||||
)
|
||||
self.camera_processes[config.name] = camera_process
|
||||
camera_process.start()
|
||||
@ -157,11 +156,7 @@ class CameraMaintainer(threading.Thread):
|
||||
self.frame_manager.create(f"{config.name}_frame{i}", frame_size)
|
||||
|
||||
capture_process = CameraCapture(
|
||||
config,
|
||||
count,
|
||||
self.camera_metrics[name],
|
||||
self.stop_event,
|
||||
self.config.logger,
|
||||
config, count, self.camera_metrics[name], self.stop_event
|
||||
)
|
||||
capture_process.daemon = True
|
||||
self.capture_processes[name] = capture_process
|
||||
|
||||
@ -132,15 +132,17 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
|
||||
if image_source == ImageSourceEnum.recordings:
|
||||
duration = final_data["end_time"] - final_data["start_time"]
|
||||
buffer_extension = min(5, duration * RECORDING_BUFFER_EXTENSION_PERCENT)
|
||||
buffer_extension = min(
|
||||
10, max(2, duration * RECORDING_BUFFER_EXTENSION_PERCENT)
|
||||
)
|
||||
|
||||
# Ensure minimum total duration for short review items
|
||||
# This provides better context for brief events
|
||||
total_duration = duration + (2 * buffer_extension)
|
||||
if total_duration < MIN_RECORDING_DURATION:
|
||||
# Expand buffer to reach minimum duration, still respecting max of 5s per side
|
||||
# Expand buffer to reach minimum duration, still respecting max of 10s per side
|
||||
additional_buffer_per_side = (MIN_RECORDING_DURATION - duration) / 2
|
||||
buffer_extension = min(5, additional_buffer_per_side)
|
||||
buffer_extension = min(10, additional_buffer_per_side)
|
||||
|
||||
thumbs = self.get_recording_frames(
|
||||
camera,
|
||||
|
||||
@ -424,7 +424,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
|
||||
if not res:
|
||||
return {
|
||||
"message": "Model is still training, please try again in a few moments.",
|
||||
"message": "No face was recognized.",
|
||||
"success": False,
|
||||
}
|
||||
|
||||
|
||||
@ -113,7 +113,6 @@ class StorageMaintainer(threading.Thread):
|
||||
recordings: Recordings = (
|
||||
Recordings.select(
|
||||
Recordings.id,
|
||||
Recordings.camera,
|
||||
Recordings.start_time,
|
||||
Recordings.end_time,
|
||||
Recordings.segment_size,
|
||||
@ -138,7 +137,7 @@ class StorageMaintainer(threading.Thread):
|
||||
)
|
||||
|
||||
event_start = 0
|
||||
deleted_recordings = []
|
||||
deleted_recordings = set()
|
||||
for recording in recordings:
|
||||
# check if 1 hour of storage has been reclaimed
|
||||
if deleted_segments_size > hourly_bandwidth:
|
||||
@ -173,7 +172,7 @@ class StorageMaintainer(threading.Thread):
|
||||
if not keep:
|
||||
try:
|
||||
clear_and_unlink(Path(recording.path), missing_ok=False)
|
||||
deleted_recordings.append(recording)
|
||||
deleted_recordings.add(recording.id)
|
||||
deleted_segments_size += recording.segment_size
|
||||
except FileNotFoundError:
|
||||
# this file was not found so we must assume no space was cleaned up
|
||||
@ -187,9 +186,6 @@ class StorageMaintainer(threading.Thread):
|
||||
recordings = (
|
||||
Recordings.select(
|
||||
Recordings.id,
|
||||
Recordings.camera,
|
||||
Recordings.start_time,
|
||||
Recordings.end_time,
|
||||
Recordings.path,
|
||||
Recordings.segment_size,
|
||||
)
|
||||
@ -205,7 +201,7 @@ class StorageMaintainer(threading.Thread):
|
||||
try:
|
||||
clear_and_unlink(Path(recording.path), missing_ok=False)
|
||||
deleted_segments_size += recording.segment_size
|
||||
deleted_recordings.append(recording)
|
||||
deleted_recordings.add(recording.id)
|
||||
except FileNotFoundError:
|
||||
# this file was not found so we must assume no space was cleaned up
|
||||
pass
|
||||
@ -215,50 +211,7 @@ class StorageMaintainer(threading.Thread):
|
||||
logger.debug(f"Expiring {len(deleted_recordings)} recordings")
|
||||
# delete up to 100,000 at a time
|
||||
max_deletes = 100000
|
||||
|
||||
# Update has_clip for events that overlap with deleted recordings
|
||||
if deleted_recordings:
|
||||
# Group deleted recordings by camera
|
||||
camera_recordings = {}
|
||||
for recording in deleted_recordings:
|
||||
if recording.camera not in camera_recordings:
|
||||
camera_recordings[recording.camera] = {
|
||||
"min_start": recording.start_time,
|
||||
"max_end": recording.end_time,
|
||||
}
|
||||
else:
|
||||
camera_recordings[recording.camera]["min_start"] = min(
|
||||
camera_recordings[recording.camera]["min_start"],
|
||||
recording.start_time,
|
||||
)
|
||||
camera_recordings[recording.camera]["max_end"] = max(
|
||||
camera_recordings[recording.camera]["max_end"],
|
||||
recording.end_time,
|
||||
)
|
||||
|
||||
# Find all events that overlap with deleted recordings time range per camera
|
||||
events_to_update = []
|
||||
for camera, time_range in camera_recordings.items():
|
||||
overlapping_events = Event.select(Event.id).where(
|
||||
Event.camera == camera,
|
||||
Event.has_clip == True,
|
||||
Event.start_time < time_range["max_end"],
|
||||
Event.end_time > time_range["min_start"],
|
||||
)
|
||||
|
||||
for event in overlapping_events:
|
||||
events_to_update.append(event.id)
|
||||
|
||||
# Update has_clip to False for overlapping events
|
||||
if events_to_update:
|
||||
for i in range(0, len(events_to_update), max_deletes):
|
||||
batch = events_to_update[i : i + max_deletes]
|
||||
Event.update(has_clip=False).where(Event.id << batch).execute()
|
||||
logger.debug(
|
||||
f"Updated has_clip to False for {len(events_to_update)} events"
|
||||
)
|
||||
|
||||
deleted_recordings_list = [r.id for r in deleted_recordings]
|
||||
deleted_recordings_list = list(deleted_recordings)
|
||||
for i in range(0, len(deleted_recordings_list), max_deletes):
|
||||
Recordings.delete().where(
|
||||
Recordings.id << deleted_recordings_list[i : i + max_deletes]
|
||||
|
||||
@ -16,7 +16,7 @@ from frigate.comms.recordings_updater import (
|
||||
RecordingsDataSubscriber,
|
||||
RecordingsDataTypeEnum,
|
||||
)
|
||||
from frigate.config import CameraConfig, DetectConfig, LoggerConfig, ModelConfig
|
||||
from frigate.config import CameraConfig, DetectConfig, ModelConfig
|
||||
from frigate.config.camera.camera import CameraTypeEnum
|
||||
from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
@ -539,7 +539,6 @@ class CameraCapture(FrigateProcess):
|
||||
shm_frame_count: int,
|
||||
camera_metrics: CameraMetrics,
|
||||
stop_event: MpEvent,
|
||||
log_config: LoggerConfig | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
stop_event,
|
||||
@ -550,10 +549,9 @@ class CameraCapture(FrigateProcess):
|
||||
self.config = config
|
||||
self.shm_frame_count = shm_frame_count
|
||||
self.camera_metrics = camera_metrics
|
||||
self.log_config = log_config
|
||||
|
||||
def run(self) -> None:
|
||||
self.pre_run_setup(self.log_config)
|
||||
self.pre_run_setup()
|
||||
camera_watchdog = CameraWatchdog(
|
||||
self.config,
|
||||
self.shm_frame_count,
|
||||
@ -579,7 +577,6 @@ class CameraTracker(FrigateProcess):
|
||||
ptz_metrics: PTZMetrics,
|
||||
region_grid: list[list[dict[str, Any]]],
|
||||
stop_event: MpEvent,
|
||||
log_config: LoggerConfig | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
stop_event,
|
||||
@ -595,10 +592,9 @@ class CameraTracker(FrigateProcess):
|
||||
self.camera_metrics = camera_metrics
|
||||
self.ptz_metrics = ptz_metrics
|
||||
self.region_grid = region_grid
|
||||
self.log_config = log_config
|
||||
|
||||
def run(self) -> None:
|
||||
self.pre_run_setup(self.log_config)
|
||||
self.pre_run_setup()
|
||||
frame_queue = self.camera_metrics.frame_queue
|
||||
frame_shape = self.config.frame_shape
|
||||
|
||||
|
||||
@ -1,5 +1 @@
|
||||
{
|
||||
"form": {
|
||||
"user": "Потребителско име"
|
||||
}
|
||||
}
|
||||
{}
|
||||
|
||||
@ -8,8 +8,5 @@
|
||||
"lastHour_other": "Последните {{count}} часа"
|
||||
},
|
||||
"select": "Избери"
|
||||
},
|
||||
"restart": {
|
||||
"title": "Сигурен ли сте, че искате да рестартирате Frigate?"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1 @@
|
||||
{
|
||||
"iconPicker": {
|
||||
"selectIcon": "Изберете иконка"
|
||||
}
|
||||
}
|
||||
{}
|
||||
|
||||
@ -1,7 +1 @@
|
||||
{
|
||||
"button": {
|
||||
"downloadVideo": {
|
||||
"label": "Свали видео"
|
||||
}
|
||||
}
|
||||
}
|
||||
{}
|
||||
|
||||
@ -18,6 +18,5 @@
|
||||
"bicycle": "Велосипед",
|
||||
"skateboard": "Скейтборд",
|
||||
"door": "Врата",
|
||||
"blender": "Блендер",
|
||||
"person": "Човек"
|
||||
"blender": "Блендер"
|
||||
}
|
||||
|
||||
@ -1,3 +1 @@
|
||||
{
|
||||
"documentTitle": "Експорт - Frigate"
|
||||
}
|
||||
{}
|
||||
|
||||
@ -63,6 +63,5 @@
|
||||
},
|
||||
"cameraSettings": {
|
||||
"cameraEnabled": "Камерата е включена"
|
||||
},
|
||||
"documentTitle": "Наживо - Frigate"
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,27 +4,6 @@
|
||||
"deleteClassificationAttempts": "Odstranit Klasifikační obrazy",
|
||||
"renameCategory": "Přejmenovat třídu",
|
||||
"deleteCategory": "Smazat třídu",
|
||||
"deleteImages": "Smazat obraz",
|
||||
"trainModel": "Trénovací model",
|
||||
"addClassification": "Přidat klasifikaci",
|
||||
"deleteModels": "Smazat modely",
|
||||
"editModel": "Upravit model"
|
||||
},
|
||||
"details": {
|
||||
"scoreInfo": "Skóre představuje průměrnou jistotu klasifikace napříč všemi detekcemi tohoto objektu."
|
||||
},
|
||||
"tooltip": {
|
||||
"trainingInProgress": "Model se právě trénuje",
|
||||
"noNewImages": "Žádné nové obrázky k trénování. Nejdříve klasifikujte více obrázků v datasetu.",
|
||||
"noChanges": "Od posledního trénování nedošlo k žádným změnám v datasetu.",
|
||||
"modelNotReady": "Model není připraven k trénování"
|
||||
},
|
||||
"toast": {
|
||||
"success": {
|
||||
"deletedImage": "Smazané obrázky",
|
||||
"deletedModel_one": "Úspěšně odstraněn {{count}} model",
|
||||
"deletedModel_few": "Úspěšně odstraněny {{count}} modely",
|
||||
"deletedModel_other": "Úspěšně odstraněno {{count}} modelů"
|
||||
}
|
||||
"deleteImages": "Smazat obraz"
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,11 +36,5 @@
|
||||
"selected_one": "{{count}} vybráno",
|
||||
"selected_other": "{{count}} vybráno",
|
||||
"suspiciousActivity": "Podezřelá aktivita",
|
||||
"threateningActivity": "Ohrožující činnost",
|
||||
"zoomIn": "Přiblížit",
|
||||
"zoomOut": "Oddálit",
|
||||
"detail": {
|
||||
"label": "Detail",
|
||||
"noDataFound": "Žádná detailní data k prohlédnutí"
|
||||
}
|
||||
"threateningActivity": "Ohrožující činnost"
|
||||
}
|
||||
|
||||
@ -13,11 +13,5 @@
|
||||
"error": {
|
||||
"renameExportFailed": "Nepodařilo se přejmenovat export: {{errorMessage}}"
|
||||
}
|
||||
},
|
||||
"tooltip": {
|
||||
"shareExport": "Sdílet export",
|
||||
"downloadVideo": "Stáhnout video",
|
||||
"deleteExport": "Smazat export",
|
||||
"editName": "Upravit jméno"
|
||||
}
|
||||
}
|
||||
|
||||
@ -306,8 +306,7 @@
|
||||
"notifications": "Notifikace",
|
||||
"frigateplus": "Frigate+",
|
||||
"enrichments": "Obohacení",
|
||||
"triggers": "Spouštěče",
|
||||
"cameraManagement": "Správa"
|
||||
"triggers": "Spouštěče"
|
||||
},
|
||||
"dialog": {
|
||||
"unsavedChanges": {
|
||||
|
||||
@ -10,94 +10,5 @@
|
||||
"general": "Generelle statistikker - Frigate",
|
||||
"enrichments": "Beredningsstatistikker - Frigate"
|
||||
},
|
||||
"title": "System",
|
||||
"logs": {
|
||||
"copy": {
|
||||
"label": "Kopier til udklipsholder",
|
||||
"success": "Logs er kopieret til udklipsholder",
|
||||
"error": "Kunne ikke kopiere logs til udklipsholder"
|
||||
},
|
||||
"type": {
|
||||
"label": "Type",
|
||||
"timestamp": "Tidsstempel",
|
||||
"message": "Besked",
|
||||
"tag": "Tag"
|
||||
},
|
||||
"tips": "Logs bliver streamet fra serveren",
|
||||
"toast": {
|
||||
"error": {
|
||||
"fetchingLogsFailed": "Fejl ved indhentning af logs: {{errorMessage}}",
|
||||
"whileStreamingLogs": "Fejl ved streaming af logs: {{errorMessage}}"
|
||||
}
|
||||
},
|
||||
"download": {
|
||||
"label": "Download logs"
|
||||
}
|
||||
},
|
||||
"general": {
|
||||
"title": "Generelt",
|
||||
"hardwareInfo": {
|
||||
"gpuUsage": "GPU forbrug",
|
||||
"gpuMemory": "GPU hukommelse",
|
||||
"gpuEncoder": "GPU indkoder",
|
||||
"gpuDecoder": "GPU afkoder",
|
||||
"title": "Hardware information",
|
||||
"gpuInfo": {
|
||||
"closeInfo": {
|
||||
"label": "Luk GPU information"
|
||||
},
|
||||
"copyInfo": {
|
||||
"label": "Kopier GPU information"
|
||||
},
|
||||
"toast": {
|
||||
"success": "Kopierede GPU information til udklipsholder"
|
||||
}
|
||||
},
|
||||
"npuUsage": "NPU forbrug",
|
||||
"npuMemory": "NPU hukommelse"
|
||||
},
|
||||
"detector": {
|
||||
"title": "Detektorer",
|
||||
"inferenceSpeed": "Detektorinferenshastighed",
|
||||
"temperature": "Detektor temperatur",
|
||||
"cpuUsage": "Detektor CPU forbrug",
|
||||
"cpuUsageInformation": "CPU brugt til at forberede input- og outputdata til/fra detektionsmodeller. Denne værdi måler ikke inferensforbrug, selvom der bruges en GPU eller accelerator.",
|
||||
"memoryUsage": "Detektorhummelsesforbrug"
|
||||
},
|
||||
"otherProcesses": {
|
||||
"title": "Andre processer",
|
||||
"processCpuUsage": "Proces CPU forbrug",
|
||||
"processMemoryUsage": "Proceshukommelsesforbrug"
|
||||
}
|
||||
},
|
||||
"metrics": "System metrikker",
|
||||
"storage": {
|
||||
"title": "Lagring",
|
||||
"overview": "Overblik",
|
||||
"recordings": {
|
||||
"title": "Optagelser",
|
||||
"tips": "Denne værdi repræsenterer den samlede lagerplads, der bruges af optagelserne i Frigates database. Frigate sporer ikke lagerpladsforbruget for alle filer på din disk.",
|
||||
"earliestRecording": "Tidligste optagelse til rådighed:"
|
||||
},
|
||||
"shm": {
|
||||
"title": "SHM (delt hukommelse) tildeling",
|
||||
"warning": "Den nuværende SHM størrelse af {{total}}MB er for lille. Øg den til minimum {{min_shm}}MB."
|
||||
},
|
||||
"cameraStorage": {
|
||||
"title": "Kamera lagring",
|
||||
"camera": "Kamera",
|
||||
"unusedStorageInformation": "Ubrugt lagringsinformation",
|
||||
"storageUsed": "Lagring",
|
||||
"percentageOfTotalUsed": "Procentandel af total",
|
||||
"bandwidth": "Båndbredde",
|
||||
"unused": {
|
||||
"title": "Ubrugt",
|
||||
"tips": "Denne værdi repræsenterer muligvis ikke nøjagtigt den ledige plads, der er tilgængelig for Frigate, hvis du har andre filer gemt på dit drev ud over Frigates optagelser. Frigate sporer ikke lagerforbrug ud over sine optagelser."
|
||||
}
|
||||
}
|
||||
},
|
||||
"cameras": {
|
||||
"title": "Kameraer",
|
||||
"overview": "Overblik"
|
||||
}
|
||||
"title": "System"
|
||||
}
|
||||
|
||||
@ -1,13 +1 @@
|
||||
{
|
||||
"documentTitle": "Klassifizierungsmodelle",
|
||||
"details": {
|
||||
"scoreInfo": "Die Punktzahl gibt die durchschnittliche Klassifizierungssicherheit aller Erkennungen dieses Objekts wieder."
|
||||
},
|
||||
"button": {
|
||||
"deleteClassificationAttempts": "Lösche Klassifizierungs-Bilder",
|
||||
"renameCategory": "Klasse umbenennen",
|
||||
"deleteCategory": "Klasse löschen",
|
||||
"deleteImages": "Bilder löschen",
|
||||
"trainModel": "Modell trainieren"
|
||||
}
|
||||
}
|
||||
{}
|
||||
|
||||
@ -196,22 +196,12 @@
|
||||
"addTrigger": {
|
||||
"aria": "Einen Trigger für dieses verfolgte Objekt hinzufügen",
|
||||
"label": "Trigger hinzufügen"
|
||||
},
|
||||
"viewTrackingDetails": {
|
||||
"label": "Details zum Verfolgen anzeigen",
|
||||
"aria": "Details zum Verfolgen anzeigen"
|
||||
},
|
||||
"showObjectDetails": {
|
||||
"label": "Objektpfad anzeigen"
|
||||
},
|
||||
"hideObjectDetails": {
|
||||
"label": "Objektpfad verbergen"
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
"confirmDelete": {
|
||||
"title": "Löschen bestätigen",
|
||||
"desc": "Beim Löschen dieses verfolgten Objekts werden der Schnappschuss, alle gespeicherten Einbettungen und alle zugehörigen Verfolgungsdetails entfernt. Aufgezeichnetes Filmmaterial dieses verfolgten Objekts in der Verlaufsansicht wird <em>NICHT</em> gelöscht. <br /><br />Sind Sie sicher, dass Sie fortfahren möchten?"
|
||||
"desc": "Beim Löschen dieses verfolgten Objekts werden der Schnappschuss, alle gespeicherten Einbettungen und alle zugehörigen Objektlebenszykluseinträge entfernt. Aufgezeichnetes Filmmaterial dieses verfolgten Objekts in der Verlaufsansicht wird <em>NICHT</em> gelöscht. <br /><br />Sind Sie sicher, dass Sie fortfahren möchten?"
|
||||
}
|
||||
},
|
||||
"searchResult": {
|
||||
@ -221,9 +211,7 @@
|
||||
"error": "Das verfolgte Objekt konnte nicht gelöscht werden: {{errorMessage}}"
|
||||
}
|
||||
},
|
||||
"tooltip": "Entspricht {{type}} bei {{confidence}}%",
|
||||
"previousTrackedObject": "Vorheriges verfolgtes Objekt",
|
||||
"nextTrackedObject": "Nächstes verfolgtes Objekt"
|
||||
"tooltip": "Entspricht {{type}} bei {{confidence}}%"
|
||||
},
|
||||
"noTrackedObjects": "Keine verfolgten Objekte gefunden",
|
||||
"fetchingTrackedObjectsFailed": "Fehler beim Abrufen von verfolgten Objekten: {{errorMessage}}",
|
||||
@ -239,44 +227,6 @@
|
||||
"trackingDetails": {
|
||||
"noImageFound": "Kein Bild mit diesem Zeitstempel gefunden.",
|
||||
"createObjectMask": "Objekt-Maske erstellen",
|
||||
"scrollViewTips": "Klicke, um die relevanten Momente aus dem Lebenszyklus dieses Objektes zu sehen.",
|
||||
"lifecycleItemDesc": {
|
||||
"visible": "{{label}} erkannt",
|
||||
"entered_zone": "{{label}} betrat {{zones}}",
|
||||
"active": "{{label}} wurde aktiv",
|
||||
"stationary": "{{label}} wurde stationär",
|
||||
"attribute": {
|
||||
"faceOrLicense_plate": "{{attribute}} erkannt für {{label}}",
|
||||
"other": "{{label}} erkannt als {{attribute}}"
|
||||
},
|
||||
"gone": "{{label}} verließ",
|
||||
"heard": "{{label}} wurde gehört",
|
||||
"external": "{{label}} erkannt",
|
||||
"header": {
|
||||
"zones": "Zonen",
|
||||
"ratio": "Verhältnis",
|
||||
"area": "Bereich"
|
||||
}
|
||||
},
|
||||
"annotationSettings": {
|
||||
"title": "Anmerkungseinstellungen",
|
||||
"showAllZones": {
|
||||
"title": "Zeige alle Zonen",
|
||||
"desc": "Immer Zonen auf Rahmen anzeigen, in die Objekte eingetreten sind."
|
||||
},
|
||||
"offset": {
|
||||
"label": "Anmerkungen Versatz",
|
||||
"desc": "Diese Daten stammen aus dem Erkennungsfeed der Kamera, werden jedoch über Bilder aus dem Aufzeichnungsfeed gelegt. Es ist unwahrscheinlich, dass beide Streams perfekt synchron sind. Daher stimmen der Begrenzungsrahmen und das Filmmaterial nicht vollständig überein. Mit dieser Einstellung lassen sich die Anmerkungen zeitlich nach vorne oder hinten verschieben, um sie besser an das aufgezeichnete Filmmaterial anzupassen.",
|
||||
"millisecondsToOffset": "Millisekunden, um Erkennungs-Anmerkungen zu verschieben. <em>Standard: 0</em>",
|
||||
"tips": "Verringere den Wert, wenn die Videowiedergabe den Boxen und Wegpunkten voraus ist, und erhöhe den Wert, wenn die Videowiedergabe hinter ihnen zurückbleibt. Dieser Wert kann negativ sein.",
|
||||
"toast": {
|
||||
"success": "Der Anmerkungs-Offset für {{camera}} wurde in der Konfigurationsdatei gespeichert. Starte Frigate neu, um Ihre Änderungen zu übernehmen."
|
||||
}
|
||||
}
|
||||
},
|
||||
"carousel": {
|
||||
"previous": "Vorherige Anzeige",
|
||||
"next": "Nächste Anzeige"
|
||||
}
|
||||
"scrollViewTips": "Klicke, um die relevanten Momente aus dem Lebenszyklus dieses Objektes zu sehen."
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"description": {
|
||||
"placeholder": "Gib einen Name für diese Kollektion ein",
|
||||
"addFace": "Füge der Gesichtsbibliothek eine neue Sammlung hinzu, indem ein erstes Bild hochgeladen wird.",
|
||||
"addFace": "Anleitung für das Hinzufügen einer neuen Kollektion zur Gesichtsbibliothek.",
|
||||
"invalidName": "Ungültiger Name. Namen dürfen nur Buchstaben, Zahlen, Leerzeichen, Apostrophe, Unterstriche und Bindestriche enthalten."
|
||||
},
|
||||
"details": {
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"documentTitle": "Klassifiseringsmodeller",
|
||||
"button": {
|
||||
"deleteClassificationAttempts": "Slett klassifiseringsbilder",
|
||||
"renameCategory": "Omdøp klasse",
|
||||
"renameCategory": "Gi nytt navn til klasse",
|
||||
"deleteCategory": "Slett klasse",
|
||||
"deleteImages": "Slett bilder",
|
||||
"trainModel": "Tren modell",
|
||||
@ -72,7 +72,7 @@
|
||||
"buttonText": "Opprett objektmodell"
|
||||
},
|
||||
"state": {
|
||||
"title": "Ingen tilstandsklassifiseringsmodeller",
|
||||
"title": "Ingen tilstandsklassifiseringsmodeller",
|
||||
"description": "Opprett en tilpasset modell for å overvåke og klassifisere tilstandsendringer i spesifikke kamerasoner.",
|
||||
"buttonText": "Opprett tilstandsmodell"
|
||||
}
|
||||
@ -102,7 +102,7 @@
|
||||
"classesTip": "Lær om kategorier",
|
||||
"classesStateDesc": "Definer de ulike tilstandene kamerasonen kan være i. For eksempel: 'åpen' og 'lukket' for en garasjeport.",
|
||||
"classesObjectDesc": "Definer kategoriene du vil klassifisere oppdagede objekter i. For eksempel: 'bud', 'beboer', 'fremmed' for personklassifisering.",
|
||||
"classPlaceholder": "Skriv inn tilstandsnavn...",
|
||||
"classPlaceholder": "Skriv inn kategorinavn...",
|
||||
"errors": {
|
||||
"nameRequired": "Modellnavn er påkrevd",
|
||||
"nameLength": "Modellnavn må være på 64 tegn eller mindre",
|
||||
|
||||
@ -1262,7 +1262,7 @@
|
||||
"desc": "Midlertidig aktiver/deaktiver inspeksjonsbeskrivelser med generativ KI for dette kameraet. Når deaktivert, vil det ikke bli bedt om KI-genererte beskrivelser for inspeksjonselementer på dette kameraet."
|
||||
},
|
||||
"review": {
|
||||
"title": "Inspiser",
|
||||
"title": "Inspeksjon",
|
||||
"desc": "Aktiver/deaktiver varsler og deteksjoner midlertidig for dette kameraet til Frigate startes på nytt. Når deaktivert, vil det ikke genereres nye inspeksjonselementer. ",
|
||||
"alerts": "Varsler ",
|
||||
"detections": "Deteksjoner "
|
||||
|
||||
@ -81,7 +81,7 @@
|
||||
"masksAndZones": "Edytor Masek i Stref - Frigate",
|
||||
"frigatePlus": "Ustawienia Frigate+ - Frigate",
|
||||
"classification": "Ustawienia Klasyfikacji - Frigate",
|
||||
"general": "Ustawienia Interfejsu - Frigate",
|
||||
"general": "Ustawienia Ogólne - Frigate",
|
||||
"authentication": "Ustawienia Uwierzytelniania - Frigate",
|
||||
"motionTuner": "Konfigurator Ruchu - Frigate",
|
||||
"object": "Debug - Frigate",
|
||||
|
||||
@ -51,7 +51,7 @@
|
||||
"h": "{{time}}h",
|
||||
"m": "{{time}}m",
|
||||
"s": "{{time}}s",
|
||||
"yr": "{{time}}l.",
|
||||
"yr": "le",
|
||||
"formattedTimestamp": {
|
||||
"12hour": "d MMM, h:mm:ss aaa",
|
||||
"24hour": "d MMM, HH:mm:ss"
|
||||
@ -84,10 +84,7 @@
|
||||
"formattedTimestampFilename": {
|
||||
"12hour": "dd-MM-yy-h-mm-ss-a",
|
||||
"24hour": "dd-MM-yy-HH-mm-ss"
|
||||
},
|
||||
"invalidStartTime": "Napačen čas začetka",
|
||||
"invalidEndTime": "Napačen čas konca",
|
||||
"inProgress": "V teku"
|
||||
}
|
||||
},
|
||||
"menu": {
|
||||
"live": {
|
||||
@ -155,13 +152,7 @@
|
||||
"bg": "Български (bulgarščina)",
|
||||
"withSystem": {
|
||||
"label": "Uporabi sistemske nastavitve za jezik"
|
||||
},
|
||||
"ptBR": "Português brasileiro (Brazilska portugalščina)",
|
||||
"ca": "Català (Katalonščina)",
|
||||
"lt": "Lietuvių (Litovščina)",
|
||||
"gl": "Galego (Galicijščina)",
|
||||
"id": "Bahasa Indonesia (Indonezijščina)",
|
||||
"ur": "اردو (Urdujščina)"
|
||||
}
|
||||
},
|
||||
"appearance": "Izgled",
|
||||
"darkMode": {
|
||||
@ -188,9 +179,7 @@
|
||||
"anonymous": "anonimen",
|
||||
"logout": "Odjava",
|
||||
"setPassword": "Nastavi Geslo"
|
||||
},
|
||||
"uiPlayground": "UI Peskovnik",
|
||||
"classification": "Klasifikacija"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"apply": "Uporabi",
|
||||
@ -227,8 +216,7 @@
|
||||
"unselect": "Odznači",
|
||||
"export": "Izvoz",
|
||||
"deleteNow": "Izbriši Zdaj",
|
||||
"next": "Naprej",
|
||||
"continue": "Nadaljuj"
|
||||
"next": "Naprej"
|
||||
},
|
||||
"unit": {
|
||||
"speed": {
|
||||
@ -238,23 +226,10 @@
|
||||
"length": {
|
||||
"feet": "čevelj",
|
||||
"meters": "metri"
|
||||
},
|
||||
"data": {
|
||||
"kbps": "kB/s",
|
||||
"mbps": "MB/s",
|
||||
"gbps": "GB/s",
|
||||
"kbph": "kB/uro",
|
||||
"mbph": "MB/uro",
|
||||
"gbph": "GB/uro"
|
||||
}
|
||||
},
|
||||
"label": {
|
||||
"back": "Pojdi nazaj",
|
||||
"hide": "Skrij {{item}}",
|
||||
"show": "Prikaži {{item}}",
|
||||
"ID": "ID",
|
||||
"none": "Brez",
|
||||
"all": "Vse"
|
||||
"back": "Pojdi nazaj"
|
||||
},
|
||||
"pagination": {
|
||||
"next": {
|
||||
@ -295,17 +270,5 @@
|
||||
"title": "404",
|
||||
"desc": "Stran ni najdena"
|
||||
},
|
||||
"readTheDocumentation": "Preberite dokumentacijo",
|
||||
"list": {
|
||||
"two": "{{0}} in {{1}}",
|
||||
"many": "{{items}}, in {{last}}",
|
||||
"separatorWithSpace": ", "
|
||||
},
|
||||
"field": {
|
||||
"optional": "Izbirno",
|
||||
"internalID": "Interni ID, ki ga Frigate uporablja v konfiguraciji in podatkovni bazi"
|
||||
},
|
||||
"information": {
|
||||
"pixels": "{{area}}px"
|
||||
}
|
||||
"readTheDocumentation": "Preberite dokumentacijo"
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"documentTitle": {
|
||||
"camera": "Kamerainställningar - Frigate",
|
||||
"default": "Inställningar - Frigate",
|
||||
"general": "Användargränssnitt Inställningar - Frigate",
|
||||
"general": "UI inställningar - Frigate",
|
||||
"authentication": "Autentiseringsinställningar - Frigate",
|
||||
"classification": "Klassificeringsinställningar - Frigate",
|
||||
"masksAndZones": "Maskerings- och zonverktyg - Frigate",
|
||||
|
||||
@ -44,16 +44,11 @@ self.addEventListener("notificationclick", (event) => {
|
||||
switch (event.action ?? "default") {
|
||||
case "markReviewed":
|
||||
if (event.notification.data) {
|
||||
event.waitUntil(
|
||||
fetch("/api/reviews/viewed", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-TOKEN": 1,
|
||||
},
|
||||
body: JSON.stringify({ ids: [event.notification.data.id] }),
|
||||
}), // eslint-disable-line comma-dangle
|
||||
);
|
||||
fetch("/api/reviews/viewed", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-CSRF-TOKEN": 1 },
|
||||
body: JSON.stringify({ ids: [event.notification.data.id] }),
|
||||
});
|
||||
}
|
||||
break;
|
||||
default:
|
||||
@ -63,7 +58,7 @@ self.addEventListener("notificationclick", (event) => {
|
||||
// eslint-disable-next-line no-undef
|
||||
if (clients.openWindow) {
|
||||
// eslint-disable-next-line no-undef
|
||||
event.waitUntil(clients.openWindow(url));
|
||||
return clients.openWindow(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -398,7 +398,11 @@ export function GroupedClassificationCard({
|
||||
threshold={threshold}
|
||||
selected={false}
|
||||
i18nLibrary={i18nLibrary}
|
||||
onClick={() => {}}
|
||||
onClick={(data, meta) => {
|
||||
if (meta || selectedItems.length > 0) {
|
||||
onClick(data);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children?.(data)}
|
||||
</ClassificationCard>
|
||||
|
||||
@ -4,7 +4,9 @@ import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { toast } from "sonner";
|
||||
import axios from "axios";
|
||||
import { LuCamera, LuDownload, LuTrash2 } from "react-icons/lu";
|
||||
import { FiMoreVertical } from "react-icons/fi";
|
||||
import { MdImageSearch } from "react-icons/md";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
ContextMenu,
|
||||
@ -29,8 +31,11 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
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;
|
||||
@ -93,6 +98,7 @@ export default function SearchResultActions({
|
||||
href={`${baseUrl}api/events/${searchResult.id}/clip.mp4`}
|
||||
download={`${searchResult.camera}_${searchResult.label}.mp4`}
|
||||
>
|
||||
<LuDownload className="mr-2 size-4" />
|
||||
<span>{t("itemMenu.downloadVideo.label")}</span>
|
||||
</a>
|
||||
</MenuItem>
|
||||
@ -104,6 +110,7 @@ export default function SearchResultActions({
|
||||
href={`${baseUrl}api/events/${searchResult.id}/snapshot.jpg`}
|
||||
download={`${searchResult.camera}_${searchResult.label}.jpg`}
|
||||
>
|
||||
<LuCamera className="mr-2 size-4" />
|
||||
<span>{t("itemMenu.downloadSnapshot.label")}</span>
|
||||
</a>
|
||||
</MenuItem>
|
||||
@ -113,31 +120,44 @@ export default function SearchResultActions({
|
||||
aria-label={t("itemMenu.viewTrackingDetails.aria")}
|
||||
onClick={showTrackingDetails}
|
||||
>
|
||||
<PiPath className="mr-2 size-4" />
|
||||
<span>{t("itemMenu.viewTrackingDetails.label")}</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
{config?.semantic_search?.enabled &&
|
||||
searchResult.data.type == "object" && (
|
||||
<MenuItem
|
||||
aria-label={t("itemMenu.findSimilar.aria")}
|
||||
onClick={findSimilar}
|
||||
>
|
||||
<span>{t("itemMenu.findSimilar.label")}</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
{config?.semantic_search?.enabled && isContextMenu && (
|
||||
<MenuItem
|
||||
aria-label={t("itemMenu.findSimilar.aria")}
|
||||
onClick={findSimilar}
|
||||
>
|
||||
<MdImageSearch className="mr-2 size-4" />
|
||||
<span>{t("itemMenu.findSimilar.label")}</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
{config?.semantic_search?.enabled &&
|
||||
searchResult.data.type == "object" && (
|
||||
<MenuItem
|
||||
aria-label={t("itemMenu.addTrigger.aria")}
|
||||
onClick={addTrigger}
|
||||
>
|
||||
<BsFillLightningFill className="mr-2 size-4" />
|
||||
<span>{t("itemMenu.addTrigger.label")}</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
{config?.semantic_search?.enabled &&
|
||||
searchResult.data.type == "object" && (
|
||||
<MenuItem
|
||||
aria-label={t("itemMenu.findSimilar.aria")}
|
||||
onClick={findSimilar}
|
||||
>
|
||||
<MdImageSearch className="mr-2 size-4" />
|
||||
<span>{t("itemMenu.findSimilar.label")}</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
aria-label={t("itemMenu.deleteTrackedObject.label")}
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
>
|
||||
<LuTrash2 className="mr-2 size-4" />
|
||||
<span>{t("button.delete", { ns: "common" })}</span>
|
||||
</MenuItem>
|
||||
</>
|
||||
|
||||
@ -46,13 +46,13 @@ export default function NavItem({
|
||||
onClick={onClick}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex flex-col items-center justify-center rounded-lg p-[6px]",
|
||||
"flex flex-col items-center justify-center rounded-lg",
|
||||
className,
|
||||
variants[item.variant ?? "primary"][isActive ? "active" : "inactive"],
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="size-5" />
|
||||
<Icon className="size-5 md:m-[6px]" />
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
|
||||
@ -13,8 +13,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import ActivityIndicator from "../indicators/activity-indicator";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import useSWR from "swr";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@ -36,7 +35,6 @@ import { LuCheck, LuX } from "react-icons/lu";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isDesktop, isMobile } from "react-device-detect";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import {
|
||||
MobilePage,
|
||||
MobilePageContent,
|
||||
@ -56,15 +54,9 @@ export default function CreateUserDialog({
|
||||
onCreate,
|
||||
onCancel,
|
||||
}: CreateUserOverlayProps) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
|
||||
const roles = useMemo(() => {
|
||||
const existingRoles = config ? Object.keys(config.auth?.roles || {}) : [];
|
||||
return Array.from(new Set(["admin", "viewer", ...(existingRoles || [])]));
|
||||
}, [config]);
|
||||
|
||||
const formSchema = z
|
||||
.object({
|
||||
user: z
|
||||
@ -77,7 +69,7 @@ export default function CreateUserDialog({
|
||||
confirmPassword: z
|
||||
.string()
|
||||
.min(1, t("users.dialog.createUser.confirmPassword")),
|
||||
role: z.string().min(1),
|
||||
role: z.enum(["admin", "viewer"]),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: t("users.dialog.form.password.notMatch"),
|
||||
@ -254,22 +246,24 @@ export default function CreateUserDialog({
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{roles.map((r) => (
|
||||
<SelectItem
|
||||
value={r}
|
||||
key={r}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{r === "admin" ? (
|
||||
<Shield className="h-4 w-4 text-primary" />
|
||||
) : (
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span>{t(`role.${r}`, { ns: "common" }) || r}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem
|
||||
value="admin"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4 text-primary" />
|
||||
<span>{t("role.admin", { ns: "common" })}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="viewer"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{t("role.viewer", { ns: "common" })}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription className="text-xs text-muted-foreground">
|
||||
|
||||
@ -12,7 +12,6 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
@ -21,6 +20,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { isDesktop, isMobile } from "react-device-detect";
|
||||
import { LuPlus, LuScanFace } from "react-icons/lu";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import React, { ReactNode, useMemo, useState } from "react";
|
||||
@ -89,26 +89,27 @@ export default function FaceSelectionDialog({
|
||||
<DropdownMenuLabel>{t("trainFaceAs")}</DropdownMenuLabel>
|
||||
<div
|
||||
className={cn(
|
||||
"flex max-h-[40dvh] flex-col overflow-y-auto overflow-x-hidden",
|
||||
"flex max-h-[40dvh] flex-col overflow-y-auto",
|
||||
isMobile && "gap-2 pb-4",
|
||||
)}
|
||||
>
|
||||
<SelectorItem
|
||||
className="flex cursor-pointer gap-2 smart-capitalize"
|
||||
onClick={() => setNewFace(true)}
|
||||
>
|
||||
<LuPlus />
|
||||
{t("createFaceLibrary.new")}
|
||||
</SelectorItem>
|
||||
{faceNames.sort().map((faceName) => (
|
||||
<SelectorItem
|
||||
key={faceName}
|
||||
className="flex cursor-pointer gap-2 smart-capitalize"
|
||||
onClick={() => onTrainAttempt(faceName)}
|
||||
>
|
||||
<LuScanFace />
|
||||
{faceName}
|
||||
</SelectorItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<SelectorItem
|
||||
className="flex cursor-pointer gap-2 smart-capitalize"
|
||||
onClick={() => setNewFace(true)}
|
||||
>
|
||||
{t("createFaceLibrary.new")}
|
||||
</SelectorItem>
|
||||
</div>
|
||||
</SelectorContent>
|
||||
</Selector>
|
||||
|
||||
@ -171,18 +171,6 @@ export default function ImagePicker({
|
||||
alt={selectedImage?.label || "Selected image"}
|
||||
className="size-16 rounded object-cover"
|
||||
onLoad={() => handleImageLoad(selectedImageId || "")}
|
||||
onError={(e) => {
|
||||
// If trigger thumbnail fails to load, fall back to event thumbnail
|
||||
if (!selectedImage) {
|
||||
const target = e.target as HTMLImageElement;
|
||||
if (
|
||||
target.src.includes("clips/triggers") &&
|
||||
selectedImageId
|
||||
) {
|
||||
target.src = `${apiHost}api/events/${selectedImageId}/thumbnail.webp`;
|
||||
}
|
||||
}
|
||||
}}
|
||||
loading="lazy"
|
||||
/>
|
||||
{selectedImageId && !loadedImages.has(selectedImageId) && (
|
||||
|
||||
@ -683,22 +683,6 @@ function ObjectDetailsTab({
|
||||
|
||||
const mutate = useGlobalMutation();
|
||||
|
||||
// Helper to map over SWR cached search results while preserving
|
||||
// either paginated format (SearchResult[][]) or flat format (SearchResult[])
|
||||
const mapSearchResults = useCallback(
|
||||
(
|
||||
currentData: SearchResult[][] | SearchResult[] | undefined,
|
||||
fn: (event: SearchResult) => SearchResult,
|
||||
) => {
|
||||
if (!currentData) return currentData;
|
||||
if (Array.isArray(currentData[0])) {
|
||||
return (currentData as SearchResult[][]).map((page) => page.map(fn));
|
||||
}
|
||||
return (currentData as SearchResult[]).map(fn);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// users
|
||||
|
||||
const isAdmin = useIsAdmin();
|
||||
@ -826,12 +810,17 @@ function ObjectDetailsTab({
|
||||
(key.includes("events") ||
|
||||
key.includes("events/search") ||
|
||||
key.includes("events/explore")),
|
||||
(currentData: SearchResult[][] | SearchResult[] | undefined) =>
|
||||
mapSearchResults(currentData, (event) =>
|
||||
event.id === search.id
|
||||
? { ...event, data: { ...event.data, description: desc } }
|
||||
: event,
|
||||
),
|
||||
(currentData: SearchResult[][] | SearchResult[] | undefined) => {
|
||||
if (!currentData) return currentData;
|
||||
// optimistic update
|
||||
return currentData
|
||||
.flat()
|
||||
.map((event) =>
|
||||
event.id === search.id
|
||||
? { ...event, data: { ...event.data, description: desc } }
|
||||
: event,
|
||||
);
|
||||
},
|
||||
{
|
||||
optimisticData: true,
|
||||
rollbackOnError: true,
|
||||
@ -854,7 +843,7 @@ function ObjectDetailsTab({
|
||||
);
|
||||
setDesc(search.data.description);
|
||||
});
|
||||
}, [desc, search, mutate, t, mapSearchResults]);
|
||||
}, [desc, search, mutate, t]);
|
||||
|
||||
const regenerateDescription = useCallback(
|
||||
(source: "snapshot" | "thumbnails") => {
|
||||
@ -926,8 +915,9 @@ function ObjectDetailsTab({
|
||||
(key.includes("events") ||
|
||||
key.includes("events/search") ||
|
||||
key.includes("events/explore")),
|
||||
(currentData: SearchResult[][] | SearchResult[] | undefined) =>
|
||||
mapSearchResults(currentData, (event) =>
|
||||
(currentData: SearchResult[][] | SearchResult[] | undefined) => {
|
||||
if (!currentData) return currentData;
|
||||
return currentData.flat().map((event) =>
|
||||
event.id === search.id
|
||||
? {
|
||||
...event,
|
||||
@ -938,7 +928,8 @@ function ObjectDetailsTab({
|
||||
},
|
||||
}
|
||||
: event,
|
||||
),
|
||||
);
|
||||
},
|
||||
{
|
||||
optimisticData: true,
|
||||
rollbackOnError: true,
|
||||
@ -972,7 +963,7 @@ function ObjectDetailsTab({
|
||||
);
|
||||
});
|
||||
},
|
||||
[search, apiHost, mutate, setSearch, t, mapSearchResults],
|
||||
[search, apiHost, mutate, setSearch, t],
|
||||
);
|
||||
|
||||
// recognized plate
|
||||
@ -1001,8 +992,9 @@ function ObjectDetailsTab({
|
||||
(key.includes("events") ||
|
||||
key.includes("events/search") ||
|
||||
key.includes("events/explore")),
|
||||
(currentData: SearchResult[][] | SearchResult[] | undefined) =>
|
||||
mapSearchResults(currentData, (event) =>
|
||||
(currentData: SearchResult[][] | SearchResult[] | undefined) => {
|
||||
if (!currentData) return currentData;
|
||||
return currentData.flat().map((event) =>
|
||||
event.id === search.id
|
||||
? {
|
||||
...event,
|
||||
@ -1013,7 +1005,8 @@ function ObjectDetailsTab({
|
||||
},
|
||||
}
|
||||
: event,
|
||||
),
|
||||
);
|
||||
},
|
||||
{
|
||||
optimisticData: true,
|
||||
rollbackOnError: true,
|
||||
@ -1047,7 +1040,7 @@ function ObjectDetailsTab({
|
||||
);
|
||||
});
|
||||
},
|
||||
[search, apiHost, mutate, setSearch, t, mapSearchResults],
|
||||
[search, apiHost, mutate, setSearch, t],
|
||||
);
|
||||
|
||||
// speech transcription
|
||||
@ -1109,12 +1102,17 @@ function ObjectDetailsTab({
|
||||
(key.includes("events") ||
|
||||
key.includes("events/search") ||
|
||||
key.includes("events/explore")),
|
||||
(currentData: SearchResult[][] | SearchResult[] | undefined) =>
|
||||
mapSearchResults(currentData, (event) =>
|
||||
event.id === search.id
|
||||
? { ...event, plus_id: "new_upload" }
|
||||
: event,
|
||||
),
|
||||
(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,
|
||||
@ -1122,7 +1120,7 @@ function ObjectDetailsTab({
|
||||
},
|
||||
);
|
||||
},
|
||||
[search, mutate, mapSearchResults],
|
||||
[search, mutate],
|
||||
);
|
||||
|
||||
const popoverContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
@ -1505,7 +1503,7 @@ function ObjectDetailsTab({
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Textarea
|
||||
className="text-md h-32 md:text-sm"
|
||||
className="text-md h-32"
|
||||
placeholder={t("details.description.placeholder")}
|
||||
value={desc}
|
||||
onChange={(e) => setDesc(e.target.value)}
|
||||
@ -1513,25 +1511,7 @@ function ObjectDetailsTab({
|
||||
onBlur={handleDescriptionBlur}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="mb-10 flex flex-row justify-end gap-5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
className="text-primary/40 hover:text-primary"
|
||||
onClick={() => {
|
||||
setIsEditingDesc(false);
|
||||
setDesc(originalDescRef.current ?? "");
|
||||
}}
|
||||
>
|
||||
<FaTimes className="size-5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className="flex flex-row justify-end gap-4">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
@ -1542,13 +1522,31 @@ function ObjectDetailsTab({
|
||||
updateDescription();
|
||||
}}
|
||||
>
|
||||
<FaCheck className="size-5" />
|
||||
<FaCheck className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("button.save", { ns: "common" })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
className="text-primary/40 hover:text-primary"
|
||||
onClick={() => {
|
||||
setIsEditingDesc(false);
|
||||
setDesc(originalDescRef.current ?? "");
|
||||
}}
|
||||
>
|
||||
<FaTimes className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import useSWR from "swr";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||
import { Event } from "@/types/event";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { TrackingDetailsSequence } from "@/types/timeline";
|
||||
@ -12,11 +11,7 @@ import { cn } from "@/lib/utils";
|
||||
import HlsVideoPlayer from "@/components/player/HlsVideoPlayer";
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { REVIEW_PADDING } from "@/types/review";
|
||||
import {
|
||||
ASPECT_VERTICAL_LAYOUT,
|
||||
ASPECT_WIDE_LAYOUT,
|
||||
Recording,
|
||||
} from "@/types/record";
|
||||
import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
@ -79,139 +74,6 @@ export function TrackingDetails({
|
||||
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
// Fetch recording segments for the event's time range to handle motion-only gaps
|
||||
const eventStartRecord = useMemo(
|
||||
() => (event.start_time ?? 0) + annotationOffset / 1000,
|
||||
[event.start_time, annotationOffset],
|
||||
);
|
||||
const eventEndRecord = useMemo(
|
||||
() => (event.end_time ?? Date.now() / 1000) + annotationOffset / 1000,
|
||||
[event.end_time, annotationOffset],
|
||||
);
|
||||
|
||||
const { data: recordings } = useSWR<Recording[]>(
|
||||
event.camera
|
||||
? [
|
||||
`${event.camera}/recordings`,
|
||||
{
|
||||
after: eventStartRecord - REVIEW_PADDING,
|
||||
before: eventEndRecord + REVIEW_PADDING,
|
||||
},
|
||||
]
|
||||
: null,
|
||||
);
|
||||
|
||||
// Convert a timeline timestamp to actual video player time, accounting for
|
||||
// motion-only recording gaps. Uses the same algorithm as DynamicVideoController.
|
||||
const timestampToVideoTime = useCallback(
|
||||
(timestamp: number): number => {
|
||||
if (!recordings || recordings.length === 0) {
|
||||
// Fallback to simple calculation if no recordings data
|
||||
return timestamp - (eventStartRecord - REVIEW_PADDING);
|
||||
}
|
||||
|
||||
const videoStartTime = eventStartRecord - REVIEW_PADDING;
|
||||
|
||||
// If timestamp is before video start, return 0
|
||||
if (timestamp < videoStartTime) return 0;
|
||||
|
||||
// Check if timestamp is before the first recording or after the last
|
||||
if (
|
||||
timestamp < recordings[0].start_time ||
|
||||
timestamp > recordings[recordings.length - 1].end_time
|
||||
) {
|
||||
// No recording available at this timestamp
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Calculate the inpoint offset - the HLS video may start partway through the first segment
|
||||
let inpointOffset = 0;
|
||||
if (
|
||||
videoStartTime > recordings[0].start_time &&
|
||||
videoStartTime < recordings[0].end_time
|
||||
) {
|
||||
inpointOffset = videoStartTime - recordings[0].start_time;
|
||||
}
|
||||
|
||||
let seekSeconds = 0;
|
||||
for (const segment of recordings) {
|
||||
// Skip segments that end before our timestamp
|
||||
if (segment.end_time <= timestamp) {
|
||||
// Add this segment's duration, but subtract inpoint offset from first segment
|
||||
if (segment === recordings[0]) {
|
||||
seekSeconds += segment.duration - inpointOffset;
|
||||
} else {
|
||||
seekSeconds += segment.duration;
|
||||
}
|
||||
} else if (segment.start_time <= timestamp) {
|
||||
// The timestamp is within this segment
|
||||
if (segment === recordings[0]) {
|
||||
// For the first segment, account for the inpoint offset
|
||||
seekSeconds +=
|
||||
timestamp - Math.max(segment.start_time, videoStartTime);
|
||||
} else {
|
||||
seekSeconds += timestamp - segment.start_time;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return seekSeconds;
|
||||
},
|
||||
[recordings, eventStartRecord],
|
||||
);
|
||||
|
||||
// Convert video player time back to timeline timestamp, accounting for
|
||||
// motion-only recording gaps. Reverse of timestampToVideoTime.
|
||||
const videoTimeToTimestamp = useCallback(
|
||||
(playerTime: number): number => {
|
||||
if (!recordings || recordings.length === 0) {
|
||||
// Fallback to simple calculation if no recordings data
|
||||
const videoStartTime = eventStartRecord - REVIEW_PADDING;
|
||||
return playerTime + videoStartTime;
|
||||
}
|
||||
|
||||
const videoStartTime = eventStartRecord - REVIEW_PADDING;
|
||||
|
||||
// Calculate the inpoint offset - the video may start partway through the first segment
|
||||
let inpointOffset = 0;
|
||||
if (
|
||||
videoStartTime > recordings[0].start_time &&
|
||||
videoStartTime < recordings[0].end_time
|
||||
) {
|
||||
inpointOffset = videoStartTime - recordings[0].start_time;
|
||||
}
|
||||
|
||||
let timestamp = 0;
|
||||
let totalTime = 0;
|
||||
|
||||
for (const segment of recordings) {
|
||||
const segmentDuration =
|
||||
segment === recordings[0]
|
||||
? segment.duration - inpointOffset
|
||||
: segment.duration;
|
||||
|
||||
if (totalTime + segmentDuration > playerTime) {
|
||||
// The player time is within this segment
|
||||
if (segment === recordings[0]) {
|
||||
// For the first segment, add the inpoint offset
|
||||
timestamp =
|
||||
Math.max(segment.start_time, videoStartTime) +
|
||||
(playerTime - totalTime);
|
||||
} else {
|
||||
timestamp = segment.start_time + (playerTime - totalTime);
|
||||
}
|
||||
break;
|
||||
} else {
|
||||
totalTime += segmentDuration;
|
||||
}
|
||||
}
|
||||
|
||||
return timestamp;
|
||||
},
|
||||
[recordings, eventStartRecord],
|
||||
);
|
||||
|
||||
eventSequence?.map((event) => {
|
||||
event.data.zones_friendly_names = event.data?.zones?.map((zone) => {
|
||||
return resolveZoneName(config, zone);
|
||||
@ -227,16 +89,9 @@ export function TrackingDetails({
|
||||
}, [manualOverride, currentTime, annotationOffset]);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const timelineContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const rowRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const [_selectedZone, setSelectedZone] = useState("");
|
||||
const [_lifecycleZones, setLifecycleZones] = useState<string[]>([]);
|
||||
const [seekToTimestamp, setSeekToTimestamp] = useState<number | null>(null);
|
||||
const [lineBottomOffsetPx, setLineBottomOffsetPx] = useState<number>(32);
|
||||
const [lineTopOffsetPx, setLineTopOffsetPx] = useState<number>(8);
|
||||
const [blueLineHeightPx, setBlueLineHeightPx] = useState<number>(0);
|
||||
|
||||
const [timelineSize] = useResizeObserver(timelineContainerRef);
|
||||
|
||||
const aspectRatio = useMemo(() => {
|
||||
if (!config) {
|
||||
@ -285,14 +140,17 @@ export function TrackingDetails({
|
||||
return;
|
||||
}
|
||||
|
||||
// For video mode: convert to video-relative time (accounting for motion-only gaps)
|
||||
const relativeTime = timestampToVideoTime(targetTimeRecord);
|
||||
// For video mode: convert to video-relative time and seek player
|
||||
const eventStartRecord =
|
||||
(event.start_time ?? 0) + annotationOffset / 1000;
|
||||
const videoStartTime = eventStartRecord - REVIEW_PADDING;
|
||||
const relativeTime = targetTimeRecord - videoStartTime;
|
||||
|
||||
if (videoRef.current) {
|
||||
videoRef.current.currentTime = relativeTime;
|
||||
}
|
||||
},
|
||||
[annotationOffset, displaySource, timestampToVideoTime],
|
||||
[event.start_time, annotationOffset, displaySource],
|
||||
);
|
||||
|
||||
const formattedStart = config
|
||||
@ -311,22 +169,21 @@ export function TrackingDetails({
|
||||
})
|
||||
: "";
|
||||
|
||||
const formattedEnd =
|
||||
config && event.end_time != null
|
||||
? formatUnixTimestampToDateTime(event.end_time, {
|
||||
timezone: config.ui.timezone,
|
||||
date_format:
|
||||
config.ui.time_format == "24hour"
|
||||
? t("time.formattedTimestamp.24hour", {
|
||||
ns: "common",
|
||||
})
|
||||
: t("time.formattedTimestamp.12hour", {
|
||||
ns: "common",
|
||||
}),
|
||||
time_style: "medium",
|
||||
date_style: "medium",
|
||||
})
|
||||
: "";
|
||||
const formattedEnd = config
|
||||
? formatUnixTimestampToDateTime(event.end_time ?? 0, {
|
||||
timezone: config.ui.timezone,
|
||||
date_format:
|
||||
config.ui.time_format == "24hour"
|
||||
? t("time.formattedTimestamp.24hour", {
|
||||
ns: "common",
|
||||
})
|
||||
: t("time.formattedTimestamp.12hour", {
|
||||
ns: "common",
|
||||
}),
|
||||
time_style: "medium",
|
||||
date_style: "medium",
|
||||
})
|
||||
: "";
|
||||
|
||||
useEffect(() => {
|
||||
if (!eventSequence || eventSequence.length === 0) return;
|
||||
@ -345,83 +202,79 @@ export function TrackingDetails({
|
||||
}
|
||||
|
||||
// seekToTimestamp is a record stream timestamp
|
||||
// Convert to video position (accounting for motion-only recording gaps)
|
||||
// event.start_time is detect stream time, convert to record
|
||||
// The video clip starts at (eventStartRecord - REVIEW_PADDING)
|
||||
if (!videoRef.current) return;
|
||||
const relativeTime = timestampToVideoTime(seekToTimestamp);
|
||||
const eventStartRecord = event.start_time + annotationOffset / 1000;
|
||||
const videoStartTime = eventStartRecord - REVIEW_PADDING;
|
||||
const relativeTime = seekToTimestamp - videoStartTime;
|
||||
if (relativeTime >= 0) {
|
||||
videoRef.current.currentTime = relativeTime;
|
||||
}
|
||||
setSeekToTimestamp(null);
|
||||
}, [seekToTimestamp, displaySource, timestampToVideoTime]);
|
||||
}, [
|
||||
seekToTimestamp,
|
||||
event.start_time,
|
||||
annotationOffset,
|
||||
apiHost,
|
||||
event.camera,
|
||||
displaySource,
|
||||
]);
|
||||
|
||||
const isWithinEventRange = useMemo(() => {
|
||||
if (effectiveTime === undefined || event.start_time === undefined) {
|
||||
return false;
|
||||
const isWithinEventRange =
|
||||
effectiveTime !== undefined &&
|
||||
event.start_time !== undefined &&
|
||||
event.end_time !== undefined &&
|
||||
effectiveTime >= event.start_time &&
|
||||
effectiveTime <= event.end_time;
|
||||
|
||||
// Calculate how far down the blue line should extend based on effectiveTime
|
||||
const calculateLineHeight = useCallback(() => {
|
||||
if (!eventSequence || eventSequence.length === 0 || !isWithinEventRange) {
|
||||
return 0;
|
||||
}
|
||||
// If an event has not ended yet, fall back to last timestamp in eventSequence
|
||||
let eventEnd = event.end_time;
|
||||
if (eventEnd == null && eventSequence && eventSequence.length > 0) {
|
||||
const last = eventSequence[eventSequence.length - 1];
|
||||
if (last && last.timestamp !== undefined) {
|
||||
eventEnd = last.timestamp;
|
||||
|
||||
const currentTime = effectiveTime ?? 0;
|
||||
|
||||
// Find which events have been passed
|
||||
let lastPassedIndex = -1;
|
||||
for (let i = 0; i < eventSequence.length; i++) {
|
||||
if (currentTime >= (eventSequence[i].timestamp ?? 0)) {
|
||||
lastPassedIndex = i;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (eventEnd == null) {
|
||||
return false;
|
||||
}
|
||||
return effectiveTime >= event.start_time && effectiveTime <= eventEnd;
|
||||
}, [effectiveTime, event.start_time, event.end_time, eventSequence]);
|
||||
// No events passed yet
|
||||
if (lastPassedIndex < 0) return 0;
|
||||
|
||||
// Dynamically compute pixel offsets so the timeline line starts at the
|
||||
// first row midpoint and ends at the last row midpoint. For accuracy,
|
||||
// measure the center Y of each lifecycle row and interpolate the current
|
||||
// effective time into a pixel position; then set the blue line height
|
||||
// so it reaches the center dot at the same time the dot becomes active.
|
||||
useEffect(() => {
|
||||
if (!timelineContainerRef.current || !eventSequence) return;
|
||||
// All events passed
|
||||
if (lastPassedIndex >= eventSequence.length - 1) return 100;
|
||||
|
||||
const containerRect = timelineContainerRef.current.getBoundingClientRect();
|
||||
const validRefs = rowRefs.current.filter((r) => r !== null);
|
||||
if (validRefs.length === 0) return;
|
||||
// Calculate percentage based on item position, not time
|
||||
// Each item occupies an equal visual space regardless of time gaps
|
||||
const itemPercentage = 100 / (eventSequence.length - 1);
|
||||
|
||||
const centers = validRefs.map((n) => {
|
||||
const r = n.getBoundingClientRect();
|
||||
return r.top + r.height / 2 - containerRect.top;
|
||||
});
|
||||
// Find progress between current and next event for smooth transition
|
||||
const currentEvent = eventSequence[lastPassedIndex];
|
||||
const nextEvent = eventSequence[lastPassedIndex + 1];
|
||||
const currentTimestamp = currentEvent.timestamp ?? 0;
|
||||
const nextTimestamp = nextEvent.timestamp ?? 0;
|
||||
|
||||
const topOffset = Math.max(0, centers[0]);
|
||||
const bottomOffset = Math.max(
|
||||
0,
|
||||
containerRect.height - centers[centers.length - 1],
|
||||
// Calculate interpolation between the two events
|
||||
const timeBetween = nextTimestamp - currentTimestamp;
|
||||
const timeElapsed = currentTime - currentTimestamp;
|
||||
const interpolation = timeBetween > 0 ? timeElapsed / timeBetween : 0;
|
||||
|
||||
// Base position plus interpolated progress to next item
|
||||
return Math.min(
|
||||
100,
|
||||
lastPassedIndex * itemPercentage + interpolation * itemPercentage,
|
||||
);
|
||||
}, [eventSequence, effectiveTime, isWithinEventRange]);
|
||||
|
||||
setLineTopOffsetPx(Math.round(topOffset));
|
||||
setLineBottomOffsetPx(Math.round(bottomOffset));
|
||||
|
||||
const eff = effectiveTime ?? 0;
|
||||
const timestamps = eventSequence.map((s) => s.timestamp ?? 0);
|
||||
|
||||
let pixelPos = centers[0];
|
||||
if (eff <= timestamps[0]) {
|
||||
pixelPos = centers[0];
|
||||
} else if (eff >= timestamps[timestamps.length - 1]) {
|
||||
pixelPos = centers[centers.length - 1];
|
||||
} else {
|
||||
for (let i = 0; i < timestamps.length - 1; i++) {
|
||||
const t1 = timestamps[i];
|
||||
const t2 = timestamps[i + 1];
|
||||
if (eff >= t1 && eff <= t2) {
|
||||
const ratio = t2 > t1 ? (eff - t1) / (t2 - t1) : 0;
|
||||
pixelPos = centers[i] + ratio * (centers[i + 1] - centers[i]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bluePx = Math.round(Math.max(0, pixelPos - topOffset));
|
||||
setBlueLineHeightPx(bluePx);
|
||||
}, [eventSequence, timelineSize.width, timelineSize.height, effectiveTime]);
|
||||
const blueLineHeight = calculateLineHeight();
|
||||
|
||||
const videoSource = useMemo(() => {
|
||||
// event.start_time and event.end_time are in DETECT stream time
|
||||
@ -459,13 +312,14 @@ export function TrackingDetails({
|
||||
|
||||
const handleTimeUpdate = useCallback(
|
||||
(time: number) => {
|
||||
// Convert video player time back to timeline timestamp
|
||||
// accounting for motion-only recording gaps
|
||||
const absoluteTime = videoTimeToTimestamp(time);
|
||||
// event.start_time is detect stream time, convert to record
|
||||
const eventStartRecord = event.start_time + annotationOffset / 1000;
|
||||
const videoStartTime = eventStartRecord - REVIEW_PADDING;
|
||||
const absoluteTime = time + videoStartTime;
|
||||
|
||||
setCurrentTime(absoluteTime);
|
||||
},
|
||||
[videoTimeToTimestamp],
|
||||
[event.start_time, annotationOffset],
|
||||
);
|
||||
|
||||
const [src, setSrc] = useState(
|
||||
@ -649,16 +503,9 @@ export function TrackingDetails({
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="capitalize">{label}</span>
|
||||
<div className="md:text-md flex items-center text-xs text-secondary-foreground">
|
||||
{formattedStart ?? ""}
|
||||
{event.end_time != null ? (
|
||||
<> - {formattedEnd}</>
|
||||
) : (
|
||||
<div className="inline-block">
|
||||
<ActivityIndicator className="ml-3 size-4" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="md:text-md text-xs text-secondary-foreground">
|
||||
{formattedStart ?? ""} - {formattedEnd ?? ""}
|
||||
</span>
|
||||
{event.data?.recognized_license_plate && (
|
||||
<>
|
||||
<span className="text-secondary-foreground">·</span>
|
||||
@ -684,21 +531,12 @@ export function TrackingDetails({
|
||||
{t("detail.noObjectDetailData", { ns: "views/events" })}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="-pb-2 relative mx-0"
|
||||
ref={timelineContainerRef}
|
||||
>
|
||||
<div
|
||||
className="absolute -top-2 left-6 z-0 w-0.5 -translate-x-1/2 bg-secondary-foreground"
|
||||
style={{ bottom: lineBottomOffsetPx }}
|
||||
/>
|
||||
<div className="-pb-2 relative mx-0">
|
||||
<div className="absolute -top-2 bottom-8 left-6 z-0 w-0.5 -translate-x-1/2 bg-secondary-foreground" />
|
||||
{isWithinEventRange && (
|
||||
<div
|
||||
className="absolute left-6 z-[5] w-0.5 -translate-x-1/2 bg-selected transition-all duration-300"
|
||||
style={{
|
||||
top: `${lineTopOffsetPx}px`,
|
||||
height: `${blueLineHeightPx}px`,
|
||||
}}
|
||||
className="absolute left-6 top-2 z-[5] max-h-[calc(100%-3rem)] w-0.5 -translate-x-1/2 bg-selected transition-all duration-300"
|
||||
style={{ height: `${blueLineHeight}%` }}
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
@ -751,26 +589,20 @@ export function TrackingDetails({
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
<LifecycleIconRow
|
||||
key={`${item.timestamp}-${item.source_id ?? ""}-${idx}`}
|
||||
ref={(el) => {
|
||||
rowRefs.current[idx] = el;
|
||||
}}
|
||||
>
|
||||
<LifecycleIconRow
|
||||
item={item}
|
||||
isActive={isActive}
|
||||
formattedEventTimestamp={formattedEventTimestamp}
|
||||
ratio={ratio}
|
||||
areaPx={areaPx}
|
||||
areaPct={areaPct}
|
||||
onClick={() => handleLifecycleClick(item)}
|
||||
setSelectedZone={setSelectedZone}
|
||||
getZoneColor={getZoneColor}
|
||||
effectiveTime={effectiveTime}
|
||||
isTimelineActive={isWithinEventRange}
|
||||
/>
|
||||
</div>
|
||||
item={item}
|
||||
isActive={isActive}
|
||||
formattedEventTimestamp={formattedEventTimestamp}
|
||||
ratio={ratio}
|
||||
areaPx={areaPx}
|
||||
areaPct={areaPct}
|
||||
onClick={() => handleLifecycleClick(item)}
|
||||
setSelectedZone={setSelectedZone}
|
||||
getZoneColor={getZoneColor}
|
||||
effectiveTime={effectiveTime}
|
||||
isTimelineActive={isWithinEventRange}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@ -318,7 +318,6 @@ export default function HlsVideoPlayer({
|
||||
{isDetailMode &&
|
||||
camera &&
|
||||
currentTime &&
|
||||
loadedMetadata &&
|
||||
videoDimensions.width > 0 &&
|
||||
videoDimensions.height > 0 && (
|
||||
<div className="absolute z-50 size-full">
|
||||
|
||||
@ -18,7 +18,7 @@ import { z } from "zod";
|
||||
import axios from "axios";
|
||||
import { toast, Toaster } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { LuTrash2, LuPlus } from "react-icons/lu";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
@ -42,15 +42,7 @@ export default function CameraEditForm({
|
||||
onCancel,
|
||||
}: CameraEditFormProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const { data: config, mutate: mutateConfig } =
|
||||
useSWR<FrigateConfig>("config");
|
||||
const { data: rawPaths, mutate: mutateRawPaths } = useSWR<{
|
||||
cameras: Record<
|
||||
string,
|
||||
{ ffmpeg: { inputs: { path: string; roles: string[] }[] } }
|
||||
>;
|
||||
go2rtc: { streams: Record<string, string | string[]> };
|
||||
}>(cameraName ? "config/raw_paths" : null);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const formSchema = useMemo(
|
||||
@ -153,23 +145,14 @@ export default function CameraEditForm({
|
||||
if (cameraName && config?.cameras[cameraName]) {
|
||||
const camera = config.cameras[cameraName];
|
||||
defaultValues.enabled = camera.enabled ?? true;
|
||||
|
||||
// Use raw paths from the admin endpoint if available, otherwise fall back to masked paths
|
||||
const rawCameraData = rawPaths?.cameras?.[cameraName];
|
||||
defaultValues.ffmpeg.inputs = rawCameraData?.ffmpeg?.inputs?.length
|
||||
? rawCameraData.ffmpeg.inputs.map((input) => ({
|
||||
defaultValues.ffmpeg.inputs = camera.ffmpeg?.inputs?.length
|
||||
? camera.ffmpeg.inputs.map((input) => ({
|
||||
path: input.path,
|
||||
roles: input.roles as Role[],
|
||||
}))
|
||||
: camera.ffmpeg?.inputs?.length
|
||||
? camera.ffmpeg.inputs.map((input) => ({
|
||||
path: input.path,
|
||||
roles: input.roles as Role[],
|
||||
}))
|
||||
: defaultValues.ffmpeg.inputs;
|
||||
: defaultValues.ffmpeg.inputs;
|
||||
|
||||
const go2rtcStreams =
|
||||
rawPaths?.go2rtc?.streams || config.go2rtc?.streams || {};
|
||||
const go2rtcStreams = config.go2rtc?.streams || {};
|
||||
const cameraStreams: Record<string, string[]> = {};
|
||||
|
||||
// get candidate stream names for this camera. could be the camera's own name,
|
||||
@ -213,60 +196,6 @@ export default function CameraEditForm({
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
// Update form values when rawPaths loads
|
||||
useEffect(() => {
|
||||
if (
|
||||
cameraName &&
|
||||
config?.cameras[cameraName] &&
|
||||
rawPaths?.cameras?.[cameraName]
|
||||
) {
|
||||
const camera = config.cameras[cameraName];
|
||||
const rawCameraData = rawPaths.cameras[cameraName];
|
||||
|
||||
// Update ffmpeg inputs with raw paths
|
||||
if (rawCameraData.ffmpeg?.inputs?.length) {
|
||||
form.setValue(
|
||||
"ffmpeg.inputs",
|
||||
rawCameraData.ffmpeg.inputs.map((input) => ({
|
||||
path: input.path,
|
||||
roles: input.roles as Role[],
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
// Update go2rtc streams with raw URLs
|
||||
if (rawPaths.go2rtc?.streams) {
|
||||
const validNames = new Set<string>();
|
||||
validNames.add(cameraName);
|
||||
|
||||
camera.ffmpeg?.inputs?.forEach((input) => {
|
||||
const restreamMatch = input.path.match(
|
||||
/^rtsp:\/\/127\.0\.0\.1:8554\/([^?#/]+)(?:[?#].*)?$/,
|
||||
);
|
||||
if (restreamMatch) {
|
||||
validNames.add(restreamMatch[1]);
|
||||
}
|
||||
});
|
||||
|
||||
const liveStreams = camera?.live?.streams;
|
||||
if (liveStreams) {
|
||||
Object.keys(liveStreams).forEach((key) => validNames.add(key));
|
||||
}
|
||||
|
||||
const cameraStreams: Record<string, string[]> = {};
|
||||
Object.entries(rawPaths.go2rtc.streams).forEach(([name, urls]) => {
|
||||
if (validNames.has(name)) {
|
||||
cameraStreams[name] = Array.isArray(urls) ? urls : [urls];
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(cameraStreams).length > 0) {
|
||||
form.setValue("go2rtcStreams", cameraStreams);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [cameraName, config, rawPaths, form]);
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "ffmpeg.inputs",
|
||||
@ -339,8 +268,6 @@ export default function CameraEditForm({
|
||||
}),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
mutateConfig();
|
||||
mutateRawPaths();
|
||||
if (onSave) onSave();
|
||||
});
|
||||
} else {
|
||||
@ -350,8 +277,6 @@ export default function CameraEditForm({
|
||||
}),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
mutateConfig();
|
||||
mutateRawPaths();
|
||||
if (onSave) onSave();
|
||||
}
|
||||
} else {
|
||||
|
||||
@ -15,7 +15,6 @@ import {
|
||||
ReviewSummary,
|
||||
SegmentedReviewData,
|
||||
} from "@/types/review";
|
||||
import { TimelineType } from "@/types/timeline";
|
||||
import {
|
||||
getBeginningOfDayTimestamp,
|
||||
getEndOfDayTimestamp,
|
||||
@ -50,16 +49,6 @@ export default function Events() {
|
||||
false,
|
||||
);
|
||||
|
||||
const [notificationTab, setNotificationTab] =
|
||||
useState<TimelineType>("timeline");
|
||||
|
||||
useSearchEffect("tab", (tab: string) => {
|
||||
if (tab === "timeline" || tab === "events" || tab === "detail") {
|
||||
setNotificationTab(tab as TimelineType);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
useSearchEffect("id", (reviewId: string) => {
|
||||
axios
|
||||
.get(`review/${reviewId}`)
|
||||
@ -77,7 +66,7 @@ export default function Events() {
|
||||
camera: resp.data.camera,
|
||||
startTime,
|
||||
severity: resp.data.severity,
|
||||
timelineType: notificationTab,
|
||||
timelineType: "detail",
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
@ -26,7 +26,7 @@ import useSWR from "swr";
|
||||
import FilterSwitch from "@/components/filter/FilterSwitch";
|
||||
import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter";
|
||||
import { PolygonType } from "@/types/canvas";
|
||||
import CameraReviewSettingsView from "@/views/settings/CameraReviewSettingsView";
|
||||
import CameraSettingsView from "@/views/settings/CameraSettingsView";
|
||||
import CameraManagementView from "@/views/settings/CameraManagementView";
|
||||
import MotionTunerView from "@/views/settings/MotionTunerView";
|
||||
import MasksAndZonesView from "@/views/settings/MasksAndZonesView";
|
||||
@ -93,7 +93,7 @@ const settingsGroups = [
|
||||
label: "cameras",
|
||||
items: [
|
||||
{ key: "cameraManagement", component: CameraManagementView },
|
||||
{ key: "cameraReview", component: CameraReviewSettingsView },
|
||||
{ key: "cameraReview", component: CameraSettingsView },
|
||||
{ key: "masksAndZones", component: MasksAndZonesView },
|
||||
{ key: "motionTuner", component: MotionTunerView },
|
||||
],
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { ReviewSeverity } from "./review";
|
||||
import { TimelineType } from "./timeline";
|
||||
|
||||
export type Recording = {
|
||||
id: string;
|
||||
@ -38,7 +37,7 @@ export type RecordingStartingPoint = {
|
||||
camera: string;
|
||||
startTime: number;
|
||||
severity: ReviewSeverity;
|
||||
timelineType?: TimelineType;
|
||||
timelineType?: "timeline" | "events" | "detail";
|
||||
};
|
||||
|
||||
export type RecordingPlayerError = "stalled" | "startup";
|
||||
|
||||
@ -16,6 +16,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaFolderPlus } from "react-icons/fa";
|
||||
import { MdModelTraining } from "react-icons/md";
|
||||
import { LuPencil, LuTrash2 } from "react-icons/lu";
|
||||
import { FiMoreVertical } from "react-icons/fi";
|
||||
import useSWR from "swr";
|
||||
import Heading from "@/components/ui/heading";
|
||||
@ -351,9 +352,11 @@ function ModelCard({ config, onClick, onUpdate, onDelete }: ModelCardProps) {
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownMenuItem onClick={handleEditClick}>
|
||||
<LuPencil className="mr-2 size-4" />
|
||||
<span>{t("button.edit", { ns: "common" })}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleDeleteClick}>
|
||||
<LuTrash2 className="mr-2 size-4" />
|
||||
<span>{t("button.delete", { ns: "common" })}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@ -799,7 +799,7 @@ function DetectionReview({
|
||||
(itemsToReview ?? 0) > 0 && (
|
||||
<div className="col-span-full flex items-center justify-center">
|
||||
<Button
|
||||
className="text-balance text-white"
|
||||
className="text-white"
|
||||
aria-label={t("markTheseItemsAsReviewed")}
|
||||
variant="select"
|
||||
onClick={() => {
|
||||
|
||||
@ -850,29 +850,6 @@ function FrigateCameraFeatures({
|
||||
}
|
||||
}, [activeToastId, t]);
|
||||
|
||||
const endEventViaBeacon = useCallback(() => {
|
||||
if (!recordingEventIdRef.current) return;
|
||||
|
||||
const url = `${window.location.origin}/api/events/${recordingEventIdRef.current}/end`;
|
||||
const payload = JSON.stringify({
|
||||
end_time: Math.ceil(Date.now() / 1000),
|
||||
});
|
||||
|
||||
// this needs to be a synchronous XMLHttpRequest to guarantee the PUT
|
||||
// reaches the server before the browser kills the page
|
||||
const xhr = new XMLHttpRequest();
|
||||
try {
|
||||
xhr.open("PUT", url, false);
|
||||
xhr.setRequestHeader("Content-Type", "application/json");
|
||||
xhr.setRequestHeader("X-CSRF-TOKEN", "1");
|
||||
xhr.setRequestHeader("X-CACHE-BYPASS", "1");
|
||||
xhr.withCredentials = true;
|
||||
xhr.send(payload);
|
||||
} catch (e) {
|
||||
// Silently ignore errors during unload
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleEventButtonClick = useCallback(() => {
|
||||
if (isRecording) {
|
||||
endEvent();
|
||||
@ -910,19 +887,8 @@ function FrigateCameraFeatures({
|
||||
}, [camera.name, isRestreamed, preferredLiveMode, t]);
|
||||
|
||||
useEffect(() => {
|
||||
// Handle page unload/close (browser close, tab close, refresh, navigation to external site)
|
||||
const handleBeforeUnload = () => {
|
||||
if (recordingEventIdRef.current) {
|
||||
endEventViaBeacon();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
|
||||
// ensure manual event is stopped when component unmounts
|
||||
return () => {
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
|
||||
if (recordingEventIdRef.current) {
|
||||
endEvent();
|
||||
}
|
||||
|
||||
@ -5,9 +5,17 @@ import { Button } from "@/components/ui/button";
|
||||
import useSWR from "swr";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import CameraEditForm from "@/components/settings/CameraEditForm";
|
||||
import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
|
||||
import { LuPlus } from "react-icons/lu";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { IoMdArrowRoundBack } from "react-icons/io";
|
||||
import { isDesktop } from "react-device-detect";
|
||||
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||
@ -82,6 +90,31 @@ export default function CameraManagementView({
|
||||
</Button>
|
||||
{cameras.length > 0 && (
|
||||
<>
|
||||
<div className="my-4 flex flex-col gap-2">
|
||||
<Label>{t("cameraManagement.editCamera")}</Label>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
setEditCameraName(value);
|
||||
setViewMode("edit");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue
|
||||
placeholder={t("cameraManagement.selectCamera")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{cameras.map((camera) => {
|
||||
return (
|
||||
<SelectItem key={camera} value={camera}>
|
||||
<CameraNameLabel camera={camera} />
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Separator className="my-2 flex bg-secondary" />
|
||||
<div className="max-w-7xl space-y-4">
|
||||
<Heading as="h4" className="my-2">
|
||||
|
||||
@ -1,738 +0,0 @@
|
||||
import Heading from "@/components/ui/heading";
|
||||
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { Toaster, toast } from "sonner";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import useSWR from "swr";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
|
||||
import axios from "axios";
|
||||
import { Link } from "react-router-dom";
|
||||
import { LuExternalLink } from "react-icons/lu";
|
||||
import { MdCircle } from "react-icons/md";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import {
|
||||
useAlertsState,
|
||||
useDetectionsState,
|
||||
useObjectDescriptionState,
|
||||
useReviewDescriptionState,
|
||||
} from "@/api/ws";
|
||||
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
||||
import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
|
||||
import { formatList } from "@/utils/stringUtil";
|
||||
|
||||
type CameraReviewSettingsViewProps = {
|
||||
selectedCamera: string;
|
||||
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
type CameraReviewSettingsValueType = {
|
||||
alerts_zones: string[];
|
||||
detections_zones: string[];
|
||||
};
|
||||
|
||||
export default function CameraReviewSettingsView({
|
||||
selectedCamera,
|
||||
setUnsavedChanges,
|
||||
}: CameraReviewSettingsViewProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const { getLocaleDocUrl } = useDocDomain();
|
||||
|
||||
const { data: config, mutate: updateConfig } =
|
||||
useSWR<FrigateConfig>("config");
|
||||
|
||||
const cameraConfig = useMemo(() => {
|
||||
if (config && selectedCamera) {
|
||||
return config.cameras[selectedCamera];
|
||||
}
|
||||
}, [config, selectedCamera]);
|
||||
|
||||
const [changedValue, setChangedValue] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectDetections, setSelectDetections] = useState(false);
|
||||
|
||||
const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!;
|
||||
|
||||
const selectCameraName = useCameraFriendlyName(selectedCamera);
|
||||
|
||||
// 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,
|
||||
}));
|
||||
}
|
||||
}, [cameraConfig]);
|
||||
|
||||
const alertsLabels = useMemo(() => {
|
||||
return cameraConfig?.review.alerts.labels
|
||||
? formatList(
|
||||
cameraConfig.review.alerts.labels.map((label) =>
|
||||
getTranslatedLabel(
|
||||
label,
|
||||
cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object",
|
||||
),
|
||||
),
|
||||
)
|
||||
: "";
|
||||
}, [cameraConfig]);
|
||||
|
||||
const detectionsLabels = useMemo(() => {
|
||||
return cameraConfig?.review.detections.labels
|
||||
? formatList(
|
||||
cameraConfig.review.detections.labels.map((label) =>
|
||||
getTranslatedLabel(
|
||||
label,
|
||||
cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object",
|
||||
),
|
||||
),
|
||||
)
|
||||
: "";
|
||||
}, [cameraConfig]);
|
||||
|
||||
// form
|
||||
|
||||
const formSchema = z.object({
|
||||
alerts_zones: z.array(z.string()),
|
||||
detections_zones: z.array(z.string()),
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
mode: "onChange",
|
||||
defaultValues: {
|
||||
alerts_zones: cameraConfig?.review.alerts.required_zones || [],
|
||||
detections_zones: cameraConfig?.review.detections.required_zones || [],
|
||||
},
|
||||
});
|
||||
|
||||
const watchedAlertsZones = form.watch("alerts_zones");
|
||||
const watchedDetectionsZones = form.watch("detections_zones");
|
||||
|
||||
const { payload: alertsState, send: sendAlerts } =
|
||||
useAlertsState(selectedCamera);
|
||||
const { payload: detectionsState, send: sendDetections } =
|
||||
useDetectionsState(selectedCamera);
|
||||
|
||||
const { payload: objDescState, send: sendObjDesc } =
|
||||
useObjectDescriptionState(selectedCamera);
|
||||
const { payload: revDescState, send: sendRevDesc } =
|
||||
useReviewDescriptionState(selectedCamera);
|
||||
|
||||
const handleCheckedChange = useCallback(
|
||||
(isChecked: boolean) => {
|
||||
if (!isChecked) {
|
||||
form.reset({
|
||||
alerts_zones: watchedAlertsZones,
|
||||
detections_zones: [],
|
||||
});
|
||||
}
|
||||
setChangedValue(true);
|
||||
setSelectDetections(isChecked as boolean);
|
||||
},
|
||||
// we know that these deps are correct
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[watchedAlertsZones],
|
||||
);
|
||||
|
||||
const saveToConfig = useCallback(
|
||||
async (
|
||||
{ alerts_zones, detections_zones }: CameraReviewSettingsValueType, // values submitted via the form
|
||||
) => {
|
||||
const createQuery = (zones: string[], type: "alerts" | "detections") =>
|
||||
zones.length
|
||||
? zones
|
||||
.map(
|
||||
(zone) =>
|
||||
`&cameras.${selectedCamera}.review.${type}.required_zones=${zone}`,
|
||||
)
|
||||
.join("")
|
||||
: cameraConfig?.review[type]?.required_zones &&
|
||||
cameraConfig?.review[type]?.required_zones.length > 0
|
||||
? `&cameras.${selectedCamera}.review.${type}.required_zones`
|
||||
: "";
|
||||
|
||||
const alertQueries = createQuery(alerts_zones, "alerts");
|
||||
const detectionQueries = createQuery(detections_zones, "detections");
|
||||
|
||||
axios
|
||||
.put(`config/set?${alertQueries}${detectionQueries}`, {
|
||||
requires_restart: 0,
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
toast.success(
|
||||
t("cameraReview.reviewClassification.toast.success"),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
updateConfig();
|
||||
} else {
|
||||
toast.error(
|
||||
t("toast.save.error.title", {
|
||||
errorMessage: res.statusText,
|
||||
ns: "common",
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(
|
||||
t("toast.save.error.title", {
|
||||
errorMessage,
|
||||
ns: "common",
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
},
|
||||
[updateConfig, setIsLoading, selectedCamera, cameraConfig, t],
|
||||
);
|
||||
|
||||
const onCancel = useCallback(() => {
|
||||
if (!cameraConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
setChangedValue(false);
|
||||
setUnsavedChanges(false);
|
||||
removeMessage(
|
||||
"camera_settings",
|
||||
`review_classification_settings_${selectedCamera}`,
|
||||
);
|
||||
form.reset({
|
||||
alerts_zones: cameraConfig?.review.alerts.required_zones ?? [],
|
||||
detections_zones: cameraConfig?.review.detections.required_zones || [],
|
||||
});
|
||||
setSelectDetections(
|
||||
!!cameraConfig?.review.detections.required_zones?.length,
|
||||
);
|
||||
// we know that these deps are correct
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [removeMessage, selectedCamera, setUnsavedChanges, cameraConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
onCancel();
|
||||
// we know that these deps are correct
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedCamera]);
|
||||
|
||||
useEffect(() => {
|
||||
if (changedValue) {
|
||||
addMessage(
|
||||
"camera_settings",
|
||||
t("cameraReview.reviewClassification.unsavedChanges", {
|
||||
camera: selectedCamera,
|
||||
}),
|
||||
undefined,
|
||||
`review_classification_settings_${selectedCamera}`,
|
||||
);
|
||||
} else {
|
||||
removeMessage(
|
||||
"camera_settings",
|
||||
`review_classification_settings_${selectedCamera}`,
|
||||
);
|
||||
}
|
||||
// we know that these deps are correct
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [changedValue, selectedCamera]);
|
||||
|
||||
function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
setIsLoading(true);
|
||||
|
||||
saveToConfig(values as CameraReviewSettingsValueType);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
document.title = t("documentTitle.cameraReview");
|
||||
}, [t]);
|
||||
|
||||
if (!cameraConfig && !selectedCamera) {
|
||||
return <ActivityIndicator />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex size-full flex-col md:flex-row">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
|
||||
<Heading as="h4" className="mb-2">
|
||||
{t("cameraReview.title")}
|
||||
</Heading>
|
||||
|
||||
<Heading as="h4" className="my-2">
|
||||
<Trans ns="views/settings">cameraReview.review.title</Trans>
|
||||
</Heading>
|
||||
|
||||
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 space-y-3 text-sm text-primary-variant">
|
||||
<div className="flex flex-row items-center">
|
||||
<Switch
|
||||
id="alerts-enabled"
|
||||
className="mr-3"
|
||||
checked={alertsState == "ON"}
|
||||
onCheckedChange={(isChecked) => {
|
||||
sendAlerts(isChecked ? "ON" : "OFF");
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="alerts-enabled">
|
||||
<Trans ns="views/settings">cameraReview.review.alerts</Trans>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-row items-center">
|
||||
<Switch
|
||||
id="detections-enabled"
|
||||
className="mr-3"
|
||||
checked={detectionsState == "ON"}
|
||||
onCheckedChange={(isChecked) => {
|
||||
sendDetections(isChecked ? "ON" : "OFF");
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="detections-enabled">
|
||||
<Trans ns="views/settings">camera.review.detections</Trans>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-muted-foreground">
|
||||
<Trans ns="views/settings">cameraReview.review.desc</Trans>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{cameraConfig?.objects?.genai?.enabled_in_config && (
|
||||
<>
|
||||
<Separator className="my-2 flex bg-secondary" />
|
||||
|
||||
<Heading as="h4" className="my-2">
|
||||
<Trans ns="views/settings">
|
||||
cameraReview.object_descriptions.title
|
||||
</Trans>
|
||||
</Heading>
|
||||
|
||||
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 space-y-3 text-sm text-primary-variant">
|
||||
<div className="flex flex-row items-center">
|
||||
<Switch
|
||||
id="alerts-enabled"
|
||||
className="mr-3"
|
||||
checked={objDescState == "ON"}
|
||||
onCheckedChange={(isChecked) => {
|
||||
sendObjDesc(isChecked ? "ON" : "OFF");
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="genai-enabled">
|
||||
<Trans>button.enabled</Trans>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-muted-foreground">
|
||||
<Trans ns="views/settings">
|
||||
cameraReview.object_descriptions.desc
|
||||
</Trans>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{cameraConfig?.review?.genai?.enabled_in_config && (
|
||||
<>
|
||||
<Separator className="my-2 flex bg-secondary" />
|
||||
|
||||
<Heading as="h4" className="my-2">
|
||||
<Trans ns="views/settings">
|
||||
cameraReview.review_descriptions.title
|
||||
</Trans>
|
||||
</Heading>
|
||||
|
||||
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 space-y-3 text-sm text-primary-variant">
|
||||
<div className="flex flex-row items-center">
|
||||
<Switch
|
||||
id="alerts-enabled"
|
||||
className="mr-3"
|
||||
checked={revDescState == "ON"}
|
||||
onCheckedChange={(isChecked) => {
|
||||
sendRevDesc(isChecked ? "ON" : "OFF");
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="genai-enabled">
|
||||
<Trans>button.enabled</Trans>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-muted-foreground">
|
||||
<Trans ns="views/settings">
|
||||
cameraReview.review_descriptions.desc
|
||||
</Trans>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator className="my-2 flex bg-secondary" />
|
||||
|
||||
<Heading as="h4" className="my-2">
|
||||
<Trans ns="views/settings">
|
||||
cameraReview.reviewClassification.title
|
||||
</Trans>
|
||||
</Heading>
|
||||
|
||||
<div className="max-w-6xl">
|
||||
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
|
||||
<p>
|
||||
<Trans ns="views/settings">
|
||||
cameraReview.reviewClassification.desc
|
||||
</Trans>
|
||||
</p>
|
||||
<div className="flex items-center text-primary">
|
||||
<Link
|
||||
to={getLocaleDocUrl("configuration/review")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
{t("readTheDocumentation", { ns: "common" })}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="mt-2 space-y-6"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full max-w-5xl space-y-0",
|
||||
zones &&
|
||||
zones?.length > 0 &&
|
||||
"grid items-start gap-5 md:grid-cols-2",
|
||||
)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="alerts_zones"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
{zones && zones?.length > 0 ? (
|
||||
<>
|
||||
<div className="mb-2">
|
||||
<FormLabel className="flex flex-row items-center text-base">
|
||||
<Trans ns="views/settings">
|
||||
camera.review.alerts
|
||||
</Trans>
|
||||
<MdCircle className="ml-3 size-2 text-severity_alert" />
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
<Trans ns="views/settings">
|
||||
cameraReview.reviewClassification.selectAlertsZones
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</div>
|
||||
<div className="max-w-md rounded-lg bg-secondary p-4 md:max-w-full">
|
||||
{zones?.map((zone) => (
|
||||
<FormField
|
||||
key={zone.name}
|
||||
control={form.control}
|
||||
name="alerts_zones"
|
||||
render={({ field }) => (
|
||||
<FormItem
|
||||
key={zone.name}
|
||||
className="mb-3 flex flex-row items-center space-x-3 space-y-0 last:mb-0"
|
||||
>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
|
||||
checked={field.value?.includes(
|
||||
zone.name,
|
||||
)}
|
||||
onCheckedChange={(checked) => {
|
||||
setChangedValue(true);
|
||||
return checked
|
||||
? field.onChange([
|
||||
...field.value,
|
||||
zone.name,
|
||||
])
|
||||
: field.onChange(
|
||||
field.value?.filter(
|
||||
(value) =>
|
||||
value !== zone.name,
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel
|
||||
className={cn(
|
||||
"font-normal",
|
||||
!zone.friendly_name &&
|
||||
"smart-capitalize",
|
||||
)}
|
||||
>
|
||||
{zone.friendly_name || zone.name}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="font-normal text-destructive">
|
||||
<Trans ns="views/settings">
|
||||
cameraReview.reviewClassification.noDefinedZones
|
||||
</Trans>
|
||||
</div>
|
||||
)}
|
||||
<FormMessage />
|
||||
<div className="text-sm">
|
||||
{watchedAlertsZones && watchedAlertsZones.length > 0
|
||||
? t(
|
||||
"cameraReview.reviewClassification.zoneObjectAlertsTips",
|
||||
{
|
||||
alertsLabels,
|
||||
zone: formatList(
|
||||
watchedAlertsZones.map((zone) =>
|
||||
getZoneName(zone),
|
||||
),
|
||||
),
|
||||
cameraName: selectCameraName,
|
||||
},
|
||||
)
|
||||
: t(
|
||||
"cameraReview.reviewClassification.objectAlertsTips",
|
||||
{
|
||||
alertsLabels,
|
||||
cameraName: selectCameraName,
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="detections_zones"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
{zones && zones?.length > 0 && (
|
||||
<>
|
||||
<div className="mb-2">
|
||||
<FormLabel className="flex flex-row items-center text-base">
|
||||
<Trans ns="views/settings">
|
||||
camera.review.detections
|
||||
</Trans>
|
||||
<MdCircle className="ml-3 size-2 text-severity_detection" />
|
||||
</FormLabel>
|
||||
{selectDetections && (
|
||||
<FormDescription>
|
||||
<Trans ns="views/settings">
|
||||
cameraReview.reviewClassification.selectDetectionsZones
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectDetections && (
|
||||
<div className="max-w-md rounded-lg bg-secondary p-4 md:max-w-full">
|
||||
{zones?.map((zone) => (
|
||||
<FormField
|
||||
key={zone.name}
|
||||
control={form.control}
|
||||
name="detections_zones"
|
||||
render={({ field }) => (
|
||||
<FormItem
|
||||
key={zone.name}
|
||||
className="mb-3 flex flex-row items-center space-x-3 space-y-0 last:mb-0"
|
||||
>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
|
||||
checked={field.value?.includes(
|
||||
zone.name,
|
||||
)}
|
||||
onCheckedChange={(checked) => {
|
||||
return checked
|
||||
? field.onChange([
|
||||
...field.value,
|
||||
zone.name,
|
||||
])
|
||||
: field.onChange(
|
||||
field.value?.filter(
|
||||
(value) =>
|
||||
value !== zone.name,
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel
|
||||
className={cn(
|
||||
"font-normal",
|
||||
!zone.friendly_name &&
|
||||
"smart-capitalize",
|
||||
)}
|
||||
>
|
||||
{zone.friendly_name || zone.name}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<FormMessage />
|
||||
|
||||
<div className="mb-0 flex flex-row items-center gap-2">
|
||||
<Checkbox
|
||||
id="select-detections"
|
||||
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
|
||||
checked={selectDetections}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<label
|
||||
htmlFor="select-detections"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
<Trans ns="views/settings">
|
||||
cameraReview.reviewClassification.limitDetections
|
||||
</Trans>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="text-sm">
|
||||
{watchedDetectionsZones &&
|
||||
watchedDetectionsZones.length > 0 ? (
|
||||
!selectDetections ? (
|
||||
<Trans
|
||||
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.text"
|
||||
values={{
|
||||
detectionsLabels,
|
||||
zone: formatList(
|
||||
watchedDetectionsZones.map((zone) =>
|
||||
getZoneName(zone),
|
||||
),
|
||||
),
|
||||
cameraName: selectCameraName,
|
||||
}}
|
||||
ns="views/settings"
|
||||
/>
|
||||
) : (
|
||||
<Trans
|
||||
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.notSelectDetections"
|
||||
values={{
|
||||
detectionsLabels,
|
||||
zone: formatList(
|
||||
watchedDetectionsZones.map((zone) =>
|
||||
getZoneName(zone),
|
||||
),
|
||||
),
|
||||
cameraName: selectCameraName,
|
||||
}}
|
||||
ns="views/settings"
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Trans
|
||||
i18nKey="cameraReview.reviewClassification.objectDetectionsTips"
|
||||
values={{
|
||||
detectionsLabels,
|
||||
cameraName: selectCameraName,
|
||||
}}
|
||||
ns="views/settings"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Separator className="my-2 flex bg-secondary" />
|
||||
|
||||
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[25%]">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.reset", { ns: "common" })}
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
<Trans>button.reset</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
disabled={isLoading}
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
type="submit"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator />
|
||||
<span>
|
||||
<Trans>button.saving</Trans>
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<Trans>button.save</Trans>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
794
web/src/views/settings/CameraSettingsView.tsx
Normal file
794
web/src/views/settings/CameraSettingsView.tsx
Normal file
@ -0,0 +1,794 @@
|
||||
import Heading from "@/components/ui/heading";
|
||||
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { Toaster, toast } from "sonner";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import useSWR from "swr";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
|
||||
import axios from "axios";
|
||||
import { Link } from "react-router-dom";
|
||||
import { LuExternalLink } from "react-icons/lu";
|
||||
import { MdCircle } from "react-icons/md";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import {
|
||||
useAlertsState,
|
||||
useDetectionsState,
|
||||
useObjectDescriptionState,
|
||||
useReviewDescriptionState,
|
||||
} from "@/api/ws";
|
||||
import CameraEditForm from "@/components/settings/CameraEditForm";
|
||||
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;
|
||||
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
type CameraReviewSettingsValueType = {
|
||||
alerts_zones: string[];
|
||||
detections_zones: string[];
|
||||
};
|
||||
|
||||
export default function CameraSettingsView({
|
||||
selectedCamera,
|
||||
setUnsavedChanges,
|
||||
}: CameraSettingsViewProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const { getLocaleDocUrl } = useDocDomain();
|
||||
|
||||
const { data: config, mutate: updateConfig } =
|
||||
useSWR<FrigateConfig>("config");
|
||||
|
||||
const cameraConfig = useMemo(() => {
|
||||
if (config && selectedCamera) {
|
||||
return config.cameras[selectedCamera];
|
||||
}
|
||||
}, [config, selectedCamera]);
|
||||
|
||||
const [changedValue, setChangedValue] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectDetections, setSelectDetections] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<"settings" | "add" | "edit">(
|
||||
"settings",
|
||||
); // Control view state
|
||||
const [editCameraName, setEditCameraName] = useState<string | undefined>(
|
||||
undefined,
|
||||
); // Track camera being edited
|
||||
const [showWizard, setShowWizard] = useState(false);
|
||||
|
||||
const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!;
|
||||
|
||||
const selectCameraName = useCameraFriendlyName(selectedCamera);
|
||||
|
||||
// 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,
|
||||
}));
|
||||
}
|
||||
}, [cameraConfig]);
|
||||
|
||||
const alertsLabels = useMemo(() => {
|
||||
return cameraConfig?.review.alerts.labels
|
||||
? formatList(
|
||||
cameraConfig.review.alerts.labels.map((label) =>
|
||||
getTranslatedLabel(
|
||||
label,
|
||||
cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object",
|
||||
),
|
||||
),
|
||||
)
|
||||
: "";
|
||||
}, [cameraConfig]);
|
||||
|
||||
const detectionsLabels = useMemo(() => {
|
||||
return cameraConfig?.review.detections.labels
|
||||
? formatList(
|
||||
cameraConfig.review.detections.labels.map((label) =>
|
||||
getTranslatedLabel(
|
||||
label,
|
||||
cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object",
|
||||
),
|
||||
),
|
||||
)
|
||||
: "";
|
||||
}, [cameraConfig]);
|
||||
|
||||
// form
|
||||
|
||||
const formSchema = z.object({
|
||||
alerts_zones: z.array(z.string()),
|
||||
detections_zones: z.array(z.string()),
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
mode: "onChange",
|
||||
defaultValues: {
|
||||
alerts_zones: cameraConfig?.review.alerts.required_zones || [],
|
||||
detections_zones: cameraConfig?.review.detections.required_zones || [],
|
||||
},
|
||||
});
|
||||
|
||||
const watchedAlertsZones = form.watch("alerts_zones");
|
||||
const watchedDetectionsZones = form.watch("detections_zones");
|
||||
|
||||
const { payload: alertsState, send: sendAlerts } =
|
||||
useAlertsState(selectedCamera);
|
||||
const { payload: detectionsState, send: sendDetections } =
|
||||
useDetectionsState(selectedCamera);
|
||||
|
||||
const { payload: objDescState, send: sendObjDesc } =
|
||||
useObjectDescriptionState(selectedCamera);
|
||||
const { payload: revDescState, send: sendRevDesc } =
|
||||
useReviewDescriptionState(selectedCamera);
|
||||
|
||||
const handleCheckedChange = useCallback(
|
||||
(isChecked: boolean) => {
|
||||
if (!isChecked) {
|
||||
form.reset({
|
||||
alerts_zones: watchedAlertsZones,
|
||||
detections_zones: [],
|
||||
});
|
||||
}
|
||||
setChangedValue(true);
|
||||
setSelectDetections(isChecked as boolean);
|
||||
},
|
||||
// we know that these deps are correct
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[watchedAlertsZones],
|
||||
);
|
||||
|
||||
const saveToConfig = useCallback(
|
||||
async (
|
||||
{ alerts_zones, detections_zones }: CameraReviewSettingsValueType, // values submitted via the form
|
||||
) => {
|
||||
const createQuery = (zones: string[], type: "alerts" | "detections") =>
|
||||
zones.length
|
||||
? zones
|
||||
.map(
|
||||
(zone) =>
|
||||
`&cameras.${selectedCamera}.review.${type}.required_zones=${zone}`,
|
||||
)
|
||||
.join("")
|
||||
: cameraConfig?.review[type]?.required_zones &&
|
||||
cameraConfig?.review[type]?.required_zones.length > 0
|
||||
? `&cameras.${selectedCamera}.review.${type}.required_zones`
|
||||
: "";
|
||||
|
||||
const alertQueries = createQuery(alerts_zones, "alerts");
|
||||
const detectionQueries = createQuery(detections_zones, "detections");
|
||||
|
||||
axios
|
||||
.put(`config/set?${alertQueries}${detectionQueries}`, {
|
||||
requires_restart: 0,
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
toast.success(
|
||||
t("cameraReview.reviewClassification.toast.success"),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
updateConfig();
|
||||
} else {
|
||||
toast.error(
|
||||
t("toast.save.error.title", {
|
||||
errorMessage: res.statusText,
|
||||
ns: "common",
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(
|
||||
t("toast.save.error.title", {
|
||||
errorMessage,
|
||||
ns: "common",
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
},
|
||||
[updateConfig, setIsLoading, selectedCamera, cameraConfig, t],
|
||||
);
|
||||
|
||||
const onCancel = useCallback(() => {
|
||||
if (!cameraConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
setChangedValue(false);
|
||||
setUnsavedChanges(false);
|
||||
removeMessage(
|
||||
"camera_settings",
|
||||
`review_classification_settings_${selectedCamera}`,
|
||||
);
|
||||
form.reset({
|
||||
alerts_zones: cameraConfig?.review.alerts.required_zones ?? [],
|
||||
detections_zones: cameraConfig?.review.detections.required_zones || [],
|
||||
});
|
||||
setSelectDetections(
|
||||
!!cameraConfig?.review.detections.required_zones?.length,
|
||||
);
|
||||
// we know that these deps are correct
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [removeMessage, selectedCamera, setUnsavedChanges, cameraConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
onCancel();
|
||||
// we know that these deps are correct
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedCamera]);
|
||||
|
||||
useEffect(() => {
|
||||
if (changedValue) {
|
||||
addMessage(
|
||||
"camera_settings",
|
||||
t("cameraReview.reviewClassification.unsavedChanges", {
|
||||
camera: selectedCamera,
|
||||
}),
|
||||
undefined,
|
||||
`review_classification_settings_${selectedCamera}`,
|
||||
);
|
||||
} else {
|
||||
removeMessage(
|
||||
"camera_settings",
|
||||
`review_classification_settings_${selectedCamera}`,
|
||||
);
|
||||
}
|
||||
// we know that these deps are correct
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [changedValue, selectedCamera]);
|
||||
|
||||
function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
setIsLoading(true);
|
||||
|
||||
saveToConfig(values as CameraReviewSettingsValueType);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
document.title = t("documentTitle.cameraReview");
|
||||
}, [t]);
|
||||
|
||||
// Handle back navigation from add/edit form
|
||||
const handleBack = useCallback(() => {
|
||||
setViewMode("settings");
|
||||
setEditCameraName(undefined);
|
||||
updateConfig();
|
||||
}, [updateConfig]);
|
||||
|
||||
if (!cameraConfig && !selectedCamera && viewMode === "settings") {
|
||||
return <ActivityIndicator />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex size-full flex-col md:flex-row">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
|
||||
{viewMode === "settings" ? (
|
||||
<>
|
||||
<Heading as="h4" className="mb-2">
|
||||
{t("cameraReview.title")}
|
||||
</Heading>
|
||||
|
||||
<Heading as="h4" className="my-2">
|
||||
<Trans ns="views/settings">cameraReview.review.title</Trans>
|
||||
</Heading>
|
||||
|
||||
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 space-y-3 text-sm text-primary-variant">
|
||||
<div className="flex flex-row items-center">
|
||||
<Switch
|
||||
id="alerts-enabled"
|
||||
className="mr-3"
|
||||
checked={alertsState == "ON"}
|
||||
onCheckedChange={(isChecked) => {
|
||||
sendAlerts(isChecked ? "ON" : "OFF");
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="alerts-enabled">
|
||||
<Trans ns="views/settings">
|
||||
cameraReview.review.alerts
|
||||
</Trans>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-row items-center">
|
||||
<Switch
|
||||
id="detections-enabled"
|
||||
className="mr-3"
|
||||
checked={detectionsState == "ON"}
|
||||
onCheckedChange={(isChecked) => {
|
||||
sendDetections(isChecked ? "ON" : "OFF");
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="detections-enabled">
|
||||
<Trans ns="views/settings">
|
||||
camera.review.detections
|
||||
</Trans>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-muted-foreground">
|
||||
<Trans ns="views/settings">cameraReview.review.desc</Trans>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{cameraConfig?.objects?.genai?.enabled_in_config && (
|
||||
<>
|
||||
<Separator className="my-2 flex bg-secondary" />
|
||||
|
||||
<Heading as="h4" className="my-2">
|
||||
<Trans ns="views/settings">
|
||||
cameraReview.object_descriptions.title
|
||||
</Trans>
|
||||
</Heading>
|
||||
|
||||
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 space-y-3 text-sm text-primary-variant">
|
||||
<div className="flex flex-row items-center">
|
||||
<Switch
|
||||
id="alerts-enabled"
|
||||
className="mr-3"
|
||||
checked={objDescState == "ON"}
|
||||
onCheckedChange={(isChecked) => {
|
||||
sendObjDesc(isChecked ? "ON" : "OFF");
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="genai-enabled">
|
||||
<Trans>button.enabled</Trans>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-muted-foreground">
|
||||
<Trans ns="views/settings">
|
||||
cameraReview.object_descriptions.desc
|
||||
</Trans>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{cameraConfig?.review?.genai?.enabled_in_config && (
|
||||
<>
|
||||
<Separator className="my-2 flex bg-secondary" />
|
||||
|
||||
<Heading as="h4" className="my-2">
|
||||
<Trans ns="views/settings">
|
||||
cameraReview.review_descriptions.title
|
||||
</Trans>
|
||||
</Heading>
|
||||
|
||||
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 space-y-3 text-sm text-primary-variant">
|
||||
<div className="flex flex-row items-center">
|
||||
<Switch
|
||||
id="alerts-enabled"
|
||||
className="mr-3"
|
||||
checked={revDescState == "ON"}
|
||||
onCheckedChange={(isChecked) => {
|
||||
sendRevDesc(isChecked ? "ON" : "OFF");
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="genai-enabled">
|
||||
<Trans>button.enabled</Trans>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-muted-foreground">
|
||||
<Trans ns="views/settings">
|
||||
cameraReview.review_descriptions.desc
|
||||
</Trans>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator className="my-2 flex bg-secondary" />
|
||||
|
||||
<Heading as="h4" className="my-2">
|
||||
<Trans ns="views/settings">
|
||||
cameraReview.reviewClassification.title
|
||||
</Trans>
|
||||
</Heading>
|
||||
|
||||
<div className="max-w-6xl">
|
||||
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
|
||||
<p>
|
||||
<Trans ns="views/settings">
|
||||
cameraReview.reviewClassification.desc
|
||||
</Trans>
|
||||
</p>
|
||||
<div className="flex items-center text-primary">
|
||||
<Link
|
||||
to={getLocaleDocUrl("configuration/review")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
{t("readTheDocumentation", { ns: "common" })}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="mt-2 space-y-6"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full max-w-5xl space-y-0",
|
||||
zones &&
|
||||
zones?.length > 0 &&
|
||||
"grid items-start gap-5 md:grid-cols-2",
|
||||
)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="alerts_zones"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
{zones && zones?.length > 0 ? (
|
||||
<>
|
||||
<div className="mb-2">
|
||||
<FormLabel className="flex flex-row items-center text-base">
|
||||
<Trans ns="views/settings">
|
||||
camera.review.alerts
|
||||
</Trans>
|
||||
<MdCircle className="ml-3 size-2 text-severity_alert" />
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
<Trans ns="views/settings">
|
||||
cameraReview.reviewClassification.selectAlertsZones
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</div>
|
||||
<div className="max-w-md rounded-lg bg-secondary p-4 md:max-w-full">
|
||||
{zones?.map((zone) => (
|
||||
<FormField
|
||||
key={zone.name}
|
||||
control={form.control}
|
||||
name="alerts_zones"
|
||||
render={({ field }) => (
|
||||
<FormItem
|
||||
key={zone.name}
|
||||
className="mb-3 flex flex-row items-center space-x-3 space-y-0 last:mb-0"
|
||||
>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
|
||||
checked={field.value?.includes(
|
||||
zone.name,
|
||||
)}
|
||||
onCheckedChange={(checked) => {
|
||||
setChangedValue(true);
|
||||
return checked
|
||||
? field.onChange([
|
||||
...field.value,
|
||||
zone.name,
|
||||
])
|
||||
: field.onChange(
|
||||
field.value?.filter(
|
||||
(value) =>
|
||||
value !== zone.name,
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel
|
||||
className={cn(
|
||||
"font-normal",
|
||||
!zone.friendly_name &&
|
||||
"smart-capitalize",
|
||||
)}
|
||||
>
|
||||
{zone.friendly_name || zone.name}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="font-normal text-destructive">
|
||||
<Trans ns="views/settings">
|
||||
cameraReview.reviewClassification.noDefinedZones
|
||||
</Trans>
|
||||
</div>
|
||||
)}
|
||||
<FormMessage />
|
||||
<div className="text-sm">
|
||||
{watchedAlertsZones && watchedAlertsZones.length > 0
|
||||
? t(
|
||||
"cameraReview.reviewClassification.zoneObjectAlertsTips",
|
||||
{
|
||||
alertsLabels,
|
||||
zone: formatList(
|
||||
watchedAlertsZones.map((zone) =>
|
||||
getZoneName(zone),
|
||||
),
|
||||
),
|
||||
cameraName: selectCameraName,
|
||||
},
|
||||
)
|
||||
: t(
|
||||
"cameraReview.reviewClassification.objectAlertsTips",
|
||||
{
|
||||
alertsLabels,
|
||||
cameraName: selectCameraName,
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="detections_zones"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
{zones && zones?.length > 0 && (
|
||||
<>
|
||||
<div className="mb-2">
|
||||
<FormLabel className="flex flex-row items-center text-base">
|
||||
<Trans ns="views/settings">
|
||||
camera.review.detections
|
||||
</Trans>
|
||||
<MdCircle className="ml-3 size-2 text-severity_detection" />
|
||||
</FormLabel>
|
||||
{selectDetections && (
|
||||
<FormDescription>
|
||||
<Trans ns="views/settings">
|
||||
cameraReview.reviewClassification.selectDetectionsZones
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectDetections && (
|
||||
<div className="max-w-md rounded-lg bg-secondary p-4 md:max-w-full">
|
||||
{zones?.map((zone) => (
|
||||
<FormField
|
||||
key={zone.name}
|
||||
control={form.control}
|
||||
name="detections_zones"
|
||||
render={({ field }) => (
|
||||
<FormItem
|
||||
key={zone.name}
|
||||
className="mb-3 flex flex-row items-center space-x-3 space-y-0 last:mb-0"
|
||||
>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
|
||||
checked={field.value?.includes(
|
||||
zone.name,
|
||||
)}
|
||||
onCheckedChange={(checked) => {
|
||||
return checked
|
||||
? field.onChange([
|
||||
...field.value,
|
||||
zone.name,
|
||||
])
|
||||
: field.onChange(
|
||||
field.value?.filter(
|
||||
(value) =>
|
||||
value !== zone.name,
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel
|
||||
className={cn(
|
||||
"font-normal",
|
||||
!zone.friendly_name &&
|
||||
"smart-capitalize",
|
||||
)}
|
||||
>
|
||||
{zone.friendly_name || zone.name}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<FormMessage />
|
||||
|
||||
<div className="mb-0 flex flex-row items-center gap-2">
|
||||
<Checkbox
|
||||
id="select-detections"
|
||||
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
|
||||
checked={selectDetections}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<label
|
||||
htmlFor="select-detections"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
<Trans ns="views/settings">
|
||||
cameraReview.reviewClassification.limitDetections
|
||||
</Trans>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="text-sm">
|
||||
{watchedDetectionsZones &&
|
||||
watchedDetectionsZones.length > 0 ? (
|
||||
!selectDetections ? (
|
||||
<Trans
|
||||
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.text"
|
||||
values={{
|
||||
detectionsLabels,
|
||||
zone: formatList(
|
||||
watchedDetectionsZones.map((zone) =>
|
||||
getZoneName(zone),
|
||||
),
|
||||
),
|
||||
cameraName: selectCameraName,
|
||||
}}
|
||||
ns="views/settings"
|
||||
/>
|
||||
) : (
|
||||
<Trans
|
||||
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.notSelectDetections"
|
||||
values={{
|
||||
detectionsLabels,
|
||||
zone: formatList(
|
||||
watchedDetectionsZones.map((zone) =>
|
||||
getZoneName(zone),
|
||||
),
|
||||
),
|
||||
cameraName: selectCameraName,
|
||||
}}
|
||||
ns="views/settings"
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Trans
|
||||
i18nKey="cameraReview.reviewClassification.objectDetectionsTips"
|
||||
values={{
|
||||
detectionsLabels,
|
||||
cameraName: selectCameraName,
|
||||
}}
|
||||
ns="views/settings"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Separator className="my-2 flex bg-secondary" />
|
||||
|
||||
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[25%]">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.reset", { ns: "common" })}
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
<Trans>button.reset</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
disabled={isLoading}
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
type="submit"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator />
|
||||
<span>
|
||||
<Trans>button.saving</Trans>
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<Trans>button.save</Trans>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Button
|
||||
className={`flex items-center gap-2.5 rounded-lg`}
|
||||
aria-label={t("label.back", { ns: "common" })}
|
||||
size="sm"
|
||||
onClick={handleBack}
|
||||
>
|
||||
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
||||
{isDesktop && (
|
||||
<div className="text-primary">
|
||||
{t("button.back", { ns: "common" })}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="md:max-w-5xl">
|
||||
<CameraEditForm
|
||||
cameraName={viewMode === "edit" ? editCameraName : undefined}
|
||||
onSave={handleBack}
|
||||
onCancel={handleBack}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CameraWizardDialog
|
||||
open={showWizard}
|
||||
onClose={() => setShowWizard(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -201,17 +201,12 @@ export default function TriggerView({
|
||||
.then((configResponse) => {
|
||||
if (configResponse.status === 200) {
|
||||
updateConfig();
|
||||
const displayName =
|
||||
friendly_name && friendly_name !== ""
|
||||
? `${friendly_name} (${name})`
|
||||
: name;
|
||||
|
||||
toast.success(
|
||||
t(
|
||||
isEdit
|
||||
? "triggers.toast.success.updateTrigger"
|
||||
: "triggers.toast.success.createTrigger",
|
||||
{ name: displayName },
|
||||
{ name },
|
||||
),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
@ -356,19 +351,8 @@ export default function TriggerView({
|
||||
.then((configResponse) => {
|
||||
if (configResponse.status === 200) {
|
||||
updateConfig();
|
||||
const friendly =
|
||||
config?.cameras?.[selectedCamera]?.semantic_search
|
||||
?.triggers?.[name]?.friendly_name;
|
||||
|
||||
const displayName =
|
||||
friendly && friendly !== ""
|
||||
? `${friendly} (${name})`
|
||||
: name;
|
||||
|
||||
toast.success(
|
||||
t("triggers.toast.success.deleteTrigger", {
|
||||
name: displayName,
|
||||
}),
|
||||
t("triggers.toast.success.deleteTrigger", { name }),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
@ -397,7 +381,7 @@ export default function TriggerView({
|
||||
setIsLoading(false);
|
||||
});
|
||||
},
|
||||
[t, updateConfig, selectedCamera, setUnsavedChanges, config],
|
||||
[t, updateConfig, selectedCamera, setUnsavedChanges],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@ -859,14 +843,7 @@ export default function TriggerView({
|
||||
/>
|
||||
<DeleteTriggerDialog
|
||||
show={showDelete}
|
||||
triggerName={
|
||||
selectedTrigger
|
||||
? selectedTrigger.friendly_name &&
|
||||
selectedTrigger.friendly_name !== ""
|
||||
? `${selectedTrigger.friendly_name} (${selectedTrigger.name})`
|
||||
: selectedTrigger.name
|
||||
: ""
|
||||
}
|
||||
triggerName={selectedTrigger?.name ?? ""}
|
||||
isLoading={isLoading}
|
||||
onCancel={() => {
|
||||
setShowDelete(false);
|
||||
|
||||
@ -72,7 +72,8 @@ export default function StorageMetrics({
|
||||
const earliestDate = useMemo(() => {
|
||||
const keys = Object.keys(recordingsSummary || {});
|
||||
return keys.length
|
||||
? new TZDate(keys[0] + "T00:00:00", timezone).getTime() / 1000
|
||||
? new TZDate(keys[keys.length - 1] + "T00:00:00", timezone).getTime() /
|
||||
1000
|
||||
: null;
|
||||
}, [recordingsSummary, timezone]);
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user