mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-21 12:36:42 +03:00
Compare commits
1 Commits
062232e745
...
708c3fb06c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
708c3fb06c |
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
},
|
||||
headers: { "Content-Type": "application/json", "X-CSRF-TOKEN": 1 },
|
||||
body: JSON.stringify({ ids: [event.notification.data.id] }),
|
||||
}), // eslint-disable-line comma-dangle
|
||||
);
|
||||
});
|
||||
}
|
||||
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,15 +120,16 @@ 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" && (
|
||||
{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>
|
||||
)}
|
||||
@ -131,13 +139,25 @@ export default function SearchResultActions({
|
||||
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}
|
||||
value="admin"
|
||||
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>
|
||||
<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) =>
|
||||
(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) =>
|
||||
(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,9 +169,8 @@ export function TrackingDetails({
|
||||
})
|
||||
: "";
|
||||
|
||||
const formattedEnd =
|
||||
config && event.end_time != null
|
||||
? formatUnixTimestampToDateTime(event.end_time, {
|
||||
const formattedEnd = config
|
||||
? formatUnixTimestampToDateTime(event.end_time ?? 0, {
|
||||
timezone: config.ui.timezone,
|
||||
date_format:
|
||||
config.ui.time_format == "24hour"
|
||||
@ -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;
|
||||
}
|
||||
// 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 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 (eventEnd == null) {
|
||||
return false;
|
||||
}
|
||||
return effectiveTime >= event.start_time && effectiveTime <= eventEnd;
|
||||
}, [effectiveTime, event.start_time, event.end_time, eventSequence]);
|
||||
const currentTime = effectiveTime ?? 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;
|
||||
|
||||
const containerRect = timelineContainerRef.current.getBoundingClientRect();
|
||||
const validRefs = rowRefs.current.filter((r) => r !== null);
|
||||
if (validRefs.length === 0) return;
|
||||
|
||||
const centers = validRefs.map((n) => {
|
||||
const r = n.getBoundingClientRect();
|
||||
return r.top + r.height / 2 - containerRect.top;
|
||||
});
|
||||
|
||||
const topOffset = Math.max(0, centers[0]);
|
||||
const bottomOffset = Math.max(
|
||||
0,
|
||||
containerRect.height - centers[centers.length - 1],
|
||||
);
|
||||
|
||||
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];
|
||||
// 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 {
|
||||
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]);
|
||||
// No events passed yet
|
||||
if (lastPassedIndex < 0) return 0;
|
||||
|
||||
// All events passed
|
||||
if (lastPassedIndex >= eventSequence.length - 1) return 100;
|
||||
|
||||
// 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);
|
||||
|
||||
// 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;
|
||||
|
||||
// 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]);
|
||||
|
||||
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,13 +589,8 @@ export function TrackingDetails({
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${item.timestamp}-${item.source_id ?? ""}-${idx}`}
|
||||
ref={(el) => {
|
||||
rowRefs.current[idx] = el;
|
||||
}}
|
||||
>
|
||||
<LifecycleIconRow
|
||||
key={`${item.timestamp}-${item.source_id ?? ""}-${idx}`}
|
||||
item={item}
|
||||
isActive={isActive}
|
||||
formattedEventTimestamp={formattedEventTimestamp}
|
||||
@ -770,7 +603,6 @@ export function TrackingDetails({
|
||||
effectiveTime={effectiveTime}
|
||||
isTimelineActive={isWithinEventRange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</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) => ({
|
||||
path: input.path,
|
||||
roles: input.roles as Role[],
|
||||
}))
|
||||
: camera.ffmpeg?.inputs?.length
|
||||
defaultValues.ffmpeg.inputs = camera.ffmpeg?.inputs?.length
|
||||
? camera.ffmpeg.inputs.map((input) => ({
|
||||
path: input.path,
|
||||
roles: input.roles as Role[],
|
||||
}))
|
||||
: 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…
Reference in New Issue
Block a user