mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-11 05:35:25 +03:00
Merge branch 'blakeblackshear:dev' into dev
This commit is contained in:
commit
29f87cc839
2
.github/workflows/pull_request.yml
vendored
2
.github/workflows/pull_request.yml
vendored
@ -65,7 +65,7 @@ jobs:
|
|||||||
- name: Check out the repository
|
- name: Check out the repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||||
uses: actions/setup-python@v5.0.0
|
uses: actions/setup-python@v5.1.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||||
- name: Install requirements
|
- name: Install requirements
|
||||||
|
|||||||
@ -4,15 +4,15 @@ imutils == 0.5.*
|
|||||||
markupsafe == 2.1.*
|
markupsafe == 2.1.*
|
||||||
matplotlib == 3.7.*
|
matplotlib == 3.7.*
|
||||||
mypy == 1.6.1
|
mypy == 1.6.1
|
||||||
numpy == 1.23.*
|
numpy == 1.26.*
|
||||||
onvif_zeep == 0.2.12
|
onvif_zeep == 0.2.12
|
||||||
opencv-python-headless == 4.7.0.*
|
opencv-python-headless == 4.7.0.*
|
||||||
paho-mqtt == 2.0.*
|
paho-mqtt == 2.0.*
|
||||||
pandas == 2.1.4
|
pandas == 2.2.*
|
||||||
peewee == 3.17.*
|
peewee == 3.17.*
|
||||||
peewee_migrate == 1.12.*
|
peewee_migrate == 1.12.*
|
||||||
psutil == 5.9.*
|
psutil == 5.9.*
|
||||||
pydantic == 2.6.*
|
pydantic == 2.7.*
|
||||||
git+https://github.com/fbcotter/py3nvml#egg=py3nvml
|
git+https://github.com/fbcotter/py3nvml#egg=py3nvml
|
||||||
PyYAML == 6.0.*
|
PyYAML == 6.0.*
|
||||||
pytz == 2024.1
|
pytz == 2024.1
|
||||||
|
|||||||
@ -62,7 +62,7 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 5000;
|
listen [::]:5000 ipv6only=off;
|
||||||
|
|
||||||
# vod settings
|
# vod settings
|
||||||
vod_base_url '';
|
vod_base_url '';
|
||||||
@ -238,14 +238,14 @@ http {
|
|||||||
|
|
||||||
location /api/stats {
|
location /api/stats {
|
||||||
access_log off;
|
access_log off;
|
||||||
rewrite ^/api/(.*)$ $1 break;
|
rewrite ^/api(/.*)$ $1 break;
|
||||||
proxy_pass http://frigate_api;
|
proxy_pass http://frigate_api;
|
||||||
include proxy.conf;
|
include proxy.conf;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /api/version {
|
location /api/version {
|
||||||
access_log off;
|
access_log off;
|
||||||
rewrite ^/api/(.*)$ $1 break;
|
rewrite ^/api(/.*)$ $1 break;
|
||||||
proxy_pass http://frigate_api;
|
proxy_pass http://frigate_api;
|
||||||
include proxy.conf;
|
include proxy.conf;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -257,6 +257,28 @@ objects:
|
|||||||
# Checks based on the bottom center of the bounding box of the object
|
# Checks based on the bottom center of the bounding box of the object
|
||||||
mask: 0,0,1000,0,1000,200,0,200
|
mask: 0,0,1000,0,1000,200,0,200
|
||||||
|
|
||||||
|
# Optional: Review configuration
|
||||||
|
# NOTE: Can be overridden at the camera level
|
||||||
|
review:
|
||||||
|
# Optional: alerts configuration
|
||||||
|
alerts:
|
||||||
|
# Optional: labels that qualify as an alert (default: shown below)
|
||||||
|
labels:
|
||||||
|
- car
|
||||||
|
- person
|
||||||
|
# Optional: required zones for an object to be marked as an alert (default: none)
|
||||||
|
required_zones:
|
||||||
|
- driveway
|
||||||
|
# Optional: detections configuration
|
||||||
|
detections:
|
||||||
|
# Optional: labels that qualify as a detection (default: all labels that are tracked / listened to)
|
||||||
|
labels:
|
||||||
|
- car
|
||||||
|
- person
|
||||||
|
# Optional: required zones for an object to be marked as a detection (default: none)
|
||||||
|
required_zones:
|
||||||
|
- driveway
|
||||||
|
|
||||||
# Optional: Motion configuration
|
# Optional: Motion configuration
|
||||||
# NOTE: Can be overridden at the camera level
|
# NOTE: Can be overridden at the camera level
|
||||||
motion:
|
motion:
|
||||||
@ -345,8 +367,6 @@ record:
|
|||||||
# Optional: Objects to save recordings for. (default: all tracked objects)
|
# Optional: Objects to save recordings for. (default: all tracked objects)
|
||||||
objects:
|
objects:
|
||||||
- person
|
- person
|
||||||
# Optional: Restrict recordings to objects that entered any of the listed zones (default: no required zones)
|
|
||||||
required_zones: []
|
|
||||||
# Optional: Retention settings for recordings of events
|
# Optional: Retention settings for recordings of events
|
||||||
retain:
|
retain:
|
||||||
# Required: Default retention days (default: shown below)
|
# Required: Default retention days (default: shown below)
|
||||||
|
|||||||
@ -19,17 +19,18 @@ To create a zone, follow [the steps for a "Motion mask"](masks.md), but use the
|
|||||||
Often you will only want events to be created when an object enters areas of interest. This is done using zones along with setting required_zones. Let's say you only want to be notified when an object enters your entire_yard zone, the config would be:
|
Often you will only want events to be created when an object enters areas of interest. This is done using zones along with setting required_zones. Let's say you only want to be notified when an object enters your entire_yard zone, the config would be:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
camera:
|
cameras:
|
||||||
record:
|
name_of_your_camera:
|
||||||
events:
|
record:
|
||||||
|
events:
|
||||||
|
required_zones:
|
||||||
|
- entire_yard
|
||||||
|
snapshots:
|
||||||
required_zones:
|
required_zones:
|
||||||
- entire_yard
|
- entire_yard
|
||||||
snapshots:
|
zones:
|
||||||
required_zones:
|
entire_yard:
|
||||||
- entire_yard
|
coordinates: ...
|
||||||
zones:
|
|
||||||
entire_yard:
|
|
||||||
coordinates: ...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Restricting zones to specific objects
|
### Restricting zones to specific objects
|
||||||
@ -37,25 +38,26 @@ camera:
|
|||||||
Sometimes you want to limit a zone to specific object types to have more granular control of when events/snapshots are saved. The following example will limit one zone to person objects and the other to cars.
|
Sometimes you want to limit a zone to specific object types to have more granular control of when events/snapshots are saved. The following example will limit one zone to person objects and the other to cars.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
camera:
|
cameras:
|
||||||
record:
|
name_of_your_camera:
|
||||||
events:
|
record:
|
||||||
|
events:
|
||||||
|
required_zones:
|
||||||
|
- entire_yard
|
||||||
|
- front_yard_street
|
||||||
|
snapshots:
|
||||||
required_zones:
|
required_zones:
|
||||||
- entire_yard
|
- entire_yard
|
||||||
- front_yard_street
|
- front_yard_street
|
||||||
snapshots:
|
zones:
|
||||||
required_zones:
|
entire_yard:
|
||||||
- entire_yard
|
coordinates: ... (everywhere you want a person)
|
||||||
- front_yard_street
|
objects:
|
||||||
zones:
|
- person
|
||||||
entire_yard:
|
front_yard_street:
|
||||||
coordinates: ... (everywhere you want a person)
|
coordinates: ... (just the street)
|
||||||
objects:
|
objects:
|
||||||
- person
|
- car
|
||||||
front_yard_street:
|
|
||||||
coordinates: ... (just the street)
|
|
||||||
objects:
|
|
||||||
- car
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Only car objects can trigger the `front_yard_street` zone and only person can trigger the `entire_yard`. You will get events for person objects that enter anywhere in the yard, and events for cars only if they enter the street.
|
Only car objects can trigger the `front_yard_street` zone and only person can trigger the `entire_yard`. You will get events for person objects that enter anywhere in the yard, and events for cars only if they enter the street.
|
||||||
@ -65,12 +67,13 @@ Only car objects can trigger the `front_yard_street` zone and only person can tr
|
|||||||
Sometimes objects are expected to be passing through a zone, but an object loitering in an area is unexpected. Zones can be configured to have a minimum loitering time before the object will be considered in the zone.
|
Sometimes objects are expected to be passing through a zone, but an object loitering in an area is unexpected. Zones can be configured to have a minimum loitering time before the object will be considered in the zone.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
camera:
|
cameras:
|
||||||
zones:
|
name_of_your_camera:
|
||||||
sidewalk:
|
zones:
|
||||||
loitering_time: 4 # unit is in seconds
|
sidewalk:
|
||||||
objects:
|
loitering_time: 4 # unit is in seconds
|
||||||
- person
|
objects:
|
||||||
|
- person
|
||||||
```
|
```
|
||||||
|
|
||||||
### Zone Inertia
|
### Zone Inertia
|
||||||
@ -78,21 +81,23 @@ camera:
|
|||||||
Sometimes an objects bounding box may be slightly incorrect and the bottom center of the bounding box is inside the zone while the object is not actually in the zone. Zone inertia helps guard against this by requiring an object's bounding box to be within the zone for multiple consecutive frames. This value can be configured:
|
Sometimes an objects bounding box may be slightly incorrect and the bottom center of the bounding box is inside the zone while the object is not actually in the zone. Zone inertia helps guard against this by requiring an object's bounding box to be within the zone for multiple consecutive frames. This value can be configured:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
camera:
|
cameras:
|
||||||
zones:
|
name_of_your_camera:
|
||||||
front_yard:
|
zones:
|
||||||
inertia: 3
|
front_yard:
|
||||||
objects:
|
inertia: 3
|
||||||
- person
|
objects:
|
||||||
|
- person
|
||||||
```
|
```
|
||||||
|
|
||||||
There may also be cases where you expect an object to quickly enter and exit a zone, like when a car is pulling into the driveway, and you may want to have the object be considered present in the zone immediately:
|
There may also be cases where you expect an object to quickly enter and exit a zone, like when a car is pulling into the driveway, and you may want to have the object be considered present in the zone immediately:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
camera:
|
cameras:
|
||||||
zones:
|
name_of_your_camera:
|
||||||
driveway_entrance:
|
zones:
|
||||||
inertia: 1
|
driveway_entrance:
|
||||||
objects:
|
inertia: 1
|
||||||
- car
|
objects:
|
||||||
|
- car
|
||||||
```
|
```
|
||||||
|
|||||||
@ -137,7 +137,10 @@ def stats_history():
|
|||||||
|
|
||||||
@bp.route("/config")
|
@bp.route("/config")
|
||||||
def config():
|
def config():
|
||||||
config = current_app.frigate_config.model_dump(mode="json", exclude_none=True)
|
config_obj: FrigateConfig = current_app.frigate_config
|
||||||
|
config: dict[str, dict[str, any]] = config_obj.model_dump(
|
||||||
|
mode="json", exclude_none=True
|
||||||
|
)
|
||||||
|
|
||||||
# remove the mqtt password
|
# remove the mqtt password
|
||||||
config["mqtt"].pop("password", None)
|
config["mqtt"].pop("password", None)
|
||||||
@ -154,9 +157,13 @@ def config():
|
|||||||
for cmd in camera_dict["ffmpeg_cmds"]:
|
for cmd in camera_dict["ffmpeg_cmds"]:
|
||||||
cmd["cmd"] = clean_camera_user_pass(" ".join(cmd["cmd"]))
|
cmd["cmd"] = clean_camera_user_pass(" ".join(cmd["cmd"]))
|
||||||
|
|
||||||
|
# ensure that zones are relative
|
||||||
|
for zone_name, zone in config_obj.cameras[camera_name].zones.items():
|
||||||
|
camera_dict["zones"][zone_name]["color"] = zone.color
|
||||||
|
|
||||||
config["plus"] = {"enabled": current_app.plus_api.is_active()}
|
config["plus"] = {"enabled": current_app.plus_api.is_active()}
|
||||||
|
|
||||||
for detector, detector_config in config["detectors"].items():
|
for detector_config in config["detectors"].values():
|
||||||
detector_config["model"]["labelmap"] = (
|
detector_config["model"]["labelmap"] = (
|
||||||
current_app.frigate_config.model.merged_labelmap
|
current_app.frigate_config.model.merged_labelmap
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1333,7 +1333,9 @@ def review_preview(id: str):
|
|||||||
|
|
||||||
padding = 8
|
padding = 8
|
||||||
start_ts = review.start_time - padding
|
start_ts = review.start_time - padding
|
||||||
end_ts = review.end_time + padding
|
end_ts = (
|
||||||
|
review.end_time + padding if review.end_time else datetime.now().timestamp()
|
||||||
|
)
|
||||||
return preview_gif(review.camera, start_ts, end_ts)
|
return preview_gif(review.camera, start_ts, end_ts)
|
||||||
|
|
||||||
|
|
||||||
@ -1344,8 +1346,15 @@ def preview_thumbnail(file_name: str):
|
|||||||
safe_file_name_current = secure_filename(file_name)
|
safe_file_name_current = secure_filename(file_name)
|
||||||
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
|
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
|
||||||
|
|
||||||
with open(os.path.join(preview_dir, safe_file_name_current), "rb") as image_file:
|
try:
|
||||||
jpg_bytes = image_file.read()
|
with open(
|
||||||
|
os.path.join(preview_dir, safe_file_name_current), "rb"
|
||||||
|
) as image_file:
|
||||||
|
jpg_bytes = image_file.read()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return make_response(
|
||||||
|
jsonify({"success": False, "message": "Image file not found"}), 404
|
||||||
|
)
|
||||||
|
|
||||||
response = make_response(jpg_bytes)
|
response = make_response(jpg_bytes)
|
||||||
response.headers["Content-Type"] = "image/jpeg"
|
response.headers["Content-Type"] = "image/jpeg"
|
||||||
|
|||||||
@ -27,10 +27,18 @@ def review():
|
|||||||
|
|
||||||
before = request.args.get("before", type=float, default=datetime.now().timestamp())
|
before = request.args.get("before", type=float, default=datetime.now().timestamp())
|
||||||
after = request.args.get(
|
after = request.args.get(
|
||||||
"after", type=float, default=(datetime.now() - timedelta(hours=18)).timestamp()
|
"after", type=float, default=(datetime.now() - timedelta(hours=24)).timestamp()
|
||||||
)
|
)
|
||||||
|
|
||||||
clauses = [((ReviewSegment.start_time > after) & (ReviewSegment.end_time < before))]
|
clauses = [
|
||||||
|
(
|
||||||
|
(ReviewSegment.start_time > after)
|
||||||
|
& (
|
||||||
|
(ReviewSegment.end_time.is_null(True))
|
||||||
|
| (ReviewSegment.end_time < before)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
if cameras != "all":
|
if cameras != "all":
|
||||||
camera_list = cameras.split(",")
|
camera_list = cameras.split(",")
|
||||||
@ -45,6 +53,7 @@ def review():
|
|||||||
for label in filtered_labels:
|
for label in filtered_labels:
|
||||||
label_clauses.append(
|
label_clauses.append(
|
||||||
(ReviewSegment.data["objects"].cast("text") % f'*"{label}"*')
|
(ReviewSegment.data["objects"].cast("text") % f'*"{label}"*')
|
||||||
|
| (ReviewSegment.data["audio"].cast("text") % f'*"{label}"*')
|
||||||
)
|
)
|
||||||
|
|
||||||
label_clause = reduce(operator.or_, label_clauses)
|
label_clause = reduce(operator.or_, label_clauses)
|
||||||
@ -94,6 +103,7 @@ def review_summary():
|
|||||||
for label in filtered_labels:
|
for label in filtered_labels:
|
||||||
label_clauses.append(
|
label_clauses.append(
|
||||||
(ReviewSegment.data["objects"].cast("text") % f'*"{label}"*')
|
(ReviewSegment.data["objects"].cast("text") % f'*"{label}"*')
|
||||||
|
| (ReviewSegment.data["audio"].cast("text") % f'*"{label}"*')
|
||||||
)
|
)
|
||||||
|
|
||||||
label_clause = reduce(operator.or_, label_clauses)
|
label_clause = reduce(operator.or_, label_clauses)
|
||||||
@ -429,12 +439,12 @@ def motion_activity():
|
|||||||
# normalize data
|
# normalize data
|
||||||
motion = (
|
motion = (
|
||||||
df["motion"]
|
df["motion"]
|
||||||
.resample(f"{scale}S")
|
.resample(f"{scale}s")
|
||||||
.apply(lambda x: max(x, key=abs, default=0.0))
|
.apply(lambda x: max(x, key=abs, default=0.0))
|
||||||
.fillna(0.0)
|
.fillna(0.0)
|
||||||
.to_frame()
|
.to_frame()
|
||||||
)
|
)
|
||||||
cameras = df["camera"].resample(f"{scale}S").agg(lambda x: ",".join(set(x)))
|
cameras = df["camera"].resample(f"{scale}s").agg(lambda x: ",".join(set(x)))
|
||||||
df = motion.join(cameras)
|
df = motion.join(cameras)
|
||||||
|
|
||||||
length = df.shape[0]
|
length = df.shape[0]
|
||||||
|
|||||||
@ -63,6 +63,7 @@ from frigate.storage import StorageMaintainer
|
|||||||
from frigate.timeline import TimelineProcessor
|
from frigate.timeline import TimelineProcessor
|
||||||
from frigate.types import CameraMetricsTypes, PTZMetricsTypes
|
from frigate.types import CameraMetricsTypes, PTZMetricsTypes
|
||||||
from frigate.util.builtin import save_default_config
|
from frigate.util.builtin import save_default_config
|
||||||
|
from frigate.util.config import migrate_frigate_config
|
||||||
from frigate.util.object import get_camera_regions_grid
|
from frigate.util.object import get_camera_regions_grid
|
||||||
from frigate.version import VERSION
|
from frigate.version import VERSION
|
||||||
from frigate.video import capture_camera, track_camera
|
from frigate.video import capture_camera, track_camera
|
||||||
@ -126,6 +127,9 @@ class FrigateApp:
|
|||||||
config_file = config_file_yaml
|
config_file = config_file_yaml
|
||||||
save_default_config(config_file)
|
save_default_config(config_file)
|
||||||
|
|
||||||
|
# check if the config file needs to be migrated
|
||||||
|
migrate_frigate_config(config_file)
|
||||||
|
|
||||||
user_config = FrigateConfig.parse_file(config_file)
|
user_config = FrigateConfig.parse_file(config_file)
|
||||||
self.config = user_config.runtime_config(self.plus_api)
|
self.config = user_config.runtime_config(self.plus_api)
|
||||||
|
|
||||||
@ -200,9 +204,6 @@ class FrigateApp:
|
|||||||
logging.getLogger("ws4py").setLevel("ERROR")
|
logging.getLogger("ws4py").setLevel("ERROR")
|
||||||
|
|
||||||
def init_queues(self) -> None:
|
def init_queues(self) -> None:
|
||||||
# Queues for clip processing
|
|
||||||
self.event_processed_queue: Queue = mp.Queue()
|
|
||||||
|
|
||||||
# Queue for cameras to push tracked objects to
|
# Queue for cameras to push tracked objects to
|
||||||
self.detected_frames_queue: Queue = mp.Queue(
|
self.detected_frames_queue: Queue = mp.Queue(
|
||||||
maxsize=sum(camera.enabled for camera in self.config.cameras.values()) * 2
|
maxsize=sum(camera.enabled for camera in self.config.cameras.values()) * 2
|
||||||
@ -420,7 +421,6 @@ class FrigateApp:
|
|||||||
self.config,
|
self.config,
|
||||||
self.dispatcher,
|
self.dispatcher,
|
||||||
self.detected_frames_queue,
|
self.detected_frames_queue,
|
||||||
self.event_processed_queue,
|
|
||||||
self.ptz_autotracker_thread,
|
self.ptz_autotracker_thread,
|
||||||
self.stop_event,
|
self.stop_event,
|
||||||
)
|
)
|
||||||
@ -517,7 +517,6 @@ class FrigateApp:
|
|||||||
def start_event_processor(self) -> None:
|
def start_event_processor(self) -> None:
|
||||||
self.event_processor = EventProcessor(
|
self.event_processor = EventProcessor(
|
||||||
self.config,
|
self.config,
|
||||||
self.event_processed_queue,
|
|
||||||
self.timeline_queue,
|
self.timeline_queue,
|
||||||
self.stop_event,
|
self.stop_event,
|
||||||
)
|
)
|
||||||
@ -672,6 +671,14 @@ class FrigateApp:
|
|||||||
logger.info("Stopping...")
|
logger.info("Stopping...")
|
||||||
self.stop_event.set()
|
self.stop_event.set()
|
||||||
|
|
||||||
|
# set an end_time on entries without an end_time before exiting
|
||||||
|
Event.update(end_time=datetime.datetime.now().timestamp()).where(
|
||||||
|
Event.end_time == None
|
||||||
|
).execute()
|
||||||
|
ReviewSegment.update(end_time=datetime.datetime.now().timestamp()).where(
|
||||||
|
ReviewSegment.end_time == None
|
||||||
|
).execute()
|
||||||
|
|
||||||
# Stop Communicators
|
# Stop Communicators
|
||||||
self.inter_process_communicator.stop()
|
self.inter_process_communicator.stop()
|
||||||
self.inter_config_updater.stop()
|
self.inter_config_updater.stop()
|
||||||
@ -704,7 +711,6 @@ class FrigateApp:
|
|||||||
shm.unlink()
|
shm.unlink()
|
||||||
|
|
||||||
for queue in [
|
for queue in [
|
||||||
self.event_processed_queue,
|
|
||||||
self.detected_frames_queue,
|
self.detected_frames_queue,
|
||||||
self.log_queue,
|
self.log_queue,
|
||||||
]:
|
]:
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import zmq
|
|||||||
|
|
||||||
SOCKET_CONTROL = "inproc://control.detections_updater"
|
SOCKET_CONTROL = "inproc://control.detections_updater"
|
||||||
SOCKET_PUB = "ipc:///tmp/cache/detect_pub"
|
SOCKET_PUB = "ipc:///tmp/cache/detect_pub"
|
||||||
SOCKET_SUB = "ipc:///tmp/cache/detect_sun"
|
SOCKET_SUB = "ipc:///tmp/cache/detect_sub"
|
||||||
|
|
||||||
|
|
||||||
class DetectionTypeEnum(str, Enum):
|
class DetectionTypeEnum(str, Enum):
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import zmq
|
|||||||
from frigate.events.types import EventStateEnum, EventTypeEnum
|
from frigate.events.types import EventStateEnum, EventTypeEnum
|
||||||
|
|
||||||
SOCKET_PUSH_PULL = "ipc:///tmp/cache/events"
|
SOCKET_PUSH_PULL = "ipc:///tmp/cache/events"
|
||||||
|
SOCKET_PUSH_PULL_END = "ipc:///tmp/cache/events_ended"
|
||||||
|
|
||||||
|
|
||||||
class EventUpdatePublisher:
|
class EventUpdatePublisher:
|
||||||
@ -37,7 +38,53 @@ class EventUpdateSubscriber:
|
|||||||
def check_for_update(
|
def check_for_update(
|
||||||
self, timeout=1
|
self, timeout=1
|
||||||
) -> tuple[EventTypeEnum, EventStateEnum, str, dict[str, any]]:
|
) -> tuple[EventTypeEnum, EventStateEnum, str, dict[str, any]]:
|
||||||
"""Returns updated config or None if no update."""
|
"""Returns events or None if no update."""
|
||||||
|
try:
|
||||||
|
has_update, _, _ = zmq.select([self.socket], [], [], timeout)
|
||||||
|
|
||||||
|
if has_update:
|
||||||
|
return self.socket.recv_pyobj()
|
||||||
|
except zmq.ZMQError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self.socket.close()
|
||||||
|
self.context.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
class EventEndPublisher:
|
||||||
|
"""Publishes events that have ended."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.context = zmq.Context()
|
||||||
|
self.socket = self.context.socket(zmq.PUSH)
|
||||||
|
self.socket.connect(SOCKET_PUSH_PULL_END)
|
||||||
|
|
||||||
|
def publish(
|
||||||
|
self, payload: tuple[EventTypeEnum, EventStateEnum, str, dict[str, any]]
|
||||||
|
) -> None:
|
||||||
|
"""There is no communication back to the processes."""
|
||||||
|
self.socket.send_pyobj(payload)
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self.socket.close()
|
||||||
|
self.context.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
class EventEndSubscriber:
|
||||||
|
"""Receives events that have ended."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.context = zmq.Context()
|
||||||
|
self.socket = self.context.socket(zmq.PULL)
|
||||||
|
self.socket.bind(SOCKET_PUSH_PULL_END)
|
||||||
|
|
||||||
|
def check_for_update(
|
||||||
|
self, timeout=1
|
||||||
|
) -> tuple[EventTypeEnum, EventStateEnum, str, dict[str, any]]:
|
||||||
|
"""Returns events ended or None if no update."""
|
||||||
try:
|
try:
|
||||||
has_update, _, _ = zmq.select([self.socket], [], [], timeout)
|
has_update, _, _ = zmq.select([self.socket], [], [], timeout)
|
||||||
|
|
||||||
|
|||||||
@ -245,10 +245,6 @@ class EventsConfig(FrigateBaseModel):
|
|||||||
default=5, title="Seconds to retain before event starts.", le=MAX_PRE_CAPTURE
|
default=5, title="Seconds to retain before event starts.", le=MAX_PRE_CAPTURE
|
||||||
)
|
)
|
||||||
post_capture: int = Field(default=5, title="Seconds to retain after event ends.")
|
post_capture: int = Field(default=5, title="Seconds to retain after event ends.")
|
||||||
required_zones: List[str] = Field(
|
|
||||||
default_factory=list,
|
|
||||||
title="List of required zones to be entered in order to save the event.",
|
|
||||||
)
|
|
||||||
objects: Optional[List[str]] = Field(
|
objects: Optional[List[str]] = Field(
|
||||||
None,
|
None,
|
||||||
title="List of objects to be detected in order to save the event.",
|
title="List of objects to be detected in order to save the event.",
|
||||||
@ -354,6 +350,34 @@ class RuntimeMotionConfig(MotionConfig):
|
|||||||
frame_shape = config.get("frame_shape", (1, 1))
|
frame_shape = config.get("frame_shape", (1, 1))
|
||||||
|
|
||||||
mask = config.get("mask", "")
|
mask = config.get("mask", "")
|
||||||
|
|
||||||
|
# masks and zones are saved as relative coordinates
|
||||||
|
# we know if any points are > 1 then it is using the
|
||||||
|
# old native resolution coordinates
|
||||||
|
if mask:
|
||||||
|
if isinstance(mask, list) and any(x > "1.0" for x in mask[0].split(",")):
|
||||||
|
relative_masks = []
|
||||||
|
for m in mask:
|
||||||
|
points = m.split(",")
|
||||||
|
relative_masks.append(
|
||||||
|
",".join(
|
||||||
|
[
|
||||||
|
f"{round(int(points[i]) / frame_shape[1], 3)},{round(int(points[i + 1]) / frame_shape[0], 3)}"
|
||||||
|
for i in range(0, len(points), 2)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
mask = relative_masks
|
||||||
|
elif isinstance(mask, str) and any(x > "1.0" for x in mask.split(",")):
|
||||||
|
points = mask.split(",")
|
||||||
|
mask = ",".join(
|
||||||
|
[
|
||||||
|
f"{round(int(points[i]) / frame_shape[1], 3)},{round(int(points[i + 1]) / frame_shape[0], 3)}"
|
||||||
|
for i in range(0, len(points), 2)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
config["raw_mask"] = mask
|
config["raw_mask"] = mask
|
||||||
|
|
||||||
if mask:
|
if mask:
|
||||||
@ -484,11 +508,40 @@ class RuntimeFilterConfig(FilterConfig):
|
|||||||
raw_mask: Optional[Union[str, List[str]]] = None
|
raw_mask: Optional[Union[str, List[str]]] = None
|
||||||
|
|
||||||
def __init__(self, **config):
|
def __init__(self, **config):
|
||||||
|
frame_shape = config.get("frame_shape", (1, 1))
|
||||||
mask = config.get("mask")
|
mask = config.get("mask")
|
||||||
|
|
||||||
|
# masks and zones are saved as relative coordinates
|
||||||
|
# we know if any points are > 1 then it is using the
|
||||||
|
# old native resolution coordinates
|
||||||
|
if mask:
|
||||||
|
if isinstance(mask, list) and any(x > "1.0" for x in mask[0].split(",")):
|
||||||
|
relative_masks = []
|
||||||
|
for m in mask:
|
||||||
|
points = m.split(",")
|
||||||
|
relative_masks.append(
|
||||||
|
",".join(
|
||||||
|
[
|
||||||
|
f"{round(int(points[i]) / frame_shape[1], 3)},{round(int(points[i + 1]) / frame_shape[0], 3)}"
|
||||||
|
for i in range(0, len(points), 2)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
mask = relative_masks
|
||||||
|
elif isinstance(mask, str) and any(x > "1.0" for x in mask.split(",")):
|
||||||
|
points = mask.split(",")
|
||||||
|
mask = ",".join(
|
||||||
|
[
|
||||||
|
f"{round(int(points[i]) / frame_shape[1], 3)},{round(int(points[i + 1]) / frame_shape[0], 3)}"
|
||||||
|
for i in range(0, len(points), 2)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
config["raw_mask"] = mask
|
config["raw_mask"] = mask
|
||||||
|
|
||||||
if mask is not None:
|
if mask is not None:
|
||||||
config["mask"] = create_mask(config.get("frame_shape", (1, 1)), mask)
|
config["mask"] = create_mask(frame_shape, mask)
|
||||||
|
|
||||||
super().__init__(**config)
|
super().__init__(**config)
|
||||||
|
|
||||||
@ -539,30 +592,106 @@ class ZoneConfig(BaseModel):
|
|||||||
super().__init__(**config)
|
super().__init__(**config)
|
||||||
|
|
||||||
self._color = config.get("color", (0, 0, 0))
|
self._color = config.get("color", (0, 0, 0))
|
||||||
coordinates = config["coordinates"]
|
self._contour = config.get("contour", np.array([]))
|
||||||
|
|
||||||
|
def generate_contour(self, frame_shape: tuple[int, int]):
|
||||||
|
coordinates = self.coordinates
|
||||||
|
|
||||||
|
# masks and zones are saved as relative coordinates
|
||||||
|
# we know if any points are > 1 then it is using the
|
||||||
|
# old native resolution coordinates
|
||||||
if isinstance(coordinates, list):
|
if isinstance(coordinates, list):
|
||||||
|
explicit = any(p.split(",")[0] > "1.0" for p in coordinates)
|
||||||
self._contour = np.array(
|
self._contour = np.array(
|
||||||
[[int(p.split(",")[0]), int(p.split(",")[1])] for p in coordinates]
|
[
|
||||||
|
(
|
||||||
|
[int(p.split(",")[0]), int(p.split(",")[1])]
|
||||||
|
if explicit
|
||||||
|
else [
|
||||||
|
int(float(p.split(",")[0]) * frame_shape[1]),
|
||||||
|
int(float(p.split(",")[1]) * frame_shape[0]),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for p in coordinates
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if explicit:
|
||||||
|
self.coordinates = ",".join(
|
||||||
|
[
|
||||||
|
f'{round(int(p.split(",")[0]) / frame_shape[1], 3)},{round(int(p.split(",")[1]) / frame_shape[0], 3)}'
|
||||||
|
for p in coordinates
|
||||||
|
]
|
||||||
|
)
|
||||||
elif isinstance(coordinates, str):
|
elif isinstance(coordinates, str):
|
||||||
points = coordinates.split(",")
|
points = coordinates.split(",")
|
||||||
|
explicit = any(p > "1.0" for p in points)
|
||||||
self._contour = np.array(
|
self._contour = np.array(
|
||||||
[[int(points[i]), int(points[i + 1])] for i in range(0, len(points), 2)]
|
[
|
||||||
|
(
|
||||||
|
[int(points[i]), int(points[i + 1])]
|
||||||
|
if explicit
|
||||||
|
else [
|
||||||
|
int(float(points[i]) * frame_shape[1]),
|
||||||
|
int(float(points[i + 1]) * frame_shape[0]),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for i in range(0, len(points), 2)
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if explicit:
|
||||||
|
self.coordinates = ",".join(
|
||||||
|
[
|
||||||
|
f"{round(int(points[i]) / frame_shape[1], 3)},{round(int(points[i + 1]) / frame_shape[0], 3)}"
|
||||||
|
for i in range(0, len(points), 2)
|
||||||
|
]
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
self._contour = np.array([])
|
self._contour = np.array([])
|
||||||
|
|
||||||
|
|
||||||
class ObjectConfig(FrigateBaseModel):
|
class ObjectConfig(FrigateBaseModel):
|
||||||
track: List[str] = Field(default=DEFAULT_TRACKED_OBJECTS, title="Objects to track.")
|
track: List[str] = Field(default=DEFAULT_TRACKED_OBJECTS, title="Objects to track.")
|
||||||
alert: List[str] = Field(
|
|
||||||
default=DEFAULT_ALERT_OBJECTS, title="Objects to create alerts for."
|
|
||||||
)
|
|
||||||
filters: Dict[str, FilterConfig] = Field(default={}, title="Object filters.")
|
filters: Dict[str, FilterConfig] = Field(default={}, title="Object filters.")
|
||||||
mask: Union[str, List[str]] = Field(default="", title="Object mask.")
|
mask: Union[str, List[str]] = Field(default="", title="Object mask.")
|
||||||
|
|
||||||
|
|
||||||
|
class AlertsConfig(FrigateBaseModel):
|
||||||
|
"""Configure alerts"""
|
||||||
|
|
||||||
|
labels: List[str] = Field(
|
||||||
|
default=DEFAULT_ALERT_OBJECTS, title="Labels to create alerts for."
|
||||||
|
)
|
||||||
|
required_zones: List[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
title="List of required zones to be entered in order to save the event as an alert.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DetectionsConfig(FrigateBaseModel):
|
||||||
|
"""Configure detections"""
|
||||||
|
|
||||||
|
labels: Optional[List[str]] = Field(
|
||||||
|
default=None, title="Labels to create detections for."
|
||||||
|
)
|
||||||
|
required_zones: List[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
title="List of required zones to be entered in order to save the event as a detection.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewConfig(FrigateBaseModel):
|
||||||
|
"""Configure reviews"""
|
||||||
|
|
||||||
|
alerts: AlertsConfig = Field(
|
||||||
|
default_factory=AlertsConfig, title="Review alerts config."
|
||||||
|
)
|
||||||
|
detections: DetectionsConfig = Field(
|
||||||
|
default_factory=DetectionsConfig, title="Review detections config."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AudioConfig(FrigateBaseModel):
|
class AudioConfig(FrigateBaseModel):
|
||||||
enabled: bool = Field(default=False, title="Enable audio events.")
|
enabled: bool = Field(default=False, title="Enable audio events.")
|
||||||
max_not_heard: int = Field(
|
max_not_heard: int = Field(
|
||||||
@ -841,6 +970,9 @@ class CameraConfig(FrigateBaseModel):
|
|||||||
objects: ObjectConfig = Field(
|
objects: ObjectConfig = Field(
|
||||||
default_factory=ObjectConfig, title="Object configuration."
|
default_factory=ObjectConfig, title="Object configuration."
|
||||||
)
|
)
|
||||||
|
review: ReviewConfig = Field(
|
||||||
|
default_factory=ReviewConfig, title="Review configuration."
|
||||||
|
)
|
||||||
audio: AudioConfig = Field(
|
audio: AudioConfig = Field(
|
||||||
default_factory=AudioConfig, title="Audio events configuration."
|
default_factory=AudioConfig, title="Audio events configuration."
|
||||||
)
|
)
|
||||||
@ -1162,6 +1294,9 @@ class FrigateConfig(FrigateBaseModel):
|
|||||||
objects: ObjectConfig = Field(
|
objects: ObjectConfig = Field(
|
||||||
default_factory=ObjectConfig, title="Global object configuration."
|
default_factory=ObjectConfig, title="Global object configuration."
|
||||||
)
|
)
|
||||||
|
review: ReviewConfig = Field(
|
||||||
|
default_factory=ReviewConfig, title="Review configuration."
|
||||||
|
)
|
||||||
audio: AudioConfig = Field(
|
audio: AudioConfig = Field(
|
||||||
default_factory=AudioConfig, title="Global Audio events configuration."
|
default_factory=AudioConfig, title="Global Audio events configuration."
|
||||||
)
|
)
|
||||||
@ -1209,6 +1344,7 @@ class FrigateConfig(FrigateBaseModel):
|
|||||||
"snapshots": ...,
|
"snapshots": ...,
|
||||||
"live": ...,
|
"live": ...,
|
||||||
"objects": ...,
|
"objects": ...,
|
||||||
|
"review": ...,
|
||||||
"motion": ...,
|
"motion": ...,
|
||||||
"detect": ...,
|
"detect": ...,
|
||||||
"ffmpeg": ...,
|
"ffmpeg": ...,
|
||||||
@ -1346,6 +1482,11 @@ class FrigateConfig(FrigateBaseModel):
|
|||||||
)
|
)
|
||||||
camera_config.motion.enabled_in_config = camera_config.motion.enabled
|
camera_config.motion.enabled_in_config = camera_config.motion.enabled
|
||||||
|
|
||||||
|
# generate zone contours
|
||||||
|
if len(camera_config.zones) > 0:
|
||||||
|
for zone in camera_config.zones.values():
|
||||||
|
zone.generate_contour(camera_config.frame_shape)
|
||||||
|
|
||||||
# Set live view stream if none is set
|
# Set live view stream if none is set
|
||||||
if not camera_config.live.stream_name:
|
if not camera_config.live.stream_name:
|
||||||
camera_config.live.stream_name = name
|
camera_config.live.stream_name = name
|
||||||
|
|||||||
@ -39,6 +39,10 @@ AUDIO_MAX_BIT_RANGE = 32768.0
|
|||||||
AUDIO_SAMPLE_RATE = 16000
|
AUDIO_SAMPLE_RATE = 16000
|
||||||
AUDIO_MIN_CONFIDENCE = 0.5
|
AUDIO_MIN_CONFIDENCE = 0.5
|
||||||
|
|
||||||
|
# DB Consts
|
||||||
|
|
||||||
|
MAX_WAL_SIZE = 10 # MB
|
||||||
|
|
||||||
# Ffmpeg Presets
|
# Ffmpeg Presets
|
||||||
|
|
||||||
FFMPEG_HWACCEL_NVIDIA = "preset-nvidia"
|
FFMPEG_HWACCEL_NVIDIA = "preset-nvidia"
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
import datetime
|
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
from multiprocessing import Queue
|
from multiprocessing import Queue
|
||||||
from multiprocessing.synchronize import Event as MpEvent
|
from multiprocessing.synchronize import Event as MpEvent
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
|
||||||
from frigate.comms.events_updater import EventUpdateSubscriber
|
from frigate.comms.events_updater import EventEndPublisher, EventUpdateSubscriber
|
||||||
from frigate.config import EventsConfig, FrigateConfig
|
from frigate.config import EventsConfig, FrigateConfig
|
||||||
from frigate.events.types import EventStateEnum, EventTypeEnum
|
from frigate.events.types import EventStateEnum, EventTypeEnum
|
||||||
from frigate.models import Event
|
from frigate.models import Event
|
||||||
@ -52,19 +51,18 @@ class EventProcessor(threading.Thread):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
config: FrigateConfig,
|
config: FrigateConfig,
|
||||||
event_processed_queue: Queue,
|
|
||||||
timeline_queue: Queue,
|
timeline_queue: Queue,
|
||||||
stop_event: MpEvent,
|
stop_event: MpEvent,
|
||||||
):
|
):
|
||||||
threading.Thread.__init__(self)
|
threading.Thread.__init__(self)
|
||||||
self.name = "event_processor"
|
self.name = "event_processor"
|
||||||
self.config = config
|
self.config = config
|
||||||
self.event_processed_queue = event_processed_queue
|
|
||||||
self.timeline_queue = timeline_queue
|
self.timeline_queue = timeline_queue
|
||||||
self.events_in_process: Dict[str, Event] = {}
|
self.events_in_process: Dict[str, Event] = {}
|
||||||
self.stop_event = stop_event
|
self.stop_event = stop_event
|
||||||
|
|
||||||
self.event_receiver = EventUpdateSubscriber()
|
self.event_receiver = EventUpdateSubscriber()
|
||||||
|
self.event_end_publisher = EventEndPublisher()
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
# set an end_time on events without an end_time on startup
|
# set an end_time on events without an end_time on startup
|
||||||
@ -113,11 +111,8 @@ class EventProcessor(threading.Thread):
|
|||||||
|
|
||||||
self.handle_external_detection(event_type, event_data)
|
self.handle_external_detection(event_type, event_data)
|
||||||
|
|
||||||
# set an end_time on events without an end_time before exiting
|
|
||||||
Event.update(end_time=datetime.datetime.now().timestamp()).where(
|
|
||||||
Event.end_time == None
|
|
||||||
).execute()
|
|
||||||
self.event_receiver.stop()
|
self.event_receiver.stop()
|
||||||
|
self.event_end_publisher.stop()
|
||||||
logger.info("Exiting event processor...")
|
logger.info("Exiting event processor...")
|
||||||
|
|
||||||
def handle_object_detection(
|
def handle_object_detection(
|
||||||
@ -242,7 +237,7 @@ class EventProcessor(threading.Thread):
|
|||||||
|
|
||||||
if event_type == EventStateEnum.end:
|
if event_type == EventStateEnum.end:
|
||||||
del self.events_in_process[event_data["id"]]
|
del self.events_in_process[event_data["id"]]
|
||||||
self.event_processed_queue.put((event_data["id"], camera))
|
self.event_end_publisher.publish((event_data["id"], camera))
|
||||||
|
|
||||||
def handle_external_detection(
|
def handle_external_detection(
|
||||||
self, event_type: EventStateEnum, event_data: Event
|
self, event_type: EventStateEnum, event_data: Event
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import os
|
|||||||
import queue
|
import queue
|
||||||
import threading
|
import threading
|
||||||
from collections import Counter, defaultdict
|
from collections import Counter, defaultdict
|
||||||
|
from multiprocessing.synchronize import Event as MpEvent
|
||||||
from statistics import median
|
from statistics import median
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
@ -14,7 +15,7 @@ import numpy as np
|
|||||||
|
|
||||||
from frigate.comms.detections_updater import DetectionPublisher, DetectionTypeEnum
|
from frigate.comms.detections_updater import DetectionPublisher, DetectionTypeEnum
|
||||||
from frigate.comms.dispatcher import Dispatcher
|
from frigate.comms.dispatcher import Dispatcher
|
||||||
from frigate.comms.events_updater import EventUpdatePublisher
|
from frigate.comms.events_updater import EventEndSubscriber, EventUpdatePublisher
|
||||||
from frigate.config import (
|
from frigate.config import (
|
||||||
CameraConfig,
|
CameraConfig,
|
||||||
FrigateConfig,
|
FrigateConfig,
|
||||||
@ -827,7 +828,6 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
config: FrigateConfig,
|
config: FrigateConfig,
|
||||||
dispatcher: Dispatcher,
|
dispatcher: Dispatcher,
|
||||||
tracked_objects_queue,
|
tracked_objects_queue,
|
||||||
event_processed_queue,
|
|
||||||
ptz_autotracker_thread,
|
ptz_autotracker_thread,
|
||||||
stop_event,
|
stop_event,
|
||||||
):
|
):
|
||||||
@ -836,14 +836,14 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
self.config = config
|
self.config = config
|
||||||
self.dispatcher = dispatcher
|
self.dispatcher = dispatcher
|
||||||
self.tracked_objects_queue = tracked_objects_queue
|
self.tracked_objects_queue = tracked_objects_queue
|
||||||
self.event_processed_queue = event_processed_queue
|
self.stop_event: MpEvent = stop_event
|
||||||
self.stop_event = stop_event
|
|
||||||
self.camera_states: dict[str, CameraState] = {}
|
self.camera_states: dict[str, CameraState] = {}
|
||||||
self.frame_manager = SharedMemoryFrameManager()
|
self.frame_manager = SharedMemoryFrameManager()
|
||||||
self.last_motion_detected: dict[str, float] = {}
|
self.last_motion_detected: dict[str, float] = {}
|
||||||
self.ptz_autotracker_thread = ptz_autotracker_thread
|
self.ptz_autotracker_thread = ptz_autotracker_thread
|
||||||
self.detection_publisher = DetectionPublisher(DetectionTypeEnum.video)
|
self.detection_publisher = DetectionPublisher(DetectionTypeEnum.video)
|
||||||
self.event_sender = EventUpdatePublisher()
|
self.event_sender = EventUpdatePublisher()
|
||||||
|
self.event_end_subscriber = EventEndSubscriber()
|
||||||
|
|
||||||
def start(camera, obj: TrackedObject, current_frame_time):
|
def start(camera, obj: TrackedObject, current_frame_time):
|
||||||
self.event_sender.publish(
|
self.event_sender.publish(
|
||||||
@ -1007,7 +1007,7 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def should_retain_recording(self, camera, obj: TrackedObject):
|
def should_retain_recording(self, camera: str, obj: TrackedObject):
|
||||||
if obj.false_positive:
|
if obj.false_positive:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@ -1022,7 +1022,11 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
# If there are required zones and there is no overlap
|
# If there are required zones and there is no overlap
|
||||||
required_zones = record_config.events.required_zones
|
review_config = self.config.cameras[camera].review
|
||||||
|
required_zones = (
|
||||||
|
review_config.alerts.required_zones
|
||||||
|
+ review_config.detections.required_zones
|
||||||
|
)
|
||||||
if len(required_zones) > 0 and not set(obj.entered_zones) & set(required_zones):
|
if len(required_zones) > 0 and not set(obj.entered_zones) & set(required_zones):
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Not creating clip for {obj.obj_data['id']} because it did not enter required zones"
|
f"Not creating clip for {obj.obj_data['id']} because it did not enter required zones"
|
||||||
@ -1215,10 +1219,16 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# cleanup event finished queue
|
# cleanup event finished queue
|
||||||
while not self.event_processed_queue.empty():
|
while not self.stop_event.is_set():
|
||||||
event_id, camera = self.event_processed_queue.get()
|
update = self.event_end_subscriber.check_for_update(timeout=0.01)
|
||||||
|
|
||||||
|
if not update:
|
||||||
|
break
|
||||||
|
|
||||||
|
event_id, camera = update
|
||||||
self.camera_states[camera].finished(event_id)
|
self.camera_states[camera].finished(event_id)
|
||||||
|
|
||||||
self.detection_publisher.stop()
|
self.detection_publisher.stop()
|
||||||
self.event_sender.stop()
|
self.event_sender.stop()
|
||||||
|
self.event_end_subscriber.stop()
|
||||||
logger.info("Exiting object processor...")
|
logger.info("Exiting object processor...")
|
||||||
|
|||||||
@ -3,12 +3,15 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import itertools
|
import itertools
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import threading
|
import threading
|
||||||
from multiprocessing.synchronize import Event as MpEvent
|
from multiprocessing.synchronize import Event as MpEvent
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from playhouse.sqlite_ext import SqliteExtDatabase
|
||||||
|
|
||||||
from frigate.config import CameraConfig, FrigateConfig, RetainModeEnum
|
from frigate.config import CameraConfig, FrigateConfig, RetainModeEnum
|
||||||
from frigate.const import CACHE_DIR, RECORD_DIR
|
from frigate.const import CACHE_DIR, MAX_WAL_SIZE, RECORD_DIR
|
||||||
from frigate.models import Event, Previews, Recordings, ReviewSegment
|
from frigate.models import Event, Previews, Recordings, ReviewSegment
|
||||||
from frigate.record.util import remove_empty_directories, sync_recordings
|
from frigate.record.util import remove_empty_directories, sync_recordings
|
||||||
from frigate.util.builtin import clear_and_unlink, get_tomorrow_at_time
|
from frigate.util.builtin import clear_and_unlink, get_tomorrow_at_time
|
||||||
@ -33,6 +36,23 @@ class RecordingCleanup(threading.Thread):
|
|||||||
logger.debug("Deleting tmp clip.")
|
logger.debug("Deleting tmp clip.")
|
||||||
clear_and_unlink(p)
|
clear_and_unlink(p)
|
||||||
|
|
||||||
|
def truncate_wal(self) -> None:
|
||||||
|
"""check if the WAL needs to be manually truncated."""
|
||||||
|
|
||||||
|
# by default the WAL should be check-pointed automatically
|
||||||
|
# however, high levels of activity can prevent an opportunity
|
||||||
|
# for the checkpoint to be finished which means the WAL will grow
|
||||||
|
# without bound
|
||||||
|
|
||||||
|
# with auto checkpoint most users should never hit this
|
||||||
|
|
||||||
|
if (
|
||||||
|
os.stat(f"{self.config.database.path}-wal").st_size / (1024 * 1024)
|
||||||
|
) > MAX_WAL_SIZE:
|
||||||
|
db = SqliteExtDatabase(self.config.database.path)
|
||||||
|
db.execute_sql("PRAGMA wal_checkpoint(TRUNCATE);")
|
||||||
|
db.close()
|
||||||
|
|
||||||
def expire_existing_camera_recordings(
|
def expire_existing_camera_recordings(
|
||||||
self, expire_date: float, config: CameraConfig, events: Event
|
self, expire_date: float, config: CameraConfig, events: Event
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -328,3 +348,4 @@ class RecordingCleanup(threading.Thread):
|
|||||||
if counter == 0:
|
if counter == 0:
|
||||||
self.expire_recordings()
|
self.expire_recordings()
|
||||||
remove_empty_directories(RECORD_DIR)
|
remove_empty_directories(RECORD_DIR)
|
||||||
|
self.truncate_wal()
|
||||||
|
|||||||
@ -103,14 +103,14 @@ class RecordingExporter(threading.Thread):
|
|||||||
|
|
||||||
if self.playback_factor == PlaybackFactorEnum.realtime:
|
if self.playback_factor == PlaybackFactorEnum.realtime:
|
||||||
ffmpeg_cmd = (
|
ffmpeg_cmd = (
|
||||||
f"ffmpeg -hide_banner {ffmpeg_input} -c copy {file_path}"
|
f"ffmpeg -hide_banner {ffmpeg_input} -c copy -movflags +faststart {file_path}"
|
||||||
).split(" ")
|
).split(" ")
|
||||||
elif self.playback_factor == PlaybackFactorEnum.timelapse_25x:
|
elif self.playback_factor == PlaybackFactorEnum.timelapse_25x:
|
||||||
ffmpeg_cmd = (
|
ffmpeg_cmd = (
|
||||||
parse_preset_hardware_acceleration_encode(
|
parse_preset_hardware_acceleration_encode(
|
||||||
self.config.ffmpeg.hwaccel_args,
|
self.config.ffmpeg.hwaccel_args,
|
||||||
f"{TIMELAPSE_DATA_INPUT_ARGS} {ffmpeg_input}",
|
f"{TIMELAPSE_DATA_INPUT_ARGS} {ffmpeg_input}",
|
||||||
f"{self.config.cameras[self.camera].record.export.timelapse_args} {file_path}",
|
f"{self.config.cameras[self.camera].record.export.timelapse_args} -movflags +faststart {file_path}",
|
||||||
EncodeTypeEnum.timelapse,
|
EncodeTypeEnum.timelapse,
|
||||||
)
|
)
|
||||||
).split(" ")
|
).split(" ")
|
||||||
|
|||||||
@ -32,13 +32,11 @@ THUMB_WIDTH = 320
|
|||||||
|
|
||||||
THRESHOLD_ALERT_ACTIVITY = 120
|
THRESHOLD_ALERT_ACTIVITY = 120
|
||||||
THRESHOLD_DETECTION_ACTIVITY = 30
|
THRESHOLD_DETECTION_ACTIVITY = 30
|
||||||
THRESHOLD_MOTION_ACTIVITY = 30
|
|
||||||
|
|
||||||
|
|
||||||
class SeverityEnum(str, Enum):
|
class SeverityEnum(str, Enum):
|
||||||
alert = "alert"
|
alert = "alert"
|
||||||
detection = "detection"
|
detection = "detection"
|
||||||
signification_motion = "significant_motion"
|
|
||||||
|
|
||||||
|
|
||||||
class PendingReviewSegment:
|
class PendingReviewSegment:
|
||||||
@ -50,7 +48,6 @@ class PendingReviewSegment:
|
|||||||
detections: dict[str, str],
|
detections: dict[str, str],
|
||||||
zones: set[str] = set(),
|
zones: set[str] = set(),
|
||||||
audio: set[str] = set(),
|
audio: set[str] = set(),
|
||||||
motion: list[int] = [],
|
|
||||||
):
|
):
|
||||||
rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
|
rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
|
||||||
self.id = f"{frame_time}-{rand_id}"
|
self.id = f"{frame_time}-{rand_id}"
|
||||||
@ -60,12 +57,12 @@ class PendingReviewSegment:
|
|||||||
self.detections = detections
|
self.detections = detections
|
||||||
self.zones = zones
|
self.zones = zones
|
||||||
self.audio = audio
|
self.audio = audio
|
||||||
self.sig_motion_areas = motion
|
|
||||||
self.last_update = frame_time
|
self.last_update = frame_time
|
||||||
|
|
||||||
# thumbnail
|
# thumbnail
|
||||||
self.frame = np.zeros((THUMB_HEIGHT * 3 // 2, THUMB_WIDTH), np.uint8)
|
self.frame = np.zeros((THUMB_HEIGHT * 3 // 2, THUMB_WIDTH), np.uint8)
|
||||||
self.frame_active_count = 0
|
self.frame_active_count = 0
|
||||||
|
self.frame_path = os.path.join(CLIPS_DIR, f"thumb-{self.camera}-{self.id}.jpg")
|
||||||
|
|
||||||
def update_frame(
|
def update_frame(
|
||||||
self, camera_config: CameraConfig, frame, objects: list[TrackedObject]
|
self, camera_config: CameraConfig, frame, objects: list[TrackedObject]
|
||||||
@ -98,25 +95,24 @@ class PendingReviewSegment:
|
|||||||
color_frame, dsize=(width, THUMB_HEIGHT), interpolation=cv2.INTER_AREA
|
color_frame, dsize=(width, THUMB_HEIGHT), interpolation=cv2.INTER_AREA
|
||||||
)
|
)
|
||||||
|
|
||||||
def end(self) -> dict:
|
|
||||||
path = os.path.join(CLIPS_DIR, f"thumb-{self.camera}-{self.id}.jpg")
|
|
||||||
|
|
||||||
if self.frame is not None:
|
if self.frame is not None:
|
||||||
cv2.imwrite(path, self.frame, [int(cv2.IMWRITE_WEBP_QUALITY), 60])
|
cv2.imwrite(
|
||||||
|
self.frame_path, self.frame, [int(cv2.IMWRITE_WEBP_QUALITY), 60]
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_data(self, ended: bool) -> dict:
|
||||||
return {
|
return {
|
||||||
ReviewSegment.id: self.id,
|
ReviewSegment.id: self.id,
|
||||||
ReviewSegment.camera: self.camera,
|
ReviewSegment.camera: self.camera,
|
||||||
ReviewSegment.start_time: self.start_time,
|
ReviewSegment.start_time: self.start_time,
|
||||||
ReviewSegment.end_time: self.last_update,
|
ReviewSegment.end_time: self.last_update if ended else None,
|
||||||
ReviewSegment.severity: self.severity.value,
|
ReviewSegment.severity: self.severity.value,
|
||||||
ReviewSegment.thumb_path: path,
|
ReviewSegment.thumb_path: self.frame_path,
|
||||||
ReviewSegment.data: {
|
ReviewSegment.data: {
|
||||||
"detections": list(set(self.detections.keys())),
|
"detections": list(set(self.detections.keys())),
|
||||||
"objects": list(set(self.detections.values())),
|
"objects": list(set(self.detections.values())),
|
||||||
"zones": list(self.zones),
|
"zones": list(self.zones),
|
||||||
"audio": list(self.audio),
|
"audio": list(self.audio),
|
||||||
"significant_motion_areas": self.sig_motion_areas,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -141,9 +137,20 @@ class ReviewSegmentMaintainer(threading.Thread):
|
|||||||
|
|
||||||
self.stop_event = stop_event
|
self.stop_event = stop_event
|
||||||
|
|
||||||
|
def update_segment(self, segment: PendingReviewSegment) -> None:
|
||||||
|
"""Update segment."""
|
||||||
|
seg_data = segment.get_data(ended=False)
|
||||||
|
self.requestor.send_data(UPSERT_REVIEW_SEGMENT, seg_data)
|
||||||
|
self.requestor.send_data(
|
||||||
|
"reviews",
|
||||||
|
json.dumps(
|
||||||
|
{"type": "update", "review": {k.name: v for k, v in seg_data.items()}}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
def end_segment(self, segment: PendingReviewSegment) -> None:
|
def end_segment(self, segment: PendingReviewSegment) -> None:
|
||||||
"""End segment."""
|
"""End segment."""
|
||||||
seg_data = segment.end()
|
seg_data = segment.get_data(ended=True)
|
||||||
self.requestor.send_data(UPSERT_REVIEW_SEGMENT, seg_data)
|
self.requestor.send_data(UPSERT_REVIEW_SEGMENT, seg_data)
|
||||||
self.requestor.send_data(
|
self.requestor.send_data(
|
||||||
"reviews",
|
"reviews",
|
||||||
@ -158,7 +165,6 @@ class ReviewSegmentMaintainer(threading.Thread):
|
|||||||
segment: PendingReviewSegment,
|
segment: PendingReviewSegment,
|
||||||
frame_time: float,
|
frame_time: float,
|
||||||
objects: list[TrackedObject],
|
objects: list[TrackedObject],
|
||||||
motion: list,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Validate if existing review segment should continue."""
|
"""Validate if existing review segment should continue."""
|
||||||
camera_config = self.config.cameras[segment.camera]
|
camera_config = self.config.cameras[segment.camera]
|
||||||
@ -168,18 +174,6 @@ class ReviewSegmentMaintainer(threading.Thread):
|
|||||||
if frame_time > segment.last_update:
|
if frame_time > segment.last_update:
|
||||||
segment.last_update = frame_time
|
segment.last_update = frame_time
|
||||||
|
|
||||||
# update type for this segment now that active objects are detected
|
|
||||||
if segment.severity == SeverityEnum.signification_motion:
|
|
||||||
segment.severity = SeverityEnum.detection
|
|
||||||
|
|
||||||
if len(active_objects) > segment.frame_active_count:
|
|
||||||
frame_id = f"{camera_config.name}{frame_time}"
|
|
||||||
yuv_frame = self.frame_manager.get(
|
|
||||||
frame_id, camera_config.frame_shape_yuv
|
|
||||||
)
|
|
||||||
segment.update_frame(camera_config, yuv_frame, active_objects)
|
|
||||||
self.frame_manager.close(frame_id)
|
|
||||||
|
|
||||||
for object in active_objects:
|
for object in active_objects:
|
||||||
if not object["sub_label"]:
|
if not object["sub_label"]:
|
||||||
segment.detections[object["id"]] = object["label"]
|
segment.detections[object["id"]] = object["label"]
|
||||||
@ -188,24 +182,38 @@ class ReviewSegmentMaintainer(threading.Thread):
|
|||||||
else:
|
else:
|
||||||
segment.detections[object["id"]] = f'{object["label"]}-verified'
|
segment.detections[object["id"]] = f'{object["label"]}-verified'
|
||||||
|
|
||||||
# if object is alert label and has qualified for recording
|
# if object is alert label
|
||||||
|
# and has entered required zones or required zones is not set
|
||||||
# mark this review as alert
|
# mark this review as alert
|
||||||
if (
|
if (
|
||||||
segment.severity == SeverityEnum.detection
|
segment.severity != SeverityEnum.alert
|
||||||
and object["has_clip"]
|
and object["label"] in camera_config.review.alerts.labels
|
||||||
and object["label"] in camera_config.objects.alert
|
and (
|
||||||
|
not camera_config.review.alerts.required_zones
|
||||||
|
or (
|
||||||
|
len(object["current_zones"]) > 0
|
||||||
|
and set(object["current_zones"])
|
||||||
|
& set(camera_config.review.alerts.required_zones)
|
||||||
|
)
|
||||||
|
)
|
||||||
):
|
):
|
||||||
segment.severity = SeverityEnum.alert
|
segment.severity = SeverityEnum.alert
|
||||||
|
|
||||||
# keep zones up to date
|
# keep zones up to date
|
||||||
if len(object["current_zones"]) > 0:
|
if len(object["current_zones"]) > 0:
|
||||||
segment.zones.update(object["current_zones"])
|
segment.zones.update(object["current_zones"])
|
||||||
elif (
|
|
||||||
segment.severity == SeverityEnum.signification_motion
|
if len(active_objects) > segment.frame_active_count:
|
||||||
and len(motion) >= THRESHOLD_MOTION_ACTIVITY
|
try:
|
||||||
):
|
frame_id = f"{camera_config.name}{frame_time}"
|
||||||
if frame_time > segment.last_update:
|
yuv_frame = self.frame_manager.get(
|
||||||
segment.last_update = frame_time
|
frame_id, camera_config.frame_shape_yuv
|
||||||
|
)
|
||||||
|
segment.update_frame(camera_config, yuv_frame, active_objects)
|
||||||
|
self.frame_manager.close(frame_id)
|
||||||
|
self.update_segment(segment)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return
|
||||||
else:
|
else:
|
||||||
if segment.severity == SeverityEnum.alert and frame_time > (
|
if segment.severity == SeverityEnum.alert and frame_time > (
|
||||||
segment.last_update + THRESHOLD_ALERT_ACTIVITY
|
segment.last_update + THRESHOLD_ALERT_ACTIVITY
|
||||||
@ -219,7 +227,6 @@ class ReviewSegmentMaintainer(threading.Thread):
|
|||||||
camera: str,
|
camera: str,
|
||||||
frame_time: float,
|
frame_time: float,
|
||||||
objects: list[TrackedObject],
|
objects: list[TrackedObject],
|
||||||
motion: list,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Check if a new review segment should be created."""
|
"""Check if a new review segment should be created."""
|
||||||
camera_config = self.config.cameras[camera]
|
camera_config = self.config.cameras[camera]
|
||||||
@ -229,15 +236,9 @@ class ReviewSegmentMaintainer(threading.Thread):
|
|||||||
has_sig_object = False
|
has_sig_object = False
|
||||||
detections: dict[str, str] = {}
|
detections: dict[str, str] = {}
|
||||||
zones: set = set()
|
zones: set = set()
|
||||||
|
severity = None
|
||||||
|
|
||||||
for object in active_objects:
|
for object in active_objects:
|
||||||
if (
|
|
||||||
not has_sig_object
|
|
||||||
and object["has_clip"]
|
|
||||||
and object["label"] in camera_config.objects.alert
|
|
||||||
):
|
|
||||||
has_sig_object = True
|
|
||||||
|
|
||||||
if not object["sub_label"]:
|
if not object["sub_label"]:
|
||||||
detections[object["id"]] = object["label"]
|
detections[object["id"]] = object["label"]
|
||||||
elif object["sub_label"][0] in ALL_ATTRIBUTE_LABELS:
|
elif object["sub_label"][0] in ALL_ATTRIBUTE_LABELS:
|
||||||
@ -245,34 +246,68 @@ class ReviewSegmentMaintainer(threading.Thread):
|
|||||||
else:
|
else:
|
||||||
detections[object["id"]] = f'{object["label"]}-verified'
|
detections[object["id"]] = f'{object["label"]}-verified'
|
||||||
|
|
||||||
|
# if object is alert label
|
||||||
|
# and has entered required zones or required zones is not set
|
||||||
|
# mark this review as alert
|
||||||
|
if (
|
||||||
|
severity != SeverityEnum.alert
|
||||||
|
and object["label"] in camera_config.review.alerts.labels
|
||||||
|
and (
|
||||||
|
not camera_config.review.alerts.required_zones
|
||||||
|
or (
|
||||||
|
len(object["current_zones"]) > 0
|
||||||
|
and set(object["current_zones"])
|
||||||
|
& set(camera_config.review.alerts.required_zones)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
):
|
||||||
|
severity = SeverityEnum.alert
|
||||||
|
|
||||||
|
# if object is detection label
|
||||||
|
# and review is not already a detection or alert
|
||||||
|
# and has entered required zones or required zones is not set
|
||||||
|
# mark this review as alert
|
||||||
|
if (
|
||||||
|
not severity
|
||||||
|
and (
|
||||||
|
not camera_config.review.detections.labels
|
||||||
|
or object["label"] in (camera_config.review.detections.labels)
|
||||||
|
)
|
||||||
|
and (
|
||||||
|
not camera_config.review.detections.required_zones
|
||||||
|
or (
|
||||||
|
len(object["current_zones"]) > 0
|
||||||
|
and set(object["current_zones"])
|
||||||
|
& set(camera_config.review.detections.required_zones)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
):
|
||||||
|
severity = SeverityEnum.detection
|
||||||
|
|
||||||
zones.update(object["current_zones"])
|
zones.update(object["current_zones"])
|
||||||
|
|
||||||
self.active_review_segments[camera] = PendingReviewSegment(
|
if severity:
|
||||||
camera,
|
self.active_review_segments[camera] = PendingReviewSegment(
|
||||||
frame_time,
|
camera,
|
||||||
SeverityEnum.alert if has_sig_object else SeverityEnum.detection,
|
frame_time,
|
||||||
detections,
|
SeverityEnum.alert if has_sig_object else SeverityEnum.detection,
|
||||||
audio=set(),
|
detections,
|
||||||
zones=zones,
|
audio=set(),
|
||||||
motion=[],
|
zones=zones,
|
||||||
)
|
)
|
||||||
|
|
||||||
frame_id = f"{camera_config.name}{frame_time}"
|
try:
|
||||||
yuv_frame = self.frame_manager.get(frame_id, camera_config.frame_shape_yuv)
|
frame_id = f"{camera_config.name}{frame_time}"
|
||||||
self.active_review_segments[camera].update_frame(
|
yuv_frame = self.frame_manager.get(
|
||||||
camera_config, yuv_frame, active_objects
|
frame_id, camera_config.frame_shape_yuv
|
||||||
)
|
)
|
||||||
self.frame_manager.close(frame_id)
|
self.active_review_segments[camera].update_frame(
|
||||||
elif len(motion) >= 20:
|
camera_config, yuv_frame, active_objects
|
||||||
self.active_review_segments[camera] = PendingReviewSegment(
|
)
|
||||||
camera,
|
self.frame_manager.close(frame_id)
|
||||||
frame_time,
|
self.update_segment(self.active_review_segments[camera])
|
||||||
SeverityEnum.signification_motion,
|
except FileNotFoundError:
|
||||||
detections={},
|
return
|
||||||
audio=set(),
|
|
||||||
motion=motion,
|
|
||||||
zones=set(),
|
|
||||||
)
|
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
while not self.stop_event.is_set():
|
while not self.stop_event.is_set():
|
||||||
@ -330,13 +365,22 @@ class ReviewSegmentMaintainer(threading.Thread):
|
|||||||
current_segment,
|
current_segment,
|
||||||
frame_time,
|
frame_time,
|
||||||
current_tracked_objects,
|
current_tracked_objects,
|
||||||
motion_boxes,
|
|
||||||
)
|
)
|
||||||
elif topic == DetectionTypeEnum.audio and len(audio_detections) > 0:
|
elif topic == DetectionTypeEnum.audio and len(audio_detections) > 0:
|
||||||
|
camera_config = self.config.cameras[camera]
|
||||||
|
|
||||||
if frame_time > current_segment.last_update:
|
if frame_time > current_segment.last_update:
|
||||||
current_segment.last_update = frame_time
|
current_segment.last_update = frame_time
|
||||||
|
|
||||||
current_segment.audio.update(audio_detections)
|
for audio in audio_detections:
|
||||||
|
if audio in camera_config.review.alerts.labels:
|
||||||
|
current_segment.audio.add(audio)
|
||||||
|
current_segment.severity = SeverityEnum.alert
|
||||||
|
elif (
|
||||||
|
not camera_config.review.detections.labels
|
||||||
|
or audio in camera_config.review.detections.labels
|
||||||
|
):
|
||||||
|
current_segment.audio.add(audio)
|
||||||
elif topic == DetectionTypeEnum.api:
|
elif topic == DetectionTypeEnum.api:
|
||||||
if manual_info["state"] == ManualEventState.complete:
|
if manual_info["state"] == ManualEventState.complete:
|
||||||
current_segment.detections[manual_info["event_id"]] = (
|
current_segment.detections[manual_info["event_id"]] = (
|
||||||
@ -364,18 +408,35 @@ class ReviewSegmentMaintainer(threading.Thread):
|
|||||||
camera,
|
camera,
|
||||||
frame_time,
|
frame_time,
|
||||||
current_tracked_objects,
|
current_tracked_objects,
|
||||||
motion_boxes,
|
|
||||||
)
|
)
|
||||||
elif topic == DetectionTypeEnum.audio and len(audio_detections) > 0:
|
elif topic == DetectionTypeEnum.audio and len(audio_detections) > 0:
|
||||||
self.active_review_segments[camera] = PendingReviewSegment(
|
severity = None
|
||||||
camera,
|
|
||||||
frame_time,
|
camera_config = self.config.cameras[camera]
|
||||||
SeverityEnum.detection,
|
detections = set()
|
||||||
{},
|
|
||||||
set(),
|
for audio in audio_detections:
|
||||||
set(audio_detections),
|
if audio in camera_config.review.alerts.labels:
|
||||||
[],
|
detections.add(audio)
|
||||||
)
|
severity = SeverityEnum.alert
|
||||||
|
elif (
|
||||||
|
not camera_config.review.detections.labels
|
||||||
|
or audio in camera_config.review.detections.labels
|
||||||
|
):
|
||||||
|
detections.add(audio)
|
||||||
|
|
||||||
|
if not severity:
|
||||||
|
severity = SeverityEnum.detection
|
||||||
|
|
||||||
|
if severity:
|
||||||
|
self.active_review_segments[camera] = PendingReviewSegment(
|
||||||
|
camera,
|
||||||
|
frame_time,
|
||||||
|
severity,
|
||||||
|
{},
|
||||||
|
set(),
|
||||||
|
detections,
|
||||||
|
)
|
||||||
elif topic == DetectionTypeEnum.api:
|
elif topic == DetectionTypeEnum.api:
|
||||||
self.active_review_segments[camera] = PendingReviewSegment(
|
self.active_review_segments[camera] = PendingReviewSegment(
|
||||||
camera,
|
camera,
|
||||||
@ -384,7 +445,6 @@ class ReviewSegmentMaintainer(threading.Thread):
|
|||||||
{manual_info["event_id"]: manual_info["label"]},
|
{manual_info["event_id"]: manual_info["label"]},
|
||||||
set(),
|
set(),
|
||||||
set(),
|
set(),
|
||||||
[],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if manual_info["state"] == ManualEventState.start:
|
if manual_info["state"] == ManualEventState.start:
|
||||||
@ -398,6 +458,11 @@ class ReviewSegmentMaintainer(threading.Thread):
|
|||||||
"end_time"
|
"end_time"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
self.config_subscriber.stop()
|
||||||
|
self.requestor.stop()
|
||||||
|
self.detection_subscriber.stop()
|
||||||
|
logger.info("Exiting review maintainer...")
|
||||||
|
|
||||||
|
|
||||||
def get_active_objects(
|
def get_active_objects(
|
||||||
frame_time: float, camera_config: CameraConfig, all_objects: list[TrackedObject]
|
frame_time: float, camera_config: CameraConfig, all_objects: list[TrackedObject]
|
||||||
@ -406,8 +471,16 @@ def get_active_objects(
|
|||||||
return [
|
return [
|
||||||
o
|
o
|
||||||
for o in all_objects
|
for o in all_objects
|
||||||
if o["motionless_count"] < camera_config.detect.stationary.threshold
|
if o["motionless_count"]
|
||||||
and o["position_changes"] > 0
|
< camera_config.detect.stationary.threshold # no stationary objects
|
||||||
and o["frame_time"] == frame_time
|
and o["position_changes"] > 0 # object must have moved at least once
|
||||||
and not o["false_positive"]
|
and o["frame_time"] == frame_time # object must be detected in this frame
|
||||||
|
and not o["false_positive"] # object must not be a false positive
|
||||||
|
and (
|
||||||
|
o["label"] in camera_config.review.alerts.labels
|
||||||
|
or (
|
||||||
|
not camera_config.review.detections.labels
|
||||||
|
or o["label"] in camera_config.review.detections.labels
|
||||||
|
)
|
||||||
|
) # object must be in the alerts or detections label list
|
||||||
]
|
]
|
||||||
|
|||||||
@ -64,7 +64,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
|
|
||||||
def test_config_class(self):
|
def test_config_class(self):
|
||||||
frigate_config = FrigateConfig(**self.minimal)
|
frigate_config = FrigateConfig(**self.minimal)
|
||||||
assert self.minimal == frigate_config.dict(exclude_unset=True)
|
assert self.minimal == frigate_config.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
runtime_config = frigate_config.runtime_config()
|
runtime_config = frigate_config.runtime_config()
|
||||||
assert "cpu" in runtime_config.detectors.keys()
|
assert "cpu" in runtime_config.detectors.keys()
|
||||||
@ -157,7 +157,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert config == frigate_config.dict(exclude_unset=True)
|
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
runtime_config = frigate_config.runtime_config()
|
runtime_config = frigate_config.runtime_config()
|
||||||
assert "dog" in runtime_config.cameras["back"].objects.track
|
assert "dog" in runtime_config.cameras["back"].objects.track
|
||||||
@ -183,7 +183,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert config == frigate_config.dict(exclude_unset=True)
|
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
runtime_config = frigate_config.runtime_config()
|
runtime_config = frigate_config.runtime_config()
|
||||||
assert not runtime_config.cameras["back"].birdseye.enabled
|
assert not runtime_config.cameras["back"].birdseye.enabled
|
||||||
@ -209,7 +209,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert config == frigate_config.dict(exclude_unset=True)
|
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
runtime_config = frigate_config.runtime_config()
|
runtime_config = frigate_config.runtime_config()
|
||||||
assert runtime_config.cameras["back"].birdseye.enabled
|
assert runtime_config.cameras["back"].birdseye.enabled
|
||||||
@ -234,7 +234,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert config == frigate_config.dict(exclude_unset=True)
|
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
runtime_config = frigate_config.runtime_config()
|
runtime_config = frigate_config.runtime_config()
|
||||||
assert runtime_config.cameras["back"].birdseye.enabled
|
assert runtime_config.cameras["back"].birdseye.enabled
|
||||||
@ -263,7 +263,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert config == frigate_config.dict(exclude_unset=True)
|
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
runtime_config = frigate_config.runtime_config()
|
runtime_config = frigate_config.runtime_config()
|
||||||
assert "cat" in runtime_config.cameras["back"].objects.track
|
assert "cat" in runtime_config.cameras["back"].objects.track
|
||||||
@ -288,7 +288,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert config == frigate_config.dict(exclude_unset=True)
|
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
runtime_config = frigate_config.runtime_config()
|
runtime_config = frigate_config.runtime_config()
|
||||||
assert "dog" in runtime_config.cameras["back"].objects.filters
|
assert "dog" in runtime_config.cameras["back"].objects.filters
|
||||||
@ -316,7 +316,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert config == frigate_config.dict(exclude_unset=True)
|
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
runtime_config = frigate_config.runtime_config()
|
runtime_config = frigate_config.runtime_config()
|
||||||
assert "dog" in runtime_config.cameras["back"].objects.filters
|
assert "dog" in runtime_config.cameras["back"].objects.filters
|
||||||
@ -345,7 +345,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert config == frigate_config.dict(exclude_unset=True)
|
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
runtime_config = frigate_config.runtime_config()
|
runtime_config = frigate_config.runtime_config()
|
||||||
assert "dog" in runtime_config.cameras["back"].objects.filters
|
assert "dog" in runtime_config.cameras["back"].objects.filters
|
||||||
@ -375,7 +375,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert config == frigate_config.dict(exclude_unset=True)
|
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
runtime_config = frigate_config.runtime_config()
|
runtime_config = frigate_config.runtime_config()
|
||||||
back_camera = runtime_config.cameras["back"]
|
back_camera = runtime_config.cameras["back"]
|
||||||
@ -383,6 +383,55 @@ class TestConfig(unittest.TestCase):
|
|||||||
assert len(back_camera.objects.filters["dog"].raw_mask) == 2
|
assert len(back_camera.objects.filters["dog"].raw_mask) == 2
|
||||||
assert len(back_camera.objects.filters["person"].raw_mask) == 1
|
assert len(back_camera.objects.filters["person"].raw_mask) == 1
|
||||||
|
|
||||||
|
def test_motion_mask_relative_matches_explicit(self):
|
||||||
|
config = {
|
||||||
|
"mqtt": {"host": "mqtt"},
|
||||||
|
"record": {
|
||||||
|
"events": {"retain": {"default": 20, "objects": {"person": 30}}}
|
||||||
|
},
|
||||||
|
"cameras": {
|
||||||
|
"explicit": {
|
||||||
|
"ffmpeg": {
|
||||||
|
"inputs": [
|
||||||
|
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"detect": {
|
||||||
|
"height": 400,
|
||||||
|
"width": 800,
|
||||||
|
"fps": 5,
|
||||||
|
},
|
||||||
|
"motion": {
|
||||||
|
"mask": [
|
||||||
|
"0,0,200,100,600,300,800,400",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"relative": {
|
||||||
|
"ffmpeg": {
|
||||||
|
"inputs": [
|
||||||
|
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"detect": {
|
||||||
|
"height": 400,
|
||||||
|
"width": 800,
|
||||||
|
"fps": 5,
|
||||||
|
},
|
||||||
|
"motion": {
|
||||||
|
"mask": [
|
||||||
|
"0.0,0.0,0.25,0.25,0.75,0.75,1.0,1.0",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
frigate_config = FrigateConfig(**config).runtime_config()
|
||||||
|
assert np.array_equal(
|
||||||
|
frigate_config.cameras["explicit"].motion.mask,
|
||||||
|
frigate_config.cameras["relative"].motion.mask,
|
||||||
|
)
|
||||||
|
|
||||||
def test_default_input_args(self):
|
def test_default_input_args(self):
|
||||||
config = {
|
config = {
|
||||||
"mqtt": {"host": "mqtt"},
|
"mqtt": {"host": "mqtt"},
|
||||||
@ -406,7 +455,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert config == frigate_config.dict(exclude_unset=True)
|
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
runtime_config = frigate_config.runtime_config()
|
runtime_config = frigate_config.runtime_config()
|
||||||
assert "-rtsp_transport" in runtime_config.cameras["back"].ffmpeg_cmds[0]["cmd"]
|
assert "-rtsp_transport" in runtime_config.cameras["back"].ffmpeg_cmds[0]["cmd"]
|
||||||
@ -435,7 +484,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert config == frigate_config.dict(exclude_unset=True)
|
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
runtime_config = frigate_config.runtime_config()
|
runtime_config = frigate_config.runtime_config()
|
||||||
assert "-re" in runtime_config.cameras["back"].ffmpeg_cmds[0]["cmd"]
|
assert "-re" in runtime_config.cameras["back"].ffmpeg_cmds[0]["cmd"]
|
||||||
@ -465,7 +514,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert config == frigate_config.dict(exclude_unset=True)
|
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
runtime_config = frigate_config.runtime_config()
|
runtime_config = frigate_config.runtime_config()
|
||||||
assert "-re" in runtime_config.cameras["back"].ffmpeg_cmds[0]["cmd"]
|
assert "-re" in runtime_config.cameras["back"].ffmpeg_cmds[0]["cmd"]
|
||||||
@ -500,7 +549,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert config == frigate_config.dict(exclude_unset=True)
|
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
runtime_config = frigate_config.runtime_config()
|
runtime_config = frigate_config.runtime_config()
|
||||||
assert "-re" in runtime_config.cameras["back"].ffmpeg_cmds[0]["cmd"]
|
assert "-re" in runtime_config.cameras["back"].ffmpeg_cmds[0]["cmd"]
|
||||||
@ -530,7 +579,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert config == frigate_config.dict(exclude_unset=True)
|
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
runtime_config = frigate_config.runtime_config()
|
runtime_config = frigate_config.runtime_config()
|
||||||
assert (
|
assert (
|
||||||
@ -608,7 +657,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert config == frigate_config.dict(exclude_unset=True)
|
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
runtime_config = frigate_config.runtime_config()
|
runtime_config = frigate_config.runtime_config()
|
||||||
assert isinstance(
|
assert isinstance(
|
||||||
@ -616,6 +665,41 @@ class TestConfig(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
assert runtime_config.cameras["back"].zones["test"].color != (0, 0, 0)
|
assert runtime_config.cameras["back"].zones["test"].color != (0, 0, 0)
|
||||||
|
|
||||||
|
def test_zone_relative_matches_explicit(self):
|
||||||
|
config = {
|
||||||
|
"mqtt": {"host": "mqtt"},
|
||||||
|
"record": {
|
||||||
|
"events": {"retain": {"default": 20, "objects": {"person": 30}}}
|
||||||
|
},
|
||||||
|
"cameras": {
|
||||||
|
"back": {
|
||||||
|
"ffmpeg": {
|
||||||
|
"inputs": [
|
||||||
|
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"detect": {
|
||||||
|
"height": 400,
|
||||||
|
"width": 800,
|
||||||
|
"fps": 5,
|
||||||
|
},
|
||||||
|
"zones": {
|
||||||
|
"explicit": {
|
||||||
|
"coordinates": "0,0,200,100,600,300,800,400",
|
||||||
|
},
|
||||||
|
"relative": {
|
||||||
|
"coordinates": "0.0,0.0,0.25,0.25,0.75,0.75,1.0,1.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
frigate_config = FrigateConfig(**config).runtime_config()
|
||||||
|
assert np.array_equal(
|
||||||
|
frigate_config.cameras["back"].zones["explicit"].contour,
|
||||||
|
frigate_config.cameras["back"].zones["relative"].contour,
|
||||||
|
)
|
||||||
|
|
||||||
def test_clips_should_default_to_global_objects(self):
|
def test_clips_should_default_to_global_objects(self):
|
||||||
config = {
|
config = {
|
||||||
"mqtt": {"host": "mqtt"},
|
"mqtt": {"host": "mqtt"},
|
||||||
@ -640,7 +724,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert config == frigate_config.dict(exclude_unset=True)
|
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
runtime_config = frigate_config.runtime_config()
|
runtime_config = frigate_config.runtime_config()
|
||||||
back_camera = runtime_config.cameras["back"]
|
back_camera = runtime_config.cameras["back"]
|
||||||
@ -671,7 +755,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert config == frigate_config.dict(exclude_unset=True)
|
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
runtime_config = frigate_config.runtime_config()
|
runtime_config = frigate_config.runtime_config()
|
||||||
ffmpeg_cmds = runtime_config.cameras["back"].ffmpeg_cmds
|
ffmpeg_cmds = runtime_config.cameras["back"].ffmpeg_cmds
|
||||||
@ -702,7 +786,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert config == frigate_config.dict(exclude_unset=True)
|
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
runtime_config = frigate_config.runtime_config()
|
runtime_config = frigate_config.runtime_config()
|
||||||
assert runtime_config.cameras["back"].detect.max_disappeared == 5 * 5
|
assert runtime_config.cameras["back"].detect.max_disappeared == 5 * 5
|
||||||
@ -730,7 +814,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert config == frigate_config.dict(exclude_unset=True)
|
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
runtime_config = frigate_config.runtime_config()
|
runtime_config = frigate_config.runtime_config()
|
||||||
assert runtime_config.cameras["back"].motion.frame_height == 100
|
assert runtime_config.cameras["back"].motion.frame_height == 100
|
||||||
@ -758,7 +842,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert config == frigate_config.dict(exclude_unset=True)
|
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
runtime_config = frigate_config.runtime_config()
|
runtime_config = frigate_config.runtime_config()
|
||||||
assert round(runtime_config.cameras["back"].motion.contour_area) == 10
|
assert round(runtime_config.cameras["back"].motion.contour_area) == 10
|
||||||
@ -787,7 +871,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert config == frigate_config.dict(exclude_unset=True)
|
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
runtime_config = frigate_config.runtime_config()
|
runtime_config = frigate_config.runtime_config()
|
||||||
assert runtime_config.model.merged_labelmap[7] == "truck"
|
assert runtime_config.model.merged_labelmap[7] == "truck"
|
||||||
@ -815,7 +899,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert config == frigate_config.dict(exclude_unset=True)
|
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
runtime_config = frigate_config.runtime_config()
|
runtime_config = frigate_config.runtime_config()
|
||||||
assert runtime_config.model.merged_labelmap[0] == "person"
|
assert runtime_config.model.merged_labelmap[0] == "person"
|
||||||
@ -844,7 +928,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert config == frigate_config.dict(exclude_unset=True)
|
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
runtime_config = frigate_config.runtime_config()
|
runtime_config = frigate_config.runtime_config()
|
||||||
assert runtime_config.model.merged_labelmap[0] == "person"
|
assert runtime_config.model.merged_labelmap[0] == "person"
|
||||||
@ -878,7 +962,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert config == frigate_config.dict(exclude_unset=True)
|
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
runtime_config = frigate_config.runtime_config(PlusApi())
|
runtime_config = frigate_config.runtime_config(PlusApi())
|
||||||
assert runtime_config.model.merged_labelmap[0] == "amazon"
|
assert runtime_config.model.merged_labelmap[0] == "amazon"
|
||||||
@ -1012,7 +1096,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert config == frigate_config.dict(exclude_unset=True)
|
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
runtime_config = frigate_config.runtime_config()
|
runtime_config = frigate_config.runtime_config()
|
||||||
assert runtime_config.cameras["back"].detect.max_disappeared == 1
|
assert runtime_config.cameras["back"].detect.max_disappeared == 1
|
||||||
@ -1040,7 +1124,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert config == frigate_config.dict(exclude_unset=True)
|
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
runtime_config = frigate_config.runtime_config()
|
runtime_config = frigate_config.runtime_config()
|
||||||
assert runtime_config.cameras["back"].detect.max_disappeared == 25
|
assert runtime_config.cameras["back"].detect.max_disappeared == 25
|
||||||
@ -1069,7 +1153,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert config == frigate_config.dict(exclude_unset=True)
|
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
runtime_config = frigate_config.runtime_config()
|
runtime_config = frigate_config.runtime_config()
|
||||||
assert runtime_config.cameras["back"].detect.max_disappeared == 1
|
assert runtime_config.cameras["back"].detect.max_disappeared == 1
|
||||||
@ -1102,7 +1186,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert config == frigate_config.dict(exclude_unset=True)
|
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
runtime_config = frigate_config.runtime_config()
|
runtime_config = frigate_config.runtime_config()
|
||||||
assert runtime_config.cameras["back"].snapshots.enabled
|
assert runtime_config.cameras["back"].snapshots.enabled
|
||||||
@ -1130,7 +1214,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert config == frigate_config.dict(exclude_unset=True)
|
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
runtime_config = frigate_config.runtime_config()
|
runtime_config = frigate_config.runtime_config()
|
||||||
assert runtime_config.cameras["back"].snapshots.bounding_box
|
assert runtime_config.cameras["back"].snapshots.bounding_box
|
||||||
@ -1163,7 +1247,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert config == frigate_config.dict(exclude_unset=True)
|
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
runtime_config = frigate_config.runtime_config()
|
runtime_config = frigate_config.runtime_config()
|
||||||
assert runtime_config.cameras["back"].snapshots.bounding_box is False
|
assert runtime_config.cameras["back"].snapshots.bounding_box is False
|
||||||
@ -1193,7 +1277,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert config == frigate_config.dict(exclude_unset=True)
|
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
runtime_config = frigate_config.runtime_config()
|
runtime_config = frigate_config.runtime_config()
|
||||||
assert runtime_config.cameras["back"].live.quality == 4
|
assert runtime_config.cameras["back"].live.quality == 4
|
||||||
@ -1220,7 +1304,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert config == frigate_config.dict(exclude_unset=True)
|
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
runtime_config = frigate_config.runtime_config()
|
runtime_config = frigate_config.runtime_config()
|
||||||
assert runtime_config.cameras["back"].live.quality == 8
|
assert runtime_config.cameras["back"].live.quality == 8
|
||||||
@ -1251,7 +1335,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert config == frigate_config.dict(exclude_unset=True)
|
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
runtime_config = frigate_config.runtime_config()
|
runtime_config = frigate_config.runtime_config()
|
||||||
assert runtime_config.cameras["back"].live.quality == 7
|
assert runtime_config.cameras["back"].live.quality == 7
|
||||||
@ -1280,7 +1364,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert config == frigate_config.dict(exclude_unset=True)
|
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
runtime_config = frigate_config.runtime_config()
|
runtime_config = frigate_config.runtime_config()
|
||||||
assert runtime_config.cameras["back"].timestamp_style.position == "bl"
|
assert runtime_config.cameras["back"].timestamp_style.position == "bl"
|
||||||
@ -1307,7 +1391,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert config == frigate_config.dict(exclude_unset=True)
|
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
runtime_config = frigate_config.runtime_config()
|
runtime_config = frigate_config.runtime_config()
|
||||||
assert runtime_config.cameras["back"].timestamp_style.position == "tl"
|
assert runtime_config.cameras["back"].timestamp_style.position == "tl"
|
||||||
@ -1336,7 +1420,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert config == frigate_config.dict(exclude_unset=True)
|
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
runtime_config = frigate_config.runtime_config()
|
runtime_config = frigate_config.runtime_config()
|
||||||
assert runtime_config.cameras["back"].timestamp_style.position == "bl"
|
assert runtime_config.cameras["back"].timestamp_style.position == "bl"
|
||||||
@ -1365,7 +1449,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert config == frigate_config.dict(exclude_unset=True)
|
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
runtime_config = frigate_config.runtime_config()
|
runtime_config = frigate_config.runtime_config()
|
||||||
assert runtime_config.cameras["back"].snapshots.retain.default == 1.5
|
assert runtime_config.cameras["back"].snapshots.retain.default == 1.5
|
||||||
@ -1505,7 +1589,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert config == frigate_config.dict(exclude_unset=True)
|
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
runtime_config = frigate_config.runtime_config()
|
runtime_config = frigate_config.runtime_config()
|
||||||
assert "dog" in runtime_config.cameras["back"].objects.filters
|
assert "dog" in runtime_config.cameras["back"].objects.filters
|
||||||
|
|||||||
127
frigate/util/config.py
Normal file
127
frigate/util/config.py
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
"""configuration utils."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
from ruamel.yaml import YAML
|
||||||
|
|
||||||
|
from frigate.const import CONFIG_DIR
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CURRENT_CONFIG_VERSION = 0.14
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_frigate_config(config_file: str):
|
||||||
|
"""handle migrating the frigate config."""
|
||||||
|
logger.info("Checking if frigate config needs migration...")
|
||||||
|
version_file = os.path.join(CONFIG_DIR, ".version")
|
||||||
|
|
||||||
|
if not os.path.isfile(version_file):
|
||||||
|
previous_version = 0.13
|
||||||
|
else:
|
||||||
|
with open(version_file) as f:
|
||||||
|
try:
|
||||||
|
previous_version = float(f.readline())
|
||||||
|
except Exception:
|
||||||
|
previous_version = 0.13
|
||||||
|
|
||||||
|
if previous_version == CURRENT_CONFIG_VERSION:
|
||||||
|
logger.info("frigate config does not need migration...")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("copying config as backup...")
|
||||||
|
shutil.copy(config_file, os.path.join(CONFIG_DIR, "backup_config.yaml"))
|
||||||
|
|
||||||
|
yaml = YAML()
|
||||||
|
yaml.indent(mapping=2, sequence=4, offset=2)
|
||||||
|
with open(config_file, "r") as f:
|
||||||
|
config: dict[str, dict[str, any]] = yaml.load(f)
|
||||||
|
|
||||||
|
if previous_version < 0.14:
|
||||||
|
logger.info(f"Migrating frigate config from {previous_version} to 0.14...")
|
||||||
|
new_config = migrate_014(config)
|
||||||
|
with open(config_file, "w") as f:
|
||||||
|
yaml.dump(new_config, f)
|
||||||
|
previous_version = 0.14
|
||||||
|
|
||||||
|
with open(version_file, "w") as f:
|
||||||
|
f.write(str(CURRENT_CONFIG_VERSION))
|
||||||
|
|
||||||
|
logger.info("Finished frigate config migration...")
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_014(config: dict[str, dict[str, any]]) -> dict[str, dict[str, any]]:
|
||||||
|
"""Handle migrating frigate config to 0.14"""
|
||||||
|
# migrate record.events.required_zones to review.alerts.required_zones
|
||||||
|
new_config = config.copy()
|
||||||
|
global_required_zones = (
|
||||||
|
config.get("record", {}).get("events", {}).get("required_zones", [])
|
||||||
|
)
|
||||||
|
|
||||||
|
if global_required_zones:
|
||||||
|
# migrate to new review config
|
||||||
|
if not new_config.get("review"):
|
||||||
|
new_config["review"] = {}
|
||||||
|
|
||||||
|
if not new_config["review"].get("alerts"):
|
||||||
|
new_config["review"]["alerts"] = {}
|
||||||
|
|
||||||
|
if not new_config["review"]["alerts"].get("required_zones"):
|
||||||
|
new_config["review"]["alerts"]["required_zones"] = global_required_zones
|
||||||
|
|
||||||
|
# remove record required zones config
|
||||||
|
del new_config["record"]["events"]["required_zones"]
|
||||||
|
|
||||||
|
# remove record altogether if there is not other config
|
||||||
|
if not new_config["record"]["events"]:
|
||||||
|
del new_config["record"]["events"]
|
||||||
|
|
||||||
|
if not new_config["record"]:
|
||||||
|
del new_config["record"]
|
||||||
|
|
||||||
|
# remove rtmp
|
||||||
|
if new_config.get("ffmpeg", {}).get("output_args", {}).get("rtmp"):
|
||||||
|
del new_config["ffmpeg"]["output_args"]["rtmp"]
|
||||||
|
|
||||||
|
if new_config.get("rtmp"):
|
||||||
|
del new_config["rtmp"]
|
||||||
|
|
||||||
|
for name, camera in config.get("cameras", {}).items():
|
||||||
|
camera_config: dict[str, dict[str, any]] = camera.copy()
|
||||||
|
required_zones = (
|
||||||
|
camera_config.get("record", {}).get("events", {}).get("required_zones", [])
|
||||||
|
)
|
||||||
|
|
||||||
|
if required_zones:
|
||||||
|
# migrate to new review config
|
||||||
|
if not camera_config.get("review"):
|
||||||
|
camera_config["review"] = {}
|
||||||
|
|
||||||
|
if not camera_config["review"].get("alerts"):
|
||||||
|
camera_config["review"]["alerts"] = {}
|
||||||
|
|
||||||
|
if not camera_config["review"]["alerts"].get("required_zones"):
|
||||||
|
camera_config["review"]["alerts"]["required_zones"] = required_zones
|
||||||
|
|
||||||
|
# remove record required zones config
|
||||||
|
del camera_config["record"]["events"]["required_zones"]
|
||||||
|
|
||||||
|
# remove record altogether if there is not other config
|
||||||
|
if not camera_config["record"]["events"]:
|
||||||
|
del camera_config["record"]["events"]
|
||||||
|
|
||||||
|
if not camera_config["record"]:
|
||||||
|
del camera_config["record"]
|
||||||
|
|
||||||
|
# remove rtmp
|
||||||
|
if camera_config.get("ffmpeg", {}).get("output_args", {}).get("rtmp"):
|
||||||
|
del camera_config["ffmpeg"]["output_args"]["rtmp"]
|
||||||
|
|
||||||
|
if camera_config.get("rtmp"):
|
||||||
|
del camera_config["rtmp"]
|
||||||
|
|
||||||
|
new_config["cameras"][name] = camera_config
|
||||||
|
|
||||||
|
return new_config
|
||||||
@ -727,9 +727,22 @@ def create_mask(frame_shape, mask):
|
|||||||
return mask_img
|
return mask_img
|
||||||
|
|
||||||
|
|
||||||
def add_mask(mask, mask_img):
|
def add_mask(mask: str, mask_img: np.ndarray):
|
||||||
points = mask.split(",")
|
points = mask.split(",")
|
||||||
|
|
||||||
|
# masks and zones are saved as relative coordinates
|
||||||
|
# we know if any points are > 1 then it is using the
|
||||||
|
# old native resolution coordinates
|
||||||
|
if any(x > "1.0" for x in points):
|
||||||
|
raise Exception("add mask expects relative coordinates only")
|
||||||
|
|
||||||
contour = np.array(
|
contour = np.array(
|
||||||
[[int(points[i]), int(points[i + 1])] for i in range(0, len(points), 2)]
|
[
|
||||||
|
[
|
||||||
|
int(float(points[i]) * mask_img.shape[1]),
|
||||||
|
int(float(points[i + 1]) * mask_img.shape[0]),
|
||||||
|
]
|
||||||
|
for i in range(0, len(points), 2)
|
||||||
|
]
|
||||||
)
|
)
|
||||||
cv2.fillPoly(mask_img, pts=[contour], color=(0))
|
cv2.fillPoly(mask_img, pts=[contour], color=(0))
|
||||||
|
|||||||
328
web/package-lock.json
generated
328
web/package-lock.json
generated
@ -37,7 +37,7 @@
|
|||||||
"hls.js": "^1.5.7",
|
"hls.js": "^1.5.7",
|
||||||
"idb-keyval": "^6.2.1",
|
"idb-keyval": "^6.2.1",
|
||||||
"immer": "^10.0.4",
|
"immer": "^10.0.4",
|
||||||
"lucide-react": "^0.360.0",
|
"lucide-react": "^0.365.0",
|
||||||
"monaco-yaml": "^5.1.1",
|
"monaco-yaml": "^5.1.1",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
@ -45,14 +45,14 @@
|
|||||||
"react-day-picker": "^8.9.1",
|
"react-day-picker": "^8.9.1",
|
||||||
"react-device-detect": "^2.2.3",
|
"react-device-detect": "^2.2.3",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-hook-form": "^7.51.1",
|
"react-hook-form": "^7.51.2",
|
||||||
"react-icons": "^5.0.1",
|
"react-icons": "^5.0.1",
|
||||||
"react-router-dom": "^6.22.3",
|
"react-router-dom": "^6.22.3",
|
||||||
"react-swipeable": "^7.0.1",
|
"react-swipeable": "^7.0.1",
|
||||||
"react-tracked": "^1.7.14",
|
"react-tracked": "^1.7.14",
|
||||||
"react-transition-group": "^4.4.5",
|
"react-transition-group": "^4.4.5",
|
||||||
"react-use-websocket": "^4.8.1",
|
"react-use-websocket": "^4.8.1",
|
||||||
"react-zoom-pan-pinch": "^3.4.3",
|
"react-zoom-pan-pinch": "^3.4.4",
|
||||||
"recoil": "^0.7.7",
|
"recoil": "^0.7.7",
|
||||||
"scroll-into-view-if-needed": "^3.1.0",
|
"scroll-into-view-if-needed": "^3.1.0",
|
||||||
"sonner": "^1.4.41",
|
"sonner": "^1.4.41",
|
||||||
@ -68,20 +68,20 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/forms": "^0.5.7",
|
"@tailwindcss/forms": "^0.5.7",
|
||||||
"@testing-library/jest-dom": "^6.1.5",
|
"@testing-library/jest-dom": "^6.1.5",
|
||||||
"@types/node": "^20.11.30",
|
"@types/node": "^20.12.5",
|
||||||
"@types/react": "^18.2.67",
|
"@types/react": "^18.2.74",
|
||||||
"@types/react-dom": "^18.2.22",
|
"@types/react-dom": "^18.2.24",
|
||||||
"@types/react-icons": "^3.0.0",
|
"@types/react-icons": "^3.0.0",
|
||||||
"@types/react-transition-group": "^4.4.10",
|
"@types/react-transition-group": "^4.4.10",
|
||||||
"@types/strftime": "^0.9.8",
|
"@types/strftime": "^0.9.8",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.3.1",
|
"@typescript-eslint/eslint-plugin": "^7.5.0",
|
||||||
"@typescript-eslint/parser": "^7.3.1",
|
"@typescript-eslint/parser": "^7.5.0",
|
||||||
"@vitejs/plugin-react-swc": "^3.6.0",
|
"@vitejs/plugin-react-swc": "^3.6.0",
|
||||||
"@vitest/coverage-v8": "^1.4.0",
|
"@vitest/coverage-v8": "^1.4.0",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
"eslint": "^8.55.0",
|
"eslint": "^8.55.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-jest": "^27.6.0",
|
"eslint-plugin-jest": "^28.2.0",
|
||||||
"eslint-plugin-prettier": "^5.0.1",
|
"eslint-plugin-prettier": "^5.0.1",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.6",
|
"eslint-plugin-react-refresh": "^0.4.6",
|
||||||
@ -89,12 +89,12 @@
|
|||||||
"fake-indexeddb": "^5.0.2",
|
"fake-indexeddb": "^5.0.2",
|
||||||
"jest-websocket-mock": "^2.5.0",
|
"jest-websocket-mock": "^2.5.0",
|
||||||
"jsdom": "^24.0.0",
|
"jsdom": "^24.0.0",
|
||||||
"msw": "^2.2.9",
|
"msw": "^2.2.13",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.38",
|
||||||
"prettier": "^3.2.5",
|
"prettier": "^3.2.5",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.3",
|
||||||
"typescript": "^5.4.3",
|
"typescript": "^5.4.4",
|
||||||
"vite": "^5.2.2",
|
"vite": "^5.2.8",
|
||||||
"vitest": "^1.3.1"
|
"vitest": "^1.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -841,9 +841,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mswjs/interceptors": {
|
"node_modules/@mswjs/interceptors": {
|
||||||
"version": "0.25.16",
|
"version": "0.26.15",
|
||||||
"resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.25.16.tgz",
|
"resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.26.15.tgz",
|
||||||
"integrity": "sha512-8QC8JyKztvoGAdPgyZy49c9vSHHAZjHagwl4RY9E8carULk8ym3iTaiawrT1YoLF/qb449h48f71XDPgkUSOUg==",
|
"integrity": "sha512-HM47Lu1YFmnYHKMBynFfjCp0U/yRskHj/8QEJW0CBEPOlw8Gkmjfll+S9b8M7V5CNDw2/ciRxjjnWeaCiblSIQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@open-draft/deferred-promise": "^2.2.0",
|
"@open-draft/deferred-promise": "^2.2.0",
|
||||||
@ -2507,9 +2507,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.11.30",
|
"version": "20.12.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.30.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.5.tgz",
|
||||||
"integrity": "sha512-dHM6ZxwlmuZaRmUPfv1p+KrdD1Dci04FbdEm/9wEMouFqxYoFl5aMkt0VMAUtYRQDyYvD41WJLukhq/ha3YuTw==",
|
"integrity": "sha512-BD+BjQ9LS/D8ST9p5uqBxghlN+S42iuNxjsUGjeZobe/ciXzk2qb1B6IXc6AnRLS+yFJRpN2IPEHMzwspfDJNw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~5.26.4"
|
"undici-types": "~5.26.4"
|
||||||
@ -2522,20 +2522,19 @@
|
|||||||
"devOptional": true
|
"devOptional": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "18.2.67",
|
"version": "18.2.74",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.67.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.74.tgz",
|
||||||
"integrity": "sha512-vkIE2vTIMHQ/xL0rgmuoECBCkZFZeHr49HeWSc24AptMbNRo7pwSBvj73rlJJs9fGKj0koS+V7kQB1jHS0uCgw==",
|
"integrity": "sha512-9AEqNZZyBx8OdZpxzQlaFEVCSFUM2YXJH46yPOiOpm078k6ZLOCcuAzGum/zK8YBwY+dbahVNbHrbgrAwIRlqw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
"@types/scheduler": "*",
|
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/react-dom": {
|
"node_modules/@types/react-dom": {
|
||||||
"version": "18.2.22",
|
"version": "18.2.24",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.22.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.24.tgz",
|
||||||
"integrity": "sha512-fHkBXPeNtfvri6gdsMYyW+dW7RXFo6Ad09nLFK0VQWR7yGLai/Cyvyj696gbwYvBnhGtevUG9cET0pmUbMtoPQ==",
|
"integrity": "sha512-cN6upcKd8zkGy4HU9F1+/s98Hrp6D4MOcippK4PoE8OZRngohHZpbJn1GsaDLz87MqvHNoT13nHvNqM9ocRHZg==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/react": "*"
|
"@types/react": "*"
|
||||||
@ -2560,12 +2559,6 @@
|
|||||||
"@types/react": "*"
|
"@types/react": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/scheduler": {
|
|
||||||
"version": "0.16.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz",
|
|
||||||
"integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==",
|
|
||||||
"devOptional": true
|
|
||||||
},
|
|
||||||
"node_modules/@types/semver": {
|
"node_modules/@types/semver": {
|
||||||
"version": "7.5.6",
|
"version": "7.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz",
|
||||||
@ -2591,16 +2584,16 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "7.3.1",
|
"version": "7.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.5.0.tgz",
|
||||||
"integrity": "sha512-STEDMVQGww5lhCuNXVSQfbfuNII5E08QWkvAw5Qwf+bj2WT+JkG1uc+5/vXA3AOYMDHVOSpL+9rcbEUiHIm2dw==",
|
"integrity": "sha512-HpqNTH8Du34nLxbKgVMGljZMG0rJd2O9ecvr2QLYp+7512ty1j42KnsFwspPXg1Vh8an9YImf6CokUBltisZFQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/regexpp": "^4.5.1",
|
"@eslint-community/regexpp": "^4.5.1",
|
||||||
"@typescript-eslint/scope-manager": "7.3.1",
|
"@typescript-eslint/scope-manager": "7.5.0",
|
||||||
"@typescript-eslint/type-utils": "7.3.1",
|
"@typescript-eslint/type-utils": "7.5.0",
|
||||||
"@typescript-eslint/utils": "7.3.1",
|
"@typescript-eslint/utils": "7.5.0",
|
||||||
"@typescript-eslint/visitor-keys": "7.3.1",
|
"@typescript-eslint/visitor-keys": "7.5.0",
|
||||||
"debug": "^4.3.4",
|
"debug": "^4.3.4",
|
||||||
"graphemer": "^1.4.0",
|
"graphemer": "^1.4.0",
|
||||||
"ignore": "^5.2.4",
|
"ignore": "^5.2.4",
|
||||||
@ -2626,15 +2619,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/parser": {
|
"node_modules/@typescript-eslint/parser": {
|
||||||
"version": "7.3.1",
|
"version": "7.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.5.0.tgz",
|
||||||
"integrity": "sha512-Rq49+pq7viTRCH48XAbTA+wdLRrB/3sRq4Lpk0oGDm0VmnjBrAOVXH/Laalmwsv2VpekiEfVFwJYVk6/e8uvQw==",
|
"integrity": "sha512-cj+XGhNujfD2/wzR1tabNsidnYRaFfEkcULdcIyVBYcXjBvBKOes+mpMBP7hMpOyk+gBcfXsrg4NBGAStQyxjQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "7.3.1",
|
"@typescript-eslint/scope-manager": "7.5.0",
|
||||||
"@typescript-eslint/types": "7.3.1",
|
"@typescript-eslint/types": "7.5.0",
|
||||||
"@typescript-eslint/typescript-estree": "7.3.1",
|
"@typescript-eslint/typescript-estree": "7.5.0",
|
||||||
"@typescript-eslint/visitor-keys": "7.3.1",
|
"@typescript-eslint/visitor-keys": "7.5.0",
|
||||||
"debug": "^4.3.4"
|
"debug": "^4.3.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -2654,13 +2647,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/scope-manager": {
|
"node_modules/@typescript-eslint/scope-manager": {
|
||||||
"version": "7.3.1",
|
"version": "7.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.5.0.tgz",
|
||||||
"integrity": "sha512-fVS6fPxldsKY2nFvyT7IP78UO1/I2huG+AYu5AMjCT9wtl6JFiDnsv4uad4jQ0GTFzcUV5HShVeN96/17bTBag==",
|
"integrity": "sha512-Z1r7uJY0MDeUlql9XJ6kRVgk/sP11sr3HKXn268HZyqL7i4cEfrdFuSSY/0tUqT37l5zT0tJOsuDP16kio85iA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "7.3.1",
|
"@typescript-eslint/types": "7.5.0",
|
||||||
"@typescript-eslint/visitor-keys": "7.3.1"
|
"@typescript-eslint/visitor-keys": "7.5.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || >=20.0.0"
|
"node": "^18.18.0 || >=20.0.0"
|
||||||
@ -2671,13 +2664,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/type-utils": {
|
"node_modules/@typescript-eslint/type-utils": {
|
||||||
"version": "7.3.1",
|
"version": "7.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.5.0.tgz",
|
||||||
"integrity": "sha512-iFhaysxFsMDQlzJn+vr3OrxN8NmdQkHks4WaqD4QBnt5hsq234wcYdyQ9uquzJJIDAj5W4wQne3yEsYA6OmXGw==",
|
"integrity": "sha512-A021Rj33+G8mx2Dqh0nMO9GyjjIBK3MqgVgZ2qlKf6CJy51wY/lkkFqq3TqqnH34XyAHUkq27IjlUkWlQRpLHw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/typescript-estree": "7.3.1",
|
"@typescript-eslint/typescript-estree": "7.5.0",
|
||||||
"@typescript-eslint/utils": "7.3.1",
|
"@typescript-eslint/utils": "7.5.0",
|
||||||
"debug": "^4.3.4",
|
"debug": "^4.3.4",
|
||||||
"ts-api-utils": "^1.0.1"
|
"ts-api-utils": "^1.0.1"
|
||||||
},
|
},
|
||||||
@ -2698,9 +2691,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/types": {
|
"node_modules/@typescript-eslint/types": {
|
||||||
"version": "7.3.1",
|
"version": "7.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.5.0.tgz",
|
||||||
"integrity": "sha512-2tUf3uWggBDl4S4183nivWQ2HqceOZh1U4hhu4p1tPiIJoRRXrab7Y+Y0p+dozYwZVvLPRI6r5wKe9kToF9FIw==",
|
"integrity": "sha512-tv5B4IHeAdhR7uS4+bf8Ov3k793VEVHd45viRRkehIUZxm0WF82VPiLgHzA/Xl4TGPg1ZD49vfxBKFPecD5/mg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || >=20.0.0"
|
"node": "^18.18.0 || >=20.0.0"
|
||||||
@ -2711,13 +2704,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree": {
|
"node_modules/@typescript-eslint/typescript-estree": {
|
||||||
"version": "7.3.1",
|
"version": "7.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.5.0.tgz",
|
||||||
"integrity": "sha512-tLpuqM46LVkduWP7JO7yVoWshpJuJzxDOPYIVWUUZbW+4dBpgGeUdl/fQkhuV0A8eGnphYw3pp8d2EnvPOfxmQ==",
|
"integrity": "sha512-YklQQfe0Rv2PZEueLTUffiQGKQneiIEKKnfIqPIOxgM9lKSZFCjT5Ad4VqRKj/U4+kQE3fa8YQpskViL7WjdPQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "7.3.1",
|
"@typescript-eslint/types": "7.5.0",
|
||||||
"@typescript-eslint/visitor-keys": "7.3.1",
|
"@typescript-eslint/visitor-keys": "7.5.0",
|
||||||
"debug": "^4.3.4",
|
"debug": "^4.3.4",
|
||||||
"globby": "^11.1.0",
|
"globby": "^11.1.0",
|
||||||
"is-glob": "^4.0.3",
|
"is-glob": "^4.0.3",
|
||||||
@ -2763,17 +2756,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/utils": {
|
"node_modules/@typescript-eslint/utils": {
|
||||||
"version": "7.3.1",
|
"version": "7.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.5.0.tgz",
|
||||||
"integrity": "sha512-jIERm/6bYQ9HkynYlNZvXpzmXWZGhMbrOvq3jJzOSOlKXsVjrrolzWBjDW6/TvT5Q3WqaN4EkmcfdQwi9tDjBQ==",
|
"integrity": "sha512-3vZl9u0R+/FLQcpy2EHyRGNqAS/ofJ3Ji8aebilfJe+fobK8+LbIFmrHciLVDxjDoONmufDcnVSF38KwMEOjzw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.4.0",
|
"@eslint-community/eslint-utils": "^4.4.0",
|
||||||
"@types/json-schema": "^7.0.12",
|
"@types/json-schema": "^7.0.12",
|
||||||
"@types/semver": "^7.5.0",
|
"@types/semver": "^7.5.0",
|
||||||
"@typescript-eslint/scope-manager": "7.3.1",
|
"@typescript-eslint/scope-manager": "7.5.0",
|
||||||
"@typescript-eslint/types": "7.3.1",
|
"@typescript-eslint/types": "7.5.0",
|
||||||
"@typescript-eslint/typescript-estree": "7.3.1",
|
"@typescript-eslint/typescript-estree": "7.5.0",
|
||||||
"semver": "^7.5.4"
|
"semver": "^7.5.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -2788,12 +2781,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/visitor-keys": {
|
"node_modules/@typescript-eslint/visitor-keys": {
|
||||||
"version": "7.3.1",
|
"version": "7.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.5.0.tgz",
|
||||||
"integrity": "sha512-9RMXwQF8knsZvfv9tdi+4D/j7dMG28X/wMJ8Jj6eOHyHWwDW4ngQJcqEczSsqIKKjFiLFr40Mnr7a5ulDD3vmw==",
|
"integrity": "sha512-mcuHM/QircmA6O7fy6nn2w/3ditQkj+SgtOc8DW3uQ10Yfj42amm2i+6F2K4YAOPNNTmE6iM1ynM6lrSwdendA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "7.3.1",
|
"@typescript-eslint/types": "7.5.0",
|
||||||
"eslint-visitor-keys": "^3.4.1"
|
"eslint-visitor-keys": "^3.4.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -4014,19 +4007,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-plugin-jest": {
|
"node_modules/eslint-plugin-jest": {
|
||||||
"version": "27.9.0",
|
"version": "28.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-27.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-28.2.0.tgz",
|
||||||
"integrity": "sha512-QIT7FH7fNmd9n4se7FFKHbsLKGQiw885Ds6Y/sxKgCZ6natwCsXdgPOADnYVxN2QrRweF0FZWbJ6S7Rsn7llug==",
|
"integrity": "sha512-yRDti/a+f+SMSmNTiT9/M/MzXGkitl8CfzUxnpoQcTyfq8gUrXMriVcWU36W1X6BZSUoyUCJrDAWWUA2N4hE5g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/utils": "^5.10.0"
|
"@typescript-eslint/utils": "^6.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
"node": "^16.10.0 || ^18.12.0 || >=20.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@typescript-eslint/eslint-plugin": "^5.0.0 || ^6.0.0 || ^7.0.0",
|
"@typescript-eslint/eslint-plugin": "^6.0.0 || ^7.0.0",
|
||||||
"eslint": "^7.0.0 || ^8.0.0",
|
"eslint": "^7.0.0 || ^8.0.0 || ^9.0.0",
|
||||||
"jest": "*"
|
"jest": "*"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
@ -4039,16 +4032,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-plugin-jest/node_modules/@typescript-eslint/scope-manager": {
|
"node_modules/eslint-plugin-jest/node_modules/@typescript-eslint/scope-manager": {
|
||||||
"version": "5.62.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz",
|
||||||
"integrity": "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==",
|
"integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "5.62.0",
|
"@typescript-eslint/types": "6.21.0",
|
||||||
"@typescript-eslint/visitor-keys": "5.62.0"
|
"@typescript-eslint/visitor-keys": "6.21.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
"node": "^16.0.0 || >=18.0.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@ -4056,12 +4049,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-plugin-jest/node_modules/@typescript-eslint/types": {
|
"node_modules/eslint-plugin-jest/node_modules/@typescript-eslint/types": {
|
||||||
"version": "5.62.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz",
|
||||||
"integrity": "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==",
|
"integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
"node": "^16.0.0 || >=18.0.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@ -4069,21 +4062,22 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-plugin-jest/node_modules/@typescript-eslint/typescript-estree": {
|
"node_modules/eslint-plugin-jest/node_modules/@typescript-eslint/typescript-estree": {
|
||||||
"version": "5.62.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz",
|
||||||
"integrity": "sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==",
|
"integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "5.62.0",
|
"@typescript-eslint/types": "6.21.0",
|
||||||
"@typescript-eslint/visitor-keys": "5.62.0",
|
"@typescript-eslint/visitor-keys": "6.21.0",
|
||||||
"debug": "^4.3.4",
|
"debug": "^4.3.4",
|
||||||
"globby": "^11.1.0",
|
"globby": "^11.1.0",
|
||||||
"is-glob": "^4.0.3",
|
"is-glob": "^4.0.3",
|
||||||
"semver": "^7.3.7",
|
"minimatch": "9.0.3",
|
||||||
"tsutils": "^3.21.0"
|
"semver": "^7.5.4",
|
||||||
|
"ts-api-utils": "^1.0.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
"node": "^16.0.0 || >=18.0.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@ -4096,68 +4090,69 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-plugin-jest/node_modules/@typescript-eslint/utils": {
|
"node_modules/eslint-plugin-jest/node_modules/@typescript-eslint/utils": {
|
||||||
"version": "5.62.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.62.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz",
|
||||||
"integrity": "sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==",
|
"integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.4.0",
|
||||||
"@types/json-schema": "^7.0.9",
|
"@types/json-schema": "^7.0.12",
|
||||||
"@types/semver": "^7.3.12",
|
"@types/semver": "^7.5.0",
|
||||||
"@typescript-eslint/scope-manager": "5.62.0",
|
"@typescript-eslint/scope-manager": "6.21.0",
|
||||||
"@typescript-eslint/types": "5.62.0",
|
"@typescript-eslint/types": "6.21.0",
|
||||||
"@typescript-eslint/typescript-estree": "5.62.0",
|
"@typescript-eslint/typescript-estree": "6.21.0",
|
||||||
"eslint-scope": "^5.1.1",
|
"semver": "^7.5.4"
|
||||||
"semver": "^7.3.7"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
"node": "^16.0.0 || >=18.0.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
"url": "https://opencollective.com/typescript-eslint"
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"eslint": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"eslint": "^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-plugin-jest/node_modules/@typescript-eslint/visitor-keys": {
|
"node_modules/eslint-plugin-jest/node_modules/@typescript-eslint/visitor-keys": {
|
||||||
"version": "5.62.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz",
|
||||||
"integrity": "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==",
|
"integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "5.62.0",
|
"@typescript-eslint/types": "6.21.0",
|
||||||
"eslint-visitor-keys": "^3.3.0"
|
"eslint-visitor-keys": "^3.4.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
"node": "^16.0.0 || >=18.0.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
"url": "https://opencollective.com/typescript-eslint"
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-plugin-jest/node_modules/eslint-scope": {
|
"node_modules/eslint-plugin-jest/node_modules/brace-expansion": {
|
||||||
"version": "5.1.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
|
||||||
"integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
|
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esrecurse": "^4.3.0",
|
"balanced-match": "^1.0.0"
|
||||||
"estraverse": "^4.1.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8.0.0"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-plugin-jest/node_modules/estraverse": {
|
"node_modules/eslint-plugin-jest/node_modules/minimatch": {
|
||||||
"version": "4.3.0",
|
"version": "9.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
|
||||||
"integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
|
"integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"brace-expansion": "^2.0.1"
|
||||||
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=4.0"
|
"node": ">=16 || 14 >=14.17"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-plugin-prettier": {
|
"node_modules/eslint-plugin-prettier": {
|
||||||
@ -5284,9 +5279,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lucide-react": {
|
"node_modules/lucide-react": {
|
||||||
"version": "0.360.0",
|
"version": "0.365.0",
|
||||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.360.0.tgz",
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.365.0.tgz",
|
||||||
"integrity": "sha512-MskvbEsAhD2zxgx/I05vXq1cjFQXrmhL97YFIi4wSaKH793ZMvU/Com4d+DE7OB3QMmZig1fY1q94aTX5skozw==",
|
"integrity": "sha512-sJYpPyyzGHI4B3pys+XSFnE4qtSWc68rFnDLxbNNKjkLST5XSx9DNn5+1Z3eFgFiw39PphNRiVBSVb+AL3oKwA==",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0"
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0"
|
||||||
}
|
}
|
||||||
@ -5528,9 +5523,9 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/msw": {
|
"node_modules/msw": {
|
||||||
"version": "2.2.9",
|
"version": "2.2.13",
|
||||||
"resolved": "https://registry.npmjs.org/msw/-/msw-2.2.9.tgz",
|
"resolved": "https://registry.npmjs.org/msw/-/msw-2.2.13.tgz",
|
||||||
"integrity": "sha512-MLIFufBe6m9c5rZKlmGl6jl1pjn7cTNiDGEgn5v2iVRs0mz+neE2r7lRyYNzvcp6FbdiUEIRp/Y2O2gRMjO8yQ==",
|
"integrity": "sha512-ljFf1xZsU0b4zv1l7xzEmC6OZA6yD06hcx0H+dc8V0VypaP3HGYJa1rMLjQbBWl32ptGhcfwcPCWDB1wjmsftw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -5538,7 +5533,7 @@
|
|||||||
"@bundled-es-modules/statuses": "^1.0.1",
|
"@bundled-es-modules/statuses": "^1.0.1",
|
||||||
"@inquirer/confirm": "^3.0.0",
|
"@inquirer/confirm": "^3.0.0",
|
||||||
"@mswjs/cookies": "^1.1.0",
|
"@mswjs/cookies": "^1.1.0",
|
||||||
"@mswjs/interceptors": "^0.25.16",
|
"@mswjs/interceptors": "^0.26.14",
|
||||||
"@open-draft/until": "^2.1.0",
|
"@open-draft/until": "^2.1.0",
|
||||||
"@types/cookie": "^0.6.0",
|
"@types/cookie": "^0.6.0",
|
||||||
"@types/statuses": "^2.0.4",
|
"@types/statuses": "^2.0.4",
|
||||||
@ -6266,9 +6261,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-hook-form": {
|
"node_modules/react-hook-form": {
|
||||||
"version": "7.51.1",
|
"version": "7.51.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.51.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.51.2.tgz",
|
||||||
"integrity": "sha512-ifnBjl+kW0ksINHd+8C/Gp6a4eZOdWyvRv0UBaByShwU8JbVx5hTcTWEcd5VdybvmPTATkVVXk9npXArHmo56w==",
|
"integrity": "sha512-y++lwaWjtzDt/XNnyGDQy6goHskFualmDlf+jzEZvjvz6KWDf7EboL7pUvRCzPTJd0EOPpdekYaQLEvvG6m6HA==",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.22.0"
|
"node": ">=12.22.0"
|
||||||
},
|
},
|
||||||
@ -6447,9 +6442,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-zoom-pan-pinch": {
|
"node_modules/react-zoom-pan-pinch": {
|
||||||
"version": "3.4.3",
|
"version": "3.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-3.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-3.4.4.tgz",
|
||||||
"integrity": "sha512-x5MFlfAx2D6NTpZu8OISqc2nYn4p+YEaM1p21w7S/VE1wbVzK8vRzTo9Bj1ydufa649MuP7JBRM3vvj1RftFZw==",
|
"integrity": "sha512-lGTu7D9lQpYEQ6sH+NSlLA7gicgKRW8j+D/4HO1AbSV2POvKRFzdWQ8eI0r3xmOsl4dYQcY+teV6MhULeg1xBw==",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8",
|
"node": ">=8",
|
||||||
"npm": ">=5"
|
"npm": ">=5"
|
||||||
@ -7200,9 +7195,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tailwindcss": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "3.4.1",
|
"version": "3.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.3.tgz",
|
||||||
"integrity": "sha512-qAYmXRfk3ENzuPBakNK0SRrUDipP8NQnEY6772uDhflcQz5EhRdD7JNZxyrFHVQNCwULPBn6FNPp9brpO7ctcA==",
|
"integrity": "sha512-U7sxQk/n397Bmx4JHbJx/iSOOv5G+II3f1kpLpY2QeUv5DcPdcTsYLlusZfq1NthHS1c1cZoyFmmkex1rzke0A==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@alloc/quick-lru": "^5.2.0",
|
"@alloc/quick-lru": "^5.2.0",
|
||||||
"arg": "^5.0.2",
|
"arg": "^5.0.2",
|
||||||
@ -7212,7 +7207,7 @@
|
|||||||
"fast-glob": "^3.3.0",
|
"fast-glob": "^3.3.0",
|
||||||
"glob-parent": "^6.0.2",
|
"glob-parent": "^6.0.2",
|
||||||
"is-glob": "^4.0.3",
|
"is-glob": "^4.0.3",
|
||||||
"jiti": "^1.19.1",
|
"jiti": "^1.21.0",
|
||||||
"lilconfig": "^2.1.0",
|
"lilconfig": "^2.1.0",
|
||||||
"micromatch": "^4.0.5",
|
"micromatch": "^4.0.5",
|
||||||
"normalize-path": "^3.0.0",
|
"normalize-path": "^3.0.0",
|
||||||
@ -7392,27 +7387,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
|
||||||
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
|
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
|
||||||
},
|
},
|
||||||
"node_modules/tsutils": {
|
|
||||||
"version": "3.21.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz",
|
|
||||||
"integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==",
|
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^1.8.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 6"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tsutils/node_modules/tslib": {
|
|
||||||
"version": "1.14.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
|
||||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
|
||||||
"dev": true
|
|
||||||
},
|
|
||||||
"node_modules/type-check": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||||
@ -7447,9 +7421,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.4.3",
|
"version": "5.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.4.tgz",
|
||||||
"integrity": "sha512-KrPd3PKaCLr78MalgiwJnA25Nm8HAmdwN3mYUYZgG/wizIo9EainNVQI9/yDavtVFRN2h3k8uf3GLHuhDMgEHg==",
|
"integrity": "sha512-dGE2Vv8cpVvw28v8HCPqyb08EzbBURxDpuhJvTrusShUfGnhHBafDsLdS1EhhxyL6BJQE+2cT3dDPAv+MQ6oLw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
@ -7660,13 +7634,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "5.2.2",
|
"version": "5.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.2.8.tgz",
|
||||||
"integrity": "sha512-FWZbz0oSdLq5snUI0b6sULbz58iXFXdvkZfZWR/F0ZJuKTSPO7v72QPXt6KqYeMFb0yytNp6kZosxJ96Nr/wDQ==",
|
"integrity": "sha512-OyZR+c1CE8yeHw5V5t59aXsUPPVTHMDjEZz8MgguLL/Q7NblxhZUlTu9xSPqlsUO/y+X7dlU05jdhvyycD55DA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.20.1",
|
"esbuild": "^0.20.1",
|
||||||
"postcss": "^8.4.36",
|
"postcss": "^8.4.38",
|
||||||
"rollup": "^4.13.0"
|
"rollup": "^4.13.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@ -42,7 +42,7 @@
|
|||||||
"hls.js": "^1.5.7",
|
"hls.js": "^1.5.7",
|
||||||
"idb-keyval": "^6.2.1",
|
"idb-keyval": "^6.2.1",
|
||||||
"immer": "^10.0.4",
|
"immer": "^10.0.4",
|
||||||
"lucide-react": "^0.360.0",
|
"lucide-react": "^0.365.0",
|
||||||
"monaco-yaml": "^5.1.1",
|
"monaco-yaml": "^5.1.1",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
@ -50,14 +50,14 @@
|
|||||||
"react-day-picker": "^8.9.1",
|
"react-day-picker": "^8.9.1",
|
||||||
"react-device-detect": "^2.2.3",
|
"react-device-detect": "^2.2.3",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-hook-form": "^7.51.1",
|
"react-hook-form": "^7.51.2",
|
||||||
"react-icons": "^5.0.1",
|
"react-icons": "^5.0.1",
|
||||||
"react-router-dom": "^6.22.3",
|
"react-router-dom": "^6.22.3",
|
||||||
"react-swipeable": "^7.0.1",
|
"react-swipeable": "^7.0.1",
|
||||||
"react-tracked": "^1.7.14",
|
"react-tracked": "^1.7.14",
|
||||||
"react-transition-group": "^4.4.5",
|
"react-transition-group": "^4.4.5",
|
||||||
"react-use-websocket": "^4.8.1",
|
"react-use-websocket": "^4.8.1",
|
||||||
"react-zoom-pan-pinch": "^3.4.3",
|
"react-zoom-pan-pinch": "^3.4.4",
|
||||||
"recoil": "^0.7.7",
|
"recoil": "^0.7.7",
|
||||||
"scroll-into-view-if-needed": "^3.1.0",
|
"scroll-into-view-if-needed": "^3.1.0",
|
||||||
"sonner": "^1.4.41",
|
"sonner": "^1.4.41",
|
||||||
@ -73,20 +73,20 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/forms": "^0.5.7",
|
"@tailwindcss/forms": "^0.5.7",
|
||||||
"@testing-library/jest-dom": "^6.1.5",
|
"@testing-library/jest-dom": "^6.1.5",
|
||||||
"@types/node": "^20.11.30",
|
"@types/node": "^20.12.5",
|
||||||
"@types/react": "^18.2.67",
|
"@types/react": "^18.2.74",
|
||||||
"@types/react-dom": "^18.2.22",
|
"@types/react-dom": "^18.2.24",
|
||||||
"@types/react-icons": "^3.0.0",
|
"@types/react-icons": "^3.0.0",
|
||||||
"@types/react-transition-group": "^4.4.10",
|
"@types/react-transition-group": "^4.4.10",
|
||||||
"@types/strftime": "^0.9.8",
|
"@types/strftime": "^0.9.8",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.3.1",
|
"@typescript-eslint/eslint-plugin": "^7.5.0",
|
||||||
"@typescript-eslint/parser": "^7.3.1",
|
"@typescript-eslint/parser": "^7.5.0",
|
||||||
"@vitejs/plugin-react-swc": "^3.6.0",
|
"@vitejs/plugin-react-swc": "^3.6.0",
|
||||||
"@vitest/coverage-v8": "^1.4.0",
|
"@vitest/coverage-v8": "^1.4.0",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
"eslint": "^8.55.0",
|
"eslint": "^8.55.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-jest": "^27.6.0",
|
"eslint-plugin-jest": "^28.2.0",
|
||||||
"eslint-plugin-prettier": "^5.0.1",
|
"eslint-plugin-prettier": "^5.0.1",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.6",
|
"eslint-plugin-react-refresh": "^0.4.6",
|
||||||
@ -94,12 +94,12 @@
|
|||||||
"fake-indexeddb": "^5.0.2",
|
"fake-indexeddb": "^5.0.2",
|
||||||
"jest-websocket-mock": "^2.5.0",
|
"jest-websocket-mock": "^2.5.0",
|
||||||
"jsdom": "^24.0.0",
|
"jsdom": "^24.0.0",
|
||||||
"msw": "^2.2.9",
|
"msw": "^2.2.13",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.38",
|
||||||
"prettier": "^3.2.5",
|
"prettier": "^3.2.5",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.3",
|
||||||
"typescript": "^5.4.3",
|
"typescript": "^5.4.4",
|
||||||
"vite": "^5.2.2",
|
"vite": "^5.2.8",
|
||||||
"vitest": "^1.3.1"
|
"vitest": "^1.3.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { isDesktop, isMobile } from "react-device-detect";
|
|||||||
import Statusbar from "./components/Statusbar";
|
import Statusbar from "./components/Statusbar";
|
||||||
import Bottombar from "./components/navigation/Bottombar";
|
import Bottombar from "./components/navigation/Bottombar";
|
||||||
import { Suspense, lazy } from "react";
|
import { Suspense, lazy } from "react";
|
||||||
|
import { Redirect } from "./components/navigation/Redirect";
|
||||||
|
|
||||||
const Live = lazy(() => import("@/pages/Live"));
|
const Live = lazy(() => import("@/pages/Live"));
|
||||||
const Events = lazy(() => import("@/pages/Events"));
|
const Events = lazy(() => import("@/pages/Events"));
|
||||||
@ -35,7 +36,8 @@ function App() {
|
|||||||
<Suspense>
|
<Suspense>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Live />} />
|
<Route path="/" element={<Live />} />
|
||||||
<Route path="/events" element={<Events />} />
|
<Route path="/events" element={<Redirect to="/review" />} />
|
||||||
|
<Route path="/review" element={<Events />} />
|
||||||
<Route path="/export" element={<Export />} />
|
<Route path="/export" element={<Export />} />
|
||||||
<Route path="/plus" element={<SubmitPlus />} />
|
<Route path="/plus" element={<SubmitPlus />} />
|
||||||
<Route path="/system" element={<System />} />
|
<Route path="/system" element={<System />} />
|
||||||
|
|||||||
@ -32,7 +32,7 @@ export default function Statusbar() {
|
|||||||
const { potentialProblems } = useStats(stats);
|
const { potentialProblems } = useStats(stats);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute left-0 bottom-0 right-0 w-full h-8 flex justify-between items-center px-4 bg-primary z-10 text-secondary-foreground border-t border-secondary-highlight">
|
<div className="absolute left-0 bottom-0 right-0 w-full h-8 flex justify-between items-center px-4 bg-background_alt z-10 dark:text-secondary-foreground border-t border-secondary-highlight">
|
||||||
<div className="h-full flex items-center gap-2">
|
<div className="h-full flex items-center gap-2">
|
||||||
{cpuPercent && (
|
{cpuPercent && (
|
||||||
<div className="flex items-center text-sm gap-2">
|
<div className="flex items-center text-sm gap-2">
|
||||||
|
|||||||
@ -20,7 +20,7 @@ export function AnimatedEventCard({ event }: AnimatedEventCardProps) {
|
|||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const onOpenReview = useCallback(() => {
|
const onOpenReview = useCallback(() => {
|
||||||
navigate("events", {
|
navigate("review", {
|
||||||
state: {
|
state: {
|
||||||
severity: event.severity,
|
severity: event.severity,
|
||||||
recording: {
|
recording: {
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { Input } from "../ui/input";
|
|||||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||||
|
|
||||||
type ExportProps = {
|
type ExportProps = {
|
||||||
|
className: string;
|
||||||
file: {
|
file: {
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
@ -19,7 +20,12 @@ type ExportProps = {
|
|||||||
onDelete: (file: string) => void;
|
onDelete: (file: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ExportCard({ file, onRename, onDelete }: ExportProps) {
|
export default function ExportCard({
|
||||||
|
className,
|
||||||
|
file,
|
||||||
|
onRename,
|
||||||
|
onDelete,
|
||||||
|
}: ExportProps) {
|
||||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
@ -94,7 +100,7 @@ export default function ExportCard({ file, onRename, onDelete }: ExportProps) {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="relative aspect-video bg-black rounded-2xl flex justify-center items-center"
|
className={`relative aspect-video bg-black rounded-2xl flex justify-center items-center ${className}`}
|
||||||
onMouseEnter={
|
onMouseEnter={
|
||||||
isDesktop && !inProgress ? () => setHovered(true) : undefined
|
isDesktop && !inProgress ? () => setHovered(true) : undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,7 +27,9 @@ export default function ReviewCard({
|
|||||||
config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p",
|
config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p",
|
||||||
);
|
);
|
||||||
const isSelected = useMemo(
|
const isSelected = useMemo(
|
||||||
() => event.start_time <= currentTime && event.end_time >= currentTime,
|
() =>
|
||||||
|
event.start_time <= currentTime &&
|
||||||
|
(event.end_time ?? Date.now() / 1000) >= currentTime,
|
||||||
[event, currentTime],
|
[event, currentTime],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -34,7 +34,6 @@ export default function NewReviewData({
|
|||||||
? "animate-in slide-in-from-top duration-500"
|
? "animate-in slide-in-from-top duration-500"
|
||||||
: "invisible"
|
: "invisible"
|
||||||
} text-center mt-5 mx-auto bg-gray-400 text-white`}
|
} text-center mt-5 mx-auto bg-gray-400 text-white`}
|
||||||
variant="secondary"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
pullLatestData();
|
pullLatestData();
|
||||||
if (contentRef.current) {
|
if (contentRef.current) {
|
||||||
|
|||||||
@ -131,7 +131,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
|||||||
size="xs"
|
size="xs"
|
||||||
onClick={() => setAddGroup(true)}
|
onClick={() => setAddGroup(true)}
|
||||||
>
|
>
|
||||||
<LuPlus className="size-4 text-primary-foreground" />
|
<LuPlus className="size-4 text-primary" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -253,7 +253,7 @@ function NewGroupDialog({ open, setOpen, currentGroups }: NewGroupDialogProps) {
|
|||||||
{currentGroups.length > 0 && <DropdownMenuSeparator />}
|
{currentGroups.length > 0 && <DropdownMenuSeparator />}
|
||||||
{editState == "none" && (
|
{editState == "none" && (
|
||||||
<Button
|
<Button
|
||||||
className="text-primary-foreground justify-start"
|
className="text-primary justify-start"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => setEditState("add")}
|
onClick={() => setEditState("add")}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -19,7 +19,7 @@ export default function FilterCheckBox({
|
|||||||
}: FilterCheckBoxProps) {
|
}: FilterCheckBoxProps) {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
className="capitalize flex justify-between items-center cursor-pointer w-full text-primary-foreground"
|
className="capitalize flex justify-between items-center cursor-pointer w-full text-primary"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => onCheckedChange(!isChecked)}
|
onClick={() => onCheckedChange(!isChecked)}
|
||||||
>
|
>
|
||||||
|
|||||||
126
web/src/components/filter/LogLevelFilter.tsx
Normal file
126
web/src/components/filter/LogLevelFilter.tsx
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { FaFilter } from "react-icons/fa";
|
||||||
|
import { isMobile } from "react-device-detect";
|
||||||
|
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||||
|
import { LogSeverity } from "@/types/log";
|
||||||
|
import { Label } from "../ui/label";
|
||||||
|
import { Switch } from "../ui/switch";
|
||||||
|
import { DropdownMenuSeparator } from "../ui/dropdown-menu";
|
||||||
|
|
||||||
|
type LogLevelFilterButtonProps = {
|
||||||
|
selectedLabels?: LogSeverity[];
|
||||||
|
updateLabelFilter: (labels: LogSeverity[] | undefined) => void;
|
||||||
|
};
|
||||||
|
export function LogLevelFilterButton({
|
||||||
|
selectedLabels,
|
||||||
|
updateLabelFilter,
|
||||||
|
}: LogLevelFilterButtonProps) {
|
||||||
|
const trigger = (
|
||||||
|
<Button size="sm" className="flex items-center gap-2">
|
||||||
|
<FaFilter className="text-secondary-foreground" />
|
||||||
|
<div className="hidden md:block text-primary">Filter</div>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
const content = (
|
||||||
|
<GeneralFilterContent
|
||||||
|
selectedLabels={selectedLabels}
|
||||||
|
updateLabelFilter={updateLabelFilter}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<Drawer>
|
||||||
|
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
|
||||||
|
<DrawerContent className="max-h-[75dvh] p-3 mx-1 overflow-hidden">
|
||||||
|
{content}
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
||||||
|
<PopoverContent>{content}</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type GeneralFilterContentProps = {
|
||||||
|
selectedLabels: LogSeverity[] | undefined;
|
||||||
|
updateLabelFilter: (labels: LogSeverity[] | undefined) => void;
|
||||||
|
};
|
||||||
|
export function GeneralFilterContent({
|
||||||
|
selectedLabels,
|
||||||
|
updateLabelFilter,
|
||||||
|
}: GeneralFilterContentProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="h-auto overflow-y-auto overflow-x-hidden">
|
||||||
|
<div className="flex justify-between items-center my-2.5">
|
||||||
|
<Label
|
||||||
|
className="mx-2 text-primary cursor-pointer"
|
||||||
|
htmlFor="allLabels"
|
||||||
|
>
|
||||||
|
All Logs
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
className="ml-1"
|
||||||
|
id="allLabels"
|
||||||
|
checked={selectedLabels == undefined}
|
||||||
|
onCheckedChange={(isChecked) => {
|
||||||
|
if (isChecked) {
|
||||||
|
updateLabelFilter(undefined);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<div className="my-2.5 flex flex-col gap-2.5">
|
||||||
|
{["debug", "info", "warning", "error"].map((item) => (
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<Label
|
||||||
|
className="w-full mx-2 text-primary capitalize cursor-pointer"
|
||||||
|
htmlFor={item}
|
||||||
|
>
|
||||||
|
{item.replaceAll("_", " ")}
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
key={item}
|
||||||
|
className="ml-1"
|
||||||
|
id={item}
|
||||||
|
checked={selectedLabels?.includes(item as LogSeverity) ?? false}
|
||||||
|
onCheckedChange={(isChecked) => {
|
||||||
|
if (isChecked) {
|
||||||
|
const updatedLabels = selectedLabels
|
||||||
|
? [...selectedLabels]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
updatedLabels.push(item as LogSeverity);
|
||||||
|
updateLabelFilter(updatedLabels);
|
||||||
|
} else {
|
||||||
|
const updatedLabels = selectedLabels
|
||||||
|
? [...selectedLabels]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// can not deselect the last item
|
||||||
|
if (updatedLabels.length > 1) {
|
||||||
|
updatedLabels.splice(
|
||||||
|
updatedLabels.indexOf(item as LogSeverity),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
updateLabelFilter(updatedLabels);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -40,7 +40,7 @@ export default function ReviewActionGroup({
|
|||||||
<div className="p-1">{`${selectedReviews.length} selected`}</div>
|
<div className="p-1">{`${selectedReviews.length} selected`}</div>
|
||||||
<div className="p-1">{"|"}</div>
|
<div className="p-1">{"|"}</div>
|
||||||
<div
|
<div
|
||||||
className="p-2 text-primary-foreground cursor-pointer hover:bg-secondary hover:rounded-lg"
|
className="p-2 text-primary cursor-pointer hover:bg-secondary hover:rounded-lg"
|
||||||
onClick={onClearSelected}
|
onClick={onClearSelected}
|
||||||
>
|
>
|
||||||
Unselect
|
Unselect
|
||||||
@ -50,7 +50,6 @@ export default function ReviewActionGroup({
|
|||||||
{selectedReviews.length == 1 && (
|
{selectedReviews.length == 1 && (
|
||||||
<Button
|
<Button
|
||||||
className="p-2 flex items-center gap-2"
|
className="p-2 flex items-center gap-2"
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onExport(selectedReviews[0]);
|
onExport(selectedReviews[0]);
|
||||||
@ -58,28 +57,24 @@ export default function ReviewActionGroup({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FaCompactDisc />
|
<FaCompactDisc />
|
||||||
{isDesktop && <div className="text-primary-foreground">Export</div>}
|
{isDesktop && <div className="text-primary">Export</div>}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
className="p-2 flex items-center gap-2"
|
className="p-2 flex items-center gap-2"
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onMarkAsReviewed}
|
onClick={onMarkAsReviewed}
|
||||||
>
|
>
|
||||||
<FaCircleCheck />
|
<FaCircleCheck />
|
||||||
{isDesktop && (
|
{isDesktop && <div className="text-primary">Mark as reviewed</div>}
|
||||||
<div className="text-primary-foreground">Mark as reviewed</div>
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="p-2 flex items-center gap-1"
|
className="p-2 flex items-center gap-1"
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
>
|
>
|
||||||
<HiTrash />
|
<HiTrash />
|
||||||
{isDesktop && <div className="text-primary-foreground">Delete</div>}
|
{isDesktop && <div className="text-primary">Delete</div>}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -75,6 +75,9 @@ export default function ReviewFilterGroup({
|
|||||||
const cameras = filter?.cameras || Object.keys(config.cameras);
|
const cameras = filter?.cameras || Object.keys(config.cameras);
|
||||||
|
|
||||||
cameras.forEach((camera) => {
|
cameras.forEach((camera) => {
|
||||||
|
if (camera == "birdseye") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const cameraConfig = config.cameras[camera];
|
const cameraConfig = config.cameras[camera];
|
||||||
cameraConfig.objects.track.forEach((label) => {
|
cameraConfig.objects.track.forEach((label) => {
|
||||||
labels.add(label);
|
labels.add(label);
|
||||||
@ -220,23 +223,31 @@ function CamerasFilterButton({
|
|||||||
const trigger = (
|
const trigger = (
|
||||||
<Button
|
<Button
|
||||||
className="flex items-center gap-2 capitalize"
|
className="flex items-center gap-2 capitalize"
|
||||||
variant="secondary"
|
variant={selectedCameras?.length == undefined ? "default" : "select"}
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
<FaVideo className="text-secondary-foreground" />
|
<FaVideo
|
||||||
<div className="hidden md:block text-primary-foreground">
|
className={`${selectedCameras?.length == 1 ? "text-selected-foreground" : "text-secondary-foreground"}`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`hidden md:block ${selectedCameras?.length ? "text-selected-foreground" : "text-primary"}`}
|
||||||
|
>
|
||||||
{selectedCameras == undefined
|
{selectedCameras == undefined
|
||||||
? "All Cameras"
|
? "All Cameras"
|
||||||
: `${selectedCameras.length} Cameras`}
|
: `${selectedCameras.includes("birdseye") ? selectedCameras.length - 1 : selectedCameras.length} Camera${selectedCameras.length !== 1 ? "s" : ""}`}
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuLabel className="flex justify-center">
|
{isMobile && (
|
||||||
Filter Cameras
|
<>
|
||||||
</DropdownMenuLabel>
|
<DropdownMenuLabel className="flex justify-center">
|
||||||
<DropdownMenuSeparator />
|
Cameras
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<div className="h-auto overflow-y-auto overflow-x-hidden">
|
<div className="h-auto overflow-y-auto overflow-x-hidden">
|
||||||
<FilterCheckBox
|
<FilterCheckBox
|
||||||
isChecked={currentCameras == undefined}
|
isChecked={currentCameras == undefined}
|
||||||
@ -305,7 +316,6 @@ function CamerasFilterButton({
|
|||||||
Apply
|
Apply
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCurrentCameras(undefined);
|
setCurrentCameras(undefined);
|
||||||
updateCameraFilter(undefined);
|
updateCameraFilter(undefined);
|
||||||
@ -368,7 +378,7 @@ function ShowReviewFilter({
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="hidden h-9 md:flex p-2 justify-start items-center text-sm bg-secondary hover:bg-secondary/80 text-primary-foreground rounded-md cursor-pointer">
|
<div className="hidden h-9 md:flex p-2 justify-start items-center text-sm bg-secondary hover:bg-secondary/80 rounded-md cursor-pointer">
|
||||||
<Switch
|
<Switch
|
||||||
id="reviewed"
|
id="reviewed"
|
||||||
checked={showReviewedSwitch == 1}
|
checked={showReviewedSwitch == 1}
|
||||||
@ -376,19 +386,19 @@ function ShowReviewFilter({
|
|||||||
setShowReviewedSwitch(showReviewedSwitch == 0 ? 1 : 0)
|
setShowReviewedSwitch(showReviewedSwitch == 0 ? 1 : 0)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Label className="ml-2 cursor-pointer" htmlFor="reviewed">
|
<Label className="ml-2 cursor-pointer text-primary" htmlFor="reviewed">
|
||||||
Show Reviewed
|
Show Reviewed
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
className="block md:hidden"
|
className="block md:hidden duration-0"
|
||||||
|
variant={showReviewedSwitch == 1 ? "select" : "default"}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
|
||||||
onClick={() => setShowReviewedSwitch(showReviewedSwitch == 0 ? 1 : 0)}
|
onClick={() => setShowReviewedSwitch(showReviewedSwitch == 0 ? 1 : 0)}
|
||||||
>
|
>
|
||||||
<FaCheckCircle
|
<FaCheckCircle
|
||||||
className={`${showReviewedSwitch == 1 ? "text-selected" : "text-muted-foreground"}`}
|
className={`${showReviewedSwitch == 1 ? "text-selected-foreground" : "text-secondary-foreground"}`}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
@ -411,9 +421,17 @@ function CalendarFilterButton({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const trigger = (
|
const trigger = (
|
||||||
<Button size="sm" className="flex items-center gap-2" variant="secondary">
|
<Button
|
||||||
<FaCalendarAlt className="text-secondary-foreground" />
|
className="flex items-center gap-2"
|
||||||
<div className="hidden md:block text-primary-foreground">
|
variant={day == undefined ? "default" : "select"}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<FaCalendarAlt
|
||||||
|
className={`${day == undefined ? "text-secondary-foreground" : "text-selected-foreground"}`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`hidden md:block ${day == undefined ? "text-primary" : "text-selected-foreground"}`}
|
||||||
|
>
|
||||||
{day == undefined ? "Last 24 Hours" : selectedDate}
|
{day == undefined ? "Last 24 Hours" : selectedDate}
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
@ -428,7 +446,6 @@ function CalendarFilterButton({
|
|||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<div className="p-2 flex justify-center items-center">
|
<div className="p-2 flex justify-center items-center">
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
updateSelectedDay(undefined);
|
updateSelectedDay(undefined);
|
||||||
}}
|
}}
|
||||||
@ -472,9 +489,19 @@ function GeneralFilterButton({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const trigger = (
|
const trigger = (
|
||||||
<Button size="sm" className="flex items-center gap-2" variant="secondary">
|
<Button
|
||||||
<FaFilter className="text-secondary-foreground" />
|
size="sm"
|
||||||
<div className="hidden md:block text-primary-foreground">Filter</div>
|
variant={selectedLabels?.length ? "select" : "default"}
|
||||||
|
className="flex items-center gap-2 capitalize"
|
||||||
|
>
|
||||||
|
<FaFilter
|
||||||
|
className={`${selectedLabels?.length ? "text-selected-foreground" : "text-secondary-foreground"}`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`hidden md:block ${selectedLabels?.length ? "text-selected-foreground" : "text-primary"}`}
|
||||||
|
>
|
||||||
|
Filter
|
||||||
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
const content = (
|
const content = (
|
||||||
@ -546,7 +573,7 @@ export function GeneralFilterContent({
|
|||||||
<div className="h-auto overflow-y-auto overflow-x-hidden">
|
<div className="h-auto overflow-y-auto overflow-x-hidden">
|
||||||
<div className="flex justify-between items-center my-2.5">
|
<div className="flex justify-between items-center my-2.5">
|
||||||
<Label
|
<Label
|
||||||
className="mx-2 text-primary-foreground cursor-pointer"
|
className="mx-2 text-primary cursor-pointer"
|
||||||
htmlFor="allLabels"
|
htmlFor="allLabels"
|
||||||
>
|
>
|
||||||
All Labels
|
All Labels
|
||||||
@ -567,7 +594,7 @@ export function GeneralFilterContent({
|
|||||||
{allLabels.map((item) => (
|
{allLabels.map((item) => (
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<Label
|
<Label
|
||||||
className="w-full mx-2 text-secondary-foreground capitalize cursor-pointer"
|
className="w-full mx-2 text-primary capitalize cursor-pointer"
|
||||||
htmlFor={item}
|
htmlFor={item}
|
||||||
>
|
>
|
||||||
{item.replaceAll("_", " ")}
|
{item.replaceAll("_", " ")}
|
||||||
@ -617,7 +644,6 @@ export function GeneralFilterContent({
|
|||||||
Apply
|
Apply
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCurrentLabels(undefined);
|
setCurrentLabels(undefined);
|
||||||
updateLabelFilter(undefined);
|
updateLabelFilter(undefined);
|
||||||
@ -645,7 +671,7 @@ function ShowMotionOnlyButton({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="hidden md:inline-flex items-center justify-center whitespace-nowrap text-sm bg-secondary hover:bg-secondary/80 text-secondary-foreground h-9 rounded-md px-3 mx-1 cursor-pointer">
|
<div className="hidden md:inline-flex items-center justify-center whitespace-nowrap text-sm bg-secondary hover:bg-secondary/80 text-primary h-9 rounded-md px-3 mx-1 cursor-pointer">
|
||||||
<Switch
|
<Switch
|
||||||
className="ml-1"
|
className="ml-1"
|
||||||
id="collapse-motion"
|
id="collapse-motion"
|
||||||
@ -653,7 +679,7 @@ function ShowMotionOnlyButton({
|
|||||||
onCheckedChange={setMotionOnlyButton}
|
onCheckedChange={setMotionOnlyButton}
|
||||||
/>
|
/>
|
||||||
<Label
|
<Label
|
||||||
className="mx-2 text-primary-foreground cursor-pointer"
|
className="mx-2 text-primary cursor-pointer"
|
||||||
htmlFor="collapse-motion"
|
htmlFor="collapse-motion"
|
||||||
>
|
>
|
||||||
Motion only
|
Motion only
|
||||||
@ -663,11 +689,12 @@ function ShowMotionOnlyButton({
|
|||||||
<div className="block md:hidden">
|
<div className="block md:hidden">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
className="duration-0"
|
||||||
|
variant={motionOnlyButton ? "select" : "default"}
|
||||||
onClick={() => setMotionOnlyButton(!motionOnlyButton)}
|
onClick={() => setMotionOnlyButton(!motionOnlyButton)}
|
||||||
>
|
>
|
||||||
<FaRunning
|
<FaRunning
|
||||||
className={`${motionOnlyButton ? "text-selected" : "text-muted-foreground"}`}
|
className={`${motionOnlyButton ? "text-selected-foreground" : "text-secondary-foreground"}`}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
|
|||||||
import { Threshold } from "@/types/graph";
|
import { Threshold } from "@/types/graph";
|
||||||
import { useCallback, useEffect, useMemo } from "react";
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
import Chart from "react-apexcharts";
|
import Chart from "react-apexcharts";
|
||||||
|
import { isMobileOnly } from "react-device-detect";
|
||||||
import { MdCircle } from "react-icons/md";
|
import { MdCircle } from "react-icons/md";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
|
||||||
@ -36,11 +37,11 @@ export function ThresholdBarGraph({
|
|||||||
|
|
||||||
const formatTime = useCallback(
|
const formatTime = useCallback(
|
||||||
(val: unknown) => {
|
(val: unknown) => {
|
||||||
if (val == 0) {
|
if (val == 1) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const date = new Date(updateTimes[Math.round(val as number)] * 1000);
|
const date = new Date(updateTimes[Math.round(val as number) - 1] * 1000);
|
||||||
return date.toLocaleTimeString([], {
|
return date.toLocaleTimeString([], {
|
||||||
hour12: config?.ui.time_format != "24hour",
|
hour12: config?.ui.time_format != "24hour",
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
@ -96,10 +97,10 @@ export function ThresholdBarGraph({
|
|||||||
size: 0,
|
size: 0,
|
||||||
},
|
},
|
||||||
xaxis: {
|
xaxis: {
|
||||||
tickAmount: 4,
|
tickAmount: isMobileOnly ? 3 : 4,
|
||||||
tickPlacement: "on",
|
tickPlacement: "on",
|
||||||
labels: {
|
labels: {
|
||||||
offsetX: -30,
|
offsetX: -18,
|
||||||
formatter: formatTime,
|
formatter: formatTime,
|
||||||
},
|
},
|
||||||
axisBorder: {
|
axisBorder: {
|
||||||
@ -110,9 +111,11 @@ export function ThresholdBarGraph({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
yaxis: {
|
yaxis: {
|
||||||
show: false,
|
show: true,
|
||||||
|
labels: {
|
||||||
|
formatter: (val: number) => Math.ceil(val).toString(),
|
||||||
|
},
|
||||||
min: 0,
|
min: 0,
|
||||||
max: threshold.warning + 10,
|
|
||||||
},
|
},
|
||||||
} as ApexCharts.ApexOptions;
|
} as ApexCharts.ApexOptions;
|
||||||
}, [graphId, threshold, systemTheme, theme, formatTime]);
|
}, [graphId, threshold, systemTheme, theme, formatTime]);
|
||||||
@ -125,7 +128,7 @@ export function ThresholdBarGraph({
|
|||||||
<div className="w-full flex flex-col">
|
<div className="w-full flex flex-col">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<div className="text-xs text-muted-foreground">{name}</div>
|
<div className="text-xs text-muted-foreground">{name}</div>
|
||||||
<div className="text-xs text-primary-foreground">
|
<div className="text-xs text-primary">
|
||||||
{lastValue}
|
{lastValue}
|
||||||
{unit}
|
{unit}
|
||||||
</div>
|
</div>
|
||||||
@ -216,15 +219,13 @@ export function StorageGraph({ graphId, used, total }: StorageGraphProps) {
|
|||||||
<div className="w-full flex flex-col gap-2.5">
|
<div className="w-full flex flex-col gap-2.5">
|
||||||
<div className="w-full flex justify-between items-center gap-1">
|
<div className="w-full flex justify-between items-center gap-1">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<div className="text-xs text-primary-foreground">
|
<div className="text-xs text-primary">{getUnitSize(used)}</div>
|
||||||
{getUnitSize(used)}
|
<div className="text-xs text-primary">/</div>
|
||||||
</div>
|
|
||||||
<div className="text-xs text-primary-foreground">/</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{getUnitSize(total)}
|
{getUnitSize(total)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-primary-foreground">
|
<div className="text-xs text-primary">
|
||||||
{Math.round((used / total) * 100)}%
|
{Math.round((used / total) * 100)}%
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -278,7 +279,7 @@ export function CameraLineGraph({
|
|||||||
|
|
||||||
const formatTime = useCallback(
|
const formatTime = useCallback(
|
||||||
(val: unknown) => {
|
(val: unknown) => {
|
||||||
if (val == 0) {
|
if (val == 1) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -326,10 +327,10 @@ export function CameraLineGraph({
|
|||||||
size: 0,
|
size: 0,
|
||||||
},
|
},
|
||||||
xaxis: {
|
xaxis: {
|
||||||
tickAmount: 4,
|
tickAmount: isMobileOnly ? 3 : 4,
|
||||||
tickPlacement: "between",
|
tickPlacement: "on",
|
||||||
labels: {
|
labels: {
|
||||||
offsetX: -30,
|
offsetX: isMobileOnly ? -18 : 0,
|
||||||
formatter: formatTime,
|
formatter: formatTime,
|
||||||
},
|
},
|
||||||
axisBorder: {
|
axisBorder: {
|
||||||
@ -340,7 +341,10 @@ export function CameraLineGraph({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
yaxis: {
|
yaxis: {
|
||||||
show: false,
|
show: true,
|
||||||
|
labels: {
|
||||||
|
formatter: (val: number) => Math.ceil(val).toString(),
|
||||||
|
},
|
||||||
min: 0,
|
min: 0,
|
||||||
},
|
},
|
||||||
} as ApexCharts.ApexOptions;
|
} as ApexCharts.ApexOptions;
|
||||||
@ -361,7 +365,7 @@ export function CameraLineGraph({
|
|||||||
style={{ color: GRAPH_COLORS[labelIdx] }}
|
style={{ color: GRAPH_COLORS[labelIdx] }}
|
||||||
/>
|
/>
|
||||||
<div className="text-xs text-muted-foreground">{label}</div>
|
<div className="text-xs text-muted-foreground">{label}</div>
|
||||||
<div className="text-xs text-primary-foreground">
|
<div className="text-xs text-primary">
|
||||||
{lastValues[labelIdx]}
|
{lastValues[labelIdx]}
|
||||||
{unit}
|
{unit}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { ReactNode, useRef } from "react";
|
import { LogSeverity } from "@/types/log";
|
||||||
|
import { ReactNode, useMemo, useRef } from "react";
|
||||||
import { CSSTransition } from "react-transition-group";
|
import { CSSTransition } from "react-transition-group";
|
||||||
|
|
||||||
type ChipProps = {
|
type ChipProps = {
|
||||||
@ -39,3 +40,35 @@ export default function Chip({
|
|||||||
</CSSTransition>
|
</CSSTransition>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LogChipProps = {
|
||||||
|
severity: LogSeverity;
|
||||||
|
onClickSeverity?: () => void;
|
||||||
|
};
|
||||||
|
export function LogChip({ severity, onClickSeverity }: LogChipProps) {
|
||||||
|
const severityClassName = useMemo(() => {
|
||||||
|
switch (severity) {
|
||||||
|
case "info":
|
||||||
|
return "text-primary/60 bg-secondary hover:bg-secondary/60";
|
||||||
|
case "warning":
|
||||||
|
return "text-warning-foreground bg-warning hover:bg-warning/80";
|
||||||
|
case "error":
|
||||||
|
return "text-destructive-foreground bg-destructive hover:bg-destructive/80";
|
||||||
|
}
|
||||||
|
}, [severity]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`py-[1px] px-1 capitalize text-xs rounded-md ${onClickSeverity ? "cursor-pointer" : ""} ${severityClassName}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (onClickSeverity) {
|
||||||
|
onClickSeverity();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{severity}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import { navbarLinks } from "@/pages/site-navigation";
|
|
||||||
import NavItem from "./NavItem";
|
import NavItem from "./NavItem";
|
||||||
import { IoIosWarning } from "react-icons/io";
|
import { IoIosWarning } from "react-icons/io";
|
||||||
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
|
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
|
||||||
@ -9,20 +8,15 @@ import { useMemo } from "react";
|
|||||||
import useStats from "@/hooks/use-stats";
|
import useStats from "@/hooks/use-stats";
|
||||||
import GeneralSettings from "../settings/GeneralSettings";
|
import GeneralSettings from "../settings/GeneralSettings";
|
||||||
import AccountSettings from "../settings/AccountSettings";
|
import AccountSettings from "../settings/AccountSettings";
|
||||||
|
import useNavigation from "@/hooks/use-navigation";
|
||||||
|
|
||||||
function Bottombar() {
|
function Bottombar() {
|
||||||
|
const navItems = useNavigation("secondary");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute h-16 inset-x-4 bottom-0 flex flex-row items-center justify-between">
|
<div className="absolute h-16 inset-x-4 bottom-0 flex flex-row items-center justify-between">
|
||||||
{navbarLinks.map((item) => (
|
{navItems.map((item) => (
|
||||||
<NavItem
|
<NavItem key={item.id} item={item} Icon={item.icon} />
|
||||||
className=""
|
|
||||||
variant="secondary"
|
|
||||||
key={item.id}
|
|
||||||
Icon={item.icon}
|
|
||||||
title={item.title}
|
|
||||||
url={item.url}
|
|
||||||
dev={item.dev}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
<GeneralSettings />
|
<GeneralSettings />
|
||||||
<AccountSettings />
|
<AccountSettings />
|
||||||
|
|||||||
@ -1,6 +1,4 @@
|
|||||||
import { IconType } from "react-icons";
|
|
||||||
import { NavLink } from "react-router-dom";
|
import { NavLink } from "react-router-dom";
|
||||||
import { ENV } from "@/env";
|
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@ -8,6 +6,8 @@ import {
|
|||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { isDesktop } from "react-device-detect";
|
import { isDesktop } from "react-device-detect";
|
||||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||||
|
import { NavData } from "@/types/navigation";
|
||||||
|
import { IconType } from "react-icons";
|
||||||
|
|
||||||
const variants = {
|
const variants = {
|
||||||
primary: {
|
primary: {
|
||||||
@ -21,37 +21,29 @@ const variants = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type NavItemProps = {
|
type NavItemProps = {
|
||||||
className: string;
|
className?: string;
|
||||||
variant?: "primary" | "secondary";
|
item: NavData;
|
||||||
Icon: IconType;
|
Icon: IconType;
|
||||||
title: string;
|
|
||||||
url: string;
|
|
||||||
dev?: boolean;
|
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function NavItem({
|
export default function NavItem({
|
||||||
className,
|
className,
|
||||||
variant = "primary",
|
item,
|
||||||
Icon,
|
Icon,
|
||||||
title,
|
|
||||||
url,
|
|
||||||
dev,
|
|
||||||
onClick,
|
onClick,
|
||||||
}: NavItemProps) {
|
}: NavItemProps) {
|
||||||
const shouldRender = dev ? ENV !== "production" : true;
|
if (item.enabled == false) {
|
||||||
|
|
||||||
if (!shouldRender) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<NavLink
|
<NavLink
|
||||||
to={url}
|
to={item.url}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`${className} flex flex-col justify-center items-center rounded-lg ${
|
`flex flex-col justify-center items-center rounded-lg ${className ?? ""} ${
|
||||||
variants[variant][isActive ? "active" : "inactive"]
|
variants[item.variant ?? "primary"][isActive ? "active" : "inactive"]
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@ -65,7 +57,7 @@ export default function NavItem({
|
|||||||
<TooltipTrigger>{content}</TooltipTrigger>
|
<TooltipTrigger>{content}</TooltipTrigger>
|
||||||
<TooltipPortal>
|
<TooltipPortal>
|
||||||
<TooltipContent side="right">
|
<TooltipContent side="right">
|
||||||
<p>{title}</p>
|
<p>{item.title}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</TooltipPortal>
|
</TooltipPortal>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
14
web/src/components/navigation/Redirect.tsx
Normal file
14
web/src/components/navigation/Redirect.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
type RedirectProps = {
|
||||||
|
to: string;
|
||||||
|
};
|
||||||
|
export function Redirect({ to }: RedirectProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
navigate(to);
|
||||||
|
}, [to, navigate]);
|
||||||
|
return <div />;
|
||||||
|
}
|
||||||
@ -1,16 +1,18 @@
|
|||||||
import Logo from "../Logo";
|
import Logo from "../Logo";
|
||||||
import { navbarLinks } from "@/pages/site-navigation";
|
|
||||||
import NavItem from "./NavItem";
|
import NavItem from "./NavItem";
|
||||||
import { CameraGroupSelector } from "../filter/CameraGroupSelector";
|
import { CameraGroupSelector } from "../filter/CameraGroupSelector";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
import GeneralSettings from "../settings/GeneralSettings";
|
import GeneralSettings from "../settings/GeneralSettings";
|
||||||
import AccountSettings from "../settings/AccountSettings";
|
import AccountSettings from "../settings/AccountSettings";
|
||||||
|
import useNavigation from "@/hooks/use-navigation";
|
||||||
|
|
||||||
function Sidebar() {
|
function Sidebar() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
|
const navbarLinks = useNavigation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="absolute w-[52px] z-10 left-o inset-y-0 overflow-y-auto scrollbar-hidden py-4 flex flex-col justify-between bg-primary border-r border-secondary-highlight">
|
<aside className="absolute w-[52px] z-10 left-o inset-y-0 overflow-y-auto scrollbar-hidden py-4 flex flex-col justify-between bg-background_alt border-r border-secondary-highlight">
|
||||||
<span tabIndex={0} className="sr-only" />
|
<span tabIndex={0} className="sr-only" />
|
||||||
<div className="w-full flex flex-col gap-0 items-center">
|
<div className="w-full flex flex-col gap-0 items-center">
|
||||||
<Logo className="w-8 h-8 mb-6" />
|
<Logo className="w-8 h-8 mb-6" />
|
||||||
@ -22,10 +24,8 @@ function Sidebar() {
|
|||||||
<div key={item.id}>
|
<div key={item.id}>
|
||||||
<NavItem
|
<NavItem
|
||||||
className={`mx-[10px] ${showCameraGroups ? "mb-2" : "mb-4"}`}
|
className={`mx-[10px] ${showCameraGroups ? "mb-2" : "mb-4"}`}
|
||||||
|
item={item}
|
||||||
Icon={item.icon}
|
Icon={item.icon}
|
||||||
title={item.title}
|
|
||||||
url={item.url}
|
|
||||||
dev={item.dev}
|
|
||||||
/>
|
/>
|
||||||
{showCameraGroups && <CameraGroupSelector className="mb-4" />}
|
{showCameraGroups && <CameraGroupSelector className="mb-4" />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -64,10 +64,13 @@ export default function ExportDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.post(`export/${camera}/start/${range.after}/end/${range.before}`, {
|
.post(
|
||||||
playback: "realtime",
|
`export/${camera}/start/${Math.round(range.after)}/end/${Math.round(range.before)}`,
|
||||||
name,
|
{
|
||||||
})
|
playback: "realtime",
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (response.status == 200) {
|
if (response.status == 200) {
|
||||||
toast.success(
|
toast.success(
|
||||||
@ -116,14 +119,13 @@ export default function ExportDialog({
|
|||||||
<Trigger asChild>
|
<Trigger asChild>
|
||||||
<Button
|
<Button
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMode("select");
|
setMode("select");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FaArrowDown className="p-1 fill-secondary bg-secondary-foreground rounded-md" />
|
<FaArrowDown className="p-1 fill-secondary bg-secondary-foreground rounded-md" />
|
||||||
{isDesktop && <div className="text-primary-foreground">Export</div>}
|
{isDesktop && <div className="text-primary">Export</div>}
|
||||||
</Button>
|
</Button>
|
||||||
</Trigger>
|
</Trigger>
|
||||||
<Content
|
<Content
|
||||||
@ -371,7 +373,7 @@ function CustomTimeSelector({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`mt-3 flex items-center bg-secondary rounded-lg ${isDesktop ? "mx-8 px-2 gap-2" : "pl-2"}`}
|
className={`mt-3 flex items-center bg-secondary text-secondary-foreground rounded-lg ${isDesktop ? "mx-8 px-2 gap-2" : "pl-2"}`}
|
||||||
>
|
>
|
||||||
<FaCalendarAlt />
|
<FaCalendarAlt />
|
||||||
<Popover
|
<Popover
|
||||||
@ -384,8 +386,8 @@ function CustomTimeSelector({
|
|||||||
>
|
>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
className={isDesktop ? "" : "text-xs"}
|
className={`text-primary ${isDesktop ? "" : "text-xs"}`}
|
||||||
variant={startOpen ? "select" : "secondary"}
|
variant={startOpen ? "select" : "default"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setStartOpen(true);
|
setStartOpen(true);
|
||||||
@ -435,7 +437,7 @@ function CustomTimeSelector({
|
|||||||
/>
|
/>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
<FaArrowRight className="size-4" />
|
<FaArrowRight className="size-4 text-primary" />
|
||||||
<Popover
|
<Popover
|
||||||
open={endOpen}
|
open={endOpen}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
@ -446,8 +448,8 @@ function CustomTimeSelector({
|
|||||||
>
|
>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
className={isDesktop ? "" : "text-xs"}
|
className={`text-primary ${isDesktop ? "" : "text-xs"}`}
|
||||||
variant={endOpen ? "select" : "secondary"}
|
variant={endOpen ? "select" : "default"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEndOpen(true);
|
setEndOpen(true);
|
||||||
|
|||||||
125
web/src/components/overlay/LogInfoDialog.tsx
Normal file
125
web/src/components/overlay/LogInfoDialog.tsx
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import { LogLine } from "@/types/log";
|
||||||
|
import { isDesktop } from "react-device-detect";
|
||||||
|
import { Sheet, SheetContent } from "../ui/sheet";
|
||||||
|
import { Drawer, DrawerContent } from "../ui/drawer";
|
||||||
|
import { LogChip } from "../indicators/Chip";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
type LogInfoDialogProps = {
|
||||||
|
logLine?: LogLine;
|
||||||
|
setLogLine: (log: LogLine | undefined) => void;
|
||||||
|
};
|
||||||
|
export default function LogInfoDialog({
|
||||||
|
logLine,
|
||||||
|
setLogLine,
|
||||||
|
}: LogInfoDialogProps) {
|
||||||
|
const Overlay = isDesktop ? Sheet : Drawer;
|
||||||
|
const Content = isDesktop ? SheetContent : DrawerContent;
|
||||||
|
|
||||||
|
const helpfulLinks = useHelpfulLinks(logLine?.content);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Overlay
|
||||||
|
open={logLine != undefined}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setLogLine(undefined);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Content className={isDesktop ? "" : "max-h-[75dvh] p-2 overflow-hidden"}>
|
||||||
|
{logLine && (
|
||||||
|
<div className="size-full flex flex-col gap-5">
|
||||||
|
<div className="w-min flex flex-col gap-1.5">
|
||||||
|
<div className="text-sm text-primary/40">Type</div>
|
||||||
|
<LogChip severity={logLine.severity} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="text-sm text-primary/40">Timestamp</div>
|
||||||
|
<div className="text-sm">{logLine.dateStamp}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="text-sm text-primary/40">Tag</div>
|
||||||
|
<div className="text-sm">{logLine.section}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="text-sm text-primary/40">Message</div>
|
||||||
|
<div className="text-sm">{logLine.content}</div>
|
||||||
|
</div>
|
||||||
|
{helpfulLinks.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="text-sm text-primary/40">Helpful Links</div>
|
||||||
|
{helpfulLinks.map((tip) => (
|
||||||
|
<Link to={tip.link} target="_blank" rel="noopener noreferrer">
|
||||||
|
<div className="text-sm text-selected hover:underline">
|
||||||
|
{tip.text}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Content>
|
||||||
|
</Overlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useHelpfulLinks(content: string | undefined) {
|
||||||
|
return useMemo(() => {
|
||||||
|
if (!content) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const links = [];
|
||||||
|
|
||||||
|
if (/Could not clear [\d.]* currently [\d.]*/.exec(content)) {
|
||||||
|
links.push({
|
||||||
|
link: "https://docs.frigate.video/configuration/record#will-frigate-delete-old-recordings-if-my-storage-runs-out",
|
||||||
|
text: "Frigate Automatic Storage Cleanup",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/Did not detect hwaccel/.exec(content)) {
|
||||||
|
links.push({
|
||||||
|
link: "https://docs.frigate.video/configuration/hardware_acceleration",
|
||||||
|
text: "Setup Hardware Acceleration",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
content.includes(
|
||||||
|
"/usr/lib/x86_64-linux-gnu/dri/iHD_drv_video.so init failed",
|
||||||
|
) ||
|
||||||
|
content.includes(
|
||||||
|
"/usr/lib/x86_64-linux-gnu/dri/i965_drv_video.so init failed",
|
||||||
|
) ||
|
||||||
|
content.includes(
|
||||||
|
"/usr/lib/x86_64-linux-gnu/dri/i965_drv_video.so init failed",
|
||||||
|
) ||
|
||||||
|
content.includes("No VA display found for device /dev/dri/renderD128")
|
||||||
|
) {
|
||||||
|
links.push({
|
||||||
|
link: "https://docs.frigate.video/configuration/hardware_acceleration",
|
||||||
|
text: "Verify Hardware Acceleration Setup",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content.includes("No EdgeTPU was detected")) {
|
||||||
|
links.push({
|
||||||
|
link: "https://docs.frigate.video/troubleshooting/edgetpu",
|
||||||
|
text: "Troubleshoot Coral",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content.includes("The current SHM size of")) {
|
||||||
|
links.push({
|
||||||
|
link: "https://docs.frigate.video/frigate/installation/#calculating-required-shm-size",
|
||||||
|
text: "Calculate Correct SHM Size",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return links;
|
||||||
|
}, [content]);
|
||||||
|
}
|
||||||
@ -23,7 +23,7 @@ export default function MobileCameraDrawer({
|
|||||||
return (
|
return (
|
||||||
<Drawer open={cameraDrawer} onOpenChange={setCameraDrawer}>
|
<Drawer open={cameraDrawer} onOpenChange={setCameraDrawer}>
|
||||||
<DrawerTrigger asChild>
|
<DrawerTrigger asChild>
|
||||||
<Button className="rounded-lg capitalize" size="sm" variant="secondary">
|
<Button className="rounded-lg capitalize" size="sm">
|
||||||
<FaVideo className="text-secondary-foreground" />
|
<FaVideo className="text-secondary-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
</DrawerTrigger>
|
</DrawerTrigger>
|
||||||
|
|||||||
@ -66,10 +66,13 @@ export default function MobileReviewSettingsDrawer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.post(`export/${camera}/start/${range.after}/end/${range.before}`, {
|
.post(
|
||||||
playback: "realtime",
|
`export/${camera}/start/${Math.round(range.after)}/end/${Math.round(range.before)}`,
|
||||||
name,
|
{
|
||||||
})
|
playback: "realtime",
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (response.status == 200) {
|
if (response.status == 200) {
|
||||||
toast.success(
|
toast.success(
|
||||||
@ -144,18 +147,24 @@ export default function MobileReviewSettingsDrawer({
|
|||||||
{features.includes("calendar") && (
|
{features.includes("calendar") && (
|
||||||
<Button
|
<Button
|
||||||
className="w-full flex justify-center items-center gap-2"
|
className="w-full flex justify-center items-center gap-2"
|
||||||
|
variant={filter?.after ? "select" : "default"}
|
||||||
onClick={() => setDrawerMode("calendar")}
|
onClick={() => setDrawerMode("calendar")}
|
||||||
>
|
>
|
||||||
<FaCalendarAlt className="fill-secondary-foreground" />
|
<FaCalendarAlt
|
||||||
|
className={`${filter?.after ? "text-selected-foreground" : "text-secondary-foreground"}`}
|
||||||
|
/>
|
||||||
Calendar
|
Calendar
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{features.includes("filter") && (
|
{features.includes("filter") && (
|
||||||
<Button
|
<Button
|
||||||
className="w-full flex justify-center items-center gap-2"
|
className="w-full flex justify-center items-center gap-2"
|
||||||
|
variant={filter?.labels ? "select" : "default"}
|
||||||
onClick={() => setDrawerMode("filter")}
|
onClick={() => setDrawerMode("filter")}
|
||||||
>
|
>
|
||||||
<FaFilter className="fill-secondary-foreground" />
|
<FaFilter
|
||||||
|
className={`${filter?.labels ? "text-selected-foreground" : "text-secondary-foreground"}`}
|
||||||
|
/>
|
||||||
Filter
|
Filter
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@ -217,7 +226,6 @@ export default function MobileReviewSettingsDrawer({
|
|||||||
<SelectSeparator />
|
<SelectSeparator />
|
||||||
<div className="p-2 flex justify-center items-center">
|
<div className="p-2 flex justify-center items-center">
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onUpdateFilter({
|
onUpdateFilter({
|
||||||
...filter,
|
...filter,
|
||||||
@ -278,11 +286,13 @@ export default function MobileReviewSettingsDrawer({
|
|||||||
<DrawerTrigger asChild>
|
<DrawerTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
className="rounded-lg capitalize"
|
className="rounded-lg capitalize"
|
||||||
|
variant={filter?.labels || filter?.after ? "select" : "default"}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
|
||||||
onClick={() => setDrawerMode("select")}
|
onClick={() => setDrawerMode("select")}
|
||||||
>
|
>
|
||||||
<FaCog className="text-secondary-foreground" />
|
<FaCog
|
||||||
|
className={`${filter?.labels || filter?.after ? "text-selected-foreground" : "text-secondary-foreground"}`}
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</DrawerTrigger>
|
</DrawerTrigger>
|
||||||
<DrawerContent className="max-h-[80dvh] overflow-hidden flex flex-col items-center gap-2 px-4 pb-4 mx-1 rounded-t-2xl">
|
<DrawerContent className="max-h-[80dvh] overflow-hidden flex flex-col items-center gap-2 px-4 pb-4 mx-1 rounded-t-2xl">
|
||||||
|
|||||||
@ -22,7 +22,7 @@ export default function MobileTimelineDrawer({
|
|||||||
return (
|
return (
|
||||||
<Drawer open={drawer} onOpenChange={setDrawer}>
|
<Drawer open={drawer} onOpenChange={setDrawer}>
|
||||||
<DrawerTrigger asChild>
|
<DrawerTrigger asChild>
|
||||||
<Button className="rounded-lg capitalize" size="sm" variant="secondary">
|
<Button className="rounded-lg capitalize" size="sm">
|
||||||
<FaFlag className="text-secondary-foreground" />
|
<FaFlag className="text-secondary-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
</DrawerTrigger>
|
</DrawerTrigger>
|
||||||
|
|||||||
@ -17,7 +17,7 @@ export default function SaveExportOverlay({
|
|||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<div
|
<div
|
||||||
className={`flex justify-center px-2 gap-2 items-center pointer-events-auto rounded-lg *:text-white ${
|
className={`flex justify-center px-2 gap-2 items-center pointer-events-auto rounded-lg ${
|
||||||
show ? "animate-in slide-in-from-top duration-500" : "invisible"
|
show ? "animate-in slide-in-from-top duration-500" : "invisible"
|
||||||
} text-center mt-5 mx-auto`}
|
} text-center mt-5 mx-auto`}
|
||||||
>
|
>
|
||||||
@ -31,9 +31,8 @@ export default function SaveExportOverlay({
|
|||||||
Save Export
|
Save Export
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="flex items-center gap-1"
|
className="flex items-center gap-1 text-primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
>
|
>
|
||||||
<LuX />
|
<LuX />
|
||||||
|
|||||||
@ -44,9 +44,7 @@ export default function VainfoDialog({
|
|||||||
<ActivityIndicator />
|
<ActivityIndicator />
|
||||||
)}
|
)}
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="secondary" onClick={() => setShowVainfo(false)}>
|
<Button onClick={() => setShowVainfo(false)}>Close</Button>
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
<Button variant="select" onClick={() => onCopyVainfo()}>
|
<Button variant="select" onClick={() => onCopyVainfo()}>
|
||||||
Copy
|
Copy
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -19,7 +19,6 @@ const unsupportedErrorCodes = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
type HlsVideoPlayerProps = {
|
type HlsVideoPlayerProps = {
|
||||||
className: string;
|
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
videoRef: MutableRefObject<HTMLVideoElement | null>;
|
videoRef: MutableRefObject<HTMLVideoElement | null>;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@ -31,7 +30,6 @@ type HlsVideoPlayerProps = {
|
|||||||
onPlaying?: () => void;
|
onPlaying?: () => void;
|
||||||
};
|
};
|
||||||
export default function HlsVideoPlayer({
|
export default function HlsVideoPlayer({
|
||||||
className,
|
|
||||||
children,
|
children,
|
||||||
videoRef,
|
videoRef,
|
||||||
visible,
|
visible,
|
||||||
@ -91,116 +89,118 @@ export default function HlsVideoPlayer({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TransformWrapper minScale={1.0}>
|
<TransformWrapper minScale={1.0}>
|
||||||
<div
|
<TransformComponent
|
||||||
className={`relative ${className ?? ""} ${visible ? "visible" : "hidden"}`}
|
wrapperStyle={{
|
||||||
onMouseOver={
|
position: "relative",
|
||||||
isDesktop
|
display: visible ? undefined : "none",
|
||||||
? () => {
|
width: "100%",
|
||||||
setControls(true);
|
height: "100%",
|
||||||
}
|
}}
|
||||||
: undefined
|
contentStyle={{
|
||||||
}
|
width: "100%",
|
||||||
onMouseOut={
|
height: isMobile ? "100%" : undefined,
|
||||||
isDesktop
|
}}
|
||||||
? () => {
|
|
||||||
setControls(controlsOpen);
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onClick={isDesktop ? undefined : () => setControls(!controls)}
|
|
||||||
>
|
>
|
||||||
<TransformComponent
|
<video
|
||||||
wrapperStyle={{
|
ref={videoRef}
|
||||||
width: "100%",
|
className={`size-full bg-black rounded-2xl ${loadedMetadata ? "" : "invisible"}`}
|
||||||
height: "100%",
|
preload="auto"
|
||||||
}}
|
autoPlay
|
||||||
contentStyle={{
|
controls={false}
|
||||||
width: "100%",
|
playsInline
|
||||||
height: isMobile ? "100%" : undefined,
|
muted
|
||||||
}}
|
onPlay={() => {
|
||||||
>
|
setIsPlaying(true);
|
||||||
<video
|
|
||||||
ref={videoRef}
|
|
||||||
className={`size-full bg-black rounded-2xl ${loadedMetadata ? "" : "invisible"}`}
|
|
||||||
preload="auto"
|
|
||||||
autoPlay
|
|
||||||
controls={false}
|
|
||||||
playsInline
|
|
||||||
muted
|
|
||||||
onPlay={() => {
|
|
||||||
setIsPlaying(true);
|
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
setControls(true);
|
setControls(true);
|
||||||
setMobileCtrlTimeout(
|
setMobileCtrlTimeout(setTimeout(() => setControls(false), 4000));
|
||||||
setTimeout(() => setControls(false), 4000),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onPlaying={onPlaying}
|
|
||||||
onPause={() => {
|
|
||||||
setIsPlaying(false);
|
|
||||||
|
|
||||||
if (isMobile && mobileCtrlTimeout) {
|
|
||||||
clearTimeout(mobileCtrlTimeout);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onTimeUpdate={() =>
|
|
||||||
onTimeUpdate && videoRef.current
|
|
||||||
? onTimeUpdate(videoRef.current.currentTime)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onLoadedData={onPlayerLoaded}
|
|
||||||
onLoadedMetadata={() => setLoadedMetadata(true)}
|
|
||||||
onEnded={onClipEnded}
|
|
||||||
onError={(e) => {
|
|
||||||
if (
|
|
||||||
!hlsRef.current &&
|
|
||||||
// @ts-expect-error code does exist
|
|
||||||
unsupportedErrorCodes.includes(e.target.error.code) &&
|
|
||||||
videoRef.current
|
|
||||||
) {
|
|
||||||
setLoadedMetadata(false);
|
|
||||||
setUseHlsCompat(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</TransformComponent>
|
|
||||||
<VideoControls
|
|
||||||
className="absolute bottom-5 left-1/2 -translate-x-1/2"
|
|
||||||
video={videoRef.current}
|
|
||||||
isPlaying={isPlaying}
|
|
||||||
show={controls}
|
|
||||||
controlsOpen={controlsOpen}
|
|
||||||
setControlsOpen={setControlsOpen}
|
|
||||||
playbackRate={videoRef.current?.playbackRate ?? 1}
|
|
||||||
hotKeys={hotKeys}
|
|
||||||
onPlayPause={(play) => {
|
|
||||||
if (!videoRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (play) {
|
|
||||||
videoRef.current.play();
|
|
||||||
} else {
|
|
||||||
videoRef.current.pause();
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onSeek={(diff) => {
|
onPlaying={onPlaying}
|
||||||
const currentTime = videoRef.current?.currentTime;
|
onPause={() => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
|
||||||
if (!videoRef.current || !currentTime) {
|
if (isMobile && mobileCtrlTimeout) {
|
||||||
return;
|
clearTimeout(mobileCtrlTimeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
videoRef.current.currentTime = Math.max(0, currentTime + diff);
|
|
||||||
}}
|
}}
|
||||||
onSetPlaybackRate={(rate) =>
|
onTimeUpdate={() =>
|
||||||
videoRef.current ? (videoRef.current.playbackRate = rate) : null
|
onTimeUpdate && videoRef.current
|
||||||
|
? onTimeUpdate(videoRef.current.currentTime)
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
|
onLoadedData={onPlayerLoaded}
|
||||||
|
onLoadedMetadata={() => setLoadedMetadata(true)}
|
||||||
|
onEnded={onClipEnded}
|
||||||
|
onError={(e) => {
|
||||||
|
if (
|
||||||
|
!hlsRef.current &&
|
||||||
|
// @ts-expect-error code does exist
|
||||||
|
unsupportedErrorCodes.includes(e.target.error.code) &&
|
||||||
|
videoRef.current
|
||||||
|
) {
|
||||||
|
setLoadedMetadata(false);
|
||||||
|
setUseHlsCompat(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{children}
|
<div
|
||||||
</div>
|
className="absolute inset-0"
|
||||||
|
onMouseOver={
|
||||||
|
isDesktop
|
||||||
|
? () => {
|
||||||
|
setControls(true);
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onMouseOut={
|
||||||
|
isDesktop
|
||||||
|
? () => {
|
||||||
|
setControls(controlsOpen);
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onClick={isDesktop ? undefined : () => setControls(!controls)}
|
||||||
|
>
|
||||||
|
<div className={`size-full relative ${visible ? "" : "hidden"}`}>
|
||||||
|
<VideoControls
|
||||||
|
className="absolute bottom-5 left-1/2 -translate-x-1/2"
|
||||||
|
video={videoRef.current}
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
show={controls}
|
||||||
|
controlsOpen={controlsOpen}
|
||||||
|
setControlsOpen={setControlsOpen}
|
||||||
|
playbackRate={videoRef.current?.playbackRate ?? 1}
|
||||||
|
hotKeys={hotKeys}
|
||||||
|
onPlayPause={(play) => {
|
||||||
|
if (!videoRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (play) {
|
||||||
|
videoRef.current.play();
|
||||||
|
} else {
|
||||||
|
videoRef.current.pause();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onSeek={(diff) => {
|
||||||
|
const currentTime = videoRef.current?.currentTime;
|
||||||
|
|
||||||
|
if (!videoRef.current || !currentTime) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
videoRef.current.currentTime = Math.max(0, currentTime + diff);
|
||||||
|
}}
|
||||||
|
onSetPlaybackRate={(rate) =>
|
||||||
|
videoRef.current ? (videoRef.current.playbackRate = rate) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TransformComponent>
|
||||||
</TransformWrapper>
|
</TransformWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,10 +7,8 @@ import MSEPlayer from "./MsePlayer";
|
|||||||
import JSMpegPlayer from "./JSMpegPlayer";
|
import JSMpegPlayer from "./JSMpegPlayer";
|
||||||
import { MdCircle } from "react-icons/md";
|
import { MdCircle } from "react-icons/md";
|
||||||
import { useCameraActivity } from "@/hooks/use-camera-activity";
|
import { useCameraActivity } from "@/hooks/use-camera-activity";
|
||||||
import { useRecordingsState } from "@/api/ws";
|
|
||||||
import { LivePlayerMode } from "@/types/live";
|
import { LivePlayerMode } from "@/types/live";
|
||||||
import useCameraLiveMode from "@/hooks/use-camera-live-mode";
|
import useCameraLiveMode from "@/hooks/use-camera-live-mode";
|
||||||
import CameraActivityIndicator from "../indicators/CameraActivityIndicator";
|
|
||||||
|
|
||||||
type LivePlayerProps = {
|
type LivePlayerProps = {
|
||||||
cameraRef?: (ref: HTMLDivElement | null) => void;
|
cameraRef?: (ref: HTMLDivElement | null) => void;
|
||||||
@ -41,8 +39,7 @@ export default function LivePlayer({
|
|||||||
}: LivePlayerProps) {
|
}: LivePlayerProps) {
|
||||||
// camera activity
|
// camera activity
|
||||||
|
|
||||||
const { activeMotion, activeAudio, activeTracking } =
|
const { activeMotion, activeTracking } = useCameraActivity(cameraConfig);
|
||||||
useCameraActivity(cameraConfig);
|
|
||||||
|
|
||||||
const cameraActive = useMemo(
|
const cameraActive = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@ -72,8 +69,6 @@ export default function LivePlayer({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [cameraActive, liveReady]);
|
}, [cameraActive, liveReady]);
|
||||||
|
|
||||||
const { payload: recording } = useRecordingsState(cameraConfig.name);
|
|
||||||
|
|
||||||
// camera still state
|
// camera still state
|
||||||
|
|
||||||
const stillReloadInterval = useMemo(() => {
|
const stillReloadInterval = useMemo(() => {
|
||||||
@ -171,15 +166,8 @@ export default function LivePlayer({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="absolute right-2 bottom-2 w-[40px]">
|
|
||||||
{(activeMotion ||
|
|
||||||
(cameraConfig.audio.enabled_in_config && activeAudio)) && (
|
|
||||||
<CameraActivityIndicator />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="absolute right-2 top-2 size-4">
|
<div className="absolute right-2 top-2 size-4">
|
||||||
{recording == "ON" && (
|
{activeMotion && (
|
||||||
<MdCircle className="size-2 drop-shadow-md shadow-danger text-danger animate-pulse" />
|
<MdCircle className="size-2 drop-shadow-md shadow-danger text-danger animate-pulse" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -235,7 +235,7 @@ function PreviewVideoPlayer({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`relative rounded-2xl bg-black overflow-hidden ${onClick ? "cursor-pointer" : ""} ${className ?? ""}`}
|
className={`relative rounded-2xl w-full flex justify-center bg-black overflow-hidden ${onClick ? "cursor-pointer" : ""} ${className ?? ""}`}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
@ -464,7 +464,7 @@ function PreviewFramesPlayer({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`relative ${className ?? ""} ${onClick ? "cursor-pointer" : ""}`}
|
className={`relative w-full flex justify-center ${className ?? ""} ${onClick ? "cursor-pointer" : ""}`}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
|
|||||||
@ -13,18 +13,22 @@ import { getIconForLabel } from "@/utils/iconUtil";
|
|||||||
import TimeAgo from "../dynamic/TimeAgo";
|
import TimeAgo from "../dynamic/TimeAgo";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { isFirefox, isMobile, isSafari } from "react-device-detect";
|
import { isFirefox, isIOS, isMobile, isSafari } from "react-device-detect";
|
||||||
import Chip from "@/components/indicators/Chip";
|
import Chip from "@/components/indicators/Chip";
|
||||||
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
|
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
|
||||||
import useImageLoaded from "@/hooks/use-image-loaded";
|
import useImageLoaded from "@/hooks/use-image-loaded";
|
||||||
import { useSwipeable } from "react-swipeable";
|
import { useSwipeable } from "react-swipeable";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||||
import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator";
|
import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator";
|
||||||
|
import useContextMenu from "@/hooks/use-contextmenu";
|
||||||
|
import ActivityIndicator from "../indicators/activity-indicator";
|
||||||
|
import { TimeRange } from "@/types/timeline";
|
||||||
|
|
||||||
type PreviewPlayerProps = {
|
type PreviewPlayerProps = {
|
||||||
review: ReviewSegment;
|
review: ReviewSegment;
|
||||||
allPreviews?: Preview[];
|
allPreviews?: Preview[];
|
||||||
scrollLock?: boolean;
|
scrollLock?: boolean;
|
||||||
|
timeRange: TimeRange;
|
||||||
onTimeUpdate?: (time: number | undefined) => void;
|
onTimeUpdate?: (time: number | undefined) => void;
|
||||||
setReviewed: (review: ReviewSegment) => void;
|
setReviewed: (review: ReviewSegment) => void;
|
||||||
onClick: (review: ReviewSegment, ctrl: boolean) => void;
|
onClick: (review: ReviewSegment, ctrl: boolean) => void;
|
||||||
@ -42,6 +46,7 @@ export default function PreviewThumbnailPlayer({
|
|||||||
review,
|
review,
|
||||||
allPreviews,
|
allPreviews,
|
||||||
scrollLock = false,
|
scrollLock = false,
|
||||||
|
timeRange,
|
||||||
setReviewed,
|
setReviewed,
|
||||||
onClick,
|
onClick,
|
||||||
onTimeUpdate,
|
onTimeUpdate,
|
||||||
@ -69,10 +74,16 @@ export default function PreviewThumbnailPlayer({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleSetReviewed = useCallback(() => {
|
const handleSetReviewed = useCallback(() => {
|
||||||
review.has_been_reviewed = true;
|
if (review.end_time && !review.has_been_reviewed) {
|
||||||
setReviewed(review);
|
review.has_been_reviewed = true;
|
||||||
|
setReviewed(review);
|
||||||
|
}
|
||||||
}, [review, setReviewed]);
|
}, [review, setReviewed]);
|
||||||
|
|
||||||
|
useContextMenu(imgRef, () => {
|
||||||
|
onClick(review, true);
|
||||||
|
});
|
||||||
|
|
||||||
// playback
|
// playback
|
||||||
|
|
||||||
const relevantPreview = useMemo(() => {
|
const relevantPreview = useMemo(() => {
|
||||||
@ -86,7 +97,7 @@ export default function PreviewThumbnailPlayer({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (review.end_time > preview.end) {
|
if ((review.end_time ?? timeRange.before) > preview.end) {
|
||||||
multiHour = true;
|
multiHour = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -103,7 +114,8 @@ export default function PreviewThumbnailPlayer({
|
|||||||
|
|
||||||
const firstPrev = allPreviews[firstIndex];
|
const firstPrev = allPreviews[firstIndex];
|
||||||
const firstDuration = firstPrev.end - review.start_time;
|
const firstDuration = firstPrev.end - review.start_time;
|
||||||
const secondDuration = review.end_time - firstPrev.end;
|
const secondDuration =
|
||||||
|
(review.end_time ?? timeRange.before) - firstPrev.end;
|
||||||
|
|
||||||
if (firstDuration > secondDuration) {
|
if (firstDuration > secondDuration) {
|
||||||
// the first preview is longer than the second, return the first
|
// the first preview is longer than the second, return the first
|
||||||
@ -118,7 +130,7 @@ export default function PreviewThumbnailPlayer({
|
|||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}, [allPreviews, review]);
|
}, [allPreviews, review, timeRange]);
|
||||||
|
|
||||||
// Hover Playback
|
// Hover Playback
|
||||||
|
|
||||||
@ -170,10 +182,6 @@ export default function PreviewThumbnailPlayer({
|
|||||||
className="relative size-full cursor-pointer"
|
className="relative size-full cursor-pointer"
|
||||||
onMouseOver={isMobile ? undefined : () => setIsHovered(true)}
|
onMouseOver={isMobile ? undefined : () => setIsHovered(true)}
|
||||||
onMouseLeave={isMobile ? undefined : () => setIsHovered(false)}
|
onMouseLeave={isMobile ? undefined : () => setIsHovered(false)}
|
||||||
onContextMenu={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onClick(review, true);
|
|
||||||
}}
|
|
||||||
onClick={handleOnClick}
|
onClick={handleOnClick}
|
||||||
{...swipeHandlers}
|
{...swipeHandlers}
|
||||||
>
|
>
|
||||||
@ -182,6 +190,7 @@ export default function PreviewThumbnailPlayer({
|
|||||||
<PreviewContent
|
<PreviewContent
|
||||||
review={review}
|
review={review}
|
||||||
relevantPreview={relevantPreview}
|
relevantPreview={relevantPreview}
|
||||||
|
timeRange={timeRange}
|
||||||
setReviewed={handleSetReviewed}
|
setReviewed={handleSetReviewed}
|
||||||
setIgnoreClick={setIgnoreClick}
|
setIgnoreClick={setIgnoreClick}
|
||||||
isPlayingBack={setPlayback}
|
isPlayingBack={setPlayback}
|
||||||
@ -196,9 +205,18 @@ export default function PreviewThumbnailPlayer({
|
|||||||
<div className={`${imgLoaded ? "visible" : "invisible"}`}>
|
<div className={`${imgLoaded ? "visible" : "invisible"}`}>
|
||||||
<img
|
<img
|
||||||
ref={imgRef}
|
ref={imgRef}
|
||||||
className={`size-full transition-opacity ${
|
className={`size-full transition-opacity select-none ${
|
||||||
playingBack ? "opacity-0" : "opacity-100"
|
playingBack ? "opacity-0" : "opacity-100"
|
||||||
}`}
|
}`}
|
||||||
|
style={
|
||||||
|
isIOS
|
||||||
|
? {
|
||||||
|
WebkitUserSelect: "none",
|
||||||
|
WebkitTouchCallout: "none",
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
draggable={false}
|
||||||
src={`${apiHost}${review.thumb_path.replace("/media/frigate/", "")}`}
|
src={`${apiHost}${review.thumb_path.replace("/media/frigate/", "")}`}
|
||||||
loading={isSafari ? "eager" : "lazy"}
|
loading={isSafari ? "eager" : "lazy"}
|
||||||
onLoad={() => {
|
onLoad={() => {
|
||||||
@ -246,7 +264,13 @@ export default function PreviewThumbnailPlayer({
|
|||||||
<div className="absolute top-0 inset-x-0 rounded-t-l z-10 w-full h-[30%] bg-gradient-to-b from-black/60 to-transparent pointer-events-none"></div>
|
<div className="absolute top-0 inset-x-0 rounded-t-l z-10 w-full h-[30%] bg-gradient-to-b from-black/60 to-transparent pointer-events-none"></div>
|
||||||
<div className="absolute bottom-0 inset-x-0 rounded-b-l z-10 w-full h-[20%] bg-gradient-to-t from-black/60 to-transparent pointer-events-none">
|
<div className="absolute bottom-0 inset-x-0 rounded-b-l z-10 w-full h-[20%] bg-gradient-to-t from-black/60 to-transparent pointer-events-none">
|
||||||
<div className="flex h-full justify-between items-end mx-3 pb-1 text-white text-sm">
|
<div className="flex h-full justify-between items-end mx-3 pb-1 text-white text-sm">
|
||||||
<TimeAgo time={review.start_time * 1000} dense />
|
{review.end_time ? (
|
||||||
|
<TimeAgo time={review.start_time * 1000} dense />
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<ActivityIndicator size={24} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{formattedDate}
|
{formattedDate}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -260,6 +284,7 @@ export default function PreviewThumbnailPlayer({
|
|||||||
type PreviewContentProps = {
|
type PreviewContentProps = {
|
||||||
review: ReviewSegment;
|
review: ReviewSegment;
|
||||||
relevantPreview: Preview | undefined;
|
relevantPreview: Preview | undefined;
|
||||||
|
timeRange: TimeRange;
|
||||||
setReviewed: () => void;
|
setReviewed: () => void;
|
||||||
setIgnoreClick: (ignore: boolean) => void;
|
setIgnoreClick: (ignore: boolean) => void;
|
||||||
isPlayingBack: (ended: boolean) => void;
|
isPlayingBack: (ended: boolean) => void;
|
||||||
@ -268,6 +293,7 @@ type PreviewContentProps = {
|
|||||||
function PreviewContent({
|
function PreviewContent({
|
||||||
review,
|
review,
|
||||||
relevantPreview,
|
relevantPreview,
|
||||||
|
timeRange,
|
||||||
setReviewed,
|
setReviewed,
|
||||||
setIgnoreClick,
|
setIgnoreClick,
|
||||||
isPlayingBack,
|
isPlayingBack,
|
||||||
@ -278,8 +304,9 @@ function PreviewContent({
|
|||||||
if (relevantPreview) {
|
if (relevantPreview) {
|
||||||
return (
|
return (
|
||||||
<VideoPreview
|
<VideoPreview
|
||||||
review={review}
|
|
||||||
relevantPreview={relevantPreview}
|
relevantPreview={relevantPreview}
|
||||||
|
startTime={review.start_time}
|
||||||
|
endTime={review.end_time}
|
||||||
setReviewed={setReviewed}
|
setReviewed={setReviewed}
|
||||||
setIgnoreClick={setIgnoreClick}
|
setIgnoreClick={setIgnoreClick}
|
||||||
isPlayingBack={isPlayingBack}
|
isPlayingBack={isPlayingBack}
|
||||||
@ -290,6 +317,7 @@ function PreviewContent({
|
|||||||
return (
|
return (
|
||||||
<InProgressPreview
|
<InProgressPreview
|
||||||
review={review}
|
review={review}
|
||||||
|
timeRange={timeRange}
|
||||||
setReviewed={setReviewed}
|
setReviewed={setReviewed}
|
||||||
setIgnoreClick={setIgnoreClick}
|
setIgnoreClick={setIgnoreClick}
|
||||||
isPlayingBack={isPlayingBack}
|
isPlayingBack={isPlayingBack}
|
||||||
@ -301,16 +329,18 @@ function PreviewContent({
|
|||||||
|
|
||||||
const PREVIEW_PADDING = 16;
|
const PREVIEW_PADDING = 16;
|
||||||
type VideoPreviewProps = {
|
type VideoPreviewProps = {
|
||||||
review: ReviewSegment;
|
|
||||||
relevantPreview: Preview;
|
relevantPreview: Preview;
|
||||||
|
startTime: number;
|
||||||
|
endTime?: number;
|
||||||
setReviewed: () => void;
|
setReviewed: () => void;
|
||||||
setIgnoreClick: (ignore: boolean) => void;
|
setIgnoreClick: (ignore: boolean) => void;
|
||||||
isPlayingBack: (ended: boolean) => void;
|
isPlayingBack: (ended: boolean) => void;
|
||||||
onTimeUpdate?: (time: number | undefined) => void;
|
onTimeUpdate?: (time: number | undefined) => void;
|
||||||
};
|
};
|
||||||
function VideoPreview({
|
function VideoPreview({
|
||||||
review,
|
|
||||||
relevantPreview,
|
relevantPreview,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
setReviewed,
|
setReviewed,
|
||||||
setIgnoreClick,
|
setIgnoreClick,
|
||||||
isPlayingBack,
|
isPlayingBack,
|
||||||
@ -329,16 +359,13 @@ function VideoPreview({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// start with a bit of padding
|
// start with a bit of padding
|
||||||
return Math.max(
|
return Math.max(0, startTime - relevantPreview.start - PREVIEW_PADDING);
|
||||||
0,
|
|
||||||
review.start_time - relevantPreview.start - PREVIEW_PADDING,
|
|
||||||
);
|
|
||||||
|
|
||||||
// we know that these deps are correct
|
// we know that these deps are correct
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
const playerDuration = useMemo(
|
const playerDuration = useMemo(
|
||||||
() => review.end_time - review.start_time + PREVIEW_PADDING,
|
() => (endTime ?? relevantPreview.end) - startTime + PREVIEW_PADDING,
|
||||||
// we know that these deps are correct
|
// we know that these deps are correct
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[],
|
[],
|
||||||
@ -379,21 +406,14 @@ function VideoPreview({
|
|||||||
// end with a bit of padding
|
// end with a bit of padding
|
||||||
const playerPercent = (playerProgress / playerDuration) * 100;
|
const playerPercent = (playerProgress / playerDuration) * 100;
|
||||||
|
|
||||||
if (
|
if (setReviewed && lastPercent < 50 && playerPercent > 50) {
|
||||||
setReviewed &&
|
|
||||||
!review.has_been_reviewed &&
|
|
||||||
lastPercent < 50 &&
|
|
||||||
playerPercent > 50
|
|
||||||
) {
|
|
||||||
setReviewed();
|
setReviewed();
|
||||||
}
|
}
|
||||||
|
|
||||||
setLastPercent(playerPercent);
|
setLastPercent(playerPercent);
|
||||||
|
|
||||||
if (playerPercent > 100) {
|
if (playerPercent > 100) {
|
||||||
if (!review.has_been_reviewed) {
|
setReviewed();
|
||||||
setReviewed();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
isPlayingBack(false);
|
isPlayingBack(false);
|
||||||
@ -458,7 +478,7 @@ function VideoPreview({
|
|||||||
setIgnoreClick(true);
|
setIgnoreClick(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (setReviewed && !review.has_been_reviewed) {
|
if (setReviewed) {
|
||||||
setReviewed();
|
setReviewed();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -541,6 +561,7 @@ function VideoPreview({
|
|||||||
const MIN_LOAD_TIMEOUT_MS = 200;
|
const MIN_LOAD_TIMEOUT_MS = 200;
|
||||||
type InProgressPreviewProps = {
|
type InProgressPreviewProps = {
|
||||||
review: ReviewSegment;
|
review: ReviewSegment;
|
||||||
|
timeRange: TimeRange;
|
||||||
setReviewed: (reviewId: string) => void;
|
setReviewed: (reviewId: string) => void;
|
||||||
setIgnoreClick: (ignore: boolean) => void;
|
setIgnoreClick: (ignore: boolean) => void;
|
||||||
isPlayingBack: (ended: boolean) => void;
|
isPlayingBack: (ended: boolean) => void;
|
||||||
@ -548,6 +569,7 @@ type InProgressPreviewProps = {
|
|||||||
};
|
};
|
||||||
function InProgressPreview({
|
function InProgressPreview({
|
||||||
review,
|
review,
|
||||||
|
timeRange,
|
||||||
setReviewed,
|
setReviewed,
|
||||||
setIgnoreClick,
|
setIgnoreClick,
|
||||||
isPlayingBack,
|
isPlayingBack,
|
||||||
@ -557,7 +579,7 @@ function InProgressPreview({
|
|||||||
const sliderRef = useRef<HTMLDivElement | null>(null);
|
const sliderRef = useRef<HTMLDivElement | null>(null);
|
||||||
const { data: previewFrames } = useSWR<string[]>(
|
const { data: previewFrames } = useSWR<string[]>(
|
||||||
`preview/${review.camera}/start/${Math.floor(review.start_time) - PREVIEW_PADDING}/end/${
|
`preview/${review.camera}/start/${Math.floor(review.start_time) - PREVIEW_PADDING}/end/${
|
||||||
Math.ceil(review.end_time) + PREVIEW_PADDING
|
Math.ceil(review.end_time ?? timeRange.before) + PREVIEW_PADDING
|
||||||
}/frames`,
|
}/frames`,
|
||||||
{ revalidateOnFocus: false },
|
{ revalidateOnFocus: false },
|
||||||
);
|
);
|
||||||
|
|||||||
@ -142,10 +142,10 @@ export default function VideoControls({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`px-4 py-2 flex justify-between items-center gap-8 text-white z-50 bg-secondary-foreground/60 dark:bg-secondary/60 rounded-lg ${className ?? ""}`}
|
className={`px-4 py-2 flex justify-between items-center gap-8 text-primary z-50 bg-background/60 rounded-lg ${className ?? ""}`}
|
||||||
>
|
>
|
||||||
{video && features.volume && (
|
{video && features.volume && (
|
||||||
<div className="flex justify-normal items-center gap-2">
|
<div className="flex justify-normal items-center gap-2 cursor-pointer">
|
||||||
<VolumeIcon
|
<VolumeIcon
|
||||||
className="size-5"
|
className="size-5"
|
||||||
onClick={(e: React.MouseEvent) => {
|
onClick={(e: React.MouseEvent) => {
|
||||||
@ -170,9 +170,9 @@ export default function VideoControls({
|
|||||||
)}
|
)}
|
||||||
<div className="cursor-pointer" onClick={onTogglePlay}>
|
<div className="cursor-pointer" onClick={onTogglePlay}>
|
||||||
{isPlaying ? (
|
{isPlaying ? (
|
||||||
<LuPause className="size-5 fill-white" />
|
<LuPause className="size-5 text-primary fill-primary" />
|
||||||
) : (
|
) : (
|
||||||
<LuPlay className="size-5 fill-white" />
|
<LuPlay className="size-5 text-primary fill-primary" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{features.seek && (
|
{features.seek && (
|
||||||
|
|||||||
@ -150,7 +150,6 @@ export default function DynamicVideoPlayer({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<HlsVideoPlayer
|
<HlsVideoPlayer
|
||||||
className={className ?? ""}
|
|
||||||
videoRef={playerRef}
|
videoRef={playerRef}
|
||||||
visible={!(isScrubbing || isLoading)}
|
visible={!(isScrubbing || isLoading)}
|
||||||
currentSource={source}
|
currentSource={source}
|
||||||
|
|||||||
@ -142,7 +142,7 @@ export default function GeneralSettings({ className }: GeneralSettings) {
|
|||||||
className={
|
className={
|
||||||
isDesktop
|
isDesktop
|
||||||
? "cursor-pointer"
|
? "cursor-pointer"
|
||||||
: "p-2 flex items-center text-sm"
|
: "w-full p-2 flex items-center text-sm"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<LuActivity className="mr-2 size-4" />
|
<LuActivity className="mr-2 size-4" />
|
||||||
@ -154,7 +154,7 @@ export default function GeneralSettings({ className }: GeneralSettings) {
|
|||||||
className={
|
className={
|
||||||
isDesktop
|
isDesktop
|
||||||
? "cursor-pointer"
|
? "cursor-pointer"
|
||||||
: "p-2 flex items-center text-sm"
|
: "w-full p-2 flex items-center text-sm"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<LuList className="mr-2 size-4" />
|
<LuList className="mr-2 size-4" />
|
||||||
@ -172,7 +172,7 @@ export default function GeneralSettings({ className }: GeneralSettings) {
|
|||||||
className={
|
className={
|
||||||
isDesktop
|
isDesktop
|
||||||
? "cursor-pointer"
|
? "cursor-pointer"
|
||||||
: "p-2 flex items-center text-sm"
|
: "w-full p-2 flex items-center text-sm"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<LuSettings className="mr-2 size-4" />
|
<LuSettings className="mr-2 size-4" />
|
||||||
@ -184,7 +184,7 @@ export default function GeneralSettings({ className }: GeneralSettings) {
|
|||||||
className={
|
className={
|
||||||
isDesktop
|
isDesktop
|
||||||
? "cursor-pointer"
|
? "cursor-pointer"
|
||||||
: "p-2 flex items-center text-sm"
|
: "w-full p-2 flex items-center text-sm"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<LuPenSquare className="mr-2 size-4" />
|
<LuPenSquare className="mr-2 size-4" />
|
||||||
|
|||||||
@ -100,8 +100,10 @@ export function MotionReviewTimeline({
|
|||||||
const overlappingReviewItems = events.some(
|
const overlappingReviewItems = events.some(
|
||||||
(item) =>
|
(item) =>
|
||||||
(item.start_time >= motionStart && item.start_time < motionEnd) ||
|
(item.start_time >= motionStart && item.start_time < motionEnd) ||
|
||||||
(item.end_time > motionStart && item.end_time <= motionEnd) ||
|
((item.end_time ?? timelineStart) > motionStart &&
|
||||||
(item.start_time <= motionStart && item.end_time >= motionEnd),
|
(item.end_time ?? timelineStart) <= motionEnd) ||
|
||||||
|
(item.start_time <= motionStart &&
|
||||||
|
(item.end_time ?? timelineStart) >= motionEnd),
|
||||||
);
|
);
|
||||||
|
|
||||||
if ((!segmentMotion || overlappingReviewItems) && motionOnly) {
|
if ((!segmentMotion || overlappingReviewItems) && motionOnly) {
|
||||||
|
|||||||
@ -107,6 +107,7 @@ export function ReviewTimeline({
|
|||||||
showDraggableElement: showHandlebar,
|
showDraggableElement: showHandlebar,
|
||||||
draggableElementTime: handlebarTime,
|
draggableElementTime: handlebarTime,
|
||||||
setDraggableElementTime: setHandlebarTime,
|
setDraggableElementTime: setHandlebarTime,
|
||||||
|
alignSetTimeToSegment: true,
|
||||||
initialScrollIntoViewOnly: onlyInitialHandlebarScroll,
|
initialScrollIntoViewOnly: onlyInitialHandlebarScroll,
|
||||||
timelineDuration,
|
timelineDuration,
|
||||||
timelineCollapsed: timelineCollapsed,
|
timelineCollapsed: timelineCollapsed,
|
||||||
@ -132,7 +133,6 @@ export function ReviewTimeline({
|
|||||||
draggableElementTime: exportStartTime,
|
draggableElementTime: exportStartTime,
|
||||||
draggableElementLatestTime: paddedExportEndTime,
|
draggableElementLatestTime: paddedExportEndTime,
|
||||||
setDraggableElementTime: setExportStartTime,
|
setDraggableElementTime: setExportStartTime,
|
||||||
alignSetTimeToSegment: true,
|
|
||||||
timelineDuration,
|
timelineDuration,
|
||||||
timelineStartAligned,
|
timelineStartAligned,
|
||||||
isDragging: isDraggingExportStart,
|
isDragging: isDraggingExportStart,
|
||||||
@ -157,7 +157,6 @@ export function ReviewTimeline({
|
|||||||
draggableElementTime: exportEndTime,
|
draggableElementTime: exportEndTime,
|
||||||
draggableElementEarliestTime: paddedExportStartTime,
|
draggableElementEarliestTime: paddedExportStartTime,
|
||||||
setDraggableElementTime: setExportEndTime,
|
setDraggableElementTime: setExportEndTime,
|
||||||
alignSetTimeToSegment: true,
|
|
||||||
timelineDuration,
|
timelineDuration,
|
||||||
timelineStartAligned,
|
timelineStartAligned,
|
||||||
isDragging: isDraggingExportEnd,
|
isDragging: isDraggingExportEnd,
|
||||||
|
|||||||
@ -355,7 +355,7 @@ export function SummaryTimeline({
|
|||||||
ref={visibleSectionRef}
|
ref={visibleSectionRef}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
onTouchStart={handleMouseDown}
|
onTouchStart={handleMouseDown}
|
||||||
className={`bg-primary-foreground/30 z-20 absolute w-full touch-none ${
|
className={`bg-primary/30 z-20 absolute w-full touch-none ${
|
||||||
isDragging ? "cursor-grabbing" : "cursor-grab"
|
isDragging ? "cursor-grabbing" : "cursor-grab"
|
||||||
}`}
|
}`}
|
||||||
></div>
|
></div>
|
||||||
|
|||||||
@ -32,7 +32,7 @@ export function MinimapBounds({
|
|||||||
<>
|
<>
|
||||||
{isFirstSegmentInMinimap && (
|
{isFirstSegmentInMinimap && (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 -bottom-7 w-full flex items-center justify-center text-primary-foreground font-medium z-20 text-center text-[10px] scroll-mt-8 pointer-events-none select-none"
|
className="absolute inset-0 -bottom-7 w-full flex items-center justify-center text-primary font-medium z-20 text-center text-[10px] scroll-mt-8 pointer-events-none select-none"
|
||||||
ref={firstMinimapSegmentRef}
|
ref={firstMinimapSegmentRef}
|
||||||
>
|
>
|
||||||
{new Date(alignedMinimapStartTime * 1000).toLocaleTimeString([], {
|
{new Date(alignedMinimapStartTime * 1000).toLocaleTimeString([], {
|
||||||
@ -44,7 +44,7 @@ export function MinimapBounds({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isLastSegmentInMinimap && (
|
{isLastSegmentInMinimap && (
|
||||||
<div className="absolute inset-0 -top-3 w-full flex items-center justify-center text-primary-foreground font-medium z-20 text-center text-[10px] pointer-events-none select-none">
|
<div className="absolute inset-0 -top-3 w-full flex items-center justify-center text-primary font-medium z-20 text-center text-[10px] pointer-events-none select-none">
|
||||||
{new Date(alignedMinimapEndTime * 1000).toLocaleTimeString([], {
|
{new Date(alignedMinimapEndTime * 1000).toLocaleTimeString([], {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const badgeVariants = cva(
|
const badgeVariants = cva(
|
||||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
@ -9,7 +9,7 @@ const badgeVariants = cva(
|
|||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
"border-transparent bg-primary text-primary hover:bg-primary/80",
|
||||||
secondary:
|
secondary:
|
||||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
destructive:
|
destructive:
|
||||||
@ -20,8 +20,8 @@ const badgeVariants = cva(
|
|||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
export interface BadgeProps
|
export interface BadgeProps
|
||||||
extends React.HTMLAttributes<HTMLDivElement>,
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
@ -30,7 +30,7 @@ export interface BadgeProps
|
|||||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Badge, badgeVariants }
|
export { Badge, badgeVariants };
|
||||||
|
|||||||
@ -9,14 +9,13 @@ const buttonVariants = cva(
|
|||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
default: "bg-secondary text-primary hover:bg-secondary/80",
|
||||||
select: "bg-selected text-white hover:bg-opacity-90",
|
select: "bg-selected text-selected-foreground hover:bg-opacity-90",
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
outline:
|
outline:
|
||||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
secondary:
|
secondary: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
||||||
ghost:
|
ghost:
|
||||||
"text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
"text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
@ -33,7 +32,7 @@ const buttonVariants = cva(
|
|||||||
variant: "default",
|
variant: "default",
|
||||||
size: "default",
|
size: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
@ -52,7 +51,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
Button.displayName = "Button";
|
Button.displayName = "Button";
|
||||||
|
|
||||||
|
|||||||
@ -15,13 +15,13 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
|||||||
toast:
|
toast:
|
||||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||||
description: "group-[.toast]:text-muted-foreground",
|
description: "group-[.toast]:text-muted-foreground",
|
||||||
actionButton:
|
actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary",
|
||||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
|
||||||
cancelButton:
|
cancelButton:
|
||||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||||
success:
|
success:
|
||||||
"group toast group-[.toaster]:bg-success group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
"group toast group-[.toaster]:bg-success group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||||
error: "group toast group-[.toaster]:bg-danger group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
error:
|
||||||
|
"group toast group-[.toaster]:bg-danger group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@ -1,8 +1,4 @@
|
|||||||
import {
|
import { useFrigateEvents, useMotionActivity } from "@/api/ws";
|
||||||
useAudioActivity,
|
|
||||||
useFrigateEvents,
|
|
||||||
useMotionActivity,
|
|
||||||
} from "@/api/ws";
|
|
||||||
import { CameraConfig } from "@/types/frigateConfig";
|
import { CameraConfig } from "@/types/frigateConfig";
|
||||||
import { MotionData, ReviewSegment } from "@/types/review";
|
import { MotionData, ReviewSegment } from "@/types/review";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
@ -11,7 +7,6 @@ import { useTimelineUtils } from "./use-timeline-utils";
|
|||||||
type useCameraActivityReturn = {
|
type useCameraActivityReturn = {
|
||||||
activeTracking: boolean;
|
activeTracking: boolean;
|
||||||
activeMotion: boolean;
|
activeMotion: boolean;
|
||||||
activeAudio: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useCameraActivity(
|
export function useCameraActivity(
|
||||||
@ -25,7 +20,6 @@ export function useCameraActivity(
|
|||||||
|
|
||||||
const { payload: detectingMotion } = useMotionActivity(camera.name);
|
const { payload: detectingMotion } = useMotionActivity(camera.name);
|
||||||
const { payload: event } = useFrigateEvents();
|
const { payload: event } = useFrigateEvents();
|
||||||
const { payload: audioRms } = useAudioActivity(camera.name);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!event) {
|
if (!event) {
|
||||||
@ -63,9 +57,6 @@ export function useCameraActivity(
|
|||||||
return {
|
return {
|
||||||
activeTracking: hasActiveObjects,
|
activeTracking: hasActiveObjects,
|
||||||
activeMotion: detectingMotion == "ON",
|
activeMotion: detectingMotion == "ON",
|
||||||
activeAudio: camera.audio.enabled_in_config
|
|
||||||
? audioRms >= camera.audio.min_volume
|
|
||||||
: false,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -116,8 +107,10 @@ export function useCameraMotionNextTimestamp(
|
|||||||
const overlappingReviewItems = reviewItems.some(
|
const overlappingReviewItems = reviewItems.some(
|
||||||
(item) =>
|
(item) =>
|
||||||
(item.start_time >= motionStart && item.start_time < motionEnd) ||
|
(item.start_time >= motionStart && item.start_time < motionEnd) ||
|
||||||
(item.end_time > motionStart && item.end_time <= motionEnd) ||
|
((item.end_time ?? Date.now() / 1000) > motionStart &&
|
||||||
(item.start_time <= motionStart && item.end_time >= motionEnd),
|
(item.end_time ?? Date.now() / 1000) <= motionEnd) ||
|
||||||
|
(item.start_time <= motionStart &&
|
||||||
|
(item.end_time ?? Date.now() / 1000) >= motionEnd),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!segmentMotion || overlappingReviewItems) {
|
if (!segmentMotion || overlappingReviewItems) {
|
||||||
|
|||||||
46
web/src/hooks/use-contextmenu.ts
Normal file
46
web/src/hooks/use-contextmenu.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { MutableRefObject, useEffect } from "react";
|
||||||
|
import { isIOS } from "react-device-detect";
|
||||||
|
|
||||||
|
export default function useContextMenu(
|
||||||
|
ref: MutableRefObject<HTMLDivElement | null>,
|
||||||
|
callback: () => void,
|
||||||
|
) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ref.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elem = ref.current;
|
||||||
|
|
||||||
|
if (isIOS) {
|
||||||
|
let timeoutId: NodeJS.Timeout;
|
||||||
|
const touchStart = () => {
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
callback();
|
||||||
|
}, 610);
|
||||||
|
};
|
||||||
|
const touchClear = () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
};
|
||||||
|
elem.addEventListener("touchstart", touchStart);
|
||||||
|
elem.addEventListener("touchmove", touchClear);
|
||||||
|
elem.addEventListener("touchend", touchClear);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
elem.removeEventListener("touchstart", touchStart);
|
||||||
|
elem.removeEventListener("touchmove", touchClear);
|
||||||
|
elem.removeEventListener("touchend", touchClear);
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const context = (e: MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
callback();
|
||||||
|
};
|
||||||
|
elem.addEventListener("contextmenu", context);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
elem.removeEventListener("contextmenu", context);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [callback, ref]);
|
||||||
|
}
|
||||||
@ -323,21 +323,21 @@ function useDraggableElement({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setTime = alignSetTimeToSegment
|
||||||
|
? targetSegmentId
|
||||||
|
: targetSegmentId + segmentDuration * (offset / segmentHeight);
|
||||||
|
|
||||||
updateDraggableElementPosition(
|
updateDraggableElementPosition(
|
||||||
newElementPosition,
|
newElementPosition,
|
||||||
targetSegmentId,
|
setTime,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (setDraggableElementTime) {
|
if (setDraggableElementTime) {
|
||||||
if (alignSetTimeToSegment) {
|
setDraggableElementTime(
|
||||||
setDraggableElementTime(targetSegmentId);
|
targetSegmentId + segmentDuration * (offset / segmentHeight),
|
||||||
} else {
|
);
|
||||||
setDraggableElementTime(
|
|
||||||
targetSegmentId + segmentDuration * (offset / segmentHeight),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (draggingAtTopEdge || draggingAtBottomEdge) {
|
if (draggingAtTopEdge || draggingAtBottomEdge) {
|
||||||
|
|||||||
59
web/src/hooks/use-navigation.ts
Normal file
59
web/src/hooks/use-navigation.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import Logo from "@/components/Logo";
|
||||||
|
import { ENV } from "@/env";
|
||||||
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
|
import { NavData } from "@/types/navigation";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { FaCompactDisc, FaVideo } from "react-icons/fa";
|
||||||
|
import { LuConstruction } from "react-icons/lu";
|
||||||
|
import { MdVideoLibrary } from "react-icons/md";
|
||||||
|
import useSWR from "swr";
|
||||||
|
|
||||||
|
export default function useNavigation(
|
||||||
|
variant: "primary" | "secondary" = "primary",
|
||||||
|
) {
|
||||||
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() =>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
variant,
|
||||||
|
icon: FaVideo,
|
||||||
|
title: "Live",
|
||||||
|
url: "/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
variant,
|
||||||
|
icon: MdVideoLibrary,
|
||||||
|
title: "Review",
|
||||||
|
url: "/review",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
variant,
|
||||||
|
icon: FaCompactDisc,
|
||||||
|
title: "Export",
|
||||||
|
url: "/export",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
variant,
|
||||||
|
icon: Logo,
|
||||||
|
title: "Frigate+",
|
||||||
|
url: "/plus",
|
||||||
|
enabled: config?.plus?.enabled == true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
variant,
|
||||||
|
icon: LuConstruction,
|
||||||
|
title: "UI Playground",
|
||||||
|
url: "/playground",
|
||||||
|
enabled: ENV !== "production",
|
||||||
|
},
|
||||||
|
] as NavData[],
|
||||||
|
[config?.plus.enabled, variant],
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -18,6 +18,12 @@ export default function useStats(stats: FrigateStats | undefined) {
|
|||||||
return problems;
|
return problems;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// if frigate has just started
|
||||||
|
// don't look for issues
|
||||||
|
if (stats.service.uptime < 120) {
|
||||||
|
return problems;
|
||||||
|
}
|
||||||
|
|
||||||
// check detectors for high inference speeds
|
// check detectors for high inference speeds
|
||||||
Object.entries(stats["detectors"]).forEach(([key, det]) => {
|
Object.entries(stats["detectors"]).forEach(([key, det]) => {
|
||||||
if (det["inference_speed"] > InferenceThreshold.error) {
|
if (det["inference_speed"] > InferenceThreshold.error) {
|
||||||
|
|||||||
@ -17,6 +17,10 @@ type SaveOptions = "saveonly" | "restart";
|
|||||||
function ConfigEditor() {
|
function ConfigEditor() {
|
||||||
const apiHost = useApiHost();
|
const apiHost = useApiHost();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = "Config Editor - Frigate";
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { data: config } = useSWR<string>("config/raw");
|
const { data: config } = useSWR<string>("config/raw");
|
||||||
|
|
||||||
const { theme, systemTheme } = useTheme();
|
const { theme, systemTheme } = useTheme();
|
||||||
|
|||||||
@ -29,10 +29,20 @@ export default function Events() {
|
|||||||
"severity",
|
"severity",
|
||||||
"alert",
|
"alert",
|
||||||
);
|
);
|
||||||
|
|
||||||
const [recording, setRecording] =
|
const [recording, setRecording] =
|
||||||
useOverlayState<RecordingStartingPoint>("recording");
|
useOverlayState<RecordingStartingPoint>("recording");
|
||||||
|
|
||||||
const [startTime, setStartTime] = useState<number>();
|
const [startTime, setStartTime] = useState<number>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (recording) {
|
||||||
|
document.title = "Recordings - Frigate";
|
||||||
|
} else {
|
||||||
|
document.title = `Review - Frigate`;
|
||||||
|
}
|
||||||
|
}, [recording, severity]);
|
||||||
|
|
||||||
// review filter
|
// review filter
|
||||||
|
|
||||||
const [reviewFilter, setReviewFilter, reviewSearchParams] =
|
const [reviewFilter, setReviewFilter, reviewSearchParams] =
|
||||||
@ -204,7 +214,7 @@ export default function Events() {
|
|||||||
const newData = [...data];
|
const newData = [...data];
|
||||||
|
|
||||||
newData.forEach((seg) => {
|
newData.forEach((seg) => {
|
||||||
if (seg.severity == severity) {
|
if (seg.end_time && seg.severity == severity) {
|
||||||
seg.has_been_reviewed = true;
|
seg.has_been_reviewed = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -214,10 +224,16 @@ export default function Events() {
|
|||||||
{ revalidate: false, populateCache: true },
|
{ revalidate: false, populateCache: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
await axios.post(`reviews/viewed`, {
|
const itemsToMarkReviewed = currentItems
|
||||||
ids: currentItems?.map((seg) => seg.id),
|
?.filter((seg) => seg.end_time)
|
||||||
});
|
?.map((seg) => seg.id);
|
||||||
reloadData();
|
|
||||||
|
if (itemsToMarkReviewed.length > 0) {
|
||||||
|
await axios.post(`reviews/viewed`, {
|
||||||
|
ids: itemsToMarkReviewed,
|
||||||
|
});
|
||||||
|
reloadData();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[reloadData, updateSegments],
|
[reloadData, updateSegments],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -10,8 +10,9 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
|
||||||
type ExportItem = {
|
type ExportItem = {
|
||||||
@ -19,24 +20,34 @@ type ExportItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function Export() {
|
function Export() {
|
||||||
const { data: exports, mutate } = useSWR<ExportItem[]>(
|
const { data: allExports, mutate } = useSWR<ExportItem[]>(
|
||||||
"exports/",
|
"exports/",
|
||||||
(url: string) => axios({ baseURL: baseUrl, url }).then((res) => res.data),
|
(url: string) => axios({ baseURL: baseUrl, url }).then((res) => res.data),
|
||||||
);
|
);
|
||||||
|
|
||||||
const [deleteClip, setDeleteClip] = useState<string | undefined>();
|
useEffect(() => {
|
||||||
|
document.title = "Export - Frigate";
|
||||||
|
}, []);
|
||||||
|
|
||||||
const onHandleRename = useCallback(
|
// Search
|
||||||
(original: string, update: string) => {
|
|
||||||
axios.patch(`export/${original}/${update}`).then((response) => {
|
const [search, setSearch] = useState("");
|
||||||
if (response.status == 200) {
|
|
||||||
setDeleteClip(undefined);
|
const exports = useMemo(() => {
|
||||||
mutate();
|
if (!search || !allExports) {
|
||||||
}
|
return allExports;
|
||||||
});
|
}
|
||||||
},
|
|
||||||
[mutate],
|
return allExports.filter((exp) =>
|
||||||
);
|
exp.name
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(search.toLowerCase().replaceAll(" ", "_")),
|
||||||
|
);
|
||||||
|
}, [allExports, search]);
|
||||||
|
|
||||||
|
// Deleting
|
||||||
|
|
||||||
|
const [deleteClip, setDeleteClip] = useState<string | undefined>();
|
||||||
|
|
||||||
const onHandleDelete = useCallback(() => {
|
const onHandleDelete = useCallback(() => {
|
||||||
if (!deleteClip) {
|
if (!deleteClip) {
|
||||||
@ -51,8 +62,22 @@ function Export() {
|
|||||||
});
|
});
|
||||||
}, [deleteClip, mutate]);
|
}, [deleteClip, mutate]);
|
||||||
|
|
||||||
|
// Renaming
|
||||||
|
|
||||||
|
const onHandleRename = useCallback(
|
||||||
|
(original: string, update: string) => {
|
||||||
|
axios.patch(`export/${original}/${update}`).then((response) => {
|
||||||
|
if (response.status == 200) {
|
||||||
|
setDeleteClip(undefined);
|
||||||
|
mutate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[mutate],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="size-full p-2 overflow-hidden flex flex-col">
|
<div className="size-full p-2 overflow-hidden flex flex-col gap-2">
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
open={deleteClip != undefined}
|
open={deleteClip != undefined}
|
||||||
onOpenChange={() => setDeleteClip(undefined)}
|
onOpenChange={() => setDeleteClip(undefined)}
|
||||||
@ -73,12 +98,24 @@ function Export() {
|
|||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
<div className="w-full p-2 flex items-center justify-center">
|
||||||
|
<Input
|
||||||
|
className="w-full md:w-1/3 bg-muted"
|
||||||
|
placeholder="Search"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="w-full overflow-hidden">
|
<div className="w-full overflow-hidden">
|
||||||
{exports && (
|
{allExports && exports && (
|
||||||
<div className="size-full grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 overflow-y-auto">
|
<div className="size-full grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 overflow-y-auto">
|
||||||
{Object.values(exports).map((item) => (
|
{Object.values(allExports).map((item) => (
|
||||||
<ExportCard
|
<ExportCard
|
||||||
key={item.name}
|
key={item.name}
|
||||||
|
className={
|
||||||
|
search == "" || exports.includes(item) ? "" : "hidden"
|
||||||
|
}
|
||||||
file={item}
|
file={item}
|
||||||
onRename={onHandleRename}
|
onRename={onHandleRename}
|
||||||
onDelete={(file) => setDeleteClip(file)}
|
onDelete={(file) => setDeleteClip(file)}
|
||||||
|
|||||||
@ -6,18 +6,37 @@ import { FrigateConfig } from "@/types/frigateConfig";
|
|||||||
import LiveBirdseyeView from "@/views/live/LiveBirdseyeView";
|
import LiveBirdseyeView from "@/views/live/LiveBirdseyeView";
|
||||||
import LiveCameraView from "@/views/live/LiveCameraView";
|
import LiveCameraView from "@/views/live/LiveCameraView";
|
||||||
import LiveDashboardView from "@/views/live/LiveDashboardView";
|
import LiveDashboardView from "@/views/live/LiveDashboardView";
|
||||||
import { useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
|
||||||
function Live() {
|
function Live() {
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
|
||||||
|
// selection
|
||||||
|
|
||||||
const [selectedCameraName, setSelectedCameraName] = useHashState();
|
const [selectedCameraName, setSelectedCameraName] = useHashState();
|
||||||
const [cameraGroup] = usePersistedOverlayState(
|
const [cameraGroup] = usePersistedOverlayState(
|
||||||
"cameraGroup",
|
"cameraGroup",
|
||||||
"default" as string,
|
"default" as string,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// document title
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedCameraName) {
|
||||||
|
const capitalized = selectedCameraName
|
||||||
|
.split("_")
|
||||||
|
.map((text) => text[0].toUpperCase() + text.substring(1));
|
||||||
|
document.title = `${capitalized.join(" ")} - Live - Frigate`;
|
||||||
|
} else if (cameraGroup && cameraGroup != "default") {
|
||||||
|
document.title = `${cameraGroup[0].toUpperCase()}${cameraGroup.substring(1)} - Live - Frigate`;
|
||||||
|
} else {
|
||||||
|
document.title = "Live - Frigate";
|
||||||
|
}
|
||||||
|
}, [cameraGroup, selectedCameraName]);
|
||||||
|
|
||||||
|
// settings
|
||||||
|
|
||||||
const includesBirdseye = useMemo(() => {
|
const includesBirdseye = useMemo(() => {
|
||||||
if (config && cameraGroup && cameraGroup != "default") {
|
if (config && cameraGroup && cameraGroup != "default") {
|
||||||
return config.camera_groups[cameraGroup].cameras.includes("birdseye");
|
return config.camera_groups[cameraGroup].cameras.includes("birdseye");
|
||||||
|
|||||||
@ -3,10 +3,14 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
|||||||
import { LogData, LogLine, LogSeverity } from "@/types/log";
|
import { LogData, LogLine, LogSeverity } from "@/types/log";
|
||||||
import copy from "copy-to-clipboard";
|
import copy from "copy-to-clipboard";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { IoIosAlert } from "react-icons/io";
|
|
||||||
import { GoAlertFill } from "react-icons/go";
|
|
||||||
import { LuCopy } from "react-icons/lu";
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import LogInfoDialog from "@/components/overlay/LogInfoDialog";
|
||||||
|
import { LogChip } from "@/components/indicators/Chip";
|
||||||
|
import { LogLevelFilterButton } from "@/components/filter/LogLevelFilter";
|
||||||
|
import { FaCopy } from "react-icons/fa6";
|
||||||
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { isDesktop } from "react-device-detect";
|
||||||
|
|
||||||
const logTypes = ["frigate", "go2rtc", "nginx"] as const;
|
const logTypes = ["frigate", "go2rtc", "nginx"] as const;
|
||||||
type LogType = (typeof logTypes)[number];
|
type LogType = (typeof logTypes)[number];
|
||||||
@ -17,7 +21,7 @@ const frigateDateStamp = /\[[\d\s-:]*]/;
|
|||||||
const frigateSeverity = /(DEBUG)|(INFO)|(WARNING)|(ERROR)/;
|
const frigateSeverity = /(DEBUG)|(INFO)|(WARNING)|(ERROR)/;
|
||||||
const frigateSection = /[\w.]*/;
|
const frigateSection = /[\w.]*/;
|
||||||
|
|
||||||
const goSeverity = /(DEB )|(INF )|(WARN )|(ERR )/;
|
const goSeverity = /(DEB )|(INF )|(WRN )|(ERR )/;
|
||||||
const goSection = /\[[\w]*]/;
|
const goSection = /\[[\w]*]/;
|
||||||
|
|
||||||
const ngSeverity = /(GET)|(POST)|(PUT)|(PATCH)|(DELETE)/;
|
const ngSeverity = /(GET)|(POST)|(PUT)|(PATCH)|(DELETE)/;
|
||||||
@ -25,6 +29,10 @@ const ngSeverity = /(GET)|(POST)|(PUT)|(PATCH)|(DELETE)/;
|
|||||||
function Logs() {
|
function Logs() {
|
||||||
const [logService, setLogService] = useState<LogType>("frigate");
|
const [logService, setLogService] = useState<LogType>("frigate");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = `${logService[0].toUpperCase()}${logService.substring(1)} Stats - Frigate`;
|
||||||
|
}, [logService]);
|
||||||
|
|
||||||
// log data handling
|
// log data handling
|
||||||
|
|
||||||
const [logRange, setLogRange] = useState<LogRange>({ start: 0, end: 0 });
|
const [logRange, setLogRange] = useState<LogRange>({ start: 0, end: 0 });
|
||||||
@ -101,7 +109,12 @@ function Logs() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return {
|
||||||
|
dateStamp: line.substring(0, 19),
|
||||||
|
severity: "unknown",
|
||||||
|
section: "unknown",
|
||||||
|
content: line.substring(30).trim(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const sectionMatch = frigateSection.exec(
|
const sectionMatch = frigateSection.exec(
|
||||||
@ -154,9 +167,28 @@ function Logs() {
|
|||||||
contentStart = line.indexOf(section) + section.length + 2;
|
contentStart = line.indexOf(section) + section.length + 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let severityCat: LogSeverity;
|
||||||
|
switch (severity?.at(0)?.toString().trim()) {
|
||||||
|
case "INF":
|
||||||
|
severityCat = "info";
|
||||||
|
break;
|
||||||
|
case "WRN":
|
||||||
|
severityCat = "warning";
|
||||||
|
break;
|
||||||
|
case "ERR":
|
||||||
|
severityCat = "error";
|
||||||
|
break;
|
||||||
|
case "DBG":
|
||||||
|
case "TRC":
|
||||||
|
severityCat = "debug";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
severityCat = "info";
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dateStamp: line.substring(0, 19),
|
dateStamp: line.substring(0, 19),
|
||||||
severity: "INFO",
|
severity: severityCat,
|
||||||
section: section,
|
section: section,
|
||||||
content: line.substring(contentStart).trim(),
|
content: line.substring(contentStart).trim(),
|
||||||
};
|
};
|
||||||
@ -171,7 +203,7 @@ function Logs() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
dateStamp: line.substring(0, 19),
|
dateStamp: line.substring(0, 19),
|
||||||
severity: "INFO",
|
severity: "info",
|
||||||
section: ngSeverity.exec(line)?.at(0)?.toString() ?? "META",
|
section: ngSeverity.exec(line)?.at(0)?.toString() ?? "META",
|
||||||
content: line.substring(line.indexOf(" ", 20)).trim(),
|
content: line.substring(line.indexOf(" ", 20)).trim(),
|
||||||
};
|
};
|
||||||
@ -185,8 +217,15 @@ function Logs() {
|
|||||||
const handleCopyLogs = useCallback(() => {
|
const handleCopyLogs = useCallback(() => {
|
||||||
if (logs) {
|
if (logs) {
|
||||||
copy(logs.join("\n"));
|
copy(logs.join("\n"));
|
||||||
|
toast.success(
|
||||||
|
logRange.start == 0
|
||||||
|
? "Coplied logs to clipboard"
|
||||||
|
: "Copied visible logs to clipboard",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.error("Could not copy logs to clipboard");
|
||||||
}
|
}
|
||||||
}, [logs]);
|
}, [logs, logRange]);
|
||||||
|
|
||||||
// scroll to bottom
|
// scroll to bottom
|
||||||
|
|
||||||
@ -279,8 +318,19 @@ function Logs() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [logLines, logService]);
|
}, [logLines, logService]);
|
||||||
|
|
||||||
|
// log filtering
|
||||||
|
|
||||||
|
const [filterSeverity, setFilterSeverity] = useState<LogSeverity[]>();
|
||||||
|
|
||||||
|
// log selection
|
||||||
|
|
||||||
|
const [selectedLog, setSelectedLog] = useState<LogLine>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="size-full p-2 flex flex-col">
|
<div className="size-full p-2 flex flex-col">
|
||||||
|
<Toaster position="top-center" />
|
||||||
|
<LogInfoDialog logLine={selectedLog} setLogLine={setSelectedLog} />
|
||||||
|
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<ToggleGroup
|
<ToggleGroup
|
||||||
className="*:px-3 *:py-4 *:rounded-md"
|
className="*:px-3 *:py-4 *:rounded-md"
|
||||||
@ -290,6 +340,7 @@ function Logs() {
|
|||||||
onValueChange={(value: LogType) => {
|
onValueChange={(value: LogType) => {
|
||||||
if (value) {
|
if (value) {
|
||||||
setLogs([]);
|
setLogs([]);
|
||||||
|
setFilterSeverity(undefined);
|
||||||
setLogService(value);
|
setLogService(value);
|
||||||
}
|
}
|
||||||
}} // don't allow the severity to be unselected
|
}} // don't allow the severity to be unselected
|
||||||
@ -301,26 +352,31 @@ function Logs() {
|
|||||||
value={item}
|
value={item}
|
||||||
aria-label={`Select ${item}`}
|
aria-label={`Select ${item}`}
|
||||||
>
|
>
|
||||||
<div className="capitalize">{`${item} Logs`}</div>
|
<div className="capitalize">{item}</div>
|
||||||
</ToggleGroupItem>
|
</ToggleGroupItem>
|
||||||
))}
|
))}
|
||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
<div>
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
className="flex justify-between items-center gap-2"
|
className="flex justify-between items-center gap-2"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleCopyLogs}
|
onClick={handleCopyLogs}
|
||||||
>
|
>
|
||||||
<LuCopy />
|
<FaCopy />
|
||||||
<div className="hidden md:block">Copy to Clipboard</div>
|
<div className="hidden md:block text-primary">
|
||||||
|
Copy to Clipboard
|
||||||
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
|
<LogLevelFilterButton
|
||||||
|
selectedLabels={filterSeverity}
|
||||||
|
updateLabelFilter={setFilterSeverity}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{initialScroll && !endVisible && (
|
{initialScroll && !endVisible && (
|
||||||
<Button
|
<Button
|
||||||
className="absolute bottom-8 left-[50%] -translate-x-[50%] rounded-xl bg-accent-foreground text-white bg-gray-400 z-20 p-2"
|
className="absolute bottom-8 left-[50%] -translate-x-[50%] rounded-md text-primary bg-secondary-foreground z-20 p-2"
|
||||||
variant="secondary"
|
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
contentRef.current?.scrollTo({
|
contentRef.current?.scrollTo({
|
||||||
top: contentRef.current?.scrollHeight,
|
top: contentRef.current?.scrollHeight,
|
||||||
@ -332,48 +388,61 @@ function Logs() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div className="size-full flex flex-col my-2 font-mono text-sm sm:p-2 whitespace-pre-wrap bg-background_alt border border-secondary rounded-md overflow-hidden">
|
||||||
ref={contentRef}
|
<div className="grid grid-cols-5 sm:grid-cols-8 md:grid-cols-12 *:px-2 *:py-3 *:text-sm *:text-primary/40">
|
||||||
className="w-full h-min my-2 font-mono text-sm rounded py-4 sm:py-2 whitespace-pre-wrap overflow-auto no-scrollbar"
|
<div className="p-1 flex items-center capitalize">Type</div>
|
||||||
>
|
<div className="col-span-2 sm:col-span-1 flex items-center">
|
||||||
<div className="py-2 sticky top-0 -translate-y-1/4 grid grid-cols-5 sm:grid-cols-8 md:grid-cols-12 bg-background *:p-2">
|
|
||||||
<div className="p-1 flex items-center capitalize border-y border-l">
|
|
||||||
Type
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2 sm:col-span-1 flex items-center border-y border-l">
|
|
||||||
Timestamp
|
Timestamp
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2 flex items-center border-y border-l border-r sm:border-r-0">
|
<div className="col-span-2 flex items-center">Tag</div>
|
||||||
Tag
|
<div className="col-span-5 sm:col-span-4 md:col-span-8 flex items-center">
|
||||||
</div>
|
|
||||||
<div className="col-span-5 sm:col-span-4 md:col-span-8 flex items-center border">
|
|
||||||
Message
|
Message
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{logLines.length > 0 &&
|
<div
|
||||||
[...Array(logRange.end).keys()].map((idx) => {
|
ref={contentRef}
|
||||||
const logLine =
|
className="w-full flex flex-col overflow-y-auto no-scrollbar"
|
||||||
idx >= logRange.start
|
>
|
||||||
? logLines[idx - logRange.start]
|
{logLines.length > 0 &&
|
||||||
: undefined;
|
[...Array(logRange.end).keys()].map((idx) => {
|
||||||
|
const logLine =
|
||||||
|
idx >= logRange.start
|
||||||
|
? logLines[idx - logRange.start]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (logLine) {
|
||||||
|
const line = logLines[idx - logRange.start];
|
||||||
|
if (filterSeverity && !filterSeverity.includes(line.severity)) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={idx == logRange.start + 10 ? startLogRef : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LogLineData
|
||||||
|
key={`${idx}-${logService}`}
|
||||||
|
startRef={
|
||||||
|
idx == logRange.start + 10 ? startLogRef : undefined
|
||||||
|
}
|
||||||
|
className={initialScroll ? "" : "invisible"}
|
||||||
|
line={line}
|
||||||
|
onClickSeverity={() => setFilterSeverity([line.severity])}
|
||||||
|
onSelect={() => setSelectedLog(line)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (logLine) {
|
|
||||||
return (
|
return (
|
||||||
<LogLineData
|
<div
|
||||||
key={`${idx}-${logService}`}
|
key={`${idx}-${logService}`}
|
||||||
startRef={
|
className={isDesktop ? "h-12" : "h-16"}
|
||||||
idx == logRange.start + 10 ? startLogRef : undefined
|
|
||||||
}
|
|
||||||
className={initialScroll ? "" : "invisible"}
|
|
||||||
offset={idx}
|
|
||||||
line={logLines[idx - logRange.start]}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
})}
|
||||||
|
{logLines.length > 0 && <div id="page-bottom" ref={endLogRef} />}
|
||||||
return <div key={`${idx}-${logService}`} className="h-12" />;
|
</div>
|
||||||
})}
|
|
||||||
{logLines.length > 0 && <div id="page-bottom" ref={endLogRef} />}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -383,70 +452,37 @@ type LogLineDataProps = {
|
|||||||
startRef?: (node: HTMLDivElement | null) => void;
|
startRef?: (node: HTMLDivElement | null) => void;
|
||||||
className: string;
|
className: string;
|
||||||
line: LogLine;
|
line: LogLine;
|
||||||
offset: number;
|
onClickSeverity: () => void;
|
||||||
|
onSelect: () => void;
|
||||||
};
|
};
|
||||||
function LogLineData({ startRef, className, line, offset }: LogLineDataProps) {
|
function LogLineData({
|
||||||
// long log message
|
startRef,
|
||||||
|
className,
|
||||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
line,
|
||||||
const [expanded, setExpanded] = useState(false);
|
onClickSeverity,
|
||||||
|
onSelect,
|
||||||
const contentOverflows = useMemo(() => {
|
}: LogLineDataProps) {
|
||||||
if (!contentRef.current) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return contentRef.current.scrollWidth > contentRef.current.clientWidth;
|
|
||||||
// update on ref change
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [contentRef.current]);
|
|
||||||
|
|
||||||
// severity coloring
|
|
||||||
|
|
||||||
const severityClassName = useMemo(() => {
|
|
||||||
switch (line.severity) {
|
|
||||||
case "info":
|
|
||||||
return "text-secondary-foreground rounded-md";
|
|
||||||
case "warning":
|
|
||||||
return "text-yellow-400 rounded-md";
|
|
||||||
case "error":
|
|
||||||
return "text-danger rounded-md";
|
|
||||||
}
|
|
||||||
}, [line]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={startRef}
|
ref={startRef}
|
||||||
className={`py-2 grid grid-cols-5 sm:grid-cols-8 md:grid-cols-12 gap-2 ${offset % 2 == 0 ? "bg-secondary" : "bg-secondary/80"} border-t border-x ${className}`}
|
className={`w-full py-2 grid grid-cols-5 sm:grid-cols-8 md:grid-cols-12 gap-2 border-secondary border-t cursor-pointer hover:bg-muted ${className} *:text-sm`}
|
||||||
|
onClick={onSelect}
|
||||||
>
|
>
|
||||||
<div
|
<div className="h-full p-1 flex items-center gap-2">
|
||||||
className={`h-full p-1 flex items-center gap-2 capitalize ${severityClassName}`}
|
<LogChip severity={line.severity} onClickSeverity={onClickSeverity} />
|
||||||
>
|
|
||||||
{line.severity == "error" ? (
|
|
||||||
<GoAlertFill className="size-5" />
|
|
||||||
) : (
|
|
||||||
<IoIosAlert className="size-5" />
|
|
||||||
)}
|
|
||||||
{line.severity}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="h-full col-span-2 sm:col-span-1 flex items-center">
|
<div className="h-full col-span-2 sm:col-span-1 flex items-center">
|
||||||
{line.dateStamp}
|
{line.dateStamp}
|
||||||
</div>
|
</div>
|
||||||
<div className="h-full col-span-2 flex items-center overflow-hidden text-ellipsis">
|
<div className="size-full pr-2 col-span-2 flex items-center">
|
||||||
{line.section}
|
<div className="w-full overflow-hidden whitespace-nowrap text-ellipsis">
|
||||||
|
{line.section}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full col-span-5 sm:col-span-4 md:col-span-8 flex justify-between items-center">
|
<div className="size-full pl-2 sm:pl-0 pr-2 col-span-5 sm:col-span-4 md:col-span-8 flex justify-between items-center">
|
||||||
<div
|
<div className="w-full overflow-hidden whitespace-nowrap text-ellipsis">
|
||||||
ref={contentRef}
|
|
||||||
className={`w-[94%] flex items-center" ${expanded ? "" : "overflow-hidden whitespace-nowrap text-ellipsis"}`}
|
|
||||||
>
|
|
||||||
{line.content}
|
{line.content}
|
||||||
</div>
|
</div>
|
||||||
{contentOverflows && (
|
|
||||||
<Button className="mr-4" onClick={() => setExpanded(!expanded)}>
|
|
||||||
...
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,6 +1,11 @@
|
|||||||
import Heading from "@/components/ui/heading";
|
import Heading from "@/components/ui/heading";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
function NoMatch() {
|
function NoMatch() {
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = "Not Found - Frigate";
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Heading as="h2">404</Heading>
|
<Heading as="h2">404</Heading>
|
||||||
|
|||||||
@ -20,7 +20,7 @@ import {
|
|||||||
import { Event } from "@/types/event";
|
import { Event } from "@/types/event";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { isMobile } from "react-device-detect";
|
import { isMobile } from "react-device-detect";
|
||||||
import { FaList, FaVideo } from "react-icons/fa";
|
import { FaList, FaVideo } from "react-icons/fa";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
@ -28,6 +28,10 @@ import useSWR from "swr";
|
|||||||
export default function SubmitPlus() {
|
export default function SubmitPlus() {
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = "Plus - Frigate";
|
||||||
|
}, []);
|
||||||
|
|
||||||
// filters
|
// filters
|
||||||
|
|
||||||
const [selectedCameras, setSelectedCameras] = useState<string[]>();
|
const [selectedCameras, setSelectedCameras] = useState<string[]>();
|
||||||
@ -135,6 +139,7 @@ export default function SubmitPlus() {
|
|||||||
This is a {upload?.label}
|
This is a {upload?.label}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
className="text-white"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => onSubmitToPlus(true)}
|
onClick={() => onSubmitToPlus(true)}
|
||||||
>
|
>
|
||||||
@ -236,9 +241,9 @@ function PlusFilterGroup({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trigger asChild>
|
<Trigger asChild>
|
||||||
<Button size="sm" className="mx-1 capitalize" variant="secondary">
|
<Button size="sm" className="mx-1 capitalize">
|
||||||
<FaVideo className="md:mr-[10px] text-secondary-foreground" />
|
<FaVideo className="md:mr-[10px] text-secondary-foreground" />
|
||||||
<div className="hidden md:block text-primary-foreground">
|
<div className="hidden md:block text-primary">
|
||||||
{selectedCameras == undefined
|
{selectedCameras == undefined
|
||||||
? "All Cameras"
|
? "All Cameras"
|
||||||
: `${selectedCameras.length} Cameras`}
|
: `${selectedCameras.length} Cameras`}
|
||||||
@ -313,9 +318,9 @@ function PlusFilterGroup({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trigger asChild>
|
<Trigger asChild>
|
||||||
<Button size="sm" className="mx-1 capitalize" variant="secondary">
|
<Button size="sm" className="mx-1 capitalize">
|
||||||
<FaList className="md:mr-[10px] text-secondary-foreground" />
|
<FaList className="md:mr-[10px] text-secondary-foreground" />
|
||||||
<div className="hidden md:block text-primary-foreground">
|
<div className="hidden md:block text-primary">
|
||||||
{selectedLabels == undefined
|
{selectedLabels == undefined
|
||||||
? "All Labels"
|
? "All Labels"
|
||||||
: `${selectedLabels.length} Labels`}
|
: `${selectedLabels.length} Labels`}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { FrigateStats } from "@/types/stats";
|
import { FrigateStats } from "@/types/stats";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import TimeAgo from "@/components/dynamic/TimeAgo";
|
import TimeAgo from "@/components/dynamic/TimeAgo";
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
import { isDesktop, isMobile } from "react-device-detect";
|
import { isDesktop, isMobile } from "react-device-detect";
|
||||||
@ -22,6 +22,10 @@ function System() {
|
|||||||
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
|
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
|
||||||
const [lastUpdated, setLastUpdated] = useState<number>(Date.now() / 1000);
|
const [lastUpdated, setLastUpdated] = useState<number>(Date.now() / 1000);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = `${pageToggle[0].toUpperCase()}${pageToggle.substring(1)} Stats - Frigate`;
|
||||||
|
}, [pageToggle]);
|
||||||
|
|
||||||
// stats collection
|
// stats collection
|
||||||
|
|
||||||
const { data: statsSnapshot } = useSWR<FrigateStats>("stats", {
|
const { data: statsSnapshot } = useSWR<FrigateStats>("stats", {
|
||||||
|
|||||||
@ -318,7 +318,11 @@ function UIPlayground() {
|
|||||||
<CameraActivityIndicator />
|
<CameraActivityIndicator />
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
<Button onClick={handleZoomOut} disabled={zoomLevel === 0}>
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={handleZoomOut}
|
||||||
|
disabled={zoomLevel === 0}
|
||||||
|
>
|
||||||
Zoom Out
|
Zoom Out
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -1,37 +0,0 @@
|
|||||||
import Logo from "@/components/Logo";
|
|
||||||
import { FaCompactDisc, FaFlag, FaVideo } from "react-icons/fa";
|
|
||||||
import { LuConstruction } from "react-icons/lu";
|
|
||||||
|
|
||||||
export const navbarLinks = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
icon: FaVideo,
|
|
||||||
title: "Live",
|
|
||||||
url: "/",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
icon: FaFlag,
|
|
||||||
title: "Events",
|
|
||||||
url: "/events",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
icon: FaCompactDisc,
|
|
||||||
title: "Export",
|
|
||||||
url: "/export",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
icon: Logo,
|
|
||||||
title: "Frigate+",
|
|
||||||
url: "/plus",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
icon: LuConstruction,
|
|
||||||
title: "UI Playground",
|
|
||||||
url: "/playground",
|
|
||||||
dev: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
10
web/src/types/navigation.ts
Normal file
10
web/src/types/navigation.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { IconType } from "react-icons";
|
||||||
|
|
||||||
|
export type NavData = {
|
||||||
|
id: number;
|
||||||
|
variant?: "primary" | "secondary";
|
||||||
|
icon: IconType;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
};
|
||||||
@ -3,7 +3,7 @@ export interface ReviewSegment {
|
|||||||
camera: string;
|
camera: string;
|
||||||
severity: ReviewSeverity;
|
severity: ReviewSeverity;
|
||||||
start_time: number;
|
start_time: number;
|
||||||
end_time: number;
|
end_time?: number;
|
||||||
thumb_path: string;
|
thumb_path: string;
|
||||||
has_been_reviewed: boolean;
|
has_been_reviewed: boolean;
|
||||||
data: ReviewData;
|
data: ReviewData;
|
||||||
|
|||||||
@ -47,7 +47,7 @@ export type ServiceStats = {
|
|||||||
last_updated: number;
|
last_updated: number;
|
||||||
storage: { [path: string]: StorageStats };
|
storage: { [path: string]: StorageStats };
|
||||||
temperatures: { [apex: string]: number };
|
temperatures: { [apex: string]: number };
|
||||||
update: number;
|
uptime: number;
|
||||||
latest_version: string;
|
latest_version: string;
|
||||||
version: string;
|
version: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -43,6 +43,7 @@ import { TimeRange } from "@/types/timeline";
|
|||||||
import { useCameraMotionNextTimestamp } from "@/hooks/use-camera-activity";
|
import { useCameraMotionNextTimestamp } from "@/hooks/use-camera-activity";
|
||||||
import useOptimisticState from "@/hooks/use-optimistic-state";
|
import useOptimisticState from "@/hooks/use-optimistic-state";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import scrollIntoView from "scroll-into-view-if-needed";
|
||||||
|
|
||||||
type EventViewProps = {
|
type EventViewProps = {
|
||||||
reviews?: ReviewSegment[];
|
reviews?: ReviewSegment[];
|
||||||
@ -289,10 +290,12 @@ export default function EventView({
|
|||||||
reviewItems={reviewItems}
|
reviewItems={reviewItems}
|
||||||
relevantPreviews={relevantPreviews}
|
relevantPreviews={relevantPreviews}
|
||||||
selectedReviews={selectedReviews}
|
selectedReviews={selectedReviews}
|
||||||
itemsToReview={reviewCounts[severity]}
|
itemsToReview={reviewCounts[severityToggle]}
|
||||||
severity={severity}
|
severity={severity}
|
||||||
filter={filter}
|
filter={filter}
|
||||||
timeRange={timeRange}
|
timeRange={timeRange}
|
||||||
|
startTime={startTime}
|
||||||
|
loading={severity != severityToggle}
|
||||||
markItemAsReviewed={markItemAsReviewed}
|
markItemAsReviewed={markItemAsReviewed}
|
||||||
markAllItemsAsReviewed={markAllItemsAsReviewed}
|
markAllItemsAsReviewed={markAllItemsAsReviewed}
|
||||||
onSelectReview={onSelectReview}
|
onSelectReview={onSelectReview}
|
||||||
@ -331,6 +334,8 @@ type DetectionReviewProps = {
|
|||||||
severity: ReviewSeverity;
|
severity: ReviewSeverity;
|
||||||
filter?: ReviewFilter;
|
filter?: ReviewFilter;
|
||||||
timeRange: { before: number; after: number };
|
timeRange: { before: number; after: number };
|
||||||
|
startTime?: number;
|
||||||
|
loading: boolean;
|
||||||
markItemAsReviewed: (review: ReviewSegment) => void;
|
markItemAsReviewed: (review: ReviewSegment) => void;
|
||||||
markAllItemsAsReviewed: (currentItems: ReviewSegment[]) => void;
|
markAllItemsAsReviewed: (currentItems: ReviewSegment[]) => void;
|
||||||
onSelectReview: (review: ReviewSegment, ctrl: boolean) => void;
|
onSelectReview: (review: ReviewSegment, ctrl: boolean) => void;
|
||||||
@ -345,6 +350,8 @@ function DetectionReview({
|
|||||||
severity,
|
severity,
|
||||||
filter,
|
filter,
|
||||||
timeRange,
|
timeRange,
|
||||||
|
startTime,
|
||||||
|
loading,
|
||||||
markItemAsReviewed,
|
markItemAsReviewed,
|
||||||
markAllItemsAsReviewed,
|
markAllItemsAsReviewed,
|
||||||
onSelectReview,
|
onSelectReview,
|
||||||
@ -495,6 +502,26 @@ function DetectionReview({
|
|||||||
[minimap],
|
[minimap],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// existing review item
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!startTime || !currentItems || currentItems.length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = contentRef.current?.querySelector(
|
||||||
|
`[data-start="${startTime}"]`,
|
||||||
|
);
|
||||||
|
if (element) {
|
||||||
|
scrollIntoView(element, {
|
||||||
|
scrollMode: "if-needed",
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// only run when start time changes
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [startTime]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
@ -506,7 +533,7 @@ function DetectionReview({
|
|||||||
className="absolute left-1/2 -translate-x-1/2 z-50 pointer-events-none"
|
className="absolute left-1/2 -translate-x-1/2 z-50 pointer-events-none"
|
||||||
contentRef={contentRef}
|
contentRef={contentRef}
|
||||||
reviewItems={currentItems}
|
reviewItems={currentItems}
|
||||||
itemsToReview={itemsToReview}
|
itemsToReview={loading ? 0 : itemsToReview}
|
||||||
pullLatestData={pullLatestData}
|
pullLatestData={pullLatestData}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -517,7 +544,7 @@ function DetectionReview({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{currentItems?.length === 0 && (
|
{!loading && currentItems?.length === 0 && (
|
||||||
<div className="absolute left-1/2 -translate-x-1/2 top-1/2 -translate-y-1/2 flex flex-col justify-center items-center text-center">
|
<div className="absolute left-1/2 -translate-x-1/2 top-1/2 -translate-y-1/2 flex flex-col justify-center items-center text-center">
|
||||||
<LuFolderCheck className="size-16" />
|
<LuFolderCheck className="size-16" />
|
||||||
There are no {severity.replace(/_/g, " ")}s to review
|
There are no {severity.replace(/_/g, " ")}s to review
|
||||||
@ -528,80 +555,95 @@ function DetectionReview({
|
|||||||
className="w-full mx-2 px-1 grid sm:grid-cols-2 md:grid-cols-3 3xl:grid-cols-4 gap-2 md:gap-4"
|
className="w-full mx-2 px-1 grid sm:grid-cols-2 md:grid-cols-3 3xl:grid-cols-4 gap-2 md:gap-4"
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
>
|
>
|
||||||
{currentItems &&
|
{!loading && currentItems
|
||||||
currentItems.map((value) => {
|
? currentItems.map((value) => {
|
||||||
const selected = selectedReviews.includes(value.id);
|
const selected = selectedReviews.includes(value.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={value.id}
|
key={value.id}
|
||||||
ref={minimapRef}
|
ref={minimapRef}
|
||||||
data-start={value.start_time}
|
data-start={value.start_time}
|
||||||
data-segment-start={
|
data-segment-start={
|
||||||
alignStartDateToTimeline(value.start_time) - segmentDuration
|
alignStartDateToTimeline(value.start_time) -
|
||||||
}
|
segmentDuration
|
||||||
className="review-item relative rounded-lg"
|
}
|
||||||
>
|
className="review-item relative rounded-lg"
|
||||||
<div className="aspect-video rounded-lg overflow-hidden">
|
>
|
||||||
<PreviewThumbnailPlayer
|
<div className="aspect-video rounded-lg overflow-hidden">
|
||||||
review={value}
|
<PreviewThumbnailPlayer
|
||||||
allPreviews={relevantPreviews}
|
review={value}
|
||||||
setReviewed={markItemAsReviewed}
|
allPreviews={relevantPreviews}
|
||||||
scrollLock={scrollLock}
|
timeRange={timeRange}
|
||||||
onTimeUpdate={onPreviewTimeUpdate}
|
setReviewed={markItemAsReviewed}
|
||||||
onClick={onSelectReview}
|
scrollLock={scrollLock}
|
||||||
|
onTimeUpdate={onPreviewTimeUpdate}
|
||||||
|
onClick={onSelectReview}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`review-item-ring pointer-events-none z-10 absolute rounded-lg inset-0 size-full -outline-offset-[2.8px] outline outline-[3px] ${selected ? `outline-severity_${value.severity} shadow-severity_${value.severity}` : "outline-transparent duration-500"}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
);
|
||||||
className={`review-item-ring pointer-events-none z-10 absolute rounded-lg inset-0 size-full -outline-offset-[2.8px] outline outline-[3px] ${selected ? `outline-severity_${value.severity} shadow-severity_${value.severity}` : "outline-transparent duration-500"}`}
|
})
|
||||||
/>
|
: Array(itemsToReview)
|
||||||
</div>
|
.fill(0)
|
||||||
);
|
.map(() => <Skeleton className="size-full aspect-video" />)}
|
||||||
})}
|
{!loading &&
|
||||||
{(currentItems?.length ?? 0) > 0 && (itemsToReview ?? 0) > 0 && (
|
(currentItems?.length ?? 0) > 0 &&
|
||||||
<div className="col-span-full flex justify-center items-center">
|
(itemsToReview ?? 0) > 0 && (
|
||||||
<Button
|
<div className="col-span-full flex justify-center items-center">
|
||||||
className="text-white"
|
<Button
|
||||||
variant="select"
|
className="text-white"
|
||||||
onClick={() => {
|
variant="select"
|
||||||
markAllItemsAsReviewed(currentItems ?? []);
|
onClick={() => {
|
||||||
}}
|
markAllItemsAsReviewed(currentItems ?? []);
|
||||||
>
|
}}
|
||||||
Mark these items as reviewed
|
>
|
||||||
</Button>
|
Mark these items as reviewed
|
||||||
</div>
|
</Button>
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-[65px] md:w-[110px] flex flex-row">
|
<div className="w-[65px] md:w-[110px] flex flex-row">
|
||||||
<div className="w-[55px] md:w-[100px] overflow-y-auto no-scrollbar">
|
<div className="w-[55px] md:w-[100px] overflow-y-auto no-scrollbar">
|
||||||
<EventReviewTimeline
|
{loading ? (
|
||||||
segmentDuration={segmentDuration}
|
<Skeleton className="size-full" />
|
||||||
timestampSpread={15}
|
) : (
|
||||||
timelineStart={timeRange.before}
|
<EventReviewTimeline
|
||||||
timelineEnd={timeRange.after}
|
segmentDuration={segmentDuration}
|
||||||
showMinimap={showMinimap && !previewTime}
|
timestampSpread={15}
|
||||||
minimapStartTime={minimapBounds.start}
|
timelineStart={timeRange.before}
|
||||||
minimapEndTime={minimapBounds.end}
|
timelineEnd={timeRange.after}
|
||||||
showHandlebar={previewTime != undefined}
|
showMinimap={showMinimap && !previewTime}
|
||||||
handlebarTime={previewTime}
|
minimapStartTime={minimapBounds.start}
|
||||||
visibleTimestamps={visibleTimestamps}
|
minimapEndTime={minimapBounds.end}
|
||||||
events={reviewItems?.all ?? []}
|
showHandlebar={previewTime != undefined}
|
||||||
severityType={severity}
|
handlebarTime={previewTime}
|
||||||
contentRef={contentRef}
|
visibleTimestamps={visibleTimestamps}
|
||||||
timelineRef={reviewTimelineRef}
|
events={reviewItems?.all ?? []}
|
||||||
dense={isMobile}
|
severityType={severity}
|
||||||
/>
|
contentRef={contentRef}
|
||||||
|
timelineRef={reviewTimelineRef}
|
||||||
|
dense={isMobile}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-[10px]">
|
<div className="w-[10px]">
|
||||||
<SummaryTimeline
|
{loading ? (
|
||||||
reviewTimelineRef={reviewTimelineRef}
|
<Skeleton className="w-full" />
|
||||||
timelineStart={timeRange.before}
|
) : (
|
||||||
timelineEnd={timeRange.after}
|
<SummaryTimeline
|
||||||
segmentDuration={segmentDuration}
|
reviewTimelineRef={reviewTimelineRef}
|
||||||
events={reviewItems?.all ?? []}
|
timelineStart={timeRange.before}
|
||||||
severityType={severity}
|
timelineEnd={timeRange.after}
|
||||||
/>
|
segmentDuration={segmentDuration}
|
||||||
|
events={reviewItems?.all ?? []}
|
||||||
|
severityType={severity}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@ -787,16 +829,18 @@ function MotionReview({
|
|||||||
} else {
|
} else {
|
||||||
const segmentStartTime = alignStartDateToTimeline(currentTime);
|
const segmentStartTime = alignStartDateToTimeline(currentTime);
|
||||||
const segmentEndTime = segmentStartTime + segmentDuration;
|
const segmentEndTime = segmentStartTime + segmentDuration;
|
||||||
const matchingItem = reviewItems?.all.find(
|
const matchingItem = reviewItems?.all.find((item) => {
|
||||||
(item) =>
|
const endTime = item.end_time ?? timeRange.before;
|
||||||
|
|
||||||
|
return (
|
||||||
((item.start_time >= segmentStartTime &&
|
((item.start_time >= segmentStartTime &&
|
||||||
item.start_time < segmentEndTime) ||
|
item.start_time < segmentEndTime) ||
|
||||||
(item.end_time > segmentStartTime &&
|
(endTime > segmentStartTime && endTime <= segmentEndTime) ||
|
||||||
item.end_time <= segmentEndTime) ||
|
|
||||||
(item.start_time <= segmentStartTime &&
|
(item.start_time <= segmentStartTime &&
|
||||||
item.end_time >= segmentEndTime)) &&
|
endTime >= segmentEndTime)) &&
|
||||||
item.camera === cameraName,
|
item.camera === cameraName
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
return matchingItem ? matchingItem.severity : null;
|
return matchingItem ? matchingItem.severity : null;
|
||||||
}
|
}
|
||||||
@ -805,6 +849,7 @@ function MotionReview({
|
|||||||
reviewItems,
|
reviewItems,
|
||||||
motionData,
|
motionData,
|
||||||
currentTime,
|
currentTime,
|
||||||
|
timeRange,
|
||||||
motionOnly,
|
motionOnly,
|
||||||
alignStartDateToTimeline,
|
alignStartDateToTimeline,
|
||||||
],
|
],
|
||||||
@ -853,7 +898,10 @@ function MotionReview({
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
onOpenRecording({
|
onOpenRecording({
|
||||||
camera: camera.name,
|
camera: camera.name,
|
||||||
startTime: currentTime,
|
startTime: Math.min(
|
||||||
|
currentTime,
|
||||||
|
Date.now() / 1000 - 30,
|
||||||
|
),
|
||||||
severity: "significant_motion",
|
severity: "significant_motion",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,6 +38,8 @@ import MobileCameraDrawer from "@/components/overlay/MobileCameraDrawer";
|
|||||||
import MobileTimelineDrawer from "@/components/overlay/MobileTimelineDrawer";
|
import MobileTimelineDrawer from "@/components/overlay/MobileTimelineDrawer";
|
||||||
import MobileReviewSettingsDrawer from "@/components/overlay/MobileReviewSettingsDrawer";
|
import MobileReviewSettingsDrawer from "@/components/overlay/MobileReviewSettingsDrawer";
|
||||||
import Logo from "@/components/Logo";
|
import Logo from "@/components/Logo";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { FaVideo } from "react-icons/fa";
|
||||||
|
|
||||||
const SEGMENT_DURATION = 30;
|
const SEGMENT_DURATION = 30;
|
||||||
|
|
||||||
@ -250,15 +252,28 @@ export function RecordingView({
|
|||||||
{isMobile && (
|
{isMobile && (
|
||||||
<Logo className="absolute inset-x-1/2 -translate-x-1/2 h-8" />
|
<Logo className="absolute inset-x-1/2 -translate-x-1/2 h-8" />
|
||||||
)}
|
)}
|
||||||
<Button
|
<div
|
||||||
className="flex items-center gap-2 rounded-lg"
|
className={`flex items-center gap-2 ${isMobile ? "landscape:flex-col" : ""}`}
|
||||||
size="sm"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => navigate(-1)}
|
|
||||||
>
|
>
|
||||||
<IoMdArrowRoundBack className="size-5" size="small" />
|
<Button
|
||||||
{isDesktop && <div className="text-primary-foreground">Back</div>}
|
className={`flex items-center gap-2.5 rounded-lg`}
|
||||||
</Button>
|
size="sm"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
>
|
||||||
|
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
||||||
|
{isDesktop && <div className="text-primary">Back</div>}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="flex items-center gap-2.5 rounded-lg"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
navigate(`/#${mainCamera}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaVideo className="size-5 text-secondary-foreground" />
|
||||||
|
{isDesktop && <div className="text-primary">Live</div>}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
<MobileCameraDrawer
|
<MobileCameraDrawer
|
||||||
allCameras={allCameras}
|
allCameras={allCameras}
|
||||||
@ -341,7 +356,7 @@ export function RecordingView({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`h-full flex justify-center overflow-hidden ${isDesktop ? "" : "flex-col gap-2"}`}
|
className={`h-full flex justify-center overflow-hidden ${isDesktop ? "" : "flex-col landscape:flex-row gap-2"}`}
|
||||||
>
|
>
|
||||||
<div className={`${isDesktop ? "w-[80%]" : ""} flex flex-1 flex-wrap`}>
|
<div className={`${isDesktop ? "w-[80%]" : ""} flex flex-1 flex-wrap`}>
|
||||||
<div
|
<div
|
||||||
@ -351,8 +366,8 @@ export function RecordingView({
|
|||||||
key={mainCamera}
|
key={mainCamera}
|
||||||
className={
|
className={
|
||||||
isDesktop
|
isDesktop
|
||||||
? `${mainCameraAspect == "tall" ? "h-[90%]" : mainCameraAspect == "wide" ? "w-full" : "w-[78%]"} px-4 flex justify-center`
|
? `${mainCameraAspect == "tall" ? "xl:h-[90%]" : mainCameraAspect == "wide" ? "w-full" : "w-[78%]"} px-4 flex justify-center`
|
||||||
: `w-full pt-2 ${mainCameraAspect == "wide" ? "aspect-wide" : "aspect-video"}`
|
: `portrait:w-full pt-2 ${mainCameraAspect == "wide" ? "landscape:w-full aspect-wide" : "landscape:h-[94%] aspect-video"}`
|
||||||
}
|
}
|
||||||
style={{
|
style={{
|
||||||
aspectRatio: isDesktop
|
aspectRatio: isDesktop
|
||||||
@ -497,32 +512,36 @@ function Timeline({
|
|||||||
className={`${
|
className={`${
|
||||||
isDesktop
|
isDesktop
|
||||||
? `${timelineType == "timeline" ? "w-[100px]" : "w-60"} overflow-y-auto no-scrollbar`
|
? `${timelineType == "timeline" ? "w-[100px]" : "w-60"} overflow-y-auto no-scrollbar`
|
||||||
: "flex-grow overflow-hidden"
|
: "portrait:flex-grow landscape:w-[20%] overflow-hidden"
|
||||||
} relative`}
|
} relative`}
|
||||||
>
|
>
|
||||||
<div className="absolute top-0 inset-x-0 z-20 w-full h-[30px] bg-gradient-to-b from-secondary to-transparent pointer-events-none"></div>
|
<div className="absolute top-0 inset-x-0 z-20 w-full h-[30px] bg-gradient-to-b from-secondary to-transparent pointer-events-none"></div>
|
||||||
<div className="absolute bottom-0 inset-x-0 z-20 w-full h-[30px] bg-gradient-to-t from-secondary to-transparent pointer-events-none"></div>
|
<div className="absolute bottom-0 inset-x-0 z-20 w-full h-[30px] bg-gradient-to-t from-secondary to-transparent pointer-events-none"></div>
|
||||||
{timelineType == "timeline" ? (
|
{timelineType == "timeline" ? (
|
||||||
<MotionReviewTimeline
|
motionData ? (
|
||||||
segmentDuration={30}
|
<MotionReviewTimeline
|
||||||
timestampSpread={15}
|
segmentDuration={30}
|
||||||
timelineStart={timeRange.before}
|
timestampSpread={15}
|
||||||
timelineEnd={timeRange.after}
|
timelineStart={timeRange.before}
|
||||||
showHandlebar={exportRange == undefined}
|
timelineEnd={timeRange.after}
|
||||||
showExportHandles={exportRange != undefined}
|
showHandlebar={exportRange == undefined}
|
||||||
exportStartTime={exportRange?.after}
|
showExportHandles={exportRange != undefined}
|
||||||
exportEndTime={exportRange?.before}
|
exportStartTime={exportRange?.after}
|
||||||
setExportStartTime={setExportStartTime}
|
exportEndTime={exportRange?.before}
|
||||||
setExportEndTime={setExportEndTime}
|
setExportStartTime={setExportStartTime}
|
||||||
handlebarTime={currentTime}
|
setExportEndTime={setExportEndTime}
|
||||||
setHandlebarTime={setCurrentTime}
|
handlebarTime={currentTime}
|
||||||
onlyInitialHandlebarScroll={true}
|
setHandlebarTime={setCurrentTime}
|
||||||
events={mainCameraReviewItems}
|
onlyInitialHandlebarScroll={true}
|
||||||
motion_events={motionData ?? []}
|
events={mainCameraReviewItems}
|
||||||
severityType="significant_motion"
|
motion_events={motionData ?? []}
|
||||||
contentRef={contentRef}
|
severityType="significant_motion"
|
||||||
onHandlebarDraggingChange={(scrubbing) => setScrubbing(scrubbing)}
|
contentRef={contentRef}
|
||||||
/>
|
onHandlebarDraggingChange={(scrubbing) => setScrubbing(scrubbing)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Skeleton className="size-full" />
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className={`h-full grid grid-cols-1 gap-4 overflow-auto p-4 bg-secondary ${isDesktop ? "" : "sm:grid-cols-2"}`}
|
className={`h-full grid grid-cols-1 gap-4 overflow-auto p-4 bg-secondary ${isDesktop ? "" : "sm:grid-cols-2"}`}
|
||||||
|
|||||||
@ -133,7 +133,7 @@ export default function LiveBirdseyeView() {
|
|||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
>
|
>
|
||||||
<IoMdArrowBack className="size-5" />
|
<IoMdArrowBack className="size-5" />
|
||||||
{isDesktop && <div className="text-primary-foreground">Back</div>}
|
{isDesktop && <div className="text-primary">Back</div>}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<div />
|
<div />
|
||||||
|
|||||||
@ -158,9 +158,9 @@ export default function LiveCameraView({ camera }: LiveCameraViewProps) {
|
|||||||
return "absolute left-2 right-2 top-[50%] -translate-y-[50%]";
|
return "absolute left-2 right-2 top-[50%] -translate-y-[50%]";
|
||||||
} else {
|
} else {
|
||||||
if (aspect > 16 / 9) {
|
if (aspect > 16 / 9) {
|
||||||
return "absolute left-0 top-[50%] -translate-y-[50%]";
|
return "p-2 absolute left-0 top-[50%] -translate-y-[50%]";
|
||||||
} else {
|
} else {
|
||||||
return "absolute top-2 bottom-2 left-[50%] -translate-x-[50%]";
|
return "p-2 absolute top-2 bottom-2 left-[50%] -translate-x-[50%]";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -209,35 +209,33 @@ export default function LiveCameraView({ camera }: LiveCameraViewProps) {
|
|||||||
className={
|
className={
|
||||||
fullscreen
|
fullscreen
|
||||||
? `fixed inset-0 bg-black z-30`
|
? `fixed inset-0 bg-black z-30`
|
||||||
: `size-full p-2 flex flex-col ${isMobile ? "landscape:flex-row" : ""}`
|
: `size-full p-2 flex flex-col ${isMobile ? "landscape:flex-row landscape:gap-1" : ""}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
fullscreen
|
fullscreen
|
||||||
? `absolute right-32 top-1 z-40 ${isMobile ? "landscape:left-2 landscape:right-auto landscape:bottom-1 landscape:top-auto" : ""}`
|
? `absolute right-32 top-1 z-40 ${isMobile ? "landscape:left-2 landscape:right-auto landscape:bottom-1 landscape:top-auto" : ""}`
|
||||||
: `w-full h-12 flex flex-row items-center justify-between ${isMobile ? "landscape:w-min landscape:h-full landscape:flex-col" : ""}`
|
: `w-full h-12 flex flex-row items-center justify-between ${isMobile ? "landscape:w-12 landscape:h-full landscape:flex-col" : ""}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{!fullscreen ? (
|
{!fullscreen ? (
|
||||||
<div className="flex items-center gap-2">
|
<div
|
||||||
|
className={`flex items-center gap-2 ${isMobile ? "landscape:flex-col" : ""}`}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
className={`flex items-center gap-2.5 rounded-lg`}
|
className={`flex items-center gap-2.5 rounded-lg`}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
>
|
>
|
||||||
<IoMdArrowRoundBack className="size-5" />
|
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
||||||
{isDesktop && (
|
{isDesktop && <div className="text-primary">Back</div>}
|
||||||
<div className="text-primary-foreground">Back</div>
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="flex items-center gap-2.5 rounded-lg"
|
className="flex items-center gap-2.5 rounded-lg"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigate("events", {
|
navigate("review", {
|
||||||
state: {
|
state: {
|
||||||
severity: "alert",
|
severity: "alert",
|
||||||
recording: {
|
recording: {
|
||||||
@ -249,10 +247,8 @@ export default function LiveCameraView({ camera }: LiveCameraViewProps) {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LuHistory className="size-5" />
|
<LuHistory className="size-5 text-secondary-foreground" />
|
||||||
{isDesktop && (
|
{isDesktop && <div className="text-primary">History</div>}
|
||||||
<div className="text-primary-foreground">History</div>
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -520,7 +516,7 @@ function PtzControlPanel({
|
|||||||
{ptz?.features?.includes("pt-r-fov") && (
|
{ptz?.features?.includes("pt-r-fov") && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
className={`${clickOverlay ? "text-selected" : "text-primary-foreground"}`}
|
className={`${clickOverlay ? "text-selected" : "text-primary"}`}
|
||||||
onClick={() => setClickOverlay(!clickOverlay)}
|
onClick={() => setClickOverlay(!clickOverlay)}
|
||||||
>
|
>
|
||||||
<HiViewfinderCircle />
|
<HiViewfinderCircle />
|
||||||
@ -619,7 +615,7 @@ function FrigateCameraFeatures({
|
|||||||
<Drawer>
|
<Drawer>
|
||||||
<DrawerTrigger>
|
<DrawerTrigger>
|
||||||
<CameraFeatureToggle
|
<CameraFeatureToggle
|
||||||
className="p-2"
|
className="p-2 landscape:size-9"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
Icon={FaCog}
|
Icon={FaCog}
|
||||||
isActive={false}
|
isActive={false}
|
||||||
|
|||||||
@ -47,8 +47,8 @@ export default function LiveDashboardView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// if event is ended and was saved, update events list
|
// if event is ended and was saved, update events list
|
||||||
if (eventUpdate.type == "end" && eventUpdate.review.severity == "alert") {
|
if (eventUpdate.review.severity == "alert") {
|
||||||
setTimeout(() => updateEvents(), 1000);
|
setTimeout(() => updateEvents(), eventUpdate.type == "end" ? 1000 : 6000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}, [eventUpdate, updateEvents]);
|
}, [eventUpdate, updateEvents]);
|
||||||
|
|||||||
@ -19,7 +19,7 @@ export default function CameraMetrics({
|
|||||||
// stats
|
// stats
|
||||||
|
|
||||||
const { data: initialStats } = useSWR<FrigateStats[]>(
|
const { data: initialStats } = useSWR<FrigateStats[]>(
|
||||||
["stats/history", { keys: "cpu_usages,cameras,service" }],
|
["stats/history", { keys: "cpu_usages,cameras,detection_fps,service" }],
|
||||||
{
|
{
|
||||||
revalidateOnFocus: false,
|
revalidateOnFocus: false,
|
||||||
},
|
},
|
||||||
@ -57,6 +57,44 @@ export default function CameraMetrics({
|
|||||||
|
|
||||||
// stats data
|
// stats data
|
||||||
|
|
||||||
|
const overallFpsSeries = useMemo(() => {
|
||||||
|
if (!statsHistory) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const series: {
|
||||||
|
[key: string]: { name: string; data: { x: number; y: number }[] };
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
series["overall_dps"] = { name: "overall detections per second", data: [] };
|
||||||
|
series["overall_skipped_dps"] = {
|
||||||
|
name: "overall skipped detections per second",
|
||||||
|
data: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
statsHistory.forEach((stats, statsIdx) => {
|
||||||
|
if (!stats) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
series["overall_dps"].data.push({
|
||||||
|
x: statsIdx,
|
||||||
|
y: stats.detection_fps,
|
||||||
|
});
|
||||||
|
|
||||||
|
let skipped = 0;
|
||||||
|
Object.values(stats.cameras).forEach(
|
||||||
|
(camStat) => (skipped += camStat.skipped_fps),
|
||||||
|
);
|
||||||
|
|
||||||
|
series["overall_skipped_dps"].data.push({
|
||||||
|
x: statsIdx,
|
||||||
|
y: skipped,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return Object.values(series);
|
||||||
|
}, [statsHistory]);
|
||||||
|
|
||||||
const cameraCpuSeries = useMemo(() => {
|
const cameraCpuSeries = useMemo(() => {
|
||||||
if (!statsHistory || statsHistory.length == 0) {
|
if (!statsHistory || statsHistory.length == 0) {
|
||||||
return {};
|
return {};
|
||||||
@ -147,19 +185,36 @@ export default function CameraMetrics({
|
|||||||
}, [statsHistory]);
|
}, [statsHistory]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="size-full mt-4 flex flex-col overflow-y-auto">
|
<div className="size-full mt-4 flex flex-col gap-3 overflow-y-auto">
|
||||||
|
<div className="text-muted-foreground text-sm font-medium">Overview</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3">
|
||||||
|
{statsHistory.length != 0 ? (
|
||||||
|
<div className="p-2.5 bg-background_alt rounded-2xl">
|
||||||
|
<div className="mb-5">DPS</div>
|
||||||
|
<CameraLineGraph
|
||||||
|
graphId="overall-stats"
|
||||||
|
unit=" DPS"
|
||||||
|
dataLabels={["detect", "skipped"]}
|
||||||
|
updateTimes={updateTimes}
|
||||||
|
data={overallFpsSeries}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Skeleton className="w-full h-32" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
{config &&
|
{config &&
|
||||||
Object.values(config.cameras).map((camera) => {
|
Object.values(config.cameras).map((camera) => {
|
||||||
if (camera.enabled) {
|
if (camera.enabled) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full flex flex-col">
|
<div className="w-full flex flex-col gap-3">
|
||||||
<div className="mb-6 capitalize">
|
<div className="capitalize text-muted-foreground text-sm font-medium">
|
||||||
{camera.name.replaceAll("_", " ")}
|
{camera.name.replaceAll("_", " ")}
|
||||||
</div>
|
</div>
|
||||||
<div key={camera.name} className="grid sm:grid-cols-2 gap-2">
|
<div key={camera.name} className="grid sm:grid-cols-2 gap-2">
|
||||||
{Object.keys(cameraCpuSeries).includes(camera.name) ? (
|
{Object.keys(cameraCpuSeries).includes(camera.name) ? (
|
||||||
<div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col">
|
<div className="p-2.5 bg-background_alt rounded-2xl">
|
||||||
<div className="mb-5">CPU</div>
|
<div className="mb-5">CPU</div>
|
||||||
<CameraLineGraph
|
<CameraLineGraph
|
||||||
graphId={`${camera.name}-cpu`}
|
graphId={`${camera.name}-cpu`}
|
||||||
@ -175,7 +230,7 @@ export default function CameraMetrics({
|
|||||||
<Skeleton className="size-full aspect-video" />
|
<Skeleton className="size-full aspect-video" />
|
||||||
)}
|
)}
|
||||||
{Object.keys(cameraFpsSeries).includes(camera.name) ? (
|
{Object.keys(cameraFpsSeries).includes(camera.name) ? (
|
||||||
<div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col">
|
<div className="p-2.5 bg-background_alt rounded-2xl">
|
||||||
<div className="mb-5">DPS</div>
|
<div className="mb-5">DPS</div>
|
||||||
<CameraLineGraph
|
<CameraLineGraph
|
||||||
graphId={`${camera.name}-dps`}
|
graphId={`${camera.name}-dps`}
|
||||||
|
|||||||
@ -99,7 +99,7 @@ export default function GeneralMetrics({
|
|||||||
series[key] = { name: key, data: [] };
|
series[key] = { name: key, data: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
series[key].data.push({ x: statsIdx, y: stats.inference_speed });
|
series[key].data.push({ x: statsIdx + 1, y: stats.inference_speed });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return Object.values(series);
|
return Object.values(series);
|
||||||
@ -125,7 +125,7 @@ export default function GeneralMetrics({
|
|||||||
}
|
}
|
||||||
|
|
||||||
series[key].data.push({
|
series[key].data.push({
|
||||||
x: statsIdx,
|
x: statsIdx + 1,
|
||||||
y: stats.cpu_usages[detStats.pid.toString()].cpu,
|
y: stats.cpu_usages[detStats.pid.toString()].cpu,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -153,7 +153,7 @@ export default function GeneralMetrics({
|
|||||||
}
|
}
|
||||||
|
|
||||||
series[key].data.push({
|
series[key].data.push({
|
||||||
x: statsIdx,
|
x: statsIdx + 1,
|
||||||
y: stats.cpu_usages[detStats.pid.toString()].mem,
|
y: stats.cpu_usages[detStats.pid.toString()].mem,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -182,7 +182,7 @@ export default function GeneralMetrics({
|
|||||||
series[key] = { name: key, data: [] };
|
series[key] = { name: key, data: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
series[key].data.push({ x: statsIdx, y: stats.gpu });
|
series[key].data.push({ x: statsIdx + 1, y: stats.gpu });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return Object.keys(series).length > 0 ? Object.values(series) : [];
|
return Object.keys(series).length > 0 ? Object.values(series) : [];
|
||||||
@ -193,6 +193,14 @@ export default function GeneralMetrics({
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
Object.keys(statsHistory?.at(0)?.gpu_usages ?? {}).length == 1 &&
|
||||||
|
Object.keys(statsHistory?.at(0)?.gpu_usages ?? {})[0].includes("intel")
|
||||||
|
) {
|
||||||
|
// intel gpu stats do not support memory
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const series: {
|
const series: {
|
||||||
[key: string]: { name: string; data: { x: number; y: string }[] };
|
[key: string]: { name: string; data: { x: number; y: string }[] };
|
||||||
} = {};
|
} = {};
|
||||||
@ -207,7 +215,7 @@ export default function GeneralMetrics({
|
|||||||
series[key] = { name: key, data: [] };
|
series[key] = { name: key, data: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
series[key].data.push({ x: statsIdx, y: stats.mem });
|
series[key].data.push({ x: statsIdx + 1, y: stats.mem });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return Object.values(series);
|
return Object.values(series);
|
||||||
@ -236,7 +244,7 @@ export default function GeneralMetrics({
|
|||||||
}
|
}
|
||||||
|
|
||||||
series[key].data.push({
|
series[key].data.push({
|
||||||
x: statsIdx,
|
x: statsIdx + 1,
|
||||||
y: stats.cpu_usages[procStats.pid.toString()].cpu,
|
y: stats.cpu_usages[procStats.pid.toString()].cpu,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -266,7 +274,7 @@ export default function GeneralMetrics({
|
|||||||
}
|
}
|
||||||
|
|
||||||
series[key].data.push({
|
series[key].data.push({
|
||||||
x: statsIdx,
|
x: statsIdx + 1,
|
||||||
y: stats.cpu_usages[procStats.pid.toString()].mem,
|
y: stats.cpu_usages[procStats.pid.toString()].mem,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -285,7 +293,7 @@ export default function GeneralMetrics({
|
|||||||
</div>
|
</div>
|
||||||
<div className="w-full mt-4 grid grid-cols-1 sm:grid-cols-3 gap-2">
|
<div className="w-full mt-4 grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||||
{statsHistory.length != 0 ? (
|
{statsHistory.length != 0 ? (
|
||||||
<div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col">
|
<div className="p-2.5 bg-background_alt rounded-2xl">
|
||||||
<div className="mb-5">Detector Inference Speed</div>
|
<div className="mb-5">Detector Inference Speed</div>
|
||||||
{detInferenceTimeSeries.map((series) => (
|
{detInferenceTimeSeries.map((series) => (
|
||||||
<ThresholdBarGraph
|
<ThresholdBarGraph
|
||||||
@ -303,7 +311,7 @@ export default function GeneralMetrics({
|
|||||||
<Skeleton className="w-full aspect-video" />
|
<Skeleton className="w-full aspect-video" />
|
||||||
)}
|
)}
|
||||||
{statsHistory.length != 0 ? (
|
{statsHistory.length != 0 ? (
|
||||||
<div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col">
|
<div className="p-2.5 bg-background_alt rounded-2xl">
|
||||||
<div className="mb-5">Detector CPU Usage</div>
|
<div className="mb-5">Detector CPU Usage</div>
|
||||||
{detCpuSeries.map((series) => (
|
{detCpuSeries.map((series) => (
|
||||||
<ThresholdBarGraph
|
<ThresholdBarGraph
|
||||||
@ -321,7 +329,7 @@ export default function GeneralMetrics({
|
|||||||
<Skeleton className="w-full aspect-video" />
|
<Skeleton className="w-full aspect-video" />
|
||||||
)}
|
)}
|
||||||
{statsHistory.length != 0 ? (
|
{statsHistory.length != 0 ? (
|
||||||
<div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col">
|
<div className="p-2.5 bg-background_alt rounded-2xl">
|
||||||
<div className="mb-5">Detector Memory Usage</div>
|
<div className="mb-5">Detector Memory Usage</div>
|
||||||
{detMemSeries.map((series) => (
|
{detMemSeries.map((series) => (
|
||||||
<ThresholdBarGraph
|
<ThresholdBarGraph
|
||||||
@ -349,7 +357,6 @@ export default function GeneralMetrics({
|
|||||||
{canGetGpuInfo && (
|
{canGetGpuInfo && (
|
||||||
<Button
|
<Button
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowVainfo(true)}
|
onClick={() => setShowVainfo(true)}
|
||||||
>
|
>
|
||||||
@ -359,7 +366,7 @@ export default function GeneralMetrics({
|
|||||||
</div>
|
</div>
|
||||||
<div className=" mt-4 grid grid-cols-1 sm:grid-cols-2 gap-2">
|
<div className=" mt-4 grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
{statsHistory.length != 0 ? (
|
{statsHistory.length != 0 ? (
|
||||||
<div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col">
|
<div className="p-2.5 bg-background_alt rounded-2xl">
|
||||||
<div className="mb-5">GPU Usage</div>
|
<div className="mb-5">GPU Usage</div>
|
||||||
{gpuSeries.map((series) => (
|
{gpuSeries.map((series) => (
|
||||||
<ThresholdBarGraph
|
<ThresholdBarGraph
|
||||||
@ -377,20 +384,24 @@ export default function GeneralMetrics({
|
|||||||
<Skeleton className="w-full aspect-video" />
|
<Skeleton className="w-full aspect-video" />
|
||||||
)}
|
)}
|
||||||
{statsHistory.length != 0 ? (
|
{statsHistory.length != 0 ? (
|
||||||
<div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col">
|
<>
|
||||||
<div className="mb-5">GPU Memory</div>
|
{gpuMemSeries && (
|
||||||
{gpuMemSeries.map((series) => (
|
<div className="p-2.5 bg-background_alt rounded-2xl">
|
||||||
<ThresholdBarGraph
|
<div className="mb-5">GPU Memory</div>
|
||||||
key={series.name}
|
{gpuMemSeries.map((series) => (
|
||||||
graphId={`${series.name}-mem`}
|
<ThresholdBarGraph
|
||||||
unit=""
|
key={series.name}
|
||||||
name={series.name}
|
graphId={`${series.name}-mem`}
|
||||||
threshold={GPUMemThreshold}
|
unit=""
|
||||||
updateTimes={updateTimes}
|
name={series.name}
|
||||||
data={[series]}
|
threshold={GPUMemThreshold}
|
||||||
/>
|
updateTimes={updateTimes}
|
||||||
))}
|
data={[series]}
|
||||||
</div>
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Skeleton className="w-full aspect-video" />
|
<Skeleton className="w-full aspect-video" />
|
||||||
)}
|
)}
|
||||||
@ -403,7 +414,7 @@ export default function GeneralMetrics({
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-2">
|
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
{statsHistory.length != 0 ? (
|
{statsHistory.length != 0 ? (
|
||||||
<div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col">
|
<div className="p-2.5 bg-background_alt rounded-2xl">
|
||||||
<div className="mb-5">Process CPU Usage</div>
|
<div className="mb-5">Process CPU Usage</div>
|
||||||
{otherProcessCpuSeries.map((series) => (
|
{otherProcessCpuSeries.map((series) => (
|
||||||
<ThresholdBarGraph
|
<ThresholdBarGraph
|
||||||
@ -421,7 +432,7 @@ export default function GeneralMetrics({
|
|||||||
<Skeleton className="w-full aspect-tall" />
|
<Skeleton className="w-full aspect-tall" />
|
||||||
)}
|
)}
|
||||||
{statsHistory.length != 0 ? (
|
{statsHistory.length != 0 ? (
|
||||||
<div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col">
|
<div className="p-2.5 bg-background_alt rounded-2xl">
|
||||||
<div className="mb-5">Process Memory Usage</div>
|
<div className="mb-5">Process Memory Usage</div>
|
||||||
{otherProcessMemSeries.map((series) => (
|
{otherProcessMemSeries.map((series) => (
|
||||||
<ThresholdBarGraph
|
<ThresholdBarGraph
|
||||||
|
|||||||
@ -43,11 +43,9 @@ export default function StorageMetrics({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="size-full mt-4 flex flex-col overflow-y-auto">
|
<div className="size-full mt-4 flex flex-col overflow-y-auto">
|
||||||
<div className="text-muted-foreground text-sm font-medium">
|
<div className="text-muted-foreground text-sm font-medium">Overview</div>
|
||||||
General Storage
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-2">
|
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||||
<div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col">
|
<div className="p-2.5 bg-background_alt rounded-2xl flex-col">
|
||||||
<div className="mb-5">Recordings</div>
|
<div className="mb-5">Recordings</div>
|
||||||
<StorageGraph
|
<StorageGraph
|
||||||
graphId="general-recordings"
|
graphId="general-recordings"
|
||||||
@ -55,7 +53,7 @@ export default function StorageMetrics({
|
|||||||
total={totalStorage.total}
|
total={totalStorage.total}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col">
|
<div className="p-2.5 bg-background_alt rounded-2xl flex-col">
|
||||||
<div className="mb-5">/tmp/cache</div>
|
<div className="mb-5">/tmp/cache</div>
|
||||||
<StorageGraph
|
<StorageGraph
|
||||||
graphId="general-cache"
|
graphId="general-cache"
|
||||||
@ -63,7 +61,7 @@ export default function StorageMetrics({
|
|||||||
total={stats.service.storage["/tmp/cache"]["total"]}
|
total={stats.service.storage["/tmp/cache"]["total"]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col">
|
<div className="p-2.5 bg-background_alt rounded-2xl flex-col">
|
||||||
<div className="mb-5">/dev/shm</div>
|
<div className="mb-5">/dev/shm</div>
|
||||||
<StorageGraph
|
<StorageGraph
|
||||||
graphId="general-shared-memory"
|
graphId="general-shared-memory"
|
||||||
@ -77,7 +75,7 @@ export default function StorageMetrics({
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-2">
|
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||||
{Object.keys(cameraStorage).map((camera) => (
|
{Object.keys(cameraStorage).map((camera) => (
|
||||||
<div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col">
|
<div className="p-2.5 bg-background_alt rounded-2xl flex-col">
|
||||||
<div className="mb-5 capitalize">{camera.replaceAll("_", " ")}</div>
|
<div className="mb-5 capitalize">{camera.replaceAll("_", " ")}</div>
|
||||||
<StorageGraph
|
<StorageGraph
|
||||||
graphId={`${camera}-storage`}
|
graphId={`${camera}-storage`}
|
||||||
|
|||||||
@ -43,13 +43,13 @@ module.exports = {
|
|||||||
ring: "hsl(var(--ring))",
|
ring: "hsl(var(--ring))",
|
||||||
danger: "#ef4444",
|
danger: "#ef4444",
|
||||||
success: "#22c55e",
|
success: "#22c55e",
|
||||||
// detection colors
|
|
||||||
motion: "#991b1b",
|
|
||||||
object: "#06b6d4",
|
|
||||||
audio: "#ea580c",
|
|
||||||
background: "hsl(var(--background))",
|
background: "hsl(var(--background))",
|
||||||
|
background_alt: "hsl(var(--background-alt))",
|
||||||
foreground: "hsl(var(--foreground))",
|
foreground: "hsl(var(--foreground))",
|
||||||
selected: "hsl(var(--selected))",
|
selected: {
|
||||||
|
DEFAULT: "hsl(var(--selected))",
|
||||||
|
foreground: "hsl(var(--selected-foreground))",
|
||||||
|
},
|
||||||
primary: {
|
primary: {
|
||||||
DEFAULT: "hsl(var(--primary))",
|
DEFAULT: "hsl(var(--primary))",
|
||||||
foreground: "hsl(var(--primary-foreground))",
|
foreground: "hsl(var(--primary-foreground))",
|
||||||
@ -63,6 +63,10 @@ module.exports = {
|
|||||||
DEFAULT: "hsl(var(--destructive))",
|
DEFAULT: "hsl(var(--destructive))",
|
||||||
foreground: "hsl(var(--destructive-foreground))",
|
foreground: "hsl(var(--destructive-foreground))",
|
||||||
},
|
},
|
||||||
|
warning: {
|
||||||
|
DEFAULT: "hsl(var(--warning))",
|
||||||
|
foreground: "hsl(var(--warning-foreground))",
|
||||||
|
},
|
||||||
muted: {
|
muted: {
|
||||||
DEFAULT: "hsl(var(--muted))",
|
DEFAULT: "hsl(var(--muted))",
|
||||||
foreground: "hsl(var(--muted-foreground))",
|
foreground: "hsl(var(--muted-foreground))",
|
||||||
|
|||||||
@ -1,68 +1,80 @@
|
|||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background-hsl: hsl(0 0% 100%);
|
--background: hsl(0, 0%, 100%);
|
||||||
--background: 0 0% 100%;
|
--background: 0 0% 100%;
|
||||||
|
|
||||||
--foreground: hsl(222.2 84% 4.9%);
|
--background-alt: hsl(0, 0%, 98.5%);
|
||||||
|
--background-alt: 0 0% 98.5%;
|
||||||
|
|
||||||
|
--foreground: hsl(222.2, 84%, 4.9%);
|
||||||
--foreground: 222.2 84% 4.9%;
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
--card: hsl(0 0% 100%);
|
--card: hsl(0, 0%, 100%);
|
||||||
--card: 0 0% 100%;
|
--card: 0 0% 100%;
|
||||||
|
|
||||||
--card-foreground: hsl(222.2 84% 4.9%);
|
--card-foreground: hsl(222.2, 84%, 4.9%);
|
||||||
--card-foreground: 222.2 84% 4.9%;
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
--popover: hsl(0 0% 100%);
|
--popover: hsl(0, 0%, 100%);
|
||||||
--popover: 0 0% 100%;
|
--popover: 0 0% 100%;
|
||||||
|
|
||||||
--popover-foreground: hsl(222.2 84% 4.9%);
|
--popover-foreground: hsl(222.2, 84%, 4.9%);
|
||||||
--popover-foreground: 222.2 84% 4.9%;
|
--popover-foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
--primary: hsl(0 0% 100%);
|
--primary: hsl(222.2, 37.4%, 11.2%);
|
||||||
--primary: 0 0% 100%;
|
--primary: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
--primary-foreground: hsl(0, 0%, 0%);
|
--primary-foreground: hsl(210, 40%, 98%);
|
||||||
--primary-foreground: 0 0% 0%;
|
--primary-foreground: 210 40% 98%;
|
||||||
|
|
||||||
--secondary: hsl(0, 0%, 96%);
|
--secondary: hsl(210, 20%, 94.1%);
|
||||||
--secondary: 0 0% 96%;
|
--secondary: 210 20% 94.1%;
|
||||||
|
|
||||||
--secondary-foreground: hsl(0, 0%, 83%);
|
--secondary-foreground: hsl(222.2, 17.4%, 36.2%);
|
||||||
--secondary-foreground: 0 0% 83%;
|
--secondary-foreground: 222.2 17.4% 36.2%;
|
||||||
|
|
||||||
--secondary-highlight: hsl(0, 0%, 94%);
|
--secondary-highlight: hsl(0, 0%, 94%);
|
||||||
--secondary-highlight: 0 0% 94%;
|
--secondary-highlight: 0 0% 94%;
|
||||||
|
|
||||||
--muted: hsl(210 40% 96.1%);
|
--muted: hsl(210, 40%, 96.1%);
|
||||||
--muted: 210 40% 96.1%;
|
--muted: 210 40% 96.1%;
|
||||||
|
|
||||||
--muted-foreground: hsl(0, 0%, 64%);
|
--muted-foreground: hsl(215.4, 6.3%, 46.9%);
|
||||||
--muted-foreground: 0, 0%, 64%;
|
--muted-foreground: 215.4 6.3% 46.9%;
|
||||||
|
|
||||||
--accent: hsl(210 40% 96.1%);
|
--accent: hsl(210, 40%, 96.1%);
|
||||||
--accent: 210 40% 96.1%;
|
--accent: 210 40% 96.1%;
|
||||||
|
|
||||||
--accent-foreground: hsl(222.2 47.4% 11.2%);
|
--accent-foreground: hsl(222.2, 47.4%, 11.2%);
|
||||||
--accent-foreground: 222.2 47.4% 11.2%;
|
--accent-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
--destructive: hsl(0 84.2% 60.2%);
|
--destructive: hsl(0, 84.2%, 60.2%);
|
||||||
--destructive: 0 84.2% 60.2%;
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
|
||||||
--destructive-foreground: hsl(210 40% 98%);
|
--destructive-foreground: hsl(0, 100%, 83%);
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 0 100% 83%;
|
||||||
|
|
||||||
--border: hsl(214.3 31.8% 91.4%);
|
--warning: hsl(17, 87%, 18%);
|
||||||
|
--warning: 17 87% 18%;
|
||||||
|
|
||||||
|
--warning-foreground: hsl(32, 100%, 74%);
|
||||||
|
--warning-foreground: 32 100% 74%;
|
||||||
|
|
||||||
|
--border: hsl(214.3, 31.8%, 91.4%);
|
||||||
--border: 214.3 31.8% 91.4%;
|
--border: 214.3 31.8% 91.4%;
|
||||||
|
|
||||||
--input: hsl(214.3 31.8% 91.4%);
|
--input: hsl(0, 0%, 85%);
|
||||||
--input: 0 0 85%;
|
--input: 0 0% 85%;
|
||||||
|
|
||||||
--ring: hsl(222.2 84% 4.9%);
|
--ring: hsla(0, 0%, 25%, 0%);
|
||||||
--ring: 222.2 84% 4.9%;
|
--ring: 0 0% 25% 0%;
|
||||||
|
|
||||||
--selected: hsl(228, 89%, 63%);
|
--selected: hsl(228, 89%, 63%);
|
||||||
--selected: 228 89% 63%;
|
--selected: 228 89% 63%;
|
||||||
|
|
||||||
|
--selected-foreground: hsl(0 0% 100%);
|
||||||
|
--selected-foreground: 0 0% 100%;
|
||||||
|
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
|
|
||||||
--severity_alert: var(--red-800);
|
--severity_alert: var(--red-800);
|
||||||
@ -85,16 +97,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background-hsl: hsl(0 0 0%);
|
--background: hsl(0, 0, 0%);
|
||||||
--background: 0 0% 0%;
|
--background: 0 0% 0%;
|
||||||
|
|
||||||
|
--background-alt: hsl(0, 0, 9%);
|
||||||
|
--background-alt: 0 0% 9%;
|
||||||
|
|
||||||
--foreground: hsl(0, 0%, 100%);
|
--foreground: hsl(0, 0%, 100%);
|
||||||
--foreground: 0, 0%, 100%;
|
--foreground: 0, 0%, 100%;
|
||||||
|
|
||||||
--card: hsl(0, 0%, 15%);
|
--card: hsl(0, 0%, 15%);
|
||||||
--card: 0, 0%, 15%;
|
--card: 0, 0%, 15%;
|
||||||
|
|
||||||
--card-foreground: hsl(210 40% 98%);
|
--card-foreground: hsl(210, 40%, 98%);
|
||||||
--card-foreground: 210 40% 98%;
|
--card-foreground: 210 40% 98%;
|
||||||
|
|
||||||
--popover: hsl(0, 0%, 15%);
|
--popover: hsl(0, 0%, 15%);
|
||||||
@ -103,11 +118,11 @@
|
|||||||
--popover-foreground: hsl(0, 0%, 100%);
|
--popover-foreground: hsl(0, 0%, 100%);
|
||||||
--popover-foreground: 210 40% 98%;
|
--popover-foreground: 210 40% 98%;
|
||||||
|
|
||||||
--primary: hsl(0, 0%, 9%);
|
--primary: hsl(0, 0%, 91%);
|
||||||
--primary: 0 0% 9%;
|
--primary: 0 0% 91%;
|
||||||
|
|
||||||
--primary-foreground: hsl(0, 0%, 100%);
|
--primary-foreground: hsl(0, 0%, 9%);
|
||||||
--primary-foreground: 0 0% 100%;
|
--primary-foreground: 0 0% 9%;
|
||||||
|
|
||||||
--secondary: hsl(0, 0%, 15%);
|
--secondary: hsl(0, 0%, 15%);
|
||||||
--secondary: 0 0% 15%;
|
--secondary: 0 0% 15%;
|
||||||
@ -127,25 +142,28 @@
|
|||||||
--accent: hsl(0, 0%, 15%);
|
--accent: hsl(0, 0%, 15%);
|
||||||
--accent: 0 0% 15%;
|
--accent: 0 0% 15%;
|
||||||
|
|
||||||
--accent-foreground: hsl(210 40% 98%);
|
--accent-foreground: hsl(210, 40%, 98%);
|
||||||
--accent-foreground: 210 40% 98%;
|
--accent-foreground: 210 40% 98%;
|
||||||
|
|
||||||
--destructive: hsl(0 62.8% 30.6%);
|
--destructive: hsl(0, 62.8%, 30.6%);
|
||||||
--destructive: 0 62.8% 30.6%;
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
|
||||||
--destructive-foreground: hsl(210 40% 98%);
|
--destructive-foreground: hsl(0, 100%, 83%);
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 0 100% 83%;
|
||||||
|
|
||||||
|
--warning: hsl(17, 87%, 18%);
|
||||||
|
--warning: 17 87% 18%;
|
||||||
|
|
||||||
|
--warning-foreground: hsl(32, 100%, 74%);
|
||||||
|
--warning-foreground: 32 100% 74%;
|
||||||
|
|
||||||
--border: hsl(0, 0%, 32%);
|
--border: hsl(0, 0%, 32%);
|
||||||
--border: 0 0% 32%;
|
--border: 0 0% 32%;
|
||||||
|
|
||||||
--input: hsl(217.2 32.6% 17.5%);
|
--input: hsl(0, 0%, 5%);
|
||||||
--input: 0 0 25%;
|
--input: 0 0% 25%;
|
||||||
|
|
||||||
--ring: hsl(212.7 26.8% 83.9%);
|
--ring: hsla(0, 0%, 25%, 0%);
|
||||||
--ring: 212.7 26.8% 83.9%;
|
--ring: 0 0% 25% 0%;
|
||||||
|
|
||||||
--selected: hsl(228, 89%, 63%);
|
|
||||||
--selected: 228 89% 63%;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user