Merge branch 'blakeblackshear:dev' into dev

This commit is contained in:
Remon Nashid 2024-04-13 06:20:38 -06:00 committed by GitHub
commit 29f87cc839
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
89 changed files with 2373 additions and 1134 deletions

View File

@ -65,7 +65,7 @@ jobs:
- name: Check out the repository
uses: actions/checkout@v4
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.0.0
uses: actions/setup-python@v5.1.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Install requirements

View File

@ -4,15 +4,15 @@ imutils == 0.5.*
markupsafe == 2.1.*
matplotlib == 3.7.*
mypy == 1.6.1
numpy == 1.23.*
numpy == 1.26.*
onvif_zeep == 0.2.12
opencv-python-headless == 4.7.0.*
paho-mqtt == 2.0.*
pandas == 2.1.4
pandas == 2.2.*
peewee == 3.17.*
peewee_migrate == 1.12.*
psutil == 5.9.*
pydantic == 2.6.*
pydantic == 2.7.*
git+https://github.com/fbcotter/py3nvml#egg=py3nvml
PyYAML == 6.0.*
pytz == 2024.1

View File

@ -62,7 +62,7 @@ http {
}
server {
listen 5000;
listen [::]:5000 ipv6only=off;
# vod settings
vod_base_url '';
@ -238,14 +238,14 @@ http {
location /api/stats {
access_log off;
rewrite ^/api/(.*)$ $1 break;
rewrite ^/api(/.*)$ $1 break;
proxy_pass http://frigate_api;
include proxy.conf;
}
location /api/version {
access_log off;
rewrite ^/api/(.*)$ $1 break;
rewrite ^/api(/.*)$ $1 break;
proxy_pass http://frigate_api;
include proxy.conf;
}

View File

@ -257,6 +257,28 @@ objects:
# Checks based on the bottom center of the bounding box of the object
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
# NOTE: Can be overridden at the camera level
motion:
@ -345,8 +367,6 @@ record:
# Optional: Objects to save recordings for. (default: all tracked objects)
objects:
- 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
retain:
# Required: Default retention days (default: shown below)

View File

@ -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:
```yaml
camera:
record:
events:
cameras:
name_of_your_camera:
record:
events:
required_zones:
- entire_yard
snapshots:
required_zones:
- entire_yard
snapshots:
required_zones:
- entire_yard
zones:
entire_yard:
coordinates: ...
zones:
entire_yard:
coordinates: ...
```
### 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.
```yaml
camera:
record:
events:
cameras:
name_of_your_camera:
record:
events:
required_zones:
- entire_yard
- front_yard_street
snapshots:
required_zones:
- entire_yard
- front_yard_street
snapshots:
required_zones:
- entire_yard
- front_yard_street
zones:
entire_yard:
coordinates: ... (everywhere you want a person)
objects:
- person
front_yard_street:
coordinates: ... (just the street)
objects:
- car
zones:
entire_yard:
coordinates: ... (everywhere you want a person)
objects:
- person
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.
@ -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.
```yaml
camera:
zones:
sidewalk:
loitering_time: 4 # unit is in seconds
objects:
- person
cameras:
name_of_your_camera:
zones:
sidewalk:
loitering_time: 4 # unit is in seconds
objects:
- person
```
### 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:
```yaml
camera:
zones:
front_yard:
inertia: 3
objects:
- person
cameras:
name_of_your_camera:
zones:
front_yard:
inertia: 3
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:
```yaml
camera:
zones:
driveway_entrance:
inertia: 1
objects:
- car
cameras:
name_of_your_camera:
zones:
driveway_entrance:
inertia: 1
objects:
- car
```

View File

@ -137,7 +137,10 @@ def stats_history():
@bp.route("/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
config["mqtt"].pop("password", None)
@ -154,9 +157,13 @@ def config():
for cmd in camera_dict["ffmpeg_cmds"]:
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()}
for detector, detector_config in config["detectors"].items():
for detector_config in config["detectors"].values():
detector_config["model"]["labelmap"] = (
current_app.frigate_config.model.merged_labelmap
)

View File

@ -1333,7 +1333,9 @@ def review_preview(id: str):
padding = 8
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)
@ -1344,8 +1346,15 @@ def preview_thumbnail(file_name: str):
safe_file_name_current = secure_filename(file_name)
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
with open(os.path.join(preview_dir, safe_file_name_current), "rb") as image_file:
jpg_bytes = image_file.read()
try:
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.headers["Content-Type"] = "image/jpeg"

View File

@ -27,10 +27,18 @@ def review():
before = request.args.get("before", type=float, default=datetime.now().timestamp())
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":
camera_list = cameras.split(",")
@ -45,6 +53,7 @@ def review():
for label in filtered_labels:
label_clauses.append(
(ReviewSegment.data["objects"].cast("text") % f'*"{label}"*')
| (ReviewSegment.data["audio"].cast("text") % f'*"{label}"*')
)
label_clause = reduce(operator.or_, label_clauses)
@ -94,6 +103,7 @@ def review_summary():
for label in filtered_labels:
label_clauses.append(
(ReviewSegment.data["objects"].cast("text") % f'*"{label}"*')
| (ReviewSegment.data["audio"].cast("text") % f'*"{label}"*')
)
label_clause = reduce(operator.or_, label_clauses)
@ -429,12 +439,12 @@ def motion_activity():
# normalize data
motion = (
df["motion"]
.resample(f"{scale}S")
.resample(f"{scale}s")
.apply(lambda x: max(x, key=abs, default=0.0))
.fillna(0.0)
.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)
length = df.shape[0]

View File

@ -63,6 +63,7 @@ from frigate.storage import StorageMaintainer
from frigate.timeline import TimelineProcessor
from frigate.types import CameraMetricsTypes, PTZMetricsTypes
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.version import VERSION
from frigate.video import capture_camera, track_camera
@ -126,6 +127,9 @@ class FrigateApp:
config_file = config_file_yaml
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)
self.config = user_config.runtime_config(self.plus_api)
@ -200,9 +204,6 @@ class FrigateApp:
logging.getLogger("ws4py").setLevel("ERROR")
def init_queues(self) -> None:
# Queues for clip processing
self.event_processed_queue: Queue = mp.Queue()
# Queue for cameras to push tracked objects to
self.detected_frames_queue: Queue = mp.Queue(
maxsize=sum(camera.enabled for camera in self.config.cameras.values()) * 2
@ -420,7 +421,6 @@ class FrigateApp:
self.config,
self.dispatcher,
self.detected_frames_queue,
self.event_processed_queue,
self.ptz_autotracker_thread,
self.stop_event,
)
@ -517,7 +517,6 @@ class FrigateApp:
def start_event_processor(self) -> None:
self.event_processor = EventProcessor(
self.config,
self.event_processed_queue,
self.timeline_queue,
self.stop_event,
)
@ -672,6 +671,14 @@ class FrigateApp:
logger.info("Stopping...")
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
self.inter_process_communicator.stop()
self.inter_config_updater.stop()
@ -704,7 +711,6 @@ class FrigateApp:
shm.unlink()
for queue in [
self.event_processed_queue,
self.detected_frames_queue,
self.log_queue,
]:

View File

@ -8,7 +8,7 @@ import zmq
SOCKET_CONTROL = "inproc://control.detections_updater"
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):

View File

@ -5,6 +5,7 @@ import zmq
from frigate.events.types import EventStateEnum, EventTypeEnum
SOCKET_PUSH_PULL = "ipc:///tmp/cache/events"
SOCKET_PUSH_PULL_END = "ipc:///tmp/cache/events_ended"
class EventUpdatePublisher:
@ -37,7 +38,53 @@ class EventUpdateSubscriber:
def check_for_update(
self, timeout=1
) -> 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:
has_update, _, _ = zmq.select([self.socket], [], [], timeout)

View File

@ -245,10 +245,6 @@ class EventsConfig(FrigateBaseModel):
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.")
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(
None,
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))
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
if mask:
@ -484,11 +508,40 @@ class RuntimeFilterConfig(FilterConfig):
raw_mask: Optional[Union[str, List[str]]] = None
def __init__(self, **config):
frame_shape = config.get("frame_shape", (1, 1))
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
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)
@ -539,30 +592,106 @@ class ZoneConfig(BaseModel):
super().__init__(**config)
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):
explicit = any(p.split(",")[0] > "1.0" for p in coordinates)
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):
points = coordinates.split(",")
explicit = any(p > "1.0" for p in points)
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:
self._contour = np.array([])
class ObjectConfig(FrigateBaseModel):
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.")
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):
enabled: bool = Field(default=False, title="Enable audio events.")
max_not_heard: int = Field(
@ -841,6 +970,9 @@ class CameraConfig(FrigateBaseModel):
objects: ObjectConfig = Field(
default_factory=ObjectConfig, title="Object configuration."
)
review: ReviewConfig = Field(
default_factory=ReviewConfig, title="Review configuration."
)
audio: AudioConfig = Field(
default_factory=AudioConfig, title="Audio events configuration."
)
@ -1162,6 +1294,9 @@ class FrigateConfig(FrigateBaseModel):
objects: ObjectConfig = Field(
default_factory=ObjectConfig, title="Global object configuration."
)
review: ReviewConfig = Field(
default_factory=ReviewConfig, title="Review configuration."
)
audio: AudioConfig = Field(
default_factory=AudioConfig, title="Global Audio events configuration."
)
@ -1209,6 +1344,7 @@ class FrigateConfig(FrigateBaseModel):
"snapshots": ...,
"live": ...,
"objects": ...,
"review": ...,
"motion": ...,
"detect": ...,
"ffmpeg": ...,
@ -1346,6 +1482,11 @@ class FrigateConfig(FrigateBaseModel):
)
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
if not camera_config.live.stream_name:
camera_config.live.stream_name = name

View File

@ -39,6 +39,10 @@ AUDIO_MAX_BIT_RANGE = 32768.0
AUDIO_SAMPLE_RATE = 16000
AUDIO_MIN_CONFIDENCE = 0.5
# DB Consts
MAX_WAL_SIZE = 10 # MB
# Ffmpeg Presets
FFMPEG_HWACCEL_NVIDIA = "preset-nvidia"

View File

@ -1,11 +1,10 @@
import datetime
import logging
import threading
from multiprocessing import Queue
from multiprocessing.synchronize import Event as MpEvent
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.events.types import EventStateEnum, EventTypeEnum
from frigate.models import Event
@ -52,19 +51,18 @@ class EventProcessor(threading.Thread):
def __init__(
self,
config: FrigateConfig,
event_processed_queue: Queue,
timeline_queue: Queue,
stop_event: MpEvent,
):
threading.Thread.__init__(self)
self.name = "event_processor"
self.config = config
self.event_processed_queue = event_processed_queue
self.timeline_queue = timeline_queue
self.events_in_process: Dict[str, Event] = {}
self.stop_event = stop_event
self.event_receiver = EventUpdateSubscriber()
self.event_end_publisher = EventEndPublisher()
def run(self) -> None:
# 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)
# 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_end_publisher.stop()
logger.info("Exiting event processor...")
def handle_object_detection(
@ -242,7 +237,7 @@ class EventProcessor(threading.Thread):
if event_type == EventStateEnum.end:
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(
self, event_type: EventStateEnum, event_data: Event

View File

@ -6,6 +6,7 @@ import os
import queue
import threading
from collections import Counter, defaultdict
from multiprocessing.synchronize import Event as MpEvent
from statistics import median
from typing import Callable
@ -14,7 +15,7 @@ import numpy as np
from frigate.comms.detections_updater import DetectionPublisher, DetectionTypeEnum
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 (
CameraConfig,
FrigateConfig,
@ -827,7 +828,6 @@ class TrackedObjectProcessor(threading.Thread):
config: FrigateConfig,
dispatcher: Dispatcher,
tracked_objects_queue,
event_processed_queue,
ptz_autotracker_thread,
stop_event,
):
@ -836,14 +836,14 @@ class TrackedObjectProcessor(threading.Thread):
self.config = config
self.dispatcher = dispatcher
self.tracked_objects_queue = tracked_objects_queue
self.event_processed_queue = event_processed_queue
self.stop_event = stop_event
self.stop_event: MpEvent = stop_event
self.camera_states: dict[str, CameraState] = {}
self.frame_manager = SharedMemoryFrameManager()
self.last_motion_detected: dict[str, float] = {}
self.ptz_autotracker_thread = ptz_autotracker_thread
self.detection_publisher = DetectionPublisher(DetectionTypeEnum.video)
self.event_sender = EventUpdatePublisher()
self.event_end_subscriber = EventEndSubscriber()
def start(camera, obj: TrackedObject, current_frame_time):
self.event_sender.publish(
@ -1007,7 +1007,7 @@ class TrackedObjectProcessor(threading.Thread):
return True
def should_retain_recording(self, camera, obj: TrackedObject):
def should_retain_recording(self, camera: str, obj: TrackedObject):
if obj.false_positive:
return False
@ -1022,7 +1022,11 @@ class TrackedObjectProcessor(threading.Thread):
return False
# 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):
logger.debug(
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
while not self.event_processed_queue.empty():
event_id, camera = self.event_processed_queue.get()
while not self.stop_event.is_set():
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.detection_publisher.stop()
self.event_sender.stop()
self.event_end_subscriber.stop()
logger.info("Exiting object processor...")

View File

@ -3,12 +3,15 @@
import datetime
import itertools
import logging
import os
import threading
from multiprocessing.synchronize import Event as MpEvent
from pathlib import Path
from playhouse.sqlite_ext import SqliteExtDatabase
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.record.util import remove_empty_directories, sync_recordings
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.")
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(
self, expire_date: float, config: CameraConfig, events: Event
) -> None:
@ -328,3 +348,4 @@ class RecordingCleanup(threading.Thread):
if counter == 0:
self.expire_recordings()
remove_empty_directories(RECORD_DIR)
self.truncate_wal()

View File

@ -103,14 +103,14 @@ class RecordingExporter(threading.Thread):
if self.playback_factor == PlaybackFactorEnum.realtime:
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(" ")
elif self.playback_factor == PlaybackFactorEnum.timelapse_25x:
ffmpeg_cmd = (
parse_preset_hardware_acceleration_encode(
self.config.ffmpeg.hwaccel_args,
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,
)
).split(" ")

View File

@ -32,13 +32,11 @@ THUMB_WIDTH = 320
THRESHOLD_ALERT_ACTIVITY = 120
THRESHOLD_DETECTION_ACTIVITY = 30
THRESHOLD_MOTION_ACTIVITY = 30
class SeverityEnum(str, Enum):
alert = "alert"
detection = "detection"
signification_motion = "significant_motion"
class PendingReviewSegment:
@ -50,7 +48,6 @@ class PendingReviewSegment:
detections: dict[str, str],
zones: set[str] = set(),
audio: set[str] = set(),
motion: list[int] = [],
):
rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
self.id = f"{frame_time}-{rand_id}"
@ -60,12 +57,12 @@ class PendingReviewSegment:
self.detections = detections
self.zones = zones
self.audio = audio
self.sig_motion_areas = motion
self.last_update = frame_time
# thumbnail
self.frame = np.zeros((THUMB_HEIGHT * 3 // 2, THUMB_WIDTH), np.uint8)
self.frame_active_count = 0
self.frame_path = os.path.join(CLIPS_DIR, f"thumb-{self.camera}-{self.id}.jpg")
def update_frame(
self, camera_config: CameraConfig, frame, objects: list[TrackedObject]
@ -98,25 +95,24 @@ class PendingReviewSegment:
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:
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 {
ReviewSegment.id: self.id,
ReviewSegment.camera: self.camera,
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.thumb_path: path,
ReviewSegment.thumb_path: self.frame_path,
ReviewSegment.data: {
"detections": list(set(self.detections.keys())),
"objects": list(set(self.detections.values())),
"zones": list(self.zones),
"audio": list(self.audio),
"significant_motion_areas": self.sig_motion_areas,
},
}
@ -141,9 +137,20 @@ class ReviewSegmentMaintainer(threading.Thread):
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:
"""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(
"reviews",
@ -158,7 +165,6 @@ class ReviewSegmentMaintainer(threading.Thread):
segment: PendingReviewSegment,
frame_time: float,
objects: list[TrackedObject],
motion: list,
) -> None:
"""Validate if existing review segment should continue."""
camera_config = self.config.cameras[segment.camera]
@ -168,18 +174,6 @@ class ReviewSegmentMaintainer(threading.Thread):
if frame_time > segment.last_update:
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:
if not object["sub_label"]:
segment.detections[object["id"]] = object["label"]
@ -188,24 +182,38 @@ class ReviewSegmentMaintainer(threading.Thread):
else:
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
if (
segment.severity == SeverityEnum.detection
and object["has_clip"]
and object["label"] in camera_config.objects.alert
segment.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)
)
)
):
segment.severity = SeverityEnum.alert
# keep zones up to date
if len(object["current_zones"]) > 0:
segment.zones.update(object["current_zones"])
elif (
segment.severity == SeverityEnum.signification_motion
and len(motion) >= THRESHOLD_MOTION_ACTIVITY
):
if frame_time > segment.last_update:
segment.last_update = frame_time
if len(active_objects) > segment.frame_active_count:
try:
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)
self.update_segment(segment)
except FileNotFoundError:
return
else:
if segment.severity == SeverityEnum.alert and frame_time > (
segment.last_update + THRESHOLD_ALERT_ACTIVITY
@ -219,7 +227,6 @@ class ReviewSegmentMaintainer(threading.Thread):
camera: str,
frame_time: float,
objects: list[TrackedObject],
motion: list,
) -> None:
"""Check if a new review segment should be created."""
camera_config = self.config.cameras[camera]
@ -229,15 +236,9 @@ class ReviewSegmentMaintainer(threading.Thread):
has_sig_object = False
detections: dict[str, str] = {}
zones: set = set()
severity = None
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"]:
detections[object["id"]] = object["label"]
elif object["sub_label"][0] in ALL_ATTRIBUTE_LABELS:
@ -245,34 +246,68 @@ class ReviewSegmentMaintainer(threading.Thread):
else:
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"])
self.active_review_segments[camera] = PendingReviewSegment(
camera,
frame_time,
SeverityEnum.alert if has_sig_object else SeverityEnum.detection,
detections,
audio=set(),
zones=zones,
motion=[],
)
if severity:
self.active_review_segments[camera] = PendingReviewSegment(
camera,
frame_time,
SeverityEnum.alert if has_sig_object else SeverityEnum.detection,
detections,
audio=set(),
zones=zones,
)
frame_id = f"{camera_config.name}{frame_time}"
yuv_frame = self.frame_manager.get(frame_id, camera_config.frame_shape_yuv)
self.active_review_segments[camera].update_frame(
camera_config, yuv_frame, active_objects
)
self.frame_manager.close(frame_id)
elif len(motion) >= 20:
self.active_review_segments[camera] = PendingReviewSegment(
camera,
frame_time,
SeverityEnum.signification_motion,
detections={},
audio=set(),
motion=motion,
zones=set(),
)
try:
frame_id = f"{camera_config.name}{frame_time}"
yuv_frame = self.frame_manager.get(
frame_id, camera_config.frame_shape_yuv
)
self.active_review_segments[camera].update_frame(
camera_config, yuv_frame, active_objects
)
self.frame_manager.close(frame_id)
self.update_segment(self.active_review_segments[camera])
except FileNotFoundError:
return
def run(self) -> None:
while not self.stop_event.is_set():
@ -330,13 +365,22 @@ class ReviewSegmentMaintainer(threading.Thread):
current_segment,
frame_time,
current_tracked_objects,
motion_boxes,
)
elif topic == DetectionTypeEnum.audio and len(audio_detections) > 0:
camera_config = self.config.cameras[camera]
if frame_time > current_segment.last_update:
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:
if manual_info["state"] == ManualEventState.complete:
current_segment.detections[manual_info["event_id"]] = (
@ -364,18 +408,35 @@ class ReviewSegmentMaintainer(threading.Thread):
camera,
frame_time,
current_tracked_objects,
motion_boxes,
)
elif topic == DetectionTypeEnum.audio and len(audio_detections) > 0:
self.active_review_segments[camera] = PendingReviewSegment(
camera,
frame_time,
SeverityEnum.detection,
{},
set(),
set(audio_detections),
[],
)
severity = None
camera_config = self.config.cameras[camera]
detections = set()
for audio in 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:
self.active_review_segments[camera] = PendingReviewSegment(
camera,
@ -384,7 +445,6 @@ class ReviewSegmentMaintainer(threading.Thread):
{manual_info["event_id"]: manual_info["label"]},
set(),
set(),
[],
)
if manual_info["state"] == ManualEventState.start:
@ -398,6 +458,11 @@ class ReviewSegmentMaintainer(threading.Thread):
"end_time"
]
self.config_subscriber.stop()
self.requestor.stop()
self.detection_subscriber.stop()
logger.info("Exiting review maintainer...")
def get_active_objects(
frame_time: float, camera_config: CameraConfig, all_objects: list[TrackedObject]
@ -406,8 +471,16 @@ def get_active_objects(
return [
o
for o in all_objects
if o["motionless_count"] < camera_config.detect.stationary.threshold
and o["position_changes"] > 0
and o["frame_time"] == frame_time
and not o["false_positive"]
if o["motionless_count"]
< camera_config.detect.stationary.threshold # no stationary objects
and o["position_changes"] > 0 # object must have moved at least once
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
]

View File

@ -64,7 +64,7 @@ class TestConfig(unittest.TestCase):
def test_config_class(self):
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()
assert "cpu" in runtime_config.detectors.keys()
@ -157,7 +157,7 @@ class TestConfig(unittest.TestCase):
},
}
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()
assert "dog" in runtime_config.cameras["back"].objects.track
@ -183,7 +183,7 @@ class TestConfig(unittest.TestCase):
},
}
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()
assert not runtime_config.cameras["back"].birdseye.enabled
@ -209,7 +209,7 @@ class TestConfig(unittest.TestCase):
},
}
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()
assert runtime_config.cameras["back"].birdseye.enabled
@ -234,7 +234,7 @@ class TestConfig(unittest.TestCase):
},
}
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()
assert runtime_config.cameras["back"].birdseye.enabled
@ -263,7 +263,7 @@ class TestConfig(unittest.TestCase):
},
}
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()
assert "cat" in runtime_config.cameras["back"].objects.track
@ -288,7 +288,7 @@ class TestConfig(unittest.TestCase):
},
}
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()
assert "dog" in runtime_config.cameras["back"].objects.filters
@ -316,7 +316,7 @@ class TestConfig(unittest.TestCase):
},
}
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()
assert "dog" in runtime_config.cameras["back"].objects.filters
@ -345,7 +345,7 @@ class TestConfig(unittest.TestCase):
},
}
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()
assert "dog" in runtime_config.cameras["back"].objects.filters
@ -375,7 +375,7 @@ class TestConfig(unittest.TestCase):
},
}
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()
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["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):
config = {
"mqtt": {"host": "mqtt"},
@ -406,7 +455,7 @@ class TestConfig(unittest.TestCase):
}
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()
assert "-rtsp_transport" in runtime_config.cameras["back"].ffmpeg_cmds[0]["cmd"]
@ -435,7 +484,7 @@ class TestConfig(unittest.TestCase):
},
}
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()
assert "-re" in runtime_config.cameras["back"].ffmpeg_cmds[0]["cmd"]
@ -465,7 +514,7 @@ class TestConfig(unittest.TestCase):
},
}
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()
assert "-re" in runtime_config.cameras["back"].ffmpeg_cmds[0]["cmd"]
@ -500,7 +549,7 @@ class TestConfig(unittest.TestCase):
},
}
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()
assert "-re" in runtime_config.cameras["back"].ffmpeg_cmds[0]["cmd"]
@ -530,7 +579,7 @@ class TestConfig(unittest.TestCase):
},
}
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()
assert (
@ -608,7 +657,7 @@ class TestConfig(unittest.TestCase):
},
}
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()
assert isinstance(
@ -616,6 +665,41 @@ class TestConfig(unittest.TestCase):
)
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):
config = {
"mqtt": {"host": "mqtt"},
@ -640,7 +724,7 @@ class TestConfig(unittest.TestCase):
},
}
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()
back_camera = runtime_config.cameras["back"]
@ -671,7 +755,7 @@ class TestConfig(unittest.TestCase):
}
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()
ffmpeg_cmds = runtime_config.cameras["back"].ffmpeg_cmds
@ -702,7 +786,7 @@ class TestConfig(unittest.TestCase):
}
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()
assert runtime_config.cameras["back"].detect.max_disappeared == 5 * 5
@ -730,7 +814,7 @@ class TestConfig(unittest.TestCase):
}
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()
assert runtime_config.cameras["back"].motion.frame_height == 100
@ -758,7 +842,7 @@ class TestConfig(unittest.TestCase):
}
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()
assert round(runtime_config.cameras["back"].motion.contour_area) == 10
@ -787,7 +871,7 @@ class TestConfig(unittest.TestCase):
}
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()
assert runtime_config.model.merged_labelmap[7] == "truck"
@ -815,7 +899,7 @@ class TestConfig(unittest.TestCase):
}
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()
assert runtime_config.model.merged_labelmap[0] == "person"
@ -844,7 +928,7 @@ class TestConfig(unittest.TestCase):
}
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()
assert runtime_config.model.merged_labelmap[0] == "person"
@ -878,7 +962,7 @@ class TestConfig(unittest.TestCase):
}
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())
assert runtime_config.model.merged_labelmap[0] == "amazon"
@ -1012,7 +1096,7 @@ class TestConfig(unittest.TestCase):
},
}
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()
assert runtime_config.cameras["back"].detect.max_disappeared == 1
@ -1040,7 +1124,7 @@ class TestConfig(unittest.TestCase):
},
}
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()
assert runtime_config.cameras["back"].detect.max_disappeared == 25
@ -1069,7 +1153,7 @@ class TestConfig(unittest.TestCase):
},
}
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()
assert runtime_config.cameras["back"].detect.max_disappeared == 1
@ -1102,7 +1186,7 @@ class TestConfig(unittest.TestCase):
},
}
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()
assert runtime_config.cameras["back"].snapshots.enabled
@ -1130,7 +1214,7 @@ class TestConfig(unittest.TestCase):
},
}
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()
assert runtime_config.cameras["back"].snapshots.bounding_box
@ -1163,7 +1247,7 @@ class TestConfig(unittest.TestCase):
},
}
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()
assert runtime_config.cameras["back"].snapshots.bounding_box is False
@ -1193,7 +1277,7 @@ class TestConfig(unittest.TestCase):
},
}
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()
assert runtime_config.cameras["back"].live.quality == 4
@ -1220,7 +1304,7 @@ class TestConfig(unittest.TestCase):
},
}
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()
assert runtime_config.cameras["back"].live.quality == 8
@ -1251,7 +1335,7 @@ class TestConfig(unittest.TestCase):
},
}
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()
assert runtime_config.cameras["back"].live.quality == 7
@ -1280,7 +1364,7 @@ class TestConfig(unittest.TestCase):
},
}
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()
assert runtime_config.cameras["back"].timestamp_style.position == "bl"
@ -1307,7 +1391,7 @@ class TestConfig(unittest.TestCase):
},
}
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()
assert runtime_config.cameras["back"].timestamp_style.position == "tl"
@ -1336,7 +1420,7 @@ class TestConfig(unittest.TestCase):
},
}
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()
assert runtime_config.cameras["back"].timestamp_style.position == "bl"
@ -1365,7 +1449,7 @@ class TestConfig(unittest.TestCase):
},
}
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()
assert runtime_config.cameras["back"].snapshots.retain.default == 1.5
@ -1505,7 +1589,7 @@ class TestConfig(unittest.TestCase):
},
}
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()
assert "dog" in runtime_config.cameras["back"].objects.filters

127
frigate/util/config.py Normal file
View 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

View File

@ -727,9 +727,22 @@ def create_mask(frame_shape, mask):
return mask_img
def add_mask(mask, mask_img):
def add_mask(mask: str, mask_img: np.ndarray):
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(
[[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))

328
web/package-lock.json generated
View File

@ -37,7 +37,7 @@
"hls.js": "^1.5.7",
"idb-keyval": "^6.2.1",
"immer": "^10.0.4",
"lucide-react": "^0.360.0",
"lucide-react": "^0.365.0",
"monaco-yaml": "^5.1.1",
"next-themes": "^0.3.0",
"react": "^18.2.0",
@ -45,14 +45,14 @@
"react-day-picker": "^8.9.1",
"react-device-detect": "^2.2.3",
"react-dom": "^18.2.0",
"react-hook-form": "^7.51.1",
"react-hook-form": "^7.51.2",
"react-icons": "^5.0.1",
"react-router-dom": "^6.22.3",
"react-swipeable": "^7.0.1",
"react-tracked": "^1.7.14",
"react-transition-group": "^4.4.5",
"react-use-websocket": "^4.8.1",
"react-zoom-pan-pinch": "^3.4.3",
"react-zoom-pan-pinch": "^3.4.4",
"recoil": "^0.7.7",
"scroll-into-view-if-needed": "^3.1.0",
"sonner": "^1.4.41",
@ -68,20 +68,20 @@
"devDependencies": {
"@tailwindcss/forms": "^0.5.7",
"@testing-library/jest-dom": "^6.1.5",
"@types/node": "^20.11.30",
"@types/react": "^18.2.67",
"@types/react-dom": "^18.2.22",
"@types/node": "^20.12.5",
"@types/react": "^18.2.74",
"@types/react-dom": "^18.2.24",
"@types/react-icons": "^3.0.0",
"@types/react-transition-group": "^4.4.10",
"@types/strftime": "^0.9.8",
"@typescript-eslint/eslint-plugin": "^7.3.1",
"@typescript-eslint/parser": "^7.3.1",
"@typescript-eslint/eslint-plugin": "^7.5.0",
"@typescript-eslint/parser": "^7.5.0",
"@vitejs/plugin-react-swc": "^3.6.0",
"@vitest/coverage-v8": "^1.4.0",
"autoprefixer": "^10.4.19",
"eslint": "^8.55.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-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
@ -89,12 +89,12 @@
"fake-indexeddb": "^5.0.2",
"jest-websocket-mock": "^2.5.0",
"jsdom": "^24.0.0",
"msw": "^2.2.9",
"msw": "^2.2.13",
"postcss": "^8.4.38",
"prettier": "^3.2.5",
"tailwindcss": "^3.4.1",
"typescript": "^5.4.3",
"vite": "^5.2.2",
"tailwindcss": "^3.4.3",
"typescript": "^5.4.4",
"vite": "^5.2.8",
"vitest": "^1.3.1"
}
},
@ -841,9 +841,9 @@
}
},
"node_modules/@mswjs/interceptors": {
"version": "0.25.16",
"resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.25.16.tgz",
"integrity": "sha512-8QC8JyKztvoGAdPgyZy49c9vSHHAZjHagwl4RY9E8carULk8ym3iTaiawrT1YoLF/qb449h48f71XDPgkUSOUg==",
"version": "0.26.15",
"resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.26.15.tgz",
"integrity": "sha512-HM47Lu1YFmnYHKMBynFfjCp0U/yRskHj/8QEJW0CBEPOlw8Gkmjfll+S9b8M7V5CNDw2/ciRxjjnWeaCiblSIQ==",
"dev": true,
"dependencies": {
"@open-draft/deferred-promise": "^2.2.0",
@ -2507,9 +2507,9 @@
}
},
"node_modules/@types/node": {
"version": "20.11.30",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.30.tgz",
"integrity": "sha512-dHM6ZxwlmuZaRmUPfv1p+KrdD1Dci04FbdEm/9wEMouFqxYoFl5aMkt0VMAUtYRQDyYvD41WJLukhq/ha3YuTw==",
"version": "20.12.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.5.tgz",
"integrity": "sha512-BD+BjQ9LS/D8ST9p5uqBxghlN+S42iuNxjsUGjeZobe/ciXzk2qb1B6IXc6AnRLS+yFJRpN2IPEHMzwspfDJNw==",
"dev": true,
"dependencies": {
"undici-types": "~5.26.4"
@ -2522,20 +2522,19 @@
"devOptional": true
},
"node_modules/@types/react": {
"version": "18.2.67",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.67.tgz",
"integrity": "sha512-vkIE2vTIMHQ/xL0rgmuoECBCkZFZeHr49HeWSc24AptMbNRo7pwSBvj73rlJJs9fGKj0koS+V7kQB1jHS0uCgw==",
"version": "18.2.74",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.74.tgz",
"integrity": "sha512-9AEqNZZyBx8OdZpxzQlaFEVCSFUM2YXJH46yPOiOpm078k6ZLOCcuAzGum/zK8YBwY+dbahVNbHrbgrAwIRlqw==",
"devOptional": true,
"dependencies": {
"@types/prop-types": "*",
"@types/scheduler": "*",
"csstype": "^3.0.2"
}
},
"node_modules/@types/react-dom": {
"version": "18.2.22",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.22.tgz",
"integrity": "sha512-fHkBXPeNtfvri6gdsMYyW+dW7RXFo6Ad09nLFK0VQWR7yGLai/Cyvyj696gbwYvBnhGtevUG9cET0pmUbMtoPQ==",
"version": "18.2.24",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.24.tgz",
"integrity": "sha512-cN6upcKd8zkGy4HU9F1+/s98Hrp6D4MOcippK4PoE8OZRngohHZpbJn1GsaDLz87MqvHNoT13nHvNqM9ocRHZg==",
"devOptional": true,
"dependencies": {
"@types/react": "*"
@ -2560,12 +2559,6 @@
"@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": {
"version": "7.5.6",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz",
@ -2591,16 +2584,16 @@
"dev": true
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.3.1.tgz",
"integrity": "sha512-STEDMVQGww5lhCuNXVSQfbfuNII5E08QWkvAw5Qwf+bj2WT+JkG1uc+5/vXA3AOYMDHVOSpL+9rcbEUiHIm2dw==",
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.5.0.tgz",
"integrity": "sha512-HpqNTH8Du34nLxbKgVMGljZMG0rJd2O9ecvr2QLYp+7512ty1j42KnsFwspPXg1Vh8an9YImf6CokUBltisZFQ==",
"dev": true,
"dependencies": {
"@eslint-community/regexpp": "^4.5.1",
"@typescript-eslint/scope-manager": "7.3.1",
"@typescript-eslint/type-utils": "7.3.1",
"@typescript-eslint/utils": "7.3.1",
"@typescript-eslint/visitor-keys": "7.3.1",
"@typescript-eslint/scope-manager": "7.5.0",
"@typescript-eslint/type-utils": "7.5.0",
"@typescript-eslint/utils": "7.5.0",
"@typescript-eslint/visitor-keys": "7.5.0",
"debug": "^4.3.4",
"graphemer": "^1.4.0",
"ignore": "^5.2.4",
@ -2626,15 +2619,15 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.3.1.tgz",
"integrity": "sha512-Rq49+pq7viTRCH48XAbTA+wdLRrB/3sRq4Lpk0oGDm0VmnjBrAOVXH/Laalmwsv2VpekiEfVFwJYVk6/e8uvQw==",
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.5.0.tgz",
"integrity": "sha512-cj+XGhNujfD2/wzR1tabNsidnYRaFfEkcULdcIyVBYcXjBvBKOes+mpMBP7hMpOyk+gBcfXsrg4NBGAStQyxjQ==",
"dev": true,
"dependencies": {
"@typescript-eslint/scope-manager": "7.3.1",
"@typescript-eslint/types": "7.3.1",
"@typescript-eslint/typescript-estree": "7.3.1",
"@typescript-eslint/visitor-keys": "7.3.1",
"@typescript-eslint/scope-manager": "7.5.0",
"@typescript-eslint/types": "7.5.0",
"@typescript-eslint/typescript-estree": "7.5.0",
"@typescript-eslint/visitor-keys": "7.5.0",
"debug": "^4.3.4"
},
"engines": {
@ -2654,13 +2647,13 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.3.1.tgz",
"integrity": "sha512-fVS6fPxldsKY2nFvyT7IP78UO1/I2huG+AYu5AMjCT9wtl6JFiDnsv4uad4jQ0GTFzcUV5HShVeN96/17bTBag==",
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.5.0.tgz",
"integrity": "sha512-Z1r7uJY0MDeUlql9XJ6kRVgk/sP11sr3HKXn268HZyqL7i4cEfrdFuSSY/0tUqT37l5zT0tJOsuDP16kio85iA==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "7.3.1",
"@typescript-eslint/visitor-keys": "7.3.1"
"@typescript-eslint/types": "7.5.0",
"@typescript-eslint/visitor-keys": "7.5.0"
},
"engines": {
"node": "^18.18.0 || >=20.0.0"
@ -2671,13 +2664,13 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.3.1.tgz",
"integrity": "sha512-iFhaysxFsMDQlzJn+vr3OrxN8NmdQkHks4WaqD4QBnt5hsq234wcYdyQ9uquzJJIDAj5W4wQne3yEsYA6OmXGw==",
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.5.0.tgz",
"integrity": "sha512-A021Rj33+G8mx2Dqh0nMO9GyjjIBK3MqgVgZ2qlKf6CJy51wY/lkkFqq3TqqnH34XyAHUkq27IjlUkWlQRpLHw==",
"dev": true,
"dependencies": {
"@typescript-eslint/typescript-estree": "7.3.1",
"@typescript-eslint/utils": "7.3.1",
"@typescript-eslint/typescript-estree": "7.5.0",
"@typescript-eslint/utils": "7.5.0",
"debug": "^4.3.4",
"ts-api-utils": "^1.0.1"
},
@ -2698,9 +2691,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.3.1.tgz",
"integrity": "sha512-2tUf3uWggBDl4S4183nivWQ2HqceOZh1U4hhu4p1tPiIJoRRXrab7Y+Y0p+dozYwZVvLPRI6r5wKe9kToF9FIw==",
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.5.0.tgz",
"integrity": "sha512-tv5B4IHeAdhR7uS4+bf8Ov3k793VEVHd45viRRkehIUZxm0WF82VPiLgHzA/Xl4TGPg1ZD49vfxBKFPecD5/mg==",
"dev": true,
"engines": {
"node": "^18.18.0 || >=20.0.0"
@ -2711,13 +2704,13 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.3.1.tgz",
"integrity": "sha512-tLpuqM46LVkduWP7JO7yVoWshpJuJzxDOPYIVWUUZbW+4dBpgGeUdl/fQkhuV0A8eGnphYw3pp8d2EnvPOfxmQ==",
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.5.0.tgz",
"integrity": "sha512-YklQQfe0Rv2PZEueLTUffiQGKQneiIEKKnfIqPIOxgM9lKSZFCjT5Ad4VqRKj/U4+kQE3fa8YQpskViL7WjdPQ==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "7.3.1",
"@typescript-eslint/visitor-keys": "7.3.1",
"@typescript-eslint/types": "7.5.0",
"@typescript-eslint/visitor-keys": "7.5.0",
"debug": "^4.3.4",
"globby": "^11.1.0",
"is-glob": "^4.0.3",
@ -2763,17 +2756,17 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.3.1.tgz",
"integrity": "sha512-jIERm/6bYQ9HkynYlNZvXpzmXWZGhMbrOvq3jJzOSOlKXsVjrrolzWBjDW6/TvT5Q3WqaN4EkmcfdQwi9tDjBQ==",
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.5.0.tgz",
"integrity": "sha512-3vZl9u0R+/FLQcpy2EHyRGNqAS/ofJ3Ji8aebilfJe+fobK8+LbIFmrHciLVDxjDoONmufDcnVSF38KwMEOjzw==",
"dev": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"@types/json-schema": "^7.0.12",
"@types/semver": "^7.5.0",
"@typescript-eslint/scope-manager": "7.3.1",
"@typescript-eslint/types": "7.3.1",
"@typescript-eslint/typescript-estree": "7.3.1",
"@typescript-eslint/scope-manager": "7.5.0",
"@typescript-eslint/types": "7.5.0",
"@typescript-eslint/typescript-estree": "7.5.0",
"semver": "^7.5.4"
},
"engines": {
@ -2788,12 +2781,12 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.3.1.tgz",
"integrity": "sha512-9RMXwQF8knsZvfv9tdi+4D/j7dMG28X/wMJ8Jj6eOHyHWwDW4ngQJcqEczSsqIKKjFiLFr40Mnr7a5ulDD3vmw==",
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.5.0.tgz",
"integrity": "sha512-mcuHM/QircmA6O7fy6nn2w/3ditQkj+SgtOc8DW3uQ10Yfj42amm2i+6F2K4YAOPNNTmE6iM1ynM6lrSwdendA==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "7.3.1",
"@typescript-eslint/types": "7.5.0",
"eslint-visitor-keys": "^3.4.1"
},
"engines": {
@ -4014,19 +4007,19 @@
}
},
"node_modules/eslint-plugin-jest": {
"version": "27.9.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-27.9.0.tgz",
"integrity": "sha512-QIT7FH7fNmd9n4se7FFKHbsLKGQiw885Ds6Y/sxKgCZ6natwCsXdgPOADnYVxN2QrRweF0FZWbJ6S7Rsn7llug==",
"version": "28.2.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-28.2.0.tgz",
"integrity": "sha512-yRDti/a+f+SMSmNTiT9/M/MzXGkitl8CfzUxnpoQcTyfq8gUrXMriVcWU36W1X6BZSUoyUCJrDAWWUA2N4hE5g==",
"dev": true,
"dependencies": {
"@typescript-eslint/utils": "^5.10.0"
"@typescript-eslint/utils": "^6.0.0"
},
"engines": {
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
"node": "^16.10.0 || ^18.12.0 || >=20.0.0"
},
"peerDependencies": {
"@typescript-eslint/eslint-plugin": "^5.0.0 || ^6.0.0 || ^7.0.0",
"eslint": "^7.0.0 || ^8.0.0",
"@typescript-eslint/eslint-plugin": "^6.0.0 || ^7.0.0",
"eslint": "^7.0.0 || ^8.0.0 || ^9.0.0",
"jest": "*"
},
"peerDependenciesMeta": {
@ -4039,16 +4032,16 @@
}
},
"node_modules/eslint-plugin-jest/node_modules/@typescript-eslint/scope-manager": {
"version": "5.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz",
"integrity": "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz",
"integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "5.62.0",
"@typescript-eslint/visitor-keys": "5.62.0"
"@typescript-eslint/types": "6.21.0",
"@typescript-eslint/visitor-keys": "6.21.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
"node": "^16.0.0 || >=18.0.0"
},
"funding": {
"type": "opencollective",
@ -4056,12 +4049,12 @@
}
},
"node_modules/eslint-plugin-jest/node_modules/@typescript-eslint/types": {
"version": "5.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz",
"integrity": "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz",
"integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==",
"dev": true,
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
"node": "^16.0.0 || >=18.0.0"
},
"funding": {
"type": "opencollective",
@ -4069,21 +4062,22 @@
}
},
"node_modules/eslint-plugin-jest/node_modules/@typescript-eslint/typescript-estree": {
"version": "5.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz",
"integrity": "sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz",
"integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "5.62.0",
"@typescript-eslint/visitor-keys": "5.62.0",
"@typescript-eslint/types": "6.21.0",
"@typescript-eslint/visitor-keys": "6.21.0",
"debug": "^4.3.4",
"globby": "^11.1.0",
"is-glob": "^4.0.3",
"semver": "^7.3.7",
"tsutils": "^3.21.0"
"minimatch": "9.0.3",
"semver": "^7.5.4",
"ts-api-utils": "^1.0.1"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
"node": "^16.0.0 || >=18.0.0"
},
"funding": {
"type": "opencollective",
@ -4096,68 +4090,69 @@
}
},
"node_modules/eslint-plugin-jest/node_modules/@typescript-eslint/utils": {
"version": "5.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.62.0.tgz",
"integrity": "sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz",
"integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==",
"dev": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@types/json-schema": "^7.0.9",
"@types/semver": "^7.3.12",
"@typescript-eslint/scope-manager": "5.62.0",
"@typescript-eslint/types": "5.62.0",
"@typescript-eslint/typescript-estree": "5.62.0",
"eslint-scope": "^5.1.1",
"semver": "^7.3.7"
"@eslint-community/eslint-utils": "^4.4.0",
"@types/json-schema": "^7.0.12",
"@types/semver": "^7.5.0",
"@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0",
"@typescript-eslint/typescript-estree": "6.21.0",
"semver": "^7.5.4"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
"node": "^16.0.0 || >=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"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": {
"version": "5.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz",
"integrity": "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz",
"integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "5.62.0",
"eslint-visitor-keys": "^3.3.0"
"@typescript-eslint/types": "6.21.0",
"eslint-visitor-keys": "^3.4.1"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
"node": "^16.0.0 || >=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/eslint-plugin-jest/node_modules/eslint-scope": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
"integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
"node_modules/eslint-plugin-jest/node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"dev": true,
"dependencies": {
"esrecurse": "^4.3.0",
"estraverse": "^4.1.1"
},
"engines": {
"node": ">=8.0.0"
"balanced-match": "^1.0.0"
}
},
"node_modules/eslint-plugin-jest/node_modules/estraverse": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
"integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
"node_modules/eslint-plugin-jest/node_modules/minimatch": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
"integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
"dev": true,
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=4.0"
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/eslint-plugin-prettier": {
@ -5284,9 +5279,9 @@
}
},
"node_modules/lucide-react": {
"version": "0.360.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.360.0.tgz",
"integrity": "sha512-MskvbEsAhD2zxgx/I05vXq1cjFQXrmhL97YFIi4wSaKH793ZMvU/Com4d+DE7OB3QMmZig1fY1q94aTX5skozw==",
"version": "0.365.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.365.0.tgz",
"integrity": "sha512-sJYpPyyzGHI4B3pys+XSFnE4qtSWc68rFnDLxbNNKjkLST5XSx9DNn5+1Z3eFgFiw39PphNRiVBSVb+AL3oKwA==",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0"
}
@ -5528,9 +5523,9 @@
"dev": true
},
"node_modules/msw": {
"version": "2.2.9",
"resolved": "https://registry.npmjs.org/msw/-/msw-2.2.9.tgz",
"integrity": "sha512-MLIFufBe6m9c5rZKlmGl6jl1pjn7cTNiDGEgn5v2iVRs0mz+neE2r7lRyYNzvcp6FbdiUEIRp/Y2O2gRMjO8yQ==",
"version": "2.2.13",
"resolved": "https://registry.npmjs.org/msw/-/msw-2.2.13.tgz",
"integrity": "sha512-ljFf1xZsU0b4zv1l7xzEmC6OZA6yD06hcx0H+dc8V0VypaP3HGYJa1rMLjQbBWl32ptGhcfwcPCWDB1wjmsftw==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
@ -5538,7 +5533,7 @@
"@bundled-es-modules/statuses": "^1.0.1",
"@inquirer/confirm": "^3.0.0",
"@mswjs/cookies": "^1.1.0",
"@mswjs/interceptors": "^0.25.16",
"@mswjs/interceptors": "^0.26.14",
"@open-draft/until": "^2.1.0",
"@types/cookie": "^0.6.0",
"@types/statuses": "^2.0.4",
@ -6266,9 +6261,9 @@
}
},
"node_modules/react-hook-form": {
"version": "7.51.1",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.51.1.tgz",
"integrity": "sha512-ifnBjl+kW0ksINHd+8C/Gp6a4eZOdWyvRv0UBaByShwU8JbVx5hTcTWEcd5VdybvmPTATkVVXk9npXArHmo56w==",
"version": "7.51.2",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.51.2.tgz",
"integrity": "sha512-y++lwaWjtzDt/XNnyGDQy6goHskFualmDlf+jzEZvjvz6KWDf7EboL7pUvRCzPTJd0EOPpdekYaQLEvvG6m6HA==",
"engines": {
"node": ">=12.22.0"
},
@ -6447,9 +6442,9 @@
}
},
"node_modules/react-zoom-pan-pinch": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-3.4.3.tgz",
"integrity": "sha512-x5MFlfAx2D6NTpZu8OISqc2nYn4p+YEaM1p21w7S/VE1wbVzK8vRzTo9Bj1ydufa649MuP7JBRM3vvj1RftFZw==",
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-3.4.4.tgz",
"integrity": "sha512-lGTu7D9lQpYEQ6sH+NSlLA7gicgKRW8j+D/4HO1AbSV2POvKRFzdWQ8eI0r3xmOsl4dYQcY+teV6MhULeg1xBw==",
"engines": {
"node": ">=8",
"npm": ">=5"
@ -7200,9 +7195,9 @@
}
},
"node_modules/tailwindcss": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.1.tgz",
"integrity": "sha512-qAYmXRfk3ENzuPBakNK0SRrUDipP8NQnEY6772uDhflcQz5EhRdD7JNZxyrFHVQNCwULPBn6FNPp9brpO7ctcA==",
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.3.tgz",
"integrity": "sha512-U7sxQk/n397Bmx4JHbJx/iSOOv5G+II3f1kpLpY2QeUv5DcPdcTsYLlusZfq1NthHS1c1cZoyFmmkex1rzke0A==",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
@ -7212,7 +7207,7 @@
"fast-glob": "^3.3.0",
"glob-parent": "^6.0.2",
"is-glob": "^4.0.3",
"jiti": "^1.19.1",
"jiti": "^1.21.0",
"lilconfig": "^2.1.0",
"micromatch": "^4.0.5",
"normalize-path": "^3.0.0",
@ -7392,27 +7387,6 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
"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": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@ -7447,9 +7421,9 @@
}
},
"node_modules/typescript": {
"version": "5.4.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.3.tgz",
"integrity": "sha512-KrPd3PKaCLr78MalgiwJnA25Nm8HAmdwN3mYUYZgG/wizIo9EainNVQI9/yDavtVFRN2h3k8uf3GLHuhDMgEHg==",
"version": "5.4.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.4.tgz",
"integrity": "sha512-dGE2Vv8cpVvw28v8HCPqyb08EzbBURxDpuhJvTrusShUfGnhHBafDsLdS1EhhxyL6BJQE+2cT3dDPAv+MQ6oLw==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
@ -7660,13 +7634,13 @@
}
},
"node_modules/vite": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.2.2.tgz",
"integrity": "sha512-FWZbz0oSdLq5snUI0b6sULbz58iXFXdvkZfZWR/F0ZJuKTSPO7v72QPXt6KqYeMFb0yytNp6kZosxJ96Nr/wDQ==",
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.2.8.tgz",
"integrity": "sha512-OyZR+c1CE8yeHw5V5t59aXsUPPVTHMDjEZz8MgguLL/Q7NblxhZUlTu9xSPqlsUO/y+X7dlU05jdhvyycD55DA==",
"dev": true,
"dependencies": {
"esbuild": "^0.20.1",
"postcss": "^8.4.36",
"postcss": "^8.4.38",
"rollup": "^4.13.0"
},
"bin": {

View File

@ -42,7 +42,7 @@
"hls.js": "^1.5.7",
"idb-keyval": "^6.2.1",
"immer": "^10.0.4",
"lucide-react": "^0.360.0",
"lucide-react": "^0.365.0",
"monaco-yaml": "^5.1.1",
"next-themes": "^0.3.0",
"react": "^18.2.0",
@ -50,14 +50,14 @@
"react-day-picker": "^8.9.1",
"react-device-detect": "^2.2.3",
"react-dom": "^18.2.0",
"react-hook-form": "^7.51.1",
"react-hook-form": "^7.51.2",
"react-icons": "^5.0.1",
"react-router-dom": "^6.22.3",
"react-swipeable": "^7.0.1",
"react-tracked": "^1.7.14",
"react-transition-group": "^4.4.5",
"react-use-websocket": "^4.8.1",
"react-zoom-pan-pinch": "^3.4.3",
"react-zoom-pan-pinch": "^3.4.4",
"recoil": "^0.7.7",
"scroll-into-view-if-needed": "^3.1.0",
"sonner": "^1.4.41",
@ -73,20 +73,20 @@
"devDependencies": {
"@tailwindcss/forms": "^0.5.7",
"@testing-library/jest-dom": "^6.1.5",
"@types/node": "^20.11.30",
"@types/react": "^18.2.67",
"@types/react-dom": "^18.2.22",
"@types/node": "^20.12.5",
"@types/react": "^18.2.74",
"@types/react-dom": "^18.2.24",
"@types/react-icons": "^3.0.0",
"@types/react-transition-group": "^4.4.10",
"@types/strftime": "^0.9.8",
"@typescript-eslint/eslint-plugin": "^7.3.1",
"@typescript-eslint/parser": "^7.3.1",
"@typescript-eslint/eslint-plugin": "^7.5.0",
"@typescript-eslint/parser": "^7.5.0",
"@vitejs/plugin-react-swc": "^3.6.0",
"@vitest/coverage-v8": "^1.4.0",
"autoprefixer": "^10.4.19",
"eslint": "^8.55.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-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
@ -94,12 +94,12 @@
"fake-indexeddb": "^5.0.2",
"jest-websocket-mock": "^2.5.0",
"jsdom": "^24.0.0",
"msw": "^2.2.9",
"msw": "^2.2.13",
"postcss": "^8.4.38",
"prettier": "^3.2.5",
"tailwindcss": "^3.4.1",
"typescript": "^5.4.3",
"vite": "^5.2.2",
"tailwindcss": "^3.4.3",
"typescript": "^5.4.4",
"vite": "^5.2.8",
"vitest": "^1.3.1"
}
}

View File

@ -7,6 +7,7 @@ import { isDesktop, isMobile } from "react-device-detect";
import Statusbar from "./components/Statusbar";
import Bottombar from "./components/navigation/Bottombar";
import { Suspense, lazy } from "react";
import { Redirect } from "./components/navigation/Redirect";
const Live = lazy(() => import("@/pages/Live"));
const Events = lazy(() => import("@/pages/Events"));
@ -35,7 +36,8 @@ function App() {
<Suspense>
<Routes>
<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="/plus" element={<SubmitPlus />} />
<Route path="/system" element={<System />} />

View File

@ -32,7 +32,7 @@ export default function Statusbar() {
const { potentialProblems } = useStats(stats);
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">
{cpuPercent && (
<div className="flex items-center text-sm gap-2">

View File

@ -20,7 +20,7 @@ export function AnimatedEventCard({ event }: AnimatedEventCardProps) {
const navigate = useNavigate();
const onOpenReview = useCallback(() => {
navigate("events", {
navigate("review", {
state: {
severity: event.severity,
recording: {

View File

@ -12,6 +12,7 @@ import { Input } from "../ui/input";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
type ExportProps = {
className: string;
file: {
name: string;
};
@ -19,7 +20,12 @@ type ExportProps = {
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 [hovered, setHovered] = useState(false);
const [playing, setPlaying] = useState(false);
@ -94,7 +100,7 @@ export default function ExportCard({ file, onRename, onDelete }: ExportProps) {
</Dialog>
<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={
isDesktop && !inProgress ? () => setHovered(true) : undefined
}

View File

@ -27,7 +27,9 @@ export default function ReviewCard({
config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p",
);
const isSelected = useMemo(
() => event.start_time <= currentTime && event.end_time >= currentTime,
() =>
event.start_time <= currentTime &&
(event.end_time ?? Date.now() / 1000) >= currentTime,
[event, currentTime],
);

View File

@ -34,7 +34,6 @@ export default function NewReviewData({
? "animate-in slide-in-from-top duration-500"
: "invisible"
} text-center mt-5 mx-auto bg-gray-400 text-white`}
variant="secondary"
onClick={() => {
pullLatestData();
if (contentRef.current) {

View File

@ -131,7 +131,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
size="xs"
onClick={() => setAddGroup(true)}
>
<LuPlus className="size-4 text-primary-foreground" />
<LuPlus className="size-4 text-primary" />
</Button>
)}
</div>
@ -253,7 +253,7 @@ function NewGroupDialog({ open, setOpen, currentGroups }: NewGroupDialogProps) {
{currentGroups.length > 0 && <DropdownMenuSeparator />}
{editState == "none" && (
<Button
className="text-primary-foreground justify-start"
className="text-primary justify-start"
variant="ghost"
onClick={() => setEditState("add")}
>

View File

@ -19,7 +19,7 @@ export default function FilterCheckBox({
}: FilterCheckBoxProps) {
return (
<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"
onClick={() => onCheckedChange(!isChecked)}
>

View 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 />
</>
);
}

View File

@ -40,7 +40,7 @@ export default function ReviewActionGroup({
<div className="p-1">{`${selectedReviews.length} selected`}</div>
<div className="p-1">{"|"}</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}
>
Unselect
@ -50,7 +50,6 @@ export default function ReviewActionGroup({
{selectedReviews.length == 1 && (
<Button
className="p-2 flex items-center gap-2"
variant="secondary"
size="sm"
onClick={() => {
onExport(selectedReviews[0]);
@ -58,28 +57,24 @@ export default function ReviewActionGroup({
}}
>
<FaCompactDisc />
{isDesktop && <div className="text-primary-foreground">Export</div>}
{isDesktop && <div className="text-primary">Export</div>}
</Button>
)}
<Button
className="p-2 flex items-center gap-2"
variant="secondary"
size="sm"
onClick={onMarkAsReviewed}
>
<FaCircleCheck />
{isDesktop && (
<div className="text-primary-foreground">Mark as reviewed</div>
)}
{isDesktop && <div className="text-primary">Mark as reviewed</div>}
</Button>
<Button
className="p-2 flex items-center gap-1"
variant="secondary"
size="sm"
onClick={onDelete}
>
<HiTrash />
{isDesktop && <div className="text-primary-foreground">Delete</div>}
{isDesktop && <div className="text-primary">Delete</div>}
</Button>
</div>
</div>

View File

@ -75,6 +75,9 @@ export default function ReviewFilterGroup({
const cameras = filter?.cameras || Object.keys(config.cameras);
cameras.forEach((camera) => {
if (camera == "birdseye") {
return;
}
const cameraConfig = config.cameras[camera];
cameraConfig.objects.track.forEach((label) => {
labels.add(label);
@ -220,23 +223,31 @@ function CamerasFilterButton({
const trigger = (
<Button
className="flex items-center gap-2 capitalize"
variant="secondary"
variant={selectedCameras?.length == undefined ? "default" : "select"}
size="sm"
>
<FaVideo className="text-secondary-foreground" />
<div className="hidden md:block text-primary-foreground">
<FaVideo
className={`${selectedCameras?.length == 1 ? "text-selected-foreground" : "text-secondary-foreground"}`}
/>
<div
className={`hidden md:block ${selectedCameras?.length ? "text-selected-foreground" : "text-primary"}`}
>
{selectedCameras == undefined
? "All Cameras"
: `${selectedCameras.length} Cameras`}
: `${selectedCameras.includes("birdseye") ? selectedCameras.length - 1 : selectedCameras.length} Camera${selectedCameras.length !== 1 ? "s" : ""}`}
</div>
</Button>
);
const content = (
<>
<DropdownMenuLabel className="flex justify-center">
Filter Cameras
</DropdownMenuLabel>
<DropdownMenuSeparator />
{isMobile && (
<>
<DropdownMenuLabel className="flex justify-center">
Cameras
</DropdownMenuLabel>
<DropdownMenuSeparator />
</>
)}
<div className="h-auto overflow-y-auto overflow-x-hidden">
<FilterCheckBox
isChecked={currentCameras == undefined}
@ -305,7 +316,6 @@ function CamerasFilterButton({
Apply
</Button>
<Button
variant="secondary"
onClick={() => {
setCurrentCameras(undefined);
updateCameraFilter(undefined);
@ -368,7 +378,7 @@ function ShowReviewFilter({
);
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
id="reviewed"
checked={showReviewedSwitch == 1}
@ -376,19 +386,19 @@ function ShowReviewFilter({
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
</Label>
</div>
<Button
className="block md:hidden"
className="block md:hidden duration-0"
variant={showReviewedSwitch == 1 ? "select" : "default"}
size="sm"
variant="secondary"
onClick={() => setShowReviewedSwitch(showReviewedSwitch == 0 ? 1 : 0)}
>
<FaCheckCircle
className={`${showReviewedSwitch == 1 ? "text-selected" : "text-muted-foreground"}`}
className={`${showReviewedSwitch == 1 ? "text-selected-foreground" : "text-secondary-foreground"}`}
/>
</Button>
</>
@ -411,9 +421,17 @@ function CalendarFilterButton({
);
const trigger = (
<Button size="sm" className="flex items-center gap-2" variant="secondary">
<FaCalendarAlt className="text-secondary-foreground" />
<div className="hidden md:block text-primary-foreground">
<Button
className="flex items-center gap-2"
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}
</div>
</Button>
@ -428,7 +446,6 @@ function CalendarFilterButton({
<DropdownMenuSeparator />
<div className="p-2 flex justify-center items-center">
<Button
variant="secondary"
onClick={() => {
updateSelectedDay(undefined);
}}
@ -472,9 +489,19 @@ function GeneralFilterButton({
);
const trigger = (
<Button size="sm" className="flex items-center gap-2" variant="secondary">
<FaFilter className="text-secondary-foreground" />
<div className="hidden md:block text-primary-foreground">Filter</div>
<Button
size="sm"
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>
);
const content = (
@ -546,7 +573,7 @@ export function GeneralFilterContent({
<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-foreground cursor-pointer"
className="mx-2 text-primary cursor-pointer"
htmlFor="allLabels"
>
All Labels
@ -567,7 +594,7 @@ export function GeneralFilterContent({
{allLabels.map((item) => (
<div className="flex justify-between items-center">
<Label
className="w-full mx-2 text-secondary-foreground capitalize cursor-pointer"
className="w-full mx-2 text-primary capitalize cursor-pointer"
htmlFor={item}
>
{item.replaceAll("_", " ")}
@ -617,7 +644,6 @@ export function GeneralFilterContent({
Apply
</Button>
<Button
variant="secondary"
onClick={() => {
setCurrentLabels(undefined);
updateLabelFilter(undefined);
@ -645,7 +671,7 @@ function ShowMotionOnlyButton({
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
className="ml-1"
id="collapse-motion"
@ -653,7 +679,7 @@ function ShowMotionOnlyButton({
onCheckedChange={setMotionOnlyButton}
/>
<Label
className="mx-2 text-primary-foreground cursor-pointer"
className="mx-2 text-primary cursor-pointer"
htmlFor="collapse-motion"
>
Motion only
@ -663,11 +689,12 @@ function ShowMotionOnlyButton({
<div className="block md:hidden">
<Button
size="sm"
variant="secondary"
className="duration-0"
variant={motionOnlyButton ? "select" : "default"}
onClick={() => setMotionOnlyButton(!motionOnlyButton)}
>
<FaRunning
className={`${motionOnlyButton ? "text-selected" : "text-muted-foreground"}`}
className={`${motionOnlyButton ? "text-selected-foreground" : "text-secondary-foreground"}`}
/>
</Button>
</div>

View File

@ -3,6 +3,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
import { Threshold } from "@/types/graph";
import { useCallback, useEffect, useMemo } from "react";
import Chart from "react-apexcharts";
import { isMobileOnly } from "react-device-detect";
import { MdCircle } from "react-icons/md";
import useSWR from "swr";
@ -36,11 +37,11 @@ export function ThresholdBarGraph({
const formatTime = useCallback(
(val: unknown) => {
if (val == 0) {
if (val == 1) {
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([], {
hour12: config?.ui.time_format != "24hour",
hour: "2-digit",
@ -96,10 +97,10 @@ export function ThresholdBarGraph({
size: 0,
},
xaxis: {
tickAmount: 4,
tickAmount: isMobileOnly ? 3 : 4,
tickPlacement: "on",
labels: {
offsetX: -30,
offsetX: -18,
formatter: formatTime,
},
axisBorder: {
@ -110,9 +111,11 @@ export function ThresholdBarGraph({
},
},
yaxis: {
show: false,
show: true,
labels: {
formatter: (val: number) => Math.ceil(val).toString(),
},
min: 0,
max: threshold.warning + 10,
},
} as ApexCharts.ApexOptions;
}, [graphId, threshold, systemTheme, theme, formatTime]);
@ -125,7 +128,7 @@ export function ThresholdBarGraph({
<div className="w-full flex flex-col">
<div className="flex items-center gap-1">
<div className="text-xs text-muted-foreground">{name}</div>
<div className="text-xs text-primary-foreground">
<div className="text-xs text-primary">
{lastValue}
{unit}
</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 justify-between items-center gap-1">
<div className="flex items-center gap-1">
<div className="text-xs text-primary-foreground">
{getUnitSize(used)}
</div>
<div className="text-xs text-primary-foreground">/</div>
<div className="text-xs text-primary">{getUnitSize(used)}</div>
<div className="text-xs text-primary">/</div>
<div className="text-xs text-muted-foreground">
{getUnitSize(total)}
</div>
</div>
<div className="text-xs text-primary-foreground">
<div className="text-xs text-primary">
{Math.round((used / total) * 100)}%
</div>
</div>
@ -278,7 +279,7 @@ export function CameraLineGraph({
const formatTime = useCallback(
(val: unknown) => {
if (val == 0) {
if (val == 1) {
return;
}
@ -326,10 +327,10 @@ export function CameraLineGraph({
size: 0,
},
xaxis: {
tickAmount: 4,
tickPlacement: "between",
tickAmount: isMobileOnly ? 3 : 4,
tickPlacement: "on",
labels: {
offsetX: -30,
offsetX: isMobileOnly ? -18 : 0,
formatter: formatTime,
},
axisBorder: {
@ -340,7 +341,10 @@ export function CameraLineGraph({
},
},
yaxis: {
show: false,
show: true,
labels: {
formatter: (val: number) => Math.ceil(val).toString(),
},
min: 0,
},
} as ApexCharts.ApexOptions;
@ -361,7 +365,7 @@ export function CameraLineGraph({
style={{ color: GRAPH_COLORS[labelIdx] }}
/>
<div className="text-xs text-muted-foreground">{label}</div>
<div className="text-xs text-primary-foreground">
<div className="text-xs text-primary">
{lastValues[labelIdx]}
{unit}
</div>

View File

@ -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";
type ChipProps = {
@ -39,3 +40,35 @@ export default function Chip({
</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>
);
}

View File

@ -1,4 +1,3 @@
import { navbarLinks } from "@/pages/site-navigation";
import NavItem from "./NavItem";
import { IoIosWarning } from "react-icons/io";
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
@ -9,20 +8,15 @@ import { useMemo } from "react";
import useStats from "@/hooks/use-stats";
import GeneralSettings from "../settings/GeneralSettings";
import AccountSettings from "../settings/AccountSettings";
import useNavigation from "@/hooks/use-navigation";
function Bottombar() {
const navItems = useNavigation("secondary");
return (
<div className="absolute h-16 inset-x-4 bottom-0 flex flex-row items-center justify-between">
{navbarLinks.map((item) => (
<NavItem
className=""
variant="secondary"
key={item.id}
Icon={item.icon}
title={item.title}
url={item.url}
dev={item.dev}
/>
{navItems.map((item) => (
<NavItem key={item.id} item={item} Icon={item.icon} />
))}
<GeneralSettings />
<AccountSettings />

View File

@ -1,6 +1,4 @@
import { IconType } from "react-icons";
import { NavLink } from "react-router-dom";
import { ENV } from "@/env";
import {
Tooltip,
TooltipContent,
@ -8,6 +6,8 @@ import {
} from "@/components/ui/tooltip";
import { isDesktop } from "react-device-detect";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import { NavData } from "@/types/navigation";
import { IconType } from "react-icons";
const variants = {
primary: {
@ -21,37 +21,29 @@ const variants = {
};
type NavItemProps = {
className: string;
variant?: "primary" | "secondary";
className?: string;
item: NavData;
Icon: IconType;
title: string;
url: string;
dev?: boolean;
onClick?: () => void;
};
export default function NavItem({
className,
variant = "primary",
item,
Icon,
title,
url,
dev,
onClick,
}: NavItemProps) {
const shouldRender = dev ? ENV !== "production" : true;
if (!shouldRender) {
if (item.enabled == false) {
return;
}
const content = (
<NavLink
to={url}
to={item.url}
onClick={onClick}
className={({ isActive }) =>
`${className} flex flex-col justify-center items-center rounded-lg ${
variants[variant][isActive ? "active" : "inactive"]
`flex flex-col justify-center items-center rounded-lg ${className ?? ""} ${
variants[item.variant ?? "primary"][isActive ? "active" : "inactive"]
}`
}
>
@ -65,7 +57,7 @@ export default function NavItem({
<TooltipTrigger>{content}</TooltipTrigger>
<TooltipPortal>
<TooltipContent side="right">
<p>{title}</p>
<p>{item.title}</p>
</TooltipContent>
</TooltipPortal>
</Tooltip>

View 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 />;
}

View File

@ -1,16 +1,18 @@
import Logo from "../Logo";
import { navbarLinks } from "@/pages/site-navigation";
import NavItem from "./NavItem";
import { CameraGroupSelector } from "../filter/CameraGroupSelector";
import { useLocation } from "react-router-dom";
import GeneralSettings from "../settings/GeneralSettings";
import AccountSettings from "../settings/AccountSettings";
import useNavigation from "@/hooks/use-navigation";
function Sidebar() {
const location = useLocation();
const navbarLinks = useNavigation();
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" />
<div className="w-full flex flex-col gap-0 items-center">
<Logo className="w-8 h-8 mb-6" />
@ -22,10 +24,8 @@ function Sidebar() {
<div key={item.id}>
<NavItem
className={`mx-[10px] ${showCameraGroups ? "mb-2" : "mb-4"}`}
item={item}
Icon={item.icon}
title={item.title}
url={item.url}
dev={item.dev}
/>
{showCameraGroups && <CameraGroupSelector className="mb-4" />}
</div>

View File

@ -64,10 +64,13 @@ export default function ExportDialog({
}
axios
.post(`export/${camera}/start/${range.after}/end/${range.before}`, {
playback: "realtime",
name,
})
.post(
`export/${camera}/start/${Math.round(range.after)}/end/${Math.round(range.before)}`,
{
playback: "realtime",
name,
},
)
.then((response) => {
if (response.status == 200) {
toast.success(
@ -116,14 +119,13 @@ export default function ExportDialog({
<Trigger asChild>
<Button
className="flex items-center gap-2"
variant="secondary"
size="sm"
onClick={() => {
setMode("select");
}}
>
<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>
</Trigger>
<Content
@ -371,7 +373,7 @@ function CustomTimeSelector({
return (
<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 />
<Popover
@ -384,8 +386,8 @@ function CustomTimeSelector({
>
<PopoverTrigger asChild>
<Button
className={isDesktop ? "" : "text-xs"}
variant={startOpen ? "select" : "secondary"}
className={`text-primary ${isDesktop ? "" : "text-xs"}`}
variant={startOpen ? "select" : "default"}
size="sm"
onClick={() => {
setStartOpen(true);
@ -435,7 +437,7 @@ function CustomTimeSelector({
/>
</PopoverContent>
</Popover>
<FaArrowRight className="size-4" />
<FaArrowRight className="size-4 text-primary" />
<Popover
open={endOpen}
onOpenChange={(open) => {
@ -446,8 +448,8 @@ function CustomTimeSelector({
>
<PopoverTrigger asChild>
<Button
className={isDesktop ? "" : "text-xs"}
variant={endOpen ? "select" : "secondary"}
className={`text-primary ${isDesktop ? "" : "text-xs"}`}
variant={endOpen ? "select" : "default"}
size="sm"
onClick={() => {
setEndOpen(true);

View 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]);
}

View File

@ -23,7 +23,7 @@ export default function MobileCameraDrawer({
return (
<Drawer open={cameraDrawer} onOpenChange={setCameraDrawer}>
<DrawerTrigger asChild>
<Button className="rounded-lg capitalize" size="sm" variant="secondary">
<Button className="rounded-lg capitalize" size="sm">
<FaVideo className="text-secondary-foreground" />
</Button>
</DrawerTrigger>

View File

@ -66,10 +66,13 @@ export default function MobileReviewSettingsDrawer({
}
axios
.post(`export/${camera}/start/${range.after}/end/${range.before}`, {
playback: "realtime",
name,
})
.post(
`export/${camera}/start/${Math.round(range.after)}/end/${Math.round(range.before)}`,
{
playback: "realtime",
name,
},
)
.then((response) => {
if (response.status == 200) {
toast.success(
@ -144,18 +147,24 @@ export default function MobileReviewSettingsDrawer({
{features.includes("calendar") && (
<Button
className="w-full flex justify-center items-center gap-2"
variant={filter?.after ? "select" : "default"}
onClick={() => setDrawerMode("calendar")}
>
<FaCalendarAlt className="fill-secondary-foreground" />
<FaCalendarAlt
className={`${filter?.after ? "text-selected-foreground" : "text-secondary-foreground"}`}
/>
Calendar
</Button>
)}
{features.includes("filter") && (
<Button
className="w-full flex justify-center items-center gap-2"
variant={filter?.labels ? "select" : "default"}
onClick={() => setDrawerMode("filter")}
>
<FaFilter className="fill-secondary-foreground" />
<FaFilter
className={`${filter?.labels ? "text-selected-foreground" : "text-secondary-foreground"}`}
/>
Filter
</Button>
)}
@ -217,7 +226,6 @@ export default function MobileReviewSettingsDrawer({
<SelectSeparator />
<div className="p-2 flex justify-center items-center">
<Button
variant="secondary"
onClick={() => {
onUpdateFilter({
...filter,
@ -278,11 +286,13 @@ export default function MobileReviewSettingsDrawer({
<DrawerTrigger asChild>
<Button
className="rounded-lg capitalize"
variant={filter?.labels || filter?.after ? "select" : "default"}
size="sm"
variant="secondary"
onClick={() => setDrawerMode("select")}
>
<FaCog className="text-secondary-foreground" />
<FaCog
className={`${filter?.labels || filter?.after ? "text-selected-foreground" : "text-secondary-foreground"}`}
/>
</Button>
</DrawerTrigger>
<DrawerContent className="max-h-[80dvh] overflow-hidden flex flex-col items-center gap-2 px-4 pb-4 mx-1 rounded-t-2xl">

View File

@ -22,7 +22,7 @@ export default function MobileTimelineDrawer({
return (
<Drawer open={drawer} onOpenChange={setDrawer}>
<DrawerTrigger asChild>
<Button className="rounded-lg capitalize" size="sm" variant="secondary">
<Button className="rounded-lg capitalize" size="sm">
<FaFlag className="text-secondary-foreground" />
</Button>
</DrawerTrigger>

View File

@ -17,7 +17,7 @@ export default function SaveExportOverlay({
return (
<div className={className}>
<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"
} text-center mt-5 mx-auto`}
>
@ -31,9 +31,8 @@ export default function SaveExportOverlay({
Save Export
</Button>
<Button
className="flex items-center gap-1"
className="flex items-center gap-1 text-primary"
size="sm"
variant="secondary"
onClick={onCancel}
>
<LuX />

View File

@ -44,9 +44,7 @@ export default function VainfoDialog({
<ActivityIndicator />
)}
<DialogFooter>
<Button variant="secondary" onClick={() => setShowVainfo(false)}>
Close
</Button>
<Button onClick={() => setShowVainfo(false)}>Close</Button>
<Button variant="select" onClick={() => onCopyVainfo()}>
Copy
</Button>

View File

@ -19,7 +19,6 @@ const unsupportedErrorCodes = [
];
type HlsVideoPlayerProps = {
className: string;
children?: ReactNode;
videoRef: MutableRefObject<HTMLVideoElement | null>;
visible: boolean;
@ -31,7 +30,6 @@ type HlsVideoPlayerProps = {
onPlaying?: () => void;
};
export default function HlsVideoPlayer({
className,
children,
videoRef,
visible,
@ -91,116 +89,118 @@ export default function HlsVideoPlayer({
return (
<TransformWrapper minScale={1.0}>
<div
className={`relative ${className ?? ""} ${visible ? "visible" : "hidden"}`}
onMouseOver={
isDesktop
? () => {
setControls(true);
}
: undefined
}
onMouseOut={
isDesktop
? () => {
setControls(controlsOpen);
}
: undefined
}
onClick={isDesktop ? undefined : () => setControls(!controls)}
<TransformComponent
wrapperStyle={{
position: "relative",
display: visible ? undefined : "none",
width: "100%",
height: "100%",
}}
contentStyle={{
width: "100%",
height: isMobile ? "100%" : undefined,
}}
>
<TransformComponent
wrapperStyle={{
width: "100%",
height: "100%",
}}
contentStyle={{
width: "100%",
height: isMobile ? "100%" : undefined,
}}
>
<video
ref={videoRef}
className={`size-full bg-black rounded-2xl ${loadedMetadata ? "" : "invisible"}`}
preload="auto"
autoPlay
controls={false}
playsInline
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) {
setControls(true);
setMobileCtrlTimeout(
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();
if (isMobile) {
setControls(true);
setMobileCtrlTimeout(setTimeout(() => setControls(false), 4000));
}
}}
onSeek={(diff) => {
const currentTime = videoRef.current?.currentTime;
onPlaying={onPlaying}
onPause={() => {
setIsPlaying(false);
if (!videoRef.current || !currentTime) {
return;
if (isMobile && mobileCtrlTimeout) {
clearTimeout(mobileCtrlTimeout);
}
videoRef.current.currentTime = Math.max(0, currentTime + diff);
}}
onSetPlaybackRate={(rate) =>
videoRef.current ? (videoRef.current.playbackRate = rate) : null
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);
}
}}
/>
{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>
);
}

View File

@ -7,10 +7,8 @@ import MSEPlayer from "./MsePlayer";
import JSMpegPlayer from "./JSMpegPlayer";
import { MdCircle } from "react-icons/md";
import { useCameraActivity } from "@/hooks/use-camera-activity";
import { useRecordingsState } from "@/api/ws";
import { LivePlayerMode } from "@/types/live";
import useCameraLiveMode from "@/hooks/use-camera-live-mode";
import CameraActivityIndicator from "../indicators/CameraActivityIndicator";
type LivePlayerProps = {
cameraRef?: (ref: HTMLDivElement | null) => void;
@ -41,8 +39,7 @@ export default function LivePlayer({
}: LivePlayerProps) {
// camera activity
const { activeMotion, activeAudio, activeTracking } =
useCameraActivity(cameraConfig);
const { activeMotion, activeTracking } = useCameraActivity(cameraConfig);
const cameraActive = useMemo(
() =>
@ -72,8 +69,6 @@ export default function LivePlayer({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cameraActive, liveReady]);
const { payload: recording } = useRecordingsState(cameraConfig.name);
// camera still state
const stillReloadInterval = useMemo(() => {
@ -171,15 +166,8 @@ export default function LivePlayer({
/>
</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">
{recording == "ON" && (
{activeMotion && (
<MdCircle className="size-2 drop-shadow-md shadow-danger text-danger animate-pulse" />
)}
</div>

View File

@ -235,7 +235,7 @@ function PreviewVideoPlayer({
return (
<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}
>
<img
@ -464,7 +464,7 @@ function PreviewFramesPlayer({
return (
<div
className={`relative ${className ?? ""} ${onClick ? "cursor-pointer" : ""}`}
className={`relative w-full flex justify-center ${className ?? ""} ${onClick ? "cursor-pointer" : ""}`}
onClick={onClick}
>
<img

View File

@ -13,18 +13,22 @@ import { getIconForLabel } from "@/utils/iconUtil";
import TimeAgo from "../dynamic/TimeAgo";
import useSWR from "swr";
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 { useFormattedTimestamp } from "@/hooks/use-date-utils";
import useImageLoaded from "@/hooks/use-image-loaded";
import { useSwipeable } from "react-swipeable";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator";
import useContextMenu from "@/hooks/use-contextmenu";
import ActivityIndicator from "../indicators/activity-indicator";
import { TimeRange } from "@/types/timeline";
type PreviewPlayerProps = {
review: ReviewSegment;
allPreviews?: Preview[];
scrollLock?: boolean;
timeRange: TimeRange;
onTimeUpdate?: (time: number | undefined) => void;
setReviewed: (review: ReviewSegment) => void;
onClick: (review: ReviewSegment, ctrl: boolean) => void;
@ -42,6 +46,7 @@ export default function PreviewThumbnailPlayer({
review,
allPreviews,
scrollLock = false,
timeRange,
setReviewed,
onClick,
onTimeUpdate,
@ -69,10 +74,16 @@ export default function PreviewThumbnailPlayer({
});
const handleSetReviewed = useCallback(() => {
review.has_been_reviewed = true;
setReviewed(review);
if (review.end_time && !review.has_been_reviewed) {
review.has_been_reviewed = true;
setReviewed(review);
}
}, [review, setReviewed]);
useContextMenu(imgRef, () => {
onClick(review, true);
});
// playback
const relevantPreview = useMemo(() => {
@ -86,7 +97,7 @@ export default function PreviewThumbnailPlayer({
return false;
}
if (review.end_time > preview.end) {
if ((review.end_time ?? timeRange.before) > preview.end) {
multiHour = true;
}
@ -103,7 +114,8 @@ export default function PreviewThumbnailPlayer({
const firstPrev = allPreviews[firstIndex];
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) {
// the first preview is longer than the second, return the first
@ -118,7 +130,7 @@ export default function PreviewThumbnailPlayer({
return undefined;
}
}, [allPreviews, review]);
}, [allPreviews, review, timeRange]);
// Hover Playback
@ -170,10 +182,6 @@ export default function PreviewThumbnailPlayer({
className="relative size-full cursor-pointer"
onMouseOver={isMobile ? undefined : () => setIsHovered(true)}
onMouseLeave={isMobile ? undefined : () => setIsHovered(false)}
onContextMenu={(e) => {
e.preventDefault();
onClick(review, true);
}}
onClick={handleOnClick}
{...swipeHandlers}
>
@ -182,6 +190,7 @@ export default function PreviewThumbnailPlayer({
<PreviewContent
review={review}
relevantPreview={relevantPreview}
timeRange={timeRange}
setReviewed={handleSetReviewed}
setIgnoreClick={setIgnoreClick}
isPlayingBack={setPlayback}
@ -196,9 +205,18 @@ export default function PreviewThumbnailPlayer({
<div className={`${imgLoaded ? "visible" : "invisible"}`}>
<img
ref={imgRef}
className={`size-full transition-opacity ${
className={`size-full transition-opacity select-none ${
playingBack ? "opacity-0" : "opacity-100"
}`}
style={
isIOS
? {
WebkitUserSelect: "none",
WebkitTouchCallout: "none",
}
: undefined
}
draggable={false}
src={`${apiHost}${review.thumb_path.replace("/media/frigate/", "")}`}
loading={isSafari ? "eager" : "lazy"}
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 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">
<TimeAgo time={review.start_time * 1000} dense />
{review.end_time ? (
<TimeAgo time={review.start_time * 1000} dense />
) : (
<div>
<ActivityIndicator size={24} />
</div>
)}
{formattedDate}
</div>
</div>
@ -260,6 +284,7 @@ export default function PreviewThumbnailPlayer({
type PreviewContentProps = {
review: ReviewSegment;
relevantPreview: Preview | undefined;
timeRange: TimeRange;
setReviewed: () => void;
setIgnoreClick: (ignore: boolean) => void;
isPlayingBack: (ended: boolean) => void;
@ -268,6 +293,7 @@ type PreviewContentProps = {
function PreviewContent({
review,
relevantPreview,
timeRange,
setReviewed,
setIgnoreClick,
isPlayingBack,
@ -278,8 +304,9 @@ function PreviewContent({
if (relevantPreview) {
return (
<VideoPreview
review={review}
relevantPreview={relevantPreview}
startTime={review.start_time}
endTime={review.end_time}
setReviewed={setReviewed}
setIgnoreClick={setIgnoreClick}
isPlayingBack={isPlayingBack}
@ -290,6 +317,7 @@ function PreviewContent({
return (
<InProgressPreview
review={review}
timeRange={timeRange}
setReviewed={setReviewed}
setIgnoreClick={setIgnoreClick}
isPlayingBack={isPlayingBack}
@ -301,16 +329,18 @@ function PreviewContent({
const PREVIEW_PADDING = 16;
type VideoPreviewProps = {
review: ReviewSegment;
relevantPreview: Preview;
startTime: number;
endTime?: number;
setReviewed: () => void;
setIgnoreClick: (ignore: boolean) => void;
isPlayingBack: (ended: boolean) => void;
onTimeUpdate?: (time: number | undefined) => void;
};
function VideoPreview({
review,
relevantPreview,
startTime,
endTime,
setReviewed,
setIgnoreClick,
isPlayingBack,
@ -329,16 +359,13 @@ function VideoPreview({
}
// start with a bit of padding
return Math.max(
0,
review.start_time - relevantPreview.start - PREVIEW_PADDING,
);
return Math.max(0, startTime - relevantPreview.start - PREVIEW_PADDING);
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const playerDuration = useMemo(
() => review.end_time - review.start_time + PREVIEW_PADDING,
() => (endTime ?? relevantPreview.end) - startTime + PREVIEW_PADDING,
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
@ -379,21 +406,14 @@ function VideoPreview({
// end with a bit of padding
const playerPercent = (playerProgress / playerDuration) * 100;
if (
setReviewed &&
!review.has_been_reviewed &&
lastPercent < 50 &&
playerPercent > 50
) {
if (setReviewed && lastPercent < 50 && playerPercent > 50) {
setReviewed();
}
setLastPercent(playerPercent);
if (playerPercent > 100) {
if (!review.has_been_reviewed) {
setReviewed();
}
setReviewed();
if (isMobile) {
isPlayingBack(false);
@ -458,7 +478,7 @@ function VideoPreview({
setIgnoreClick(true);
}
if (setReviewed && !review.has_been_reviewed) {
if (setReviewed) {
setReviewed();
}
@ -541,6 +561,7 @@ function VideoPreview({
const MIN_LOAD_TIMEOUT_MS = 200;
type InProgressPreviewProps = {
review: ReviewSegment;
timeRange: TimeRange;
setReviewed: (reviewId: string) => void;
setIgnoreClick: (ignore: boolean) => void;
isPlayingBack: (ended: boolean) => void;
@ -548,6 +569,7 @@ type InProgressPreviewProps = {
};
function InProgressPreview({
review,
timeRange,
setReviewed,
setIgnoreClick,
isPlayingBack,
@ -557,7 +579,7 @@ function InProgressPreview({
const sliderRef = useRef<HTMLDivElement | null>(null);
const { data: previewFrames } = useSWR<string[]>(
`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`,
{ revalidateOnFocus: false },
);

View File

@ -142,10 +142,10 @@ export default function VideoControls({
return (
<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 && (
<div className="flex justify-normal items-center gap-2">
<div className="flex justify-normal items-center gap-2 cursor-pointer">
<VolumeIcon
className="size-5"
onClick={(e: React.MouseEvent) => {
@ -170,9 +170,9 @@ export default function VideoControls({
)}
<div className="cursor-pointer" onClick={onTogglePlay}>
{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>
{features.seek && (

View File

@ -150,7 +150,6 @@ export default function DynamicVideoPlayer({
return (
<>
<HlsVideoPlayer
className={className ?? ""}
videoRef={playerRef}
visible={!(isScrubbing || isLoading)}
currentSource={source}

View File

@ -142,7 +142,7 @@ export default function GeneralSettings({ className }: GeneralSettings) {
className={
isDesktop
? "cursor-pointer"
: "p-2 flex items-center text-sm"
: "w-full p-2 flex items-center text-sm"
}
>
<LuActivity className="mr-2 size-4" />
@ -154,7 +154,7 @@ export default function GeneralSettings({ className }: GeneralSettings) {
className={
isDesktop
? "cursor-pointer"
: "p-2 flex items-center text-sm"
: "w-full p-2 flex items-center text-sm"
}
>
<LuList className="mr-2 size-4" />
@ -172,7 +172,7 @@ export default function GeneralSettings({ className }: GeneralSettings) {
className={
isDesktop
? "cursor-pointer"
: "p-2 flex items-center text-sm"
: "w-full p-2 flex items-center text-sm"
}
>
<LuSettings className="mr-2 size-4" />
@ -184,7 +184,7 @@ export default function GeneralSettings({ className }: GeneralSettings) {
className={
isDesktop
? "cursor-pointer"
: "p-2 flex items-center text-sm"
: "w-full p-2 flex items-center text-sm"
}
>
<LuPenSquare className="mr-2 size-4" />

View File

@ -100,8 +100,10 @@ export function MotionReviewTimeline({
const overlappingReviewItems = events.some(
(item) =>
(item.start_time >= motionStart && item.start_time < motionEnd) ||
(item.end_time > motionStart && item.end_time <= motionEnd) ||
(item.start_time <= motionStart && item.end_time >= motionEnd),
((item.end_time ?? timelineStart) > motionStart &&
(item.end_time ?? timelineStart) <= motionEnd) ||
(item.start_time <= motionStart &&
(item.end_time ?? timelineStart) >= motionEnd),
);
if ((!segmentMotion || overlappingReviewItems) && motionOnly) {

View File

@ -107,6 +107,7 @@ export function ReviewTimeline({
showDraggableElement: showHandlebar,
draggableElementTime: handlebarTime,
setDraggableElementTime: setHandlebarTime,
alignSetTimeToSegment: true,
initialScrollIntoViewOnly: onlyInitialHandlebarScroll,
timelineDuration,
timelineCollapsed: timelineCollapsed,
@ -132,7 +133,6 @@ export function ReviewTimeline({
draggableElementTime: exportStartTime,
draggableElementLatestTime: paddedExportEndTime,
setDraggableElementTime: setExportStartTime,
alignSetTimeToSegment: true,
timelineDuration,
timelineStartAligned,
isDragging: isDraggingExportStart,
@ -157,7 +157,6 @@ export function ReviewTimeline({
draggableElementTime: exportEndTime,
draggableElementEarliestTime: paddedExportStartTime,
setDraggableElementTime: setExportEndTime,
alignSetTimeToSegment: true,
timelineDuration,
timelineStartAligned,
isDragging: isDraggingExportEnd,

View File

@ -355,7 +355,7 @@ export function SummaryTimeline({
ref={visibleSectionRef}
onMouseDown={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"
}`}
></div>

View File

@ -32,7 +32,7 @@ export function MinimapBounds({
<>
{isFirstSegmentInMinimap && (
<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}
>
{new Date(alignedMinimapStartTime * 1000).toLocaleTimeString([], {
@ -44,7 +44,7 @@ export function MinimapBounds({
)}
{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([], {
hour: "2-digit",
minute: "2-digit",

View File

@ -1,7 +1,7 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
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",
@ -9,7 +9,7 @@ const badgeVariants = cva(
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
"border-transparent bg-primary text-primary hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
@ -20,8 +20,8 @@ const badgeVariants = cva(
defaultVariants: {
variant: "default",
},
}
)
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
@ -30,7 +30,7 @@ export interface BadgeProps
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
);
}
export { Badge, badgeVariants }
export { Badge, badgeVariants };

View File

@ -9,14 +9,13 @@ const buttonVariants = cva(
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
select: "bg-selected text-white hover:bg-opacity-90",
default: "bg-secondary text-primary hover:bg-secondary/80",
select: "bg-selected text-selected-foreground hover:bg-opacity-90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
secondary: "bg-primary text-primary-foreground hover:bg-primary/90",
ghost:
"text-muted-foreground hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
@ -33,7 +32,7 @@ const buttonVariants = cva(
variant: "default",
size: "default",
},
}
},
);
export interface ButtonProps
@ -52,7 +51,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
{...props}
/>
);
}
},
);
Button.displayName = "Button";

View File

@ -15,13 +15,13 @@ const Toaster = ({ ...props }: ToasterProps) => {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
success:
"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}

View File

@ -1,8 +1,4 @@
import {
useAudioActivity,
useFrigateEvents,
useMotionActivity,
} from "@/api/ws";
import { useFrigateEvents, useMotionActivity } from "@/api/ws";
import { CameraConfig } from "@/types/frigateConfig";
import { MotionData, ReviewSegment } from "@/types/review";
import { useEffect, useMemo, useState } from "react";
@ -11,7 +7,6 @@ import { useTimelineUtils } from "./use-timeline-utils";
type useCameraActivityReturn = {
activeTracking: boolean;
activeMotion: boolean;
activeAudio: boolean;
};
export function useCameraActivity(
@ -25,7 +20,6 @@ export function useCameraActivity(
const { payload: detectingMotion } = useMotionActivity(camera.name);
const { payload: event } = useFrigateEvents();
const { payload: audioRms } = useAudioActivity(camera.name);
useEffect(() => {
if (!event) {
@ -63,9 +57,6 @@ export function useCameraActivity(
return {
activeTracking: hasActiveObjects,
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(
(item) =>
(item.start_time >= motionStart && item.start_time < motionEnd) ||
(item.end_time > motionStart && item.end_time <= motionEnd) ||
(item.start_time <= motionStart && item.end_time >= motionEnd),
((item.end_time ?? Date.now() / 1000) > motionStart &&
(item.end_time ?? Date.now() / 1000) <= motionEnd) ||
(item.start_time <= motionStart &&
(item.end_time ?? Date.now() / 1000) >= motionEnd),
);
if (!segmentMotion || overlappingReviewItems) {

View 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]);
}

View File

@ -323,21 +323,21 @@ function useDraggableElement({
}
}
const setTime = alignSetTimeToSegment
? targetSegmentId
: targetSegmentId + segmentDuration * (offset / segmentHeight);
updateDraggableElementPosition(
newElementPosition,
targetSegmentId,
setTime,
false,
false,
);
if (setDraggableElementTime) {
if (alignSetTimeToSegment) {
setDraggableElementTime(targetSegmentId);
} else {
setDraggableElementTime(
targetSegmentId + segmentDuration * (offset / segmentHeight),
);
}
setDraggableElementTime(
targetSegmentId + segmentDuration * (offset / segmentHeight),
);
}
if (draggingAtTopEdge || draggingAtBottomEdge) {

View 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],
);
}

View File

@ -18,6 +18,12 @@ export default function useStats(stats: FrigateStats | undefined) {
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
Object.entries(stats["detectors"]).forEach(([key, det]) => {
if (det["inference_speed"] > InferenceThreshold.error) {

View File

@ -17,6 +17,10 @@ type SaveOptions = "saveonly" | "restart";
function ConfigEditor() {
const apiHost = useApiHost();
useEffect(() => {
document.title = "Config Editor - Frigate";
}, []);
const { data: config } = useSWR<string>("config/raw");
const { theme, systemTheme } = useTheme();

View File

@ -29,10 +29,20 @@ export default function Events() {
"severity",
"alert",
);
const [recording, setRecording] =
useOverlayState<RecordingStartingPoint>("recording");
const [startTime, setStartTime] = useState<number>();
useEffect(() => {
if (recording) {
document.title = "Recordings - Frigate";
} else {
document.title = `Review - Frigate`;
}
}, [recording, severity]);
// review filter
const [reviewFilter, setReviewFilter, reviewSearchParams] =
@ -204,7 +214,7 @@ export default function Events() {
const newData = [...data];
newData.forEach((seg) => {
if (seg.severity == severity) {
if (seg.end_time && seg.severity == severity) {
seg.has_been_reviewed = true;
}
});
@ -214,10 +224,16 @@ export default function Events() {
{ revalidate: false, populateCache: true },
);
await axios.post(`reviews/viewed`, {
ids: currentItems?.map((seg) => seg.id),
});
reloadData();
const itemsToMarkReviewed = currentItems
?.filter((seg) => seg.end_time)
?.map((seg) => seg.id);
if (itemsToMarkReviewed.length > 0) {
await axios.post(`reviews/viewed`, {
ids: itemsToMarkReviewed,
});
reloadData();
}
},
[reloadData, updateSegments],
);

View File

@ -10,8 +10,9 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import axios from "axios";
import { useCallback, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import useSWR from "swr";
type ExportItem = {
@ -19,24 +20,34 @@ type ExportItem = {
};
function Export() {
const { data: exports, mutate } = useSWR<ExportItem[]>(
const { data: allExports, mutate } = useSWR<ExportItem[]>(
"exports/",
(url: string) => axios({ baseURL: baseUrl, url }).then((res) => res.data),
);
const [deleteClip, setDeleteClip] = useState<string | undefined>();
useEffect(() => {
document.title = "Export - Frigate";
}, []);
const onHandleRename = useCallback(
(original: string, update: string) => {
axios.patch(`export/${original}/${update}`).then((response) => {
if (response.status == 200) {
setDeleteClip(undefined);
mutate();
}
});
},
[mutate],
);
// Search
const [search, setSearch] = useState("");
const exports = useMemo(() => {
if (!search || !allExports) {
return allExports;
}
return allExports.filter((exp) =>
exp.name
.toLowerCase()
.includes(search.toLowerCase().replaceAll(" ", "_")),
);
}, [allExports, search]);
// Deleting
const [deleteClip, setDeleteClip] = useState<string | undefined>();
const onHandleDelete = useCallback(() => {
if (!deleteClip) {
@ -51,8 +62,22 @@ function Export() {
});
}, [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 (
<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
open={deleteClip != undefined}
onOpenChange={() => setDeleteClip(undefined)}
@ -73,12 +98,24 @@ function Export() {
</AlertDialogContent>
</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">
{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">
{Object.values(exports).map((item) => (
{Object.values(allExports).map((item) => (
<ExportCard
key={item.name}
className={
search == "" || exports.includes(item) ? "" : "hidden"
}
file={item}
onRename={onHandleRename}
onDelete={(file) => setDeleteClip(file)}

View File

@ -6,18 +6,37 @@ import { FrigateConfig } from "@/types/frigateConfig";
import LiveBirdseyeView from "@/views/live/LiveBirdseyeView";
import LiveCameraView from "@/views/live/LiveCameraView";
import LiveDashboardView from "@/views/live/LiveDashboardView";
import { useMemo } from "react";
import { useEffect, useMemo } from "react";
import useSWR from "swr";
function Live() {
const { data: config } = useSWR<FrigateConfig>("config");
// selection
const [selectedCameraName, setSelectedCameraName] = useHashState();
const [cameraGroup] = usePersistedOverlayState(
"cameraGroup",
"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(() => {
if (config && cameraGroup && cameraGroup != "default") {
return config.camera_groups[cameraGroup].cameras.includes("birdseye");

View File

@ -3,10 +3,14 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { LogData, LogLine, LogSeverity } from "@/types/log";
import copy from "copy-to-clipboard";
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 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;
type LogType = (typeof logTypes)[number];
@ -17,7 +21,7 @@ const frigateDateStamp = /\[[\d\s-:]*]/;
const frigateSeverity = /(DEBUG)|(INFO)|(WARNING)|(ERROR)/;
const frigateSection = /[\w.]*/;
const goSeverity = /(DEB )|(INF )|(WARN )|(ERR )/;
const goSeverity = /(DEB )|(INF )|(WRN )|(ERR )/;
const goSection = /\[[\w]*]/;
const ngSeverity = /(GET)|(POST)|(PUT)|(PATCH)|(DELETE)/;
@ -25,6 +29,10 @@ const ngSeverity = /(GET)|(POST)|(PUT)|(PATCH)|(DELETE)/;
function Logs() {
const [logService, setLogService] = useState<LogType>("frigate");
useEffect(() => {
document.title = `${logService[0].toUpperCase()}${logService.substring(1)} Stats - Frigate`;
}, [logService]);
// log data handling
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(
@ -154,9 +167,28 @@ function Logs() {
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 {
dateStamp: line.substring(0, 19),
severity: "INFO",
severity: severityCat,
section: section,
content: line.substring(contentStart).trim(),
};
@ -171,7 +203,7 @@ function Logs() {
return {
dateStamp: line.substring(0, 19),
severity: "INFO",
severity: "info",
section: ngSeverity.exec(line)?.at(0)?.toString() ?? "META",
content: line.substring(line.indexOf(" ", 20)).trim(),
};
@ -185,8 +217,15 @@ function Logs() {
const handleCopyLogs = useCallback(() => {
if (logs) {
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
@ -279,8 +318,19 @@ function Logs() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [logLines, logService]);
// log filtering
const [filterSeverity, setFilterSeverity] = useState<LogSeverity[]>();
// log selection
const [selectedLog, setSelectedLog] = useState<LogLine>();
return (
<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">
<ToggleGroup
className="*:px-3 *:py-4 *:rounded-md"
@ -290,6 +340,7 @@ function Logs() {
onValueChange={(value: LogType) => {
if (value) {
setLogs([]);
setFilterSeverity(undefined);
setLogService(value);
}
}} // don't allow the severity to be unselected
@ -301,26 +352,31 @@ function Logs() {
value={item}
aria-label={`Select ${item}`}
>
<div className="capitalize">{`${item} Logs`}</div>
<div className="capitalize">{item}</div>
</ToggleGroupItem>
))}
</ToggleGroup>
<div>
<div className="flex items-center gap-2">
<Button
className="flex justify-between items-center gap-2"
size="sm"
onClick={handleCopyLogs}
>
<LuCopy />
<div className="hidden md:block">Copy to Clipboard</div>
<FaCopy />
<div className="hidden md:block text-primary">
Copy to Clipboard
</div>
</Button>
<LogLevelFilterButton
selectedLabels={filterSeverity}
updateLabelFilter={setFilterSeverity}
/>
</div>
</div>
{initialScroll && !endVisible && (
<Button
className="absolute bottom-8 left-[50%] -translate-x-[50%] rounded-xl bg-accent-foreground text-white bg-gray-400 z-20 p-2"
variant="secondary"
className="absolute bottom-8 left-[50%] -translate-x-[50%] rounded-md text-primary bg-secondary-foreground z-20 p-2"
onClick={() =>
contentRef.current?.scrollTo({
top: contentRef.current?.scrollHeight,
@ -332,48 +388,61 @@ function Logs() {
</Button>
)}
<div
ref={contentRef}
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="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">
<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">
<div className="grid grid-cols-5 sm:grid-cols-8 md:grid-cols-12 *:px-2 *:py-3 *:text-sm *:text-primary/40">
<div className="p-1 flex items-center capitalize">Type</div>
<div className="col-span-2 sm:col-span-1 flex items-center">
Timestamp
</div>
<div className="col-span-2 flex items-center border-y border-l border-r sm:border-r-0">
Tag
</div>
<div className="col-span-5 sm:col-span-4 md:col-span-8 flex items-center border">
<div className="col-span-2 flex items-center">Tag</div>
<div className="col-span-5 sm:col-span-4 md:col-span-8 flex items-center">
Message
</div>
</div>
{logLines.length > 0 &&
[...Array(logRange.end).keys()].map((idx) => {
const logLine =
idx >= logRange.start
? logLines[idx - logRange.start]
: undefined;
<div
ref={contentRef}
className="w-full flex flex-col overflow-y-auto no-scrollbar"
>
{logLines.length > 0 &&
[...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 (
<LogLineData
<div
key={`${idx}-${logService}`}
startRef={
idx == logRange.start + 10 ? startLogRef : undefined
}
className={initialScroll ? "" : "invisible"}
offset={idx}
line={logLines[idx - logRange.start]}
className={isDesktop ? "h-12" : "h-16"}
/>
);
}
return <div key={`${idx}-${logService}`} className="h-12" />;
})}
{logLines.length > 0 && <div id="page-bottom" ref={endLogRef} />}
})}
{logLines.length > 0 && <div id="page-bottom" ref={endLogRef} />}
</div>
</div>
</div>
);
@ -383,70 +452,37 @@ type LogLineDataProps = {
startRef?: (node: HTMLDivElement | null) => void;
className: string;
line: LogLine;
offset: number;
onClickSeverity: () => void;
onSelect: () => void;
};
function LogLineData({ startRef, className, line, offset }: LogLineDataProps) {
// long log message
const contentRef = useRef<HTMLDivElement | null>(null);
const [expanded, setExpanded] = useState(false);
const contentOverflows = useMemo(() => {
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]);
function LogLineData({
startRef,
className,
line,
onClickSeverity,
onSelect,
}: LogLineDataProps) {
return (
<div
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
className={`h-full p-1 flex items-center gap-2 capitalize ${severityClassName}`}
>
{line.severity == "error" ? (
<GoAlertFill className="size-5" />
) : (
<IoIosAlert className="size-5" />
)}
{line.severity}
<div className="h-full p-1 flex items-center gap-2">
<LogChip severity={line.severity} onClickSeverity={onClickSeverity} />
</div>
<div className="h-full col-span-2 sm:col-span-1 flex items-center">
{line.dateStamp}
</div>
<div className="h-full col-span-2 flex items-center overflow-hidden text-ellipsis">
{line.section}
<div className="size-full pr-2 col-span-2 flex items-center">
<div className="w-full overflow-hidden whitespace-nowrap text-ellipsis">
{line.section}
</div>
</div>
<div className="w-full col-span-5 sm:col-span-4 md:col-span-8 flex justify-between items-center">
<div
ref={contentRef}
className={`w-[94%] flex items-center" ${expanded ? "" : "overflow-hidden whitespace-nowrap text-ellipsis"}`}
>
<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 className="w-full overflow-hidden whitespace-nowrap text-ellipsis">
{line.content}
</div>
{contentOverflows && (
<Button className="mr-4" onClick={() => setExpanded(!expanded)}>
...
</Button>
)}
</div>
</div>
);

View File

@ -1,6 +1,11 @@
import Heading from "@/components/ui/heading";
import { useEffect } from "react";
function NoMatch() {
useEffect(() => {
document.title = "Not Found - Frigate";
}, []);
return (
<>
<Heading as="h2">404</Heading>

View File

@ -20,7 +20,7 @@ import {
import { Event } from "@/types/event";
import { FrigateConfig } from "@/types/frigateConfig";
import axios from "axios";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { isMobile } from "react-device-detect";
import { FaList, FaVideo } from "react-icons/fa";
import useSWR from "swr";
@ -28,6 +28,10 @@ import useSWR from "swr";
export default function SubmitPlus() {
const { data: config } = useSWR<FrigateConfig>("config");
useEffect(() => {
document.title = "Plus - Frigate";
}, []);
// filters
const [selectedCameras, setSelectedCameras] = useState<string[]>();
@ -135,6 +139,7 @@ export default function SubmitPlus() {
This is a {upload?.label}
</Button>
<Button
className="text-white"
variant="destructive"
onClick={() => onSubmitToPlus(true)}
>
@ -236,9 +241,9 @@ function PlusFilterGroup({
}}
>
<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" />
<div className="hidden md:block text-primary-foreground">
<div className="hidden md:block text-primary">
{selectedCameras == undefined
? "All Cameras"
: `${selectedCameras.length} Cameras`}
@ -313,9 +318,9 @@ function PlusFilterGroup({
}}
>
<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" />
<div className="hidden md:block text-primary-foreground">
<div className="hidden md:block text-primary">
{selectedLabels == undefined
? "All Labels"
: `${selectedLabels.length} Labels`}

View File

@ -1,6 +1,6 @@
import useSWR from "swr";
import { FrigateStats } from "@/types/stats";
import { useState } from "react";
import { useEffect, useState } from "react";
import TimeAgo from "@/components/dynamic/TimeAgo";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { isDesktop, isMobile } from "react-device-detect";
@ -22,6 +22,10 @@ function System() {
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
const [lastUpdated, setLastUpdated] = useState<number>(Date.now() / 1000);
useEffect(() => {
document.title = `${pageToggle[0].toUpperCase()}${pageToggle.substring(1)} Stats - Frigate`;
}, [pageToggle]);
// stats collection
const { data: statsSnapshot } = useSWR<FrigateStats>("stats", {

View File

@ -318,7 +318,11 @@ function UIPlayground() {
<CameraActivityIndicator />
</div>
<p>
<Button onClick={handleZoomOut} disabled={zoomLevel === 0}>
<Button
variant="default"
onClick={handleZoomOut}
disabled={zoomLevel === 0}
>
Zoom Out
</Button>
<Button

View File

@ -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,
},
];

View 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;
};

View File

@ -3,7 +3,7 @@ export interface ReviewSegment {
camera: string;
severity: ReviewSeverity;
start_time: number;
end_time: number;
end_time?: number;
thumb_path: string;
has_been_reviewed: boolean;
data: ReviewData;

View File

@ -47,7 +47,7 @@ export type ServiceStats = {
last_updated: number;
storage: { [path: string]: StorageStats };
temperatures: { [apex: string]: number };
update: number;
uptime: number;
latest_version: string;
version: string;
};

View File

@ -43,6 +43,7 @@ import { TimeRange } from "@/types/timeline";
import { useCameraMotionNextTimestamp } from "@/hooks/use-camera-activity";
import useOptimisticState from "@/hooks/use-optimistic-state";
import { Skeleton } from "@/components/ui/skeleton";
import scrollIntoView from "scroll-into-view-if-needed";
type EventViewProps = {
reviews?: ReviewSegment[];
@ -289,10 +290,12 @@ export default function EventView({
reviewItems={reviewItems}
relevantPreviews={relevantPreviews}
selectedReviews={selectedReviews}
itemsToReview={reviewCounts[severity]}
itemsToReview={reviewCounts[severityToggle]}
severity={severity}
filter={filter}
timeRange={timeRange}
startTime={startTime}
loading={severity != severityToggle}
markItemAsReviewed={markItemAsReviewed}
markAllItemsAsReviewed={markAllItemsAsReviewed}
onSelectReview={onSelectReview}
@ -331,6 +334,8 @@ type DetectionReviewProps = {
severity: ReviewSeverity;
filter?: ReviewFilter;
timeRange: { before: number; after: number };
startTime?: number;
loading: boolean;
markItemAsReviewed: (review: ReviewSegment) => void;
markAllItemsAsReviewed: (currentItems: ReviewSegment[]) => void;
onSelectReview: (review: ReviewSegment, ctrl: boolean) => void;
@ -345,6 +350,8 @@ function DetectionReview({
severity,
filter,
timeRange,
startTime,
loading,
markItemAsReviewed,
markAllItemsAsReviewed,
onSelectReview,
@ -495,6 +502,26 @@ function DetectionReview({
[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 (
<>
<div
@ -506,7 +533,7 @@ function DetectionReview({
className="absolute left-1/2 -translate-x-1/2 z-50 pointer-events-none"
contentRef={contentRef}
reviewItems={currentItems}
itemsToReview={itemsToReview}
itemsToReview={loading ? 0 : itemsToReview}
pullLatestData={pullLatestData}
/>
)}
@ -517,7 +544,7 @@ function DetectionReview({
</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">
<LuFolderCheck className="size-16" />
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"
ref={contentRef}
>
{currentItems &&
currentItems.map((value) => {
const selected = selectedReviews.includes(value.id);
{!loading && currentItems
? currentItems.map((value) => {
const selected = selectedReviews.includes(value.id);
return (
<div
key={value.id}
ref={minimapRef}
data-start={value.start_time}
data-segment-start={
alignStartDateToTimeline(value.start_time) - segmentDuration
}
className="review-item relative rounded-lg"
>
<div className="aspect-video rounded-lg overflow-hidden">
<PreviewThumbnailPlayer
review={value}
allPreviews={relevantPreviews}
setReviewed={markItemAsReviewed}
scrollLock={scrollLock}
onTimeUpdate={onPreviewTimeUpdate}
onClick={onSelectReview}
return (
<div
key={value.id}
ref={minimapRef}
data-start={value.start_time}
data-segment-start={
alignStartDateToTimeline(value.start_time) -
segmentDuration
}
className="review-item relative rounded-lg"
>
<div className="aspect-video rounded-lg overflow-hidden">
<PreviewThumbnailPlayer
review={value}
allPreviews={relevantPreviews}
timeRange={timeRange}
setReviewed={markItemAsReviewed}
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
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>
);
})}
{(currentItems?.length ?? 0) > 0 && (itemsToReview ?? 0) > 0 && (
<div className="col-span-full flex justify-center items-center">
<Button
className="text-white"
variant="select"
onClick={() => {
markAllItemsAsReviewed(currentItems ?? []);
}}
>
Mark these items as reviewed
</Button>
</div>
)}
);
})
: Array(itemsToReview)
.fill(0)
.map(() => <Skeleton className="size-full aspect-video" />)}
{!loading &&
(currentItems?.length ?? 0) > 0 &&
(itemsToReview ?? 0) > 0 && (
<div className="col-span-full flex justify-center items-center">
<Button
className="text-white"
variant="select"
onClick={() => {
markAllItemsAsReviewed(currentItems ?? []);
}}
>
Mark these items as reviewed
</Button>
</div>
)}
</div>
</div>
<div className="w-[65px] md:w-[110px] flex flex-row">
<div className="w-[55px] md:w-[100px] overflow-y-auto no-scrollbar">
<EventReviewTimeline
segmentDuration={segmentDuration}
timestampSpread={15}
timelineStart={timeRange.before}
timelineEnd={timeRange.after}
showMinimap={showMinimap && !previewTime}
minimapStartTime={minimapBounds.start}
minimapEndTime={minimapBounds.end}
showHandlebar={previewTime != undefined}
handlebarTime={previewTime}
visibleTimestamps={visibleTimestamps}
events={reviewItems?.all ?? []}
severityType={severity}
contentRef={contentRef}
timelineRef={reviewTimelineRef}
dense={isMobile}
/>
{loading ? (
<Skeleton className="size-full" />
) : (
<EventReviewTimeline
segmentDuration={segmentDuration}
timestampSpread={15}
timelineStart={timeRange.before}
timelineEnd={timeRange.after}
showMinimap={showMinimap && !previewTime}
minimapStartTime={minimapBounds.start}
minimapEndTime={minimapBounds.end}
showHandlebar={previewTime != undefined}
handlebarTime={previewTime}
visibleTimestamps={visibleTimestamps}
events={reviewItems?.all ?? []}
severityType={severity}
contentRef={contentRef}
timelineRef={reviewTimelineRef}
dense={isMobile}
/>
)}
</div>
<div className="w-[10px]">
<SummaryTimeline
reviewTimelineRef={reviewTimelineRef}
timelineStart={timeRange.before}
timelineEnd={timeRange.after}
segmentDuration={segmentDuration}
events={reviewItems?.all ?? []}
severityType={severity}
/>
{loading ? (
<Skeleton className="w-full" />
) : (
<SummaryTimeline
reviewTimelineRef={reviewTimelineRef}
timelineStart={timeRange.before}
timelineEnd={timeRange.after}
segmentDuration={segmentDuration}
events={reviewItems?.all ?? []}
severityType={severity}
/>
)}
</div>
</div>
</>
@ -787,16 +829,18 @@ function MotionReview({
} else {
const segmentStartTime = alignStartDateToTimeline(currentTime);
const segmentEndTime = segmentStartTime + segmentDuration;
const matchingItem = reviewItems?.all.find(
(item) =>
const matchingItem = reviewItems?.all.find((item) => {
const endTime = item.end_time ?? timeRange.before;
return (
((item.start_time >= segmentStartTime &&
item.start_time < segmentEndTime) ||
(item.end_time > segmentStartTime &&
item.end_time <= segmentEndTime) ||
(endTime > segmentStartTime && endTime <= segmentEndTime) ||
(item.start_time <= segmentStartTime &&
item.end_time >= segmentEndTime)) &&
item.camera === cameraName,
);
endTime >= segmentEndTime)) &&
item.camera === cameraName
);
});
return matchingItem ? matchingItem.severity : null;
}
@ -805,6 +849,7 @@ function MotionReview({
reviewItems,
motionData,
currentTime,
timeRange,
motionOnly,
alignStartDateToTimeline,
],
@ -853,7 +898,10 @@ function MotionReview({
onClick={() =>
onOpenRecording({
camera: camera.name,
startTime: currentTime,
startTime: Math.min(
currentTime,
Date.now() / 1000 - 30,
),
severity: "significant_motion",
})
}

View File

@ -38,6 +38,8 @@ import MobileCameraDrawer from "@/components/overlay/MobileCameraDrawer";
import MobileTimelineDrawer from "@/components/overlay/MobileTimelineDrawer";
import MobileReviewSettingsDrawer from "@/components/overlay/MobileReviewSettingsDrawer";
import Logo from "@/components/Logo";
import { Skeleton } from "@/components/ui/skeleton";
import { FaVideo } from "react-icons/fa";
const SEGMENT_DURATION = 30;
@ -250,15 +252,28 @@ export function RecordingView({
{isMobile && (
<Logo className="absolute inset-x-1/2 -translate-x-1/2 h-8" />
)}
<Button
className="flex items-center gap-2 rounded-lg"
size="sm"
variant="secondary"
onClick={() => navigate(-1)}
<div
className={`flex items-center gap-2 ${isMobile ? "landscape:flex-col" : ""}`}
>
<IoMdArrowRoundBack className="size-5" size="small" />
{isDesktop && <div className="text-primary-foreground">Back</div>}
</Button>
<Button
className={`flex items-center gap-2.5 rounded-lg`}
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">
<MobileCameraDrawer
allCameras={allCameras}
@ -341,7 +356,7 @@ export function RecordingView({
</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
@ -351,8 +366,8 @@ export function RecordingView({
key={mainCamera}
className={
isDesktop
? `${mainCameraAspect == "tall" ? "h-[90%]" : mainCameraAspect == "wide" ? "w-full" : "w-[78%]"} px-4 flex justify-center`
: `w-full pt-2 ${mainCameraAspect == "wide" ? "aspect-wide" : "aspect-video"}`
? `${mainCameraAspect == "tall" ? "xl:h-[90%]" : mainCameraAspect == "wide" ? "w-full" : "w-[78%]"} px-4 flex justify-center`
: `portrait:w-full pt-2 ${mainCameraAspect == "wide" ? "landscape:w-full aspect-wide" : "landscape:h-[94%] aspect-video"}`
}
style={{
aspectRatio: isDesktop
@ -497,32 +512,36 @@ function Timeline({
className={`${
isDesktop
? `${timelineType == "timeline" ? "w-[100px]" : "w-60"} overflow-y-auto no-scrollbar`
: "flex-grow overflow-hidden"
: "portrait:flex-grow landscape:w-[20%] overflow-hidden"
} 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 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" ? (
<MotionReviewTimeline
segmentDuration={30}
timestampSpread={15}
timelineStart={timeRange.before}
timelineEnd={timeRange.after}
showHandlebar={exportRange == undefined}
showExportHandles={exportRange != undefined}
exportStartTime={exportRange?.after}
exportEndTime={exportRange?.before}
setExportStartTime={setExportStartTime}
setExportEndTime={setExportEndTime}
handlebarTime={currentTime}
setHandlebarTime={setCurrentTime}
onlyInitialHandlebarScroll={true}
events={mainCameraReviewItems}
motion_events={motionData ?? []}
severityType="significant_motion"
contentRef={contentRef}
onHandlebarDraggingChange={(scrubbing) => setScrubbing(scrubbing)}
/>
motionData ? (
<MotionReviewTimeline
segmentDuration={30}
timestampSpread={15}
timelineStart={timeRange.before}
timelineEnd={timeRange.after}
showHandlebar={exportRange == undefined}
showExportHandles={exportRange != undefined}
exportStartTime={exportRange?.after}
exportEndTime={exportRange?.before}
setExportStartTime={setExportStartTime}
setExportEndTime={setExportEndTime}
handlebarTime={currentTime}
setHandlebarTime={setCurrentTime}
onlyInitialHandlebarScroll={true}
events={mainCameraReviewItems}
motion_events={motionData ?? []}
severityType="significant_motion"
contentRef={contentRef}
onHandlebarDraggingChange={(scrubbing) => setScrubbing(scrubbing)}
/>
) : (
<Skeleton className="size-full" />
)
) : (
<div
className={`h-full grid grid-cols-1 gap-4 overflow-auto p-4 bg-secondary ${isDesktop ? "" : "sm:grid-cols-2"}`}

View File

@ -133,7 +133,7 @@ export default function LiveBirdseyeView() {
onClick={() => navigate(-1)}
>
<IoMdArrowBack className="size-5" />
{isDesktop && <div className="text-primary-foreground">Back</div>}
{isDesktop && <div className="text-primary">Back</div>}
</Button>
) : (
<div />

View File

@ -158,9 +158,9 @@ export default function LiveCameraView({ camera }: LiveCameraViewProps) {
return "absolute left-2 right-2 top-[50%] -translate-y-[50%]";
} else {
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 {
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={
fullscreen
? `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
className={
fullscreen
? `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 ? (
<div className="flex items-center gap-2">
<div
className={`flex items-center gap-2 ${isMobile ? "landscape:flex-col" : ""}`}
>
<Button
className={`flex items-center gap-2.5 rounded-lg`}
size="sm"
variant="secondary"
onClick={() => navigate(-1)}
>
<IoMdArrowRoundBack className="size-5" />
{isDesktop && (
<div className="text-primary-foreground">Back</div>
)}
<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"
variant="secondary"
onClick={() => {
navigate("events", {
navigate("review", {
state: {
severity: "alert",
recording: {
@ -249,10 +247,8 @@ export default function LiveCameraView({ camera }: LiveCameraViewProps) {
});
}}
>
<LuHistory className="size-5" />
{isDesktop && (
<div className="text-primary-foreground">History</div>
)}
<LuHistory className="size-5 text-secondary-foreground" />
{isDesktop && <div className="text-primary">History</div>}
</Button>
</div>
) : (
@ -520,7 +516,7 @@ function PtzControlPanel({
{ptz?.features?.includes("pt-r-fov") && (
<>
<Button
className={`${clickOverlay ? "text-selected" : "text-primary-foreground"}`}
className={`${clickOverlay ? "text-selected" : "text-primary"}`}
onClick={() => setClickOverlay(!clickOverlay)}
>
<HiViewfinderCircle />
@ -619,7 +615,7 @@ function FrigateCameraFeatures({
<Drawer>
<DrawerTrigger>
<CameraFeatureToggle
className="p-2"
className="p-2 landscape:size-9"
variant="primary"
Icon={FaCog}
isActive={false}

View File

@ -47,8 +47,8 @@ export default function LiveDashboardView({
}
// if event is ended and was saved, update events list
if (eventUpdate.type == "end" && eventUpdate.review.severity == "alert") {
setTimeout(() => updateEvents(), 1000);
if (eventUpdate.review.severity == "alert") {
setTimeout(() => updateEvents(), eventUpdate.type == "end" ? 1000 : 6000);
return;
}
}, [eventUpdate, updateEvents]);

View File

@ -19,7 +19,7 @@ export default function CameraMetrics({
// stats
const { data: initialStats } = useSWR<FrigateStats[]>(
["stats/history", { keys: "cpu_usages,cameras,service" }],
["stats/history", { keys: "cpu_usages,cameras,detection_fps,service" }],
{
revalidateOnFocus: false,
},
@ -57,6 +57,44 @@ export default function CameraMetrics({
// 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(() => {
if (!statsHistory || statsHistory.length == 0) {
return {};
@ -147,19 +185,36 @@ export default function CameraMetrics({
}, [statsHistory]);
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">
{config &&
Object.values(config.cameras).map((camera) => {
if (camera.enabled) {
return (
<div className="w-full flex flex-col">
<div className="mb-6 capitalize">
<div className="w-full flex flex-col gap-3">
<div className="capitalize text-muted-foreground text-sm font-medium">
{camera.name.replaceAll("_", " ")}
</div>
<div key={camera.name} className="grid sm:grid-cols-2 gap-2">
{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>
<CameraLineGraph
graphId={`${camera.name}-cpu`}
@ -175,7 +230,7 @@ export default function CameraMetrics({
<Skeleton className="size-full aspect-video" />
)}
{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>
<CameraLineGraph
graphId={`${camera.name}-dps`}

View File

@ -99,7 +99,7 @@ export default function GeneralMetrics({
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);
@ -125,7 +125,7 @@ export default function GeneralMetrics({
}
series[key].data.push({
x: statsIdx,
x: statsIdx + 1,
y: stats.cpu_usages[detStats.pid.toString()].cpu,
});
});
@ -153,7 +153,7 @@ export default function GeneralMetrics({
}
series[key].data.push({
x: statsIdx,
x: statsIdx + 1,
y: stats.cpu_usages[detStats.pid.toString()].mem,
});
});
@ -182,7 +182,7 @@ export default function GeneralMetrics({
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) : [];
@ -193,6 +193,14 @@ export default function GeneralMetrics({
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: {
[key: string]: { name: string; data: { x: number; y: string }[] };
} = {};
@ -207,7 +215,7 @@ export default function GeneralMetrics({
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);
@ -236,7 +244,7 @@ export default function GeneralMetrics({
}
series[key].data.push({
x: statsIdx,
x: statsIdx + 1,
y: stats.cpu_usages[procStats.pid.toString()].cpu,
});
}
@ -266,7 +274,7 @@ export default function GeneralMetrics({
}
series[key].data.push({
x: statsIdx,
x: statsIdx + 1,
y: stats.cpu_usages[procStats.pid.toString()].mem,
});
}
@ -285,7 +293,7 @@ export default function GeneralMetrics({
</div>
<div className="w-full mt-4 grid grid-cols-1 sm:grid-cols-3 gap-2">
{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>
{detInferenceTimeSeries.map((series) => (
<ThresholdBarGraph
@ -303,7 +311,7 @@ export default function GeneralMetrics({
<Skeleton className="w-full aspect-video" />
)}
{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>
{detCpuSeries.map((series) => (
<ThresholdBarGraph
@ -321,7 +329,7 @@ export default function GeneralMetrics({
<Skeleton className="w-full aspect-video" />
)}
{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>
{detMemSeries.map((series) => (
<ThresholdBarGraph
@ -349,7 +357,6 @@ export default function GeneralMetrics({
{canGetGpuInfo && (
<Button
className="cursor-pointer"
variant="secondary"
size="sm"
onClick={() => setShowVainfo(true)}
>
@ -359,7 +366,7 @@ export default function GeneralMetrics({
</div>
<div className=" mt-4 grid grid-cols-1 sm:grid-cols-2 gap-2">
{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>
{gpuSeries.map((series) => (
<ThresholdBarGraph
@ -377,20 +384,24 @@ export default function GeneralMetrics({
<Skeleton className="w-full aspect-video" />
)}
{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.map((series) => (
<ThresholdBarGraph
key={series.name}
graphId={`${series.name}-mem`}
unit=""
name={series.name}
threshold={GPUMemThreshold}
updateTimes={updateTimes}
data={[series]}
/>
))}
</div>
<>
{gpuMemSeries && (
<div className="p-2.5 bg-background_alt rounded-2xl">
<div className="mb-5">GPU Memory</div>
{gpuMemSeries.map((series) => (
<ThresholdBarGraph
key={series.name}
graphId={`${series.name}-mem`}
unit=""
name={series.name}
threshold={GPUMemThreshold}
updateTimes={updateTimes}
data={[series]}
/>
))}
</div>
)}
</>
) : (
<Skeleton className="w-full aspect-video" />
)}
@ -403,7 +414,7 @@ export default function GeneralMetrics({
</div>
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-2">
{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>
{otherProcessCpuSeries.map((series) => (
<ThresholdBarGraph
@ -421,7 +432,7 @@ export default function GeneralMetrics({
<Skeleton className="w-full aspect-tall" />
)}
{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>
{otherProcessMemSeries.map((series) => (
<ThresholdBarGraph

View File

@ -43,11 +43,9 @@ export default function StorageMetrics({
return (
<div className="size-full mt-4 flex flex-col overflow-y-auto">
<div className="text-muted-foreground text-sm font-medium">
General Storage
</div>
<div className="text-muted-foreground text-sm font-medium">Overview</div>
<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>
<StorageGraph
graphId="general-recordings"
@ -55,7 +53,7 @@ export default function StorageMetrics({
total={totalStorage.total}
/>
</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>
<StorageGraph
graphId="general-cache"
@ -63,7 +61,7 @@ export default function StorageMetrics({
total={stats.service.storage["/tmp/cache"]["total"]}
/>
</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>
<StorageGraph
graphId="general-shared-memory"
@ -77,7 +75,7 @@ export default function StorageMetrics({
</div>
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-2">
{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>
<StorageGraph
graphId={`${camera}-storage`}

View File

@ -43,13 +43,13 @@ module.exports = {
ring: "hsl(var(--ring))",
danger: "#ef4444",
success: "#22c55e",
// detection colors
motion: "#991b1b",
object: "#06b6d4",
audio: "#ea580c",
background: "hsl(var(--background))",
background_alt: "hsl(var(--background-alt))",
foreground: "hsl(var(--foreground))",
selected: "hsl(var(--selected))",
selected: {
DEFAULT: "hsl(var(--selected))",
foreground: "hsl(var(--selected-foreground))",
},
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
@ -63,6 +63,10 @@ module.exports = {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
warning: {
DEFAULT: "hsl(var(--warning))",
foreground: "hsl(var(--warning-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",

View File

@ -1,68 +1,80 @@
@layer base {
:root {
--background-hsl: hsl(0 0% 100%);
--background: hsl(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%;
--card: hsl(0 0% 100%);
--card: hsl(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%;
--popover: hsl(0 0% 100%);
--popover: hsl(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%;
--primary: hsl(0 0% 100%);
--primary: 0 0% 100%;
--primary: hsl(222.2, 37.4%, 11.2%);
--primary: 222.2 47.4% 11.2%;
--primary-foreground: hsl(0, 0%, 0%);
--primary-foreground: 0 0% 0%;
--primary-foreground: hsl(210, 40%, 98%);
--primary-foreground: 210 40% 98%;
--secondary: hsl(0, 0%, 96%);
--secondary: 0 0% 96%;
--secondary: hsl(210, 20%, 94.1%);
--secondary: 210 20% 94.1%;
--secondary-foreground: hsl(0, 0%, 83%);
--secondary-foreground: 0 0% 83%;
--secondary-foreground: hsl(222.2, 17.4%, 36.2%);
--secondary-foreground: 222.2 17.4% 36.2%;
--secondary-highlight: hsl(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-foreground: hsl(0, 0%, 64%);
--muted-foreground: 0, 0%, 64%;
--muted-foreground: hsl(215.4, 6.3%, 46.9%);
--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-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%;
--destructive: hsl(0 84.2% 60.2%);
--destructive: hsl(0, 84.2%, 60.2%);
--destructive: 0 84.2% 60.2%;
--destructive-foreground: hsl(210 40% 98%);
--destructive-foreground: 210 40% 98%;
--destructive-foreground: hsl(0, 100%, 83%);
--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%;
--input: hsl(214.3 31.8% 91.4%);
--input: 0 0 85%;
--input: hsl(0, 0%, 85%);
--input: 0 0% 85%;
--ring: hsl(222.2 84% 4.9%);
--ring: 222.2 84% 4.9%;
--ring: hsla(0, 0%, 25%, 0%);
--ring: 0 0% 25% 0%;
--selected: hsl(228, 89%, 63%);
--selected: 228 89% 63%;
--selected-foreground: hsl(0 0% 100%);
--selected-foreground: 0 0% 100%;
--radius: 0.5rem;
--severity_alert: var(--red-800);
@ -85,16 +97,19 @@
}
.dark {
--background-hsl: hsl(0 0 0%);
--background: hsl(0, 0, 0%);
--background: 0 0% 0%;
--background-alt: hsl(0, 0, 9%);
--background-alt: 0 0% 9%;
--foreground: hsl(0, 0%, 100%);
--foreground: 0, 0%, 100%;
--card: hsl(0, 0%, 15%);
--card: 0, 0%, 15%;
--card-foreground: hsl(210 40% 98%);
--card-foreground: hsl(210, 40%, 98%);
--card-foreground: 210 40% 98%;
--popover: hsl(0, 0%, 15%);
@ -103,11 +118,11 @@
--popover-foreground: hsl(0, 0%, 100%);
--popover-foreground: 210 40% 98%;
--primary: hsl(0, 0%, 9%);
--primary: 0 0% 9%;
--primary: hsl(0, 0%, 91%);
--primary: 0 0% 91%;
--primary-foreground: hsl(0, 0%, 100%);
--primary-foreground: 0 0% 100%;
--primary-foreground: hsl(0, 0%, 9%);
--primary-foreground: 0 0% 9%;
--secondary: hsl(0, 0%, 15%);
--secondary: 0 0% 15%;
@ -127,25 +142,28 @@
--accent: hsl(0, 0%, 15%);
--accent: 0 0% 15%;
--accent-foreground: hsl(210 40% 98%);
--accent-foreground: hsl(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-foreground: hsl(210 40% 98%);
--destructive-foreground: 210 40% 98%;
--destructive-foreground: hsl(0, 100%, 83%);
--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: 0 0% 32%;
--input: hsl(217.2 32.6% 17.5%);
--input: 0 0 25%;
--input: hsl(0, 0%, 5%);
--input: 0 0% 25%;
--ring: hsl(212.7 26.8% 83.9%);
--ring: 212.7 26.8% 83.9%;
--selected: hsl(228, 89%, 63%);
--selected: 228 89% 63%;
--ring: hsla(0, 0%, 25%, 0%);
--ring: 0 0% 25% 0%;
}
}