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 - name: Check out the repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.0.0 uses: actions/setup-python@v5.1.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
- name: Install requirements - name: Install requirements

View File

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

View File

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

View File

@ -257,6 +257,28 @@ objects:
# Checks based on the bottom center of the bounding box of the object # Checks based on the bottom center of the bounding box of the object
mask: 0,0,1000,0,1000,200,0,200 mask: 0,0,1000,0,1000,200,0,200
# Optional: Review configuration
# NOTE: Can be overridden at the camera level
review:
# Optional: alerts configuration
alerts:
# Optional: labels that qualify as an alert (default: shown below)
labels:
- car
- person
# Optional: required zones for an object to be marked as an alert (default: none)
required_zones:
- driveway
# Optional: detections configuration
detections:
# Optional: labels that qualify as a detection (default: all labels that are tracked / listened to)
labels:
- car
- person
# Optional: required zones for an object to be marked as a detection (default: none)
required_zones:
- driveway
# Optional: Motion configuration # Optional: Motion configuration
# NOTE: Can be overridden at the camera level # NOTE: Can be overridden at the camera level
motion: motion:
@ -345,8 +367,6 @@ record:
# Optional: Objects to save recordings for. (default: all tracked objects) # Optional: Objects to save recordings for. (default: all tracked objects)
objects: objects:
- person - person
# Optional: Restrict recordings to objects that entered any of the listed zones (default: no required zones)
required_zones: []
# Optional: Retention settings for recordings of events # Optional: Retention settings for recordings of events
retain: retain:
# Required: Default retention days (default: shown below) # Required: Default retention days (default: shown below)

View File

@ -19,7 +19,8 @@ To create a zone, follow [the steps for a "Motion mask"](masks.md), but use the
Often you will only want events to be created when an object enters areas of interest. This is done using zones along with setting required_zones. Let's say you only want to be notified when an object enters your entire_yard zone, the config would be: Often you will only want events to be created when an object enters areas of interest. This is done using zones along with setting required_zones. Let's say you only want to be notified when an object enters your entire_yard zone, the config would be:
```yaml ```yaml
camera: cameras:
name_of_your_camera:
record: record:
events: events:
required_zones: required_zones:
@ -37,7 +38,8 @@ camera:
Sometimes you want to limit a zone to specific object types to have more granular control of when events/snapshots are saved. The following example will limit one zone to person objects and the other to cars. Sometimes you want to limit a zone to specific object types to have more granular control of when events/snapshots are saved. The following example will limit one zone to person objects and the other to cars.
```yaml ```yaml
camera: cameras:
name_of_your_camera:
record: record:
events: events:
required_zones: required_zones:
@ -65,7 +67,8 @@ Only car objects can trigger the `front_yard_street` zone and only person can tr
Sometimes objects are expected to be passing through a zone, but an object loitering in an area is unexpected. Zones can be configured to have a minimum loitering time before the object will be considered in the zone. Sometimes objects are expected to be passing through a zone, but an object loitering in an area is unexpected. Zones can be configured to have a minimum loitering time before the object will be considered in the zone.
```yaml ```yaml
camera: cameras:
name_of_your_camera:
zones: zones:
sidewalk: sidewalk:
loitering_time: 4 # unit is in seconds loitering_time: 4 # unit is in seconds
@ -78,7 +81,8 @@ camera:
Sometimes an objects bounding box may be slightly incorrect and the bottom center of the bounding box is inside the zone while the object is not actually in the zone. Zone inertia helps guard against this by requiring an object's bounding box to be within the zone for multiple consecutive frames. This value can be configured: Sometimes an objects bounding box may be slightly incorrect and the bottom center of the bounding box is inside the zone while the object is not actually in the zone. Zone inertia helps guard against this by requiring an object's bounding box to be within the zone for multiple consecutive frames. This value can be configured:
```yaml ```yaml
camera: cameras:
name_of_your_camera:
zones: zones:
front_yard: front_yard:
inertia: 3 inertia: 3
@ -89,7 +93,8 @@ camera:
There may also be cases where you expect an object to quickly enter and exit a zone, like when a car is pulling into the driveway, and you may want to have the object be considered present in the zone immediately: There may also be cases where you expect an object to quickly enter and exit a zone, like when a car is pulling into the driveway, and you may want to have the object be considered present in the zone immediately:
```yaml ```yaml
camera: cameras:
name_of_your_camera:
zones: zones:
driveway_entrance: driveway_entrance:
inertia: 1 inertia: 1

View File

@ -137,7 +137,10 @@ def stats_history():
@bp.route("/config") @bp.route("/config")
def config(): def config():
config = current_app.frigate_config.model_dump(mode="json", exclude_none=True) config_obj: FrigateConfig = current_app.frigate_config
config: dict[str, dict[str, any]] = config_obj.model_dump(
mode="json", exclude_none=True
)
# remove the mqtt password # remove the mqtt password
config["mqtt"].pop("password", None) config["mqtt"].pop("password", None)
@ -154,9 +157,13 @@ def config():
for cmd in camera_dict["ffmpeg_cmds"]: for cmd in camera_dict["ffmpeg_cmds"]:
cmd["cmd"] = clean_camera_user_pass(" ".join(cmd["cmd"])) cmd["cmd"] = clean_camera_user_pass(" ".join(cmd["cmd"]))
# ensure that zones are relative
for zone_name, zone in config_obj.cameras[camera_name].zones.items():
camera_dict["zones"][zone_name]["color"] = zone.color
config["plus"] = {"enabled": current_app.plus_api.is_active()} config["plus"] = {"enabled": current_app.plus_api.is_active()}
for detector, detector_config in config["detectors"].items(): for detector_config in config["detectors"].values():
detector_config["model"]["labelmap"] = ( detector_config["model"]["labelmap"] = (
current_app.frigate_config.model.merged_labelmap current_app.frigate_config.model.merged_labelmap
) )

View File

@ -1333,7 +1333,9 @@ def review_preview(id: str):
padding = 8 padding = 8
start_ts = review.start_time - padding start_ts = review.start_time - padding
end_ts = review.end_time + padding end_ts = (
review.end_time + padding if review.end_time else datetime.now().timestamp()
)
return preview_gif(review.camera, start_ts, end_ts) return preview_gif(review.camera, start_ts, end_ts)
@ -1344,8 +1346,15 @@ def preview_thumbnail(file_name: str):
safe_file_name_current = secure_filename(file_name) safe_file_name_current = secure_filename(file_name)
preview_dir = os.path.join(CACHE_DIR, "preview_frames") preview_dir = os.path.join(CACHE_DIR, "preview_frames")
with open(os.path.join(preview_dir, safe_file_name_current), "rb") as image_file: try:
with open(
os.path.join(preview_dir, safe_file_name_current), "rb"
) as image_file:
jpg_bytes = image_file.read() jpg_bytes = image_file.read()
except FileNotFoundError:
return make_response(
jsonify({"success": False, "message": "Image file not found"}), 404
)
response = make_response(jpg_bytes) response = make_response(jpg_bytes)
response.headers["Content-Type"] = "image/jpeg" response.headers["Content-Type"] = "image/jpeg"

View File

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

View File

@ -63,6 +63,7 @@ from frigate.storage import StorageMaintainer
from frigate.timeline import TimelineProcessor from frigate.timeline import TimelineProcessor
from frigate.types import CameraMetricsTypes, PTZMetricsTypes from frigate.types import CameraMetricsTypes, PTZMetricsTypes
from frigate.util.builtin import save_default_config from frigate.util.builtin import save_default_config
from frigate.util.config import migrate_frigate_config
from frigate.util.object import get_camera_regions_grid from frigate.util.object import get_camera_regions_grid
from frigate.version import VERSION from frigate.version import VERSION
from frigate.video import capture_camera, track_camera from frigate.video import capture_camera, track_camera
@ -126,6 +127,9 @@ class FrigateApp:
config_file = config_file_yaml config_file = config_file_yaml
save_default_config(config_file) save_default_config(config_file)
# check if the config file needs to be migrated
migrate_frigate_config(config_file)
user_config = FrigateConfig.parse_file(config_file) user_config = FrigateConfig.parse_file(config_file)
self.config = user_config.runtime_config(self.plus_api) self.config = user_config.runtime_config(self.plus_api)
@ -200,9 +204,6 @@ class FrigateApp:
logging.getLogger("ws4py").setLevel("ERROR") logging.getLogger("ws4py").setLevel("ERROR")
def init_queues(self) -> None: def init_queues(self) -> None:
# Queues for clip processing
self.event_processed_queue: Queue = mp.Queue()
# Queue for cameras to push tracked objects to # Queue for cameras to push tracked objects to
self.detected_frames_queue: Queue = mp.Queue( self.detected_frames_queue: Queue = mp.Queue(
maxsize=sum(camera.enabled for camera in self.config.cameras.values()) * 2 maxsize=sum(camera.enabled for camera in self.config.cameras.values()) * 2
@ -420,7 +421,6 @@ class FrigateApp:
self.config, self.config,
self.dispatcher, self.dispatcher,
self.detected_frames_queue, self.detected_frames_queue,
self.event_processed_queue,
self.ptz_autotracker_thread, self.ptz_autotracker_thread,
self.stop_event, self.stop_event,
) )
@ -517,7 +517,6 @@ class FrigateApp:
def start_event_processor(self) -> None: def start_event_processor(self) -> None:
self.event_processor = EventProcessor( self.event_processor = EventProcessor(
self.config, self.config,
self.event_processed_queue,
self.timeline_queue, self.timeline_queue,
self.stop_event, self.stop_event,
) )
@ -672,6 +671,14 @@ class FrigateApp:
logger.info("Stopping...") logger.info("Stopping...")
self.stop_event.set() self.stop_event.set()
# set an end_time on entries without an end_time before exiting
Event.update(end_time=datetime.datetime.now().timestamp()).where(
Event.end_time == None
).execute()
ReviewSegment.update(end_time=datetime.datetime.now().timestamp()).where(
ReviewSegment.end_time == None
).execute()
# Stop Communicators # Stop Communicators
self.inter_process_communicator.stop() self.inter_process_communicator.stop()
self.inter_config_updater.stop() self.inter_config_updater.stop()
@ -704,7 +711,6 @@ class FrigateApp:
shm.unlink() shm.unlink()
for queue in [ for queue in [
self.event_processed_queue,
self.detected_frames_queue, self.detected_frames_queue,
self.log_queue, self.log_queue,
]: ]:

View File

@ -8,7 +8,7 @@ import zmq
SOCKET_CONTROL = "inproc://control.detections_updater" SOCKET_CONTROL = "inproc://control.detections_updater"
SOCKET_PUB = "ipc:///tmp/cache/detect_pub" SOCKET_PUB = "ipc:///tmp/cache/detect_pub"
SOCKET_SUB = "ipc:///tmp/cache/detect_sun" SOCKET_SUB = "ipc:///tmp/cache/detect_sub"
class DetectionTypeEnum(str, Enum): class DetectionTypeEnum(str, Enum):

View File

@ -5,6 +5,7 @@ import zmq
from frigate.events.types import EventStateEnum, EventTypeEnum from frigate.events.types import EventStateEnum, EventTypeEnum
SOCKET_PUSH_PULL = "ipc:///tmp/cache/events" SOCKET_PUSH_PULL = "ipc:///tmp/cache/events"
SOCKET_PUSH_PULL_END = "ipc:///tmp/cache/events_ended"
class EventUpdatePublisher: class EventUpdatePublisher:
@ -37,7 +38,53 @@ class EventUpdateSubscriber:
def check_for_update( def check_for_update(
self, timeout=1 self, timeout=1
) -> tuple[EventTypeEnum, EventStateEnum, str, dict[str, any]]: ) -> tuple[EventTypeEnum, EventStateEnum, str, dict[str, any]]:
"""Returns updated config or None if no update.""" """Returns events or None if no update."""
try:
has_update, _, _ = zmq.select([self.socket], [], [], timeout)
if has_update:
return self.socket.recv_pyobj()
except zmq.ZMQError:
pass
return None
def stop(self) -> None:
self.socket.close()
self.context.destroy()
class EventEndPublisher:
"""Publishes events that have ended."""
def __init__(self) -> None:
self.context = zmq.Context()
self.socket = self.context.socket(zmq.PUSH)
self.socket.connect(SOCKET_PUSH_PULL_END)
def publish(
self, payload: tuple[EventTypeEnum, EventStateEnum, str, dict[str, any]]
) -> None:
"""There is no communication back to the processes."""
self.socket.send_pyobj(payload)
def stop(self) -> None:
self.socket.close()
self.context.destroy()
class EventEndSubscriber:
"""Receives events that have ended."""
def __init__(self) -> None:
self.context = zmq.Context()
self.socket = self.context.socket(zmq.PULL)
self.socket.bind(SOCKET_PUSH_PULL_END)
def check_for_update(
self, timeout=1
) -> tuple[EventTypeEnum, EventStateEnum, str, dict[str, any]]:
"""Returns events ended or None if no update."""
try: try:
has_update, _, _ = zmq.select([self.socket], [], [], timeout) has_update, _, _ = zmq.select([self.socket], [], [], timeout)

View File

@ -245,10 +245,6 @@ class EventsConfig(FrigateBaseModel):
default=5, title="Seconds to retain before event starts.", le=MAX_PRE_CAPTURE default=5, title="Seconds to retain before event starts.", le=MAX_PRE_CAPTURE
) )
post_capture: int = Field(default=5, title="Seconds to retain after event ends.") post_capture: int = Field(default=5, title="Seconds to retain after event ends.")
required_zones: List[str] = Field(
default_factory=list,
title="List of required zones to be entered in order to save the event.",
)
objects: Optional[List[str]] = Field( objects: Optional[List[str]] = Field(
None, None,
title="List of objects to be detected in order to save the event.", title="List of objects to be detected in order to save the event.",
@ -354,6 +350,34 @@ class RuntimeMotionConfig(MotionConfig):
frame_shape = config.get("frame_shape", (1, 1)) frame_shape = config.get("frame_shape", (1, 1))
mask = config.get("mask", "") mask = config.get("mask", "")
# masks and zones are saved as relative coordinates
# we know if any points are > 1 then it is using the
# old native resolution coordinates
if mask:
if isinstance(mask, list) and any(x > "1.0" for x in mask[0].split(",")):
relative_masks = []
for m in mask:
points = m.split(",")
relative_masks.append(
",".join(
[
f"{round(int(points[i]) / frame_shape[1], 3)},{round(int(points[i + 1]) / frame_shape[0], 3)}"
for i in range(0, len(points), 2)
]
)
)
mask = relative_masks
elif isinstance(mask, str) and any(x > "1.0" for x in mask.split(",")):
points = mask.split(",")
mask = ",".join(
[
f"{round(int(points[i]) / frame_shape[1], 3)},{round(int(points[i + 1]) / frame_shape[0], 3)}"
for i in range(0, len(points), 2)
]
)
config["raw_mask"] = mask config["raw_mask"] = mask
if mask: if mask:
@ -484,11 +508,40 @@ class RuntimeFilterConfig(FilterConfig):
raw_mask: Optional[Union[str, List[str]]] = None raw_mask: Optional[Union[str, List[str]]] = None
def __init__(self, **config): def __init__(self, **config):
frame_shape = config.get("frame_shape", (1, 1))
mask = config.get("mask") mask = config.get("mask")
# masks and zones are saved as relative coordinates
# we know if any points are > 1 then it is using the
# old native resolution coordinates
if mask:
if isinstance(mask, list) and any(x > "1.0" for x in mask[0].split(",")):
relative_masks = []
for m in mask:
points = m.split(",")
relative_masks.append(
",".join(
[
f"{round(int(points[i]) / frame_shape[1], 3)},{round(int(points[i + 1]) / frame_shape[0], 3)}"
for i in range(0, len(points), 2)
]
)
)
mask = relative_masks
elif isinstance(mask, str) and any(x > "1.0" for x in mask.split(",")):
points = mask.split(",")
mask = ",".join(
[
f"{round(int(points[i]) / frame_shape[1], 3)},{round(int(points[i + 1]) / frame_shape[0], 3)}"
for i in range(0, len(points), 2)
]
)
config["raw_mask"] = mask config["raw_mask"] = mask
if mask is not None: if mask is not None:
config["mask"] = create_mask(config.get("frame_shape", (1, 1)), mask) config["mask"] = create_mask(frame_shape, mask)
super().__init__(**config) super().__init__(**config)
@ -539,16 +592,60 @@ class ZoneConfig(BaseModel):
super().__init__(**config) super().__init__(**config)
self._color = config.get("color", (0, 0, 0)) self._color = config.get("color", (0, 0, 0))
coordinates = config["coordinates"] self._contour = config.get("contour", np.array([]))
def generate_contour(self, frame_shape: tuple[int, int]):
coordinates = self.coordinates
# masks and zones are saved as relative coordinates
# we know if any points are > 1 then it is using the
# old native resolution coordinates
if isinstance(coordinates, list): if isinstance(coordinates, list):
explicit = any(p.split(",")[0] > "1.0" for p in coordinates)
self._contour = np.array( self._contour = np.array(
[[int(p.split(",")[0]), int(p.split(",")[1])] for p in coordinates] [
(
[int(p.split(",")[0]), int(p.split(",")[1])]
if explicit
else [
int(float(p.split(",")[0]) * frame_shape[1]),
int(float(p.split(",")[1]) * frame_shape[0]),
]
)
for p in coordinates
]
)
if explicit:
self.coordinates = ",".join(
[
f'{round(int(p.split(",")[0]) / frame_shape[1], 3)},{round(int(p.split(",")[1]) / frame_shape[0], 3)}'
for p in coordinates
]
) )
elif isinstance(coordinates, str): elif isinstance(coordinates, str):
points = coordinates.split(",") points = coordinates.split(",")
explicit = any(p > "1.0" for p in points)
self._contour = np.array( self._contour = np.array(
[[int(points[i]), int(points[i + 1])] for i in range(0, len(points), 2)] [
(
[int(points[i]), int(points[i + 1])]
if explicit
else [
int(float(points[i]) * frame_shape[1]),
int(float(points[i + 1]) * frame_shape[0]),
]
)
for i in range(0, len(points), 2)
]
)
if explicit:
self.coordinates = ",".join(
[
f"{round(int(points[i]) / frame_shape[1], 3)},{round(int(points[i + 1]) / frame_shape[0], 3)}"
for i in range(0, len(points), 2)
]
) )
else: else:
self._contour = np.array([]) self._contour = np.array([])
@ -556,13 +653,45 @@ class ZoneConfig(BaseModel):
class ObjectConfig(FrigateBaseModel): class ObjectConfig(FrigateBaseModel):
track: List[str] = Field(default=DEFAULT_TRACKED_OBJECTS, title="Objects to track.") track: List[str] = Field(default=DEFAULT_TRACKED_OBJECTS, title="Objects to track.")
alert: List[str] = Field(
default=DEFAULT_ALERT_OBJECTS, title="Objects to create alerts for."
)
filters: Dict[str, FilterConfig] = Field(default={}, title="Object filters.") filters: Dict[str, FilterConfig] = Field(default={}, title="Object filters.")
mask: Union[str, List[str]] = Field(default="", title="Object mask.") mask: Union[str, List[str]] = Field(default="", title="Object mask.")
class AlertsConfig(FrigateBaseModel):
"""Configure alerts"""
labels: List[str] = Field(
default=DEFAULT_ALERT_OBJECTS, title="Labels to create alerts for."
)
required_zones: List[str] = Field(
default_factory=list,
title="List of required zones to be entered in order to save the event as an alert.",
)
class DetectionsConfig(FrigateBaseModel):
"""Configure detections"""
labels: Optional[List[str]] = Field(
default=None, title="Labels to create detections for."
)
required_zones: List[str] = Field(
default_factory=list,
title="List of required zones to be entered in order to save the event as a detection.",
)
class ReviewConfig(FrigateBaseModel):
"""Configure reviews"""
alerts: AlertsConfig = Field(
default_factory=AlertsConfig, title="Review alerts config."
)
detections: DetectionsConfig = Field(
default_factory=DetectionsConfig, title="Review detections config."
)
class AudioConfig(FrigateBaseModel): class AudioConfig(FrigateBaseModel):
enabled: bool = Field(default=False, title="Enable audio events.") enabled: bool = Field(default=False, title="Enable audio events.")
max_not_heard: int = Field( max_not_heard: int = Field(
@ -841,6 +970,9 @@ class CameraConfig(FrigateBaseModel):
objects: ObjectConfig = Field( objects: ObjectConfig = Field(
default_factory=ObjectConfig, title="Object configuration." default_factory=ObjectConfig, title="Object configuration."
) )
review: ReviewConfig = Field(
default_factory=ReviewConfig, title="Review configuration."
)
audio: AudioConfig = Field( audio: AudioConfig = Field(
default_factory=AudioConfig, title="Audio events configuration." default_factory=AudioConfig, title="Audio events configuration."
) )
@ -1162,6 +1294,9 @@ class FrigateConfig(FrigateBaseModel):
objects: ObjectConfig = Field( objects: ObjectConfig = Field(
default_factory=ObjectConfig, title="Global object configuration." default_factory=ObjectConfig, title="Global object configuration."
) )
review: ReviewConfig = Field(
default_factory=ReviewConfig, title="Review configuration."
)
audio: AudioConfig = Field( audio: AudioConfig = Field(
default_factory=AudioConfig, title="Global Audio events configuration." default_factory=AudioConfig, title="Global Audio events configuration."
) )
@ -1209,6 +1344,7 @@ class FrigateConfig(FrigateBaseModel):
"snapshots": ..., "snapshots": ...,
"live": ..., "live": ...,
"objects": ..., "objects": ...,
"review": ...,
"motion": ..., "motion": ...,
"detect": ..., "detect": ...,
"ffmpeg": ..., "ffmpeg": ...,
@ -1346,6 +1482,11 @@ class FrigateConfig(FrigateBaseModel):
) )
camera_config.motion.enabled_in_config = camera_config.motion.enabled camera_config.motion.enabled_in_config = camera_config.motion.enabled
# generate zone contours
if len(camera_config.zones) > 0:
for zone in camera_config.zones.values():
zone.generate_contour(camera_config.frame_shape)
# Set live view stream if none is set # Set live view stream if none is set
if not camera_config.live.stream_name: if not camera_config.live.stream_name:
camera_config.live.stream_name = name camera_config.live.stream_name = name

View File

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

View File

@ -1,11 +1,10 @@
import datetime
import logging import logging
import threading import threading
from multiprocessing import Queue from multiprocessing import Queue
from multiprocessing.synchronize import Event as MpEvent from multiprocessing.synchronize import Event as MpEvent
from typing import Dict from typing import Dict
from frigate.comms.events_updater import EventUpdateSubscriber from frigate.comms.events_updater import EventEndPublisher, EventUpdateSubscriber
from frigate.config import EventsConfig, FrigateConfig from frigate.config import EventsConfig, FrigateConfig
from frigate.events.types import EventStateEnum, EventTypeEnum from frigate.events.types import EventStateEnum, EventTypeEnum
from frigate.models import Event from frigate.models import Event
@ -52,19 +51,18 @@ class EventProcessor(threading.Thread):
def __init__( def __init__(
self, self,
config: FrigateConfig, config: FrigateConfig,
event_processed_queue: Queue,
timeline_queue: Queue, timeline_queue: Queue,
stop_event: MpEvent, stop_event: MpEvent,
): ):
threading.Thread.__init__(self) threading.Thread.__init__(self)
self.name = "event_processor" self.name = "event_processor"
self.config = config self.config = config
self.event_processed_queue = event_processed_queue
self.timeline_queue = timeline_queue self.timeline_queue = timeline_queue
self.events_in_process: Dict[str, Event] = {} self.events_in_process: Dict[str, Event] = {}
self.stop_event = stop_event self.stop_event = stop_event
self.event_receiver = EventUpdateSubscriber() self.event_receiver = EventUpdateSubscriber()
self.event_end_publisher = EventEndPublisher()
def run(self) -> None: def run(self) -> None:
# set an end_time on events without an end_time on startup # set an end_time on events without an end_time on startup
@ -113,11 +111,8 @@ class EventProcessor(threading.Thread):
self.handle_external_detection(event_type, event_data) self.handle_external_detection(event_type, event_data)
# set an end_time on events without an end_time before exiting
Event.update(end_time=datetime.datetime.now().timestamp()).where(
Event.end_time == None
).execute()
self.event_receiver.stop() self.event_receiver.stop()
self.event_end_publisher.stop()
logger.info("Exiting event processor...") logger.info("Exiting event processor...")
def handle_object_detection( def handle_object_detection(
@ -242,7 +237,7 @@ class EventProcessor(threading.Thread):
if event_type == EventStateEnum.end: if event_type == EventStateEnum.end:
del self.events_in_process[event_data["id"]] del self.events_in_process[event_data["id"]]
self.event_processed_queue.put((event_data["id"], camera)) self.event_end_publisher.publish((event_data["id"], camera))
def handle_external_detection( def handle_external_detection(
self, event_type: EventStateEnum, event_data: Event self, event_type: EventStateEnum, event_data: Event

View File

@ -6,6 +6,7 @@ import os
import queue import queue
import threading import threading
from collections import Counter, defaultdict from collections import Counter, defaultdict
from multiprocessing.synchronize import Event as MpEvent
from statistics import median from statistics import median
from typing import Callable from typing import Callable
@ -14,7 +15,7 @@ import numpy as np
from frigate.comms.detections_updater import DetectionPublisher, DetectionTypeEnum from frigate.comms.detections_updater import DetectionPublisher, DetectionTypeEnum
from frigate.comms.dispatcher import Dispatcher from frigate.comms.dispatcher import Dispatcher
from frigate.comms.events_updater import EventUpdatePublisher from frigate.comms.events_updater import EventEndSubscriber, EventUpdatePublisher
from frigate.config import ( from frigate.config import (
CameraConfig, CameraConfig,
FrigateConfig, FrigateConfig,
@ -827,7 +828,6 @@ class TrackedObjectProcessor(threading.Thread):
config: FrigateConfig, config: FrigateConfig,
dispatcher: Dispatcher, dispatcher: Dispatcher,
tracked_objects_queue, tracked_objects_queue,
event_processed_queue,
ptz_autotracker_thread, ptz_autotracker_thread,
stop_event, stop_event,
): ):
@ -836,14 +836,14 @@ class TrackedObjectProcessor(threading.Thread):
self.config = config self.config = config
self.dispatcher = dispatcher self.dispatcher = dispatcher
self.tracked_objects_queue = tracked_objects_queue self.tracked_objects_queue = tracked_objects_queue
self.event_processed_queue = event_processed_queue self.stop_event: MpEvent = stop_event
self.stop_event = stop_event
self.camera_states: dict[str, CameraState] = {} self.camera_states: dict[str, CameraState] = {}
self.frame_manager = SharedMemoryFrameManager() self.frame_manager = SharedMemoryFrameManager()
self.last_motion_detected: dict[str, float] = {} self.last_motion_detected: dict[str, float] = {}
self.ptz_autotracker_thread = ptz_autotracker_thread self.ptz_autotracker_thread = ptz_autotracker_thread
self.detection_publisher = DetectionPublisher(DetectionTypeEnum.video) self.detection_publisher = DetectionPublisher(DetectionTypeEnum.video)
self.event_sender = EventUpdatePublisher() self.event_sender = EventUpdatePublisher()
self.event_end_subscriber = EventEndSubscriber()
def start(camera, obj: TrackedObject, current_frame_time): def start(camera, obj: TrackedObject, current_frame_time):
self.event_sender.publish( self.event_sender.publish(
@ -1007,7 +1007,7 @@ class TrackedObjectProcessor(threading.Thread):
return True return True
def should_retain_recording(self, camera, obj: TrackedObject): def should_retain_recording(self, camera: str, obj: TrackedObject):
if obj.false_positive: if obj.false_positive:
return False return False
@ -1022,7 +1022,11 @@ class TrackedObjectProcessor(threading.Thread):
return False return False
# If there are required zones and there is no overlap # If there are required zones and there is no overlap
required_zones = record_config.events.required_zones review_config = self.config.cameras[camera].review
required_zones = (
review_config.alerts.required_zones
+ review_config.detections.required_zones
)
if len(required_zones) > 0 and not set(obj.entered_zones) & set(required_zones): if len(required_zones) > 0 and not set(obj.entered_zones) & set(required_zones):
logger.debug( logger.debug(
f"Not creating clip for {obj.obj_data['id']} because it did not enter required zones" f"Not creating clip for {obj.obj_data['id']} because it did not enter required zones"
@ -1215,10 +1219,16 @@ class TrackedObjectProcessor(threading.Thread):
) )
# cleanup event finished queue # cleanup event finished queue
while not self.event_processed_queue.empty(): while not self.stop_event.is_set():
event_id, camera = self.event_processed_queue.get() update = self.event_end_subscriber.check_for_update(timeout=0.01)
if not update:
break
event_id, camera = update
self.camera_states[camera].finished(event_id) self.camera_states[camera].finished(event_id)
self.detection_publisher.stop() self.detection_publisher.stop()
self.event_sender.stop() self.event_sender.stop()
self.event_end_subscriber.stop()
logger.info("Exiting object processor...") logger.info("Exiting object processor...")

View File

@ -3,12 +3,15 @@
import datetime import datetime
import itertools import itertools
import logging import logging
import os
import threading import threading
from multiprocessing.synchronize import Event as MpEvent from multiprocessing.synchronize import Event as MpEvent
from pathlib import Path from pathlib import Path
from playhouse.sqlite_ext import SqliteExtDatabase
from frigate.config import CameraConfig, FrigateConfig, RetainModeEnum from frigate.config import CameraConfig, FrigateConfig, RetainModeEnum
from frigate.const import CACHE_DIR, RECORD_DIR from frigate.const import CACHE_DIR, MAX_WAL_SIZE, RECORD_DIR
from frigate.models import Event, Previews, Recordings, ReviewSegment from frigate.models import Event, Previews, Recordings, ReviewSegment
from frigate.record.util import remove_empty_directories, sync_recordings from frigate.record.util import remove_empty_directories, sync_recordings
from frigate.util.builtin import clear_and_unlink, get_tomorrow_at_time from frigate.util.builtin import clear_and_unlink, get_tomorrow_at_time
@ -33,6 +36,23 @@ class RecordingCleanup(threading.Thread):
logger.debug("Deleting tmp clip.") logger.debug("Deleting tmp clip.")
clear_and_unlink(p) clear_and_unlink(p)
def truncate_wal(self) -> None:
"""check if the WAL needs to be manually truncated."""
# by default the WAL should be check-pointed automatically
# however, high levels of activity can prevent an opportunity
# for the checkpoint to be finished which means the WAL will grow
# without bound
# with auto checkpoint most users should never hit this
if (
os.stat(f"{self.config.database.path}-wal").st_size / (1024 * 1024)
) > MAX_WAL_SIZE:
db = SqliteExtDatabase(self.config.database.path)
db.execute_sql("PRAGMA wal_checkpoint(TRUNCATE);")
db.close()
def expire_existing_camera_recordings( def expire_existing_camera_recordings(
self, expire_date: float, config: CameraConfig, events: Event self, expire_date: float, config: CameraConfig, events: Event
) -> None: ) -> None:
@ -328,3 +348,4 @@ class RecordingCleanup(threading.Thread):
if counter == 0: if counter == 0:
self.expire_recordings() self.expire_recordings()
remove_empty_directories(RECORD_DIR) remove_empty_directories(RECORD_DIR)
self.truncate_wal()

View File

@ -103,14 +103,14 @@ class RecordingExporter(threading.Thread):
if self.playback_factor == PlaybackFactorEnum.realtime: if self.playback_factor == PlaybackFactorEnum.realtime:
ffmpeg_cmd = ( ffmpeg_cmd = (
f"ffmpeg -hide_banner {ffmpeg_input} -c copy {file_path}" f"ffmpeg -hide_banner {ffmpeg_input} -c copy -movflags +faststart {file_path}"
).split(" ") ).split(" ")
elif self.playback_factor == PlaybackFactorEnum.timelapse_25x: elif self.playback_factor == PlaybackFactorEnum.timelapse_25x:
ffmpeg_cmd = ( ffmpeg_cmd = (
parse_preset_hardware_acceleration_encode( parse_preset_hardware_acceleration_encode(
self.config.ffmpeg.hwaccel_args, self.config.ffmpeg.hwaccel_args,
f"{TIMELAPSE_DATA_INPUT_ARGS} {ffmpeg_input}", f"{TIMELAPSE_DATA_INPUT_ARGS} {ffmpeg_input}",
f"{self.config.cameras[self.camera].record.export.timelapse_args} {file_path}", f"{self.config.cameras[self.camera].record.export.timelapse_args} -movflags +faststart {file_path}",
EncodeTypeEnum.timelapse, EncodeTypeEnum.timelapse,
) )
).split(" ") ).split(" ")

View File

@ -32,13 +32,11 @@ THUMB_WIDTH = 320
THRESHOLD_ALERT_ACTIVITY = 120 THRESHOLD_ALERT_ACTIVITY = 120
THRESHOLD_DETECTION_ACTIVITY = 30 THRESHOLD_DETECTION_ACTIVITY = 30
THRESHOLD_MOTION_ACTIVITY = 30
class SeverityEnum(str, Enum): class SeverityEnum(str, Enum):
alert = "alert" alert = "alert"
detection = "detection" detection = "detection"
signification_motion = "significant_motion"
class PendingReviewSegment: class PendingReviewSegment:
@ -50,7 +48,6 @@ class PendingReviewSegment:
detections: dict[str, str], detections: dict[str, str],
zones: set[str] = set(), zones: set[str] = set(),
audio: set[str] = set(), audio: set[str] = set(),
motion: list[int] = [],
): ):
rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6)) rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
self.id = f"{frame_time}-{rand_id}" self.id = f"{frame_time}-{rand_id}"
@ -60,12 +57,12 @@ class PendingReviewSegment:
self.detections = detections self.detections = detections
self.zones = zones self.zones = zones
self.audio = audio self.audio = audio
self.sig_motion_areas = motion
self.last_update = frame_time self.last_update = frame_time
# thumbnail # thumbnail
self.frame = np.zeros((THUMB_HEIGHT * 3 // 2, THUMB_WIDTH), np.uint8) self.frame = np.zeros((THUMB_HEIGHT * 3 // 2, THUMB_WIDTH), np.uint8)
self.frame_active_count = 0 self.frame_active_count = 0
self.frame_path = os.path.join(CLIPS_DIR, f"thumb-{self.camera}-{self.id}.jpg")
def update_frame( def update_frame(
self, camera_config: CameraConfig, frame, objects: list[TrackedObject] self, camera_config: CameraConfig, frame, objects: list[TrackedObject]
@ -98,25 +95,24 @@ class PendingReviewSegment:
color_frame, dsize=(width, THUMB_HEIGHT), interpolation=cv2.INTER_AREA color_frame, dsize=(width, THUMB_HEIGHT), interpolation=cv2.INTER_AREA
) )
def end(self) -> dict:
path = os.path.join(CLIPS_DIR, f"thumb-{self.camera}-{self.id}.jpg")
if self.frame is not None: if self.frame is not None:
cv2.imwrite(path, self.frame, [int(cv2.IMWRITE_WEBP_QUALITY), 60]) cv2.imwrite(
self.frame_path, self.frame, [int(cv2.IMWRITE_WEBP_QUALITY), 60]
)
def get_data(self, ended: bool) -> dict:
return { return {
ReviewSegment.id: self.id, ReviewSegment.id: self.id,
ReviewSegment.camera: self.camera, ReviewSegment.camera: self.camera,
ReviewSegment.start_time: self.start_time, ReviewSegment.start_time: self.start_time,
ReviewSegment.end_time: self.last_update, ReviewSegment.end_time: self.last_update if ended else None,
ReviewSegment.severity: self.severity.value, ReviewSegment.severity: self.severity.value,
ReviewSegment.thumb_path: path, ReviewSegment.thumb_path: self.frame_path,
ReviewSegment.data: { ReviewSegment.data: {
"detections": list(set(self.detections.keys())), "detections": list(set(self.detections.keys())),
"objects": list(set(self.detections.values())), "objects": list(set(self.detections.values())),
"zones": list(self.zones), "zones": list(self.zones),
"audio": list(self.audio), "audio": list(self.audio),
"significant_motion_areas": self.sig_motion_areas,
}, },
} }
@ -141,9 +137,20 @@ class ReviewSegmentMaintainer(threading.Thread):
self.stop_event = stop_event self.stop_event = stop_event
def update_segment(self, segment: PendingReviewSegment) -> None:
"""Update segment."""
seg_data = segment.get_data(ended=False)
self.requestor.send_data(UPSERT_REVIEW_SEGMENT, seg_data)
self.requestor.send_data(
"reviews",
json.dumps(
{"type": "update", "review": {k.name: v for k, v in seg_data.items()}}
),
)
def end_segment(self, segment: PendingReviewSegment) -> None: def end_segment(self, segment: PendingReviewSegment) -> None:
"""End segment.""" """End segment."""
seg_data = segment.end() seg_data = segment.get_data(ended=True)
self.requestor.send_data(UPSERT_REVIEW_SEGMENT, seg_data) self.requestor.send_data(UPSERT_REVIEW_SEGMENT, seg_data)
self.requestor.send_data( self.requestor.send_data(
"reviews", "reviews",
@ -158,7 +165,6 @@ class ReviewSegmentMaintainer(threading.Thread):
segment: PendingReviewSegment, segment: PendingReviewSegment,
frame_time: float, frame_time: float,
objects: list[TrackedObject], objects: list[TrackedObject],
motion: list,
) -> None: ) -> None:
"""Validate if existing review segment should continue.""" """Validate if existing review segment should continue."""
camera_config = self.config.cameras[segment.camera] camera_config = self.config.cameras[segment.camera]
@ -168,18 +174,6 @@ class ReviewSegmentMaintainer(threading.Thread):
if frame_time > segment.last_update: if frame_time > segment.last_update:
segment.last_update = frame_time segment.last_update = frame_time
# update type for this segment now that active objects are detected
if segment.severity == SeverityEnum.signification_motion:
segment.severity = SeverityEnum.detection
if len(active_objects) > segment.frame_active_count:
frame_id = f"{camera_config.name}{frame_time}"
yuv_frame = self.frame_manager.get(
frame_id, camera_config.frame_shape_yuv
)
segment.update_frame(camera_config, yuv_frame, active_objects)
self.frame_manager.close(frame_id)
for object in active_objects: for object in active_objects:
if not object["sub_label"]: if not object["sub_label"]:
segment.detections[object["id"]] = object["label"] segment.detections[object["id"]] = object["label"]
@ -188,24 +182,38 @@ class ReviewSegmentMaintainer(threading.Thread):
else: else:
segment.detections[object["id"]] = f'{object["label"]}-verified' segment.detections[object["id"]] = f'{object["label"]}-verified'
# if object is alert label and has qualified for recording # if object is alert label
# and has entered required zones or required zones is not set
# mark this review as alert # mark this review as alert
if ( if (
segment.severity == SeverityEnum.detection segment.severity != SeverityEnum.alert
and object["has_clip"] and object["label"] in camera_config.review.alerts.labels
and object["label"] in camera_config.objects.alert and (
not camera_config.review.alerts.required_zones
or (
len(object["current_zones"]) > 0
and set(object["current_zones"])
& set(camera_config.review.alerts.required_zones)
)
)
): ):
segment.severity = SeverityEnum.alert segment.severity = SeverityEnum.alert
# keep zones up to date # keep zones up to date
if len(object["current_zones"]) > 0: if len(object["current_zones"]) > 0:
segment.zones.update(object["current_zones"]) segment.zones.update(object["current_zones"])
elif (
segment.severity == SeverityEnum.signification_motion if len(active_objects) > segment.frame_active_count:
and len(motion) >= THRESHOLD_MOTION_ACTIVITY try:
): frame_id = f"{camera_config.name}{frame_time}"
if frame_time > segment.last_update: yuv_frame = self.frame_manager.get(
segment.last_update = frame_time frame_id, camera_config.frame_shape_yuv
)
segment.update_frame(camera_config, yuv_frame, active_objects)
self.frame_manager.close(frame_id)
self.update_segment(segment)
except FileNotFoundError:
return
else: else:
if segment.severity == SeverityEnum.alert and frame_time > ( if segment.severity == SeverityEnum.alert and frame_time > (
segment.last_update + THRESHOLD_ALERT_ACTIVITY segment.last_update + THRESHOLD_ALERT_ACTIVITY
@ -219,7 +227,6 @@ class ReviewSegmentMaintainer(threading.Thread):
camera: str, camera: str,
frame_time: float, frame_time: float,
objects: list[TrackedObject], objects: list[TrackedObject],
motion: list,
) -> None: ) -> None:
"""Check if a new review segment should be created.""" """Check if a new review segment should be created."""
camera_config = self.config.cameras[camera] camera_config = self.config.cameras[camera]
@ -229,15 +236,9 @@ class ReviewSegmentMaintainer(threading.Thread):
has_sig_object = False has_sig_object = False
detections: dict[str, str] = {} detections: dict[str, str] = {}
zones: set = set() zones: set = set()
severity = None
for object in active_objects: for object in active_objects:
if (
not has_sig_object
and object["has_clip"]
and object["label"] in camera_config.objects.alert
):
has_sig_object = True
if not object["sub_label"]: if not object["sub_label"]:
detections[object["id"]] = object["label"] detections[object["id"]] = object["label"]
elif object["sub_label"][0] in ALL_ATTRIBUTE_LABELS: elif object["sub_label"][0] in ALL_ATTRIBUTE_LABELS:
@ -245,8 +246,47 @@ class ReviewSegmentMaintainer(threading.Thread):
else: else:
detections[object["id"]] = f'{object["label"]}-verified' detections[object["id"]] = f'{object["label"]}-verified'
# if object is alert label
# and has entered required zones or required zones is not set
# mark this review as alert
if (
severity != SeverityEnum.alert
and object["label"] in camera_config.review.alerts.labels
and (
not camera_config.review.alerts.required_zones
or (
len(object["current_zones"]) > 0
and set(object["current_zones"])
& set(camera_config.review.alerts.required_zones)
)
)
):
severity = SeverityEnum.alert
# if object is detection label
# and review is not already a detection or alert
# and has entered required zones or required zones is not set
# mark this review as alert
if (
not severity
and (
not camera_config.review.detections.labels
or object["label"] in (camera_config.review.detections.labels)
)
and (
not camera_config.review.detections.required_zones
or (
len(object["current_zones"]) > 0
and set(object["current_zones"])
& set(camera_config.review.detections.required_zones)
)
)
):
severity = SeverityEnum.detection
zones.update(object["current_zones"]) zones.update(object["current_zones"])
if severity:
self.active_review_segments[camera] = PendingReviewSegment( self.active_review_segments[camera] = PendingReviewSegment(
camera, camera,
frame_time, frame_time,
@ -254,25 +294,20 @@ class ReviewSegmentMaintainer(threading.Thread):
detections, detections,
audio=set(), audio=set(),
zones=zones, zones=zones,
motion=[],
) )
try:
frame_id = f"{camera_config.name}{frame_time}" frame_id = f"{camera_config.name}{frame_time}"
yuv_frame = self.frame_manager.get(frame_id, camera_config.frame_shape_yuv) yuv_frame = self.frame_manager.get(
frame_id, camera_config.frame_shape_yuv
)
self.active_review_segments[camera].update_frame( self.active_review_segments[camera].update_frame(
camera_config, yuv_frame, active_objects camera_config, yuv_frame, active_objects
) )
self.frame_manager.close(frame_id) self.frame_manager.close(frame_id)
elif len(motion) >= 20: self.update_segment(self.active_review_segments[camera])
self.active_review_segments[camera] = PendingReviewSegment( except FileNotFoundError:
camera, return
frame_time,
SeverityEnum.signification_motion,
detections={},
audio=set(),
motion=motion,
zones=set(),
)
def run(self) -> None: def run(self) -> None:
while not self.stop_event.is_set(): while not self.stop_event.is_set():
@ -330,13 +365,22 @@ class ReviewSegmentMaintainer(threading.Thread):
current_segment, current_segment,
frame_time, frame_time,
current_tracked_objects, current_tracked_objects,
motion_boxes,
) )
elif topic == DetectionTypeEnum.audio and len(audio_detections) > 0: elif topic == DetectionTypeEnum.audio and len(audio_detections) > 0:
camera_config = self.config.cameras[camera]
if frame_time > current_segment.last_update: if frame_time > current_segment.last_update:
current_segment.last_update = frame_time current_segment.last_update = frame_time
current_segment.audio.update(audio_detections) for audio in audio_detections:
if audio in camera_config.review.alerts.labels:
current_segment.audio.add(audio)
current_segment.severity = SeverityEnum.alert
elif (
not camera_config.review.detections.labels
or audio in camera_config.review.detections.labels
):
current_segment.audio.add(audio)
elif topic == DetectionTypeEnum.api: elif topic == DetectionTypeEnum.api:
if manual_info["state"] == ManualEventState.complete: if manual_info["state"] == ManualEventState.complete:
current_segment.detections[manual_info["event_id"]] = ( current_segment.detections[manual_info["event_id"]] = (
@ -364,17 +408,34 @@ class ReviewSegmentMaintainer(threading.Thread):
camera, camera,
frame_time, frame_time,
current_tracked_objects, current_tracked_objects,
motion_boxes,
) )
elif topic == DetectionTypeEnum.audio and len(audio_detections) > 0: elif topic == DetectionTypeEnum.audio and len(audio_detections) > 0:
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( self.active_review_segments[camera] = PendingReviewSegment(
camera, camera,
frame_time, frame_time,
SeverityEnum.detection, severity,
{}, {},
set(), set(),
set(audio_detections), detections,
[],
) )
elif topic == DetectionTypeEnum.api: elif topic == DetectionTypeEnum.api:
self.active_review_segments[camera] = PendingReviewSegment( self.active_review_segments[camera] = PendingReviewSegment(
@ -384,7 +445,6 @@ class ReviewSegmentMaintainer(threading.Thread):
{manual_info["event_id"]: manual_info["label"]}, {manual_info["event_id"]: manual_info["label"]},
set(), set(),
set(), set(),
[],
) )
if manual_info["state"] == ManualEventState.start: if manual_info["state"] == ManualEventState.start:
@ -398,6 +458,11 @@ class ReviewSegmentMaintainer(threading.Thread):
"end_time" "end_time"
] ]
self.config_subscriber.stop()
self.requestor.stop()
self.detection_subscriber.stop()
logger.info("Exiting review maintainer...")
def get_active_objects( def get_active_objects(
frame_time: float, camera_config: CameraConfig, all_objects: list[TrackedObject] frame_time: float, camera_config: CameraConfig, all_objects: list[TrackedObject]
@ -406,8 +471,16 @@ def get_active_objects(
return [ return [
o o
for o in all_objects for o in all_objects
if o["motionless_count"] < camera_config.detect.stationary.threshold if o["motionless_count"]
and o["position_changes"] > 0 < camera_config.detect.stationary.threshold # no stationary objects
and o["frame_time"] == frame_time and o["position_changes"] > 0 # object must have moved at least once
and not o["false_positive"] and o["frame_time"] == frame_time # object must be detected in this frame
and not o["false_positive"] # object must not be a false positive
and (
o["label"] in camera_config.review.alerts.labels
or (
not camera_config.review.detections.labels
or o["label"] in camera_config.review.detections.labels
)
) # object must be in the alerts or detections label list
] ]

View File

@ -64,7 +64,7 @@ class TestConfig(unittest.TestCase):
def test_config_class(self): def test_config_class(self):
frigate_config = FrigateConfig(**self.minimal) frigate_config = FrigateConfig(**self.minimal)
assert self.minimal == frigate_config.dict(exclude_unset=True) assert self.minimal == frigate_config.model_dump(exclude_unset=True)
runtime_config = frigate_config.runtime_config() runtime_config = frigate_config.runtime_config()
assert "cpu" in runtime_config.detectors.keys() assert "cpu" in runtime_config.detectors.keys()
@ -157,7 +157,7 @@ class TestConfig(unittest.TestCase):
}, },
} }
frigate_config = FrigateConfig(**config) frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True) assert config == frigate_config.model_dump(exclude_unset=True)
runtime_config = frigate_config.runtime_config() runtime_config = frigate_config.runtime_config()
assert "dog" in runtime_config.cameras["back"].objects.track assert "dog" in runtime_config.cameras["back"].objects.track
@ -183,7 +183,7 @@ class TestConfig(unittest.TestCase):
}, },
} }
frigate_config = FrigateConfig(**config) frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True) assert config == frigate_config.model_dump(exclude_unset=True)
runtime_config = frigate_config.runtime_config() runtime_config = frigate_config.runtime_config()
assert not runtime_config.cameras["back"].birdseye.enabled assert not runtime_config.cameras["back"].birdseye.enabled
@ -209,7 +209,7 @@ class TestConfig(unittest.TestCase):
}, },
} }
frigate_config = FrigateConfig(**config) frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True) assert config == frigate_config.model_dump(exclude_unset=True)
runtime_config = frigate_config.runtime_config() runtime_config = frigate_config.runtime_config()
assert runtime_config.cameras["back"].birdseye.enabled assert runtime_config.cameras["back"].birdseye.enabled
@ -234,7 +234,7 @@ class TestConfig(unittest.TestCase):
}, },
} }
frigate_config = FrigateConfig(**config) frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True) assert config == frigate_config.model_dump(exclude_unset=True)
runtime_config = frigate_config.runtime_config() runtime_config = frigate_config.runtime_config()
assert runtime_config.cameras["back"].birdseye.enabled assert runtime_config.cameras["back"].birdseye.enabled
@ -263,7 +263,7 @@ class TestConfig(unittest.TestCase):
}, },
} }
frigate_config = FrigateConfig(**config) frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True) assert config == frigate_config.model_dump(exclude_unset=True)
runtime_config = frigate_config.runtime_config() runtime_config = frigate_config.runtime_config()
assert "cat" in runtime_config.cameras["back"].objects.track assert "cat" in runtime_config.cameras["back"].objects.track
@ -288,7 +288,7 @@ class TestConfig(unittest.TestCase):
}, },
} }
frigate_config = FrigateConfig(**config) frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True) assert config == frigate_config.model_dump(exclude_unset=True)
runtime_config = frigate_config.runtime_config() runtime_config = frigate_config.runtime_config()
assert "dog" in runtime_config.cameras["back"].objects.filters assert "dog" in runtime_config.cameras["back"].objects.filters
@ -316,7 +316,7 @@ class TestConfig(unittest.TestCase):
}, },
} }
frigate_config = FrigateConfig(**config) frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True) assert config == frigate_config.model_dump(exclude_unset=True)
runtime_config = frigate_config.runtime_config() runtime_config = frigate_config.runtime_config()
assert "dog" in runtime_config.cameras["back"].objects.filters assert "dog" in runtime_config.cameras["back"].objects.filters
@ -345,7 +345,7 @@ class TestConfig(unittest.TestCase):
}, },
} }
frigate_config = FrigateConfig(**config) frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True) assert config == frigate_config.model_dump(exclude_unset=True)
runtime_config = frigate_config.runtime_config() runtime_config = frigate_config.runtime_config()
assert "dog" in runtime_config.cameras["back"].objects.filters assert "dog" in runtime_config.cameras["back"].objects.filters
@ -375,7 +375,7 @@ class TestConfig(unittest.TestCase):
}, },
} }
frigate_config = FrigateConfig(**config) frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True) assert config == frigate_config.model_dump(exclude_unset=True)
runtime_config = frigate_config.runtime_config() runtime_config = frigate_config.runtime_config()
back_camera = runtime_config.cameras["back"] back_camera = runtime_config.cameras["back"]
@ -383,6 +383,55 @@ class TestConfig(unittest.TestCase):
assert len(back_camera.objects.filters["dog"].raw_mask) == 2 assert len(back_camera.objects.filters["dog"].raw_mask) == 2
assert len(back_camera.objects.filters["person"].raw_mask) == 1 assert len(back_camera.objects.filters["person"].raw_mask) == 1
def test_motion_mask_relative_matches_explicit(self):
config = {
"mqtt": {"host": "mqtt"},
"record": {
"events": {"retain": {"default": 20, "objects": {"person": 30}}}
},
"cameras": {
"explicit": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 400,
"width": 800,
"fps": 5,
},
"motion": {
"mask": [
"0,0,200,100,600,300,800,400",
]
},
},
"relative": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 400,
"width": 800,
"fps": 5,
},
"motion": {
"mask": [
"0.0,0.0,0.25,0.25,0.75,0.75,1.0,1.0",
]
},
},
},
}
frigate_config = FrigateConfig(**config).runtime_config()
assert np.array_equal(
frigate_config.cameras["explicit"].motion.mask,
frigate_config.cameras["relative"].motion.mask,
)
def test_default_input_args(self): def test_default_input_args(self):
config = { config = {
"mqtt": {"host": "mqtt"}, "mqtt": {"host": "mqtt"},
@ -406,7 +455,7 @@ class TestConfig(unittest.TestCase):
} }
frigate_config = FrigateConfig(**config) frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True) assert config == frigate_config.model_dump(exclude_unset=True)
runtime_config = frigate_config.runtime_config() runtime_config = frigate_config.runtime_config()
assert "-rtsp_transport" in runtime_config.cameras["back"].ffmpeg_cmds[0]["cmd"] assert "-rtsp_transport" in runtime_config.cameras["back"].ffmpeg_cmds[0]["cmd"]
@ -435,7 +484,7 @@ class TestConfig(unittest.TestCase):
}, },
} }
frigate_config = FrigateConfig(**config) frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True) assert config == frigate_config.model_dump(exclude_unset=True)
runtime_config = frigate_config.runtime_config() runtime_config = frigate_config.runtime_config()
assert "-re" in runtime_config.cameras["back"].ffmpeg_cmds[0]["cmd"] assert "-re" in runtime_config.cameras["back"].ffmpeg_cmds[0]["cmd"]
@ -465,7 +514,7 @@ class TestConfig(unittest.TestCase):
}, },
} }
frigate_config = FrigateConfig(**config) frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True) assert config == frigate_config.model_dump(exclude_unset=True)
runtime_config = frigate_config.runtime_config() runtime_config = frigate_config.runtime_config()
assert "-re" in runtime_config.cameras["back"].ffmpeg_cmds[0]["cmd"] assert "-re" in runtime_config.cameras["back"].ffmpeg_cmds[0]["cmd"]
@ -500,7 +549,7 @@ class TestConfig(unittest.TestCase):
}, },
} }
frigate_config = FrigateConfig(**config) frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True) assert config == frigate_config.model_dump(exclude_unset=True)
runtime_config = frigate_config.runtime_config() runtime_config = frigate_config.runtime_config()
assert "-re" in runtime_config.cameras["back"].ffmpeg_cmds[0]["cmd"] assert "-re" in runtime_config.cameras["back"].ffmpeg_cmds[0]["cmd"]
@ -530,7 +579,7 @@ class TestConfig(unittest.TestCase):
}, },
} }
frigate_config = FrigateConfig(**config) frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True) assert config == frigate_config.model_dump(exclude_unset=True)
runtime_config = frigate_config.runtime_config() runtime_config = frigate_config.runtime_config()
assert ( assert (
@ -608,7 +657,7 @@ class TestConfig(unittest.TestCase):
}, },
} }
frigate_config = FrigateConfig(**config) frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True) assert config == frigate_config.model_dump(exclude_unset=True)
runtime_config = frigate_config.runtime_config() runtime_config = frigate_config.runtime_config()
assert isinstance( assert isinstance(
@ -616,6 +665,41 @@ class TestConfig(unittest.TestCase):
) )
assert runtime_config.cameras["back"].zones["test"].color != (0, 0, 0) assert runtime_config.cameras["back"].zones["test"].color != (0, 0, 0)
def test_zone_relative_matches_explicit(self):
config = {
"mqtt": {"host": "mqtt"},
"record": {
"events": {"retain": {"default": 20, "objects": {"person": 30}}}
},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 400,
"width": 800,
"fps": 5,
},
"zones": {
"explicit": {
"coordinates": "0,0,200,100,600,300,800,400",
},
"relative": {
"coordinates": "0.0,0.0,0.25,0.25,0.75,0.75,1.0,1.0",
},
},
}
},
}
frigate_config = FrigateConfig(**config).runtime_config()
assert np.array_equal(
frigate_config.cameras["back"].zones["explicit"].contour,
frigate_config.cameras["back"].zones["relative"].contour,
)
def test_clips_should_default_to_global_objects(self): def test_clips_should_default_to_global_objects(self):
config = { config = {
"mqtt": {"host": "mqtt"}, "mqtt": {"host": "mqtt"},
@ -640,7 +724,7 @@ class TestConfig(unittest.TestCase):
}, },
} }
frigate_config = FrigateConfig(**config) frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True) assert config == frigate_config.model_dump(exclude_unset=True)
runtime_config = frigate_config.runtime_config() runtime_config = frigate_config.runtime_config()
back_camera = runtime_config.cameras["back"] back_camera = runtime_config.cameras["back"]
@ -671,7 +755,7 @@ class TestConfig(unittest.TestCase):
} }
frigate_config = FrigateConfig(**config) frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True) assert config == frigate_config.model_dump(exclude_unset=True)
runtime_config = frigate_config.runtime_config() runtime_config = frigate_config.runtime_config()
ffmpeg_cmds = runtime_config.cameras["back"].ffmpeg_cmds ffmpeg_cmds = runtime_config.cameras["back"].ffmpeg_cmds
@ -702,7 +786,7 @@ class TestConfig(unittest.TestCase):
} }
frigate_config = FrigateConfig(**config) frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True) assert config == frigate_config.model_dump(exclude_unset=True)
runtime_config = frigate_config.runtime_config() runtime_config = frigate_config.runtime_config()
assert runtime_config.cameras["back"].detect.max_disappeared == 5 * 5 assert runtime_config.cameras["back"].detect.max_disappeared == 5 * 5
@ -730,7 +814,7 @@ class TestConfig(unittest.TestCase):
} }
frigate_config = FrigateConfig(**config) frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True) assert config == frigate_config.model_dump(exclude_unset=True)
runtime_config = frigate_config.runtime_config() runtime_config = frigate_config.runtime_config()
assert runtime_config.cameras["back"].motion.frame_height == 100 assert runtime_config.cameras["back"].motion.frame_height == 100
@ -758,7 +842,7 @@ class TestConfig(unittest.TestCase):
} }
frigate_config = FrigateConfig(**config) frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True) assert config == frigate_config.model_dump(exclude_unset=True)
runtime_config = frigate_config.runtime_config() runtime_config = frigate_config.runtime_config()
assert round(runtime_config.cameras["back"].motion.contour_area) == 10 assert round(runtime_config.cameras["back"].motion.contour_area) == 10
@ -787,7 +871,7 @@ class TestConfig(unittest.TestCase):
} }
frigate_config = FrigateConfig(**config) frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True) assert config == frigate_config.model_dump(exclude_unset=True)
runtime_config = frigate_config.runtime_config() runtime_config = frigate_config.runtime_config()
assert runtime_config.model.merged_labelmap[7] == "truck" assert runtime_config.model.merged_labelmap[7] == "truck"
@ -815,7 +899,7 @@ class TestConfig(unittest.TestCase):
} }
frigate_config = FrigateConfig(**config) frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True) assert config == frigate_config.model_dump(exclude_unset=True)
runtime_config = frigate_config.runtime_config() runtime_config = frigate_config.runtime_config()
assert runtime_config.model.merged_labelmap[0] == "person" assert runtime_config.model.merged_labelmap[0] == "person"
@ -844,7 +928,7 @@ class TestConfig(unittest.TestCase):
} }
frigate_config = FrigateConfig(**config) frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True) assert config == frigate_config.model_dump(exclude_unset=True)
runtime_config = frigate_config.runtime_config() runtime_config = frigate_config.runtime_config()
assert runtime_config.model.merged_labelmap[0] == "person" assert runtime_config.model.merged_labelmap[0] == "person"
@ -878,7 +962,7 @@ class TestConfig(unittest.TestCase):
} }
frigate_config = FrigateConfig(**config) frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True) assert config == frigate_config.model_dump(exclude_unset=True)
runtime_config = frigate_config.runtime_config(PlusApi()) runtime_config = frigate_config.runtime_config(PlusApi())
assert runtime_config.model.merged_labelmap[0] == "amazon" assert runtime_config.model.merged_labelmap[0] == "amazon"
@ -1012,7 +1096,7 @@ class TestConfig(unittest.TestCase):
}, },
} }
frigate_config = FrigateConfig(**config) frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True) assert config == frigate_config.model_dump(exclude_unset=True)
runtime_config = frigate_config.runtime_config() runtime_config = frigate_config.runtime_config()
assert runtime_config.cameras["back"].detect.max_disappeared == 1 assert runtime_config.cameras["back"].detect.max_disappeared == 1
@ -1040,7 +1124,7 @@ class TestConfig(unittest.TestCase):
}, },
} }
frigate_config = FrigateConfig(**config) frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True) assert config == frigate_config.model_dump(exclude_unset=True)
runtime_config = frigate_config.runtime_config() runtime_config = frigate_config.runtime_config()
assert runtime_config.cameras["back"].detect.max_disappeared == 25 assert runtime_config.cameras["back"].detect.max_disappeared == 25
@ -1069,7 +1153,7 @@ class TestConfig(unittest.TestCase):
}, },
} }
frigate_config = FrigateConfig(**config) frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True) assert config == frigate_config.model_dump(exclude_unset=True)
runtime_config = frigate_config.runtime_config() runtime_config = frigate_config.runtime_config()
assert runtime_config.cameras["back"].detect.max_disappeared == 1 assert runtime_config.cameras["back"].detect.max_disappeared == 1
@ -1102,7 +1186,7 @@ class TestConfig(unittest.TestCase):
}, },
} }
frigate_config = FrigateConfig(**config) frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True) assert config == frigate_config.model_dump(exclude_unset=True)
runtime_config = frigate_config.runtime_config() runtime_config = frigate_config.runtime_config()
assert runtime_config.cameras["back"].snapshots.enabled assert runtime_config.cameras["back"].snapshots.enabled
@ -1130,7 +1214,7 @@ class TestConfig(unittest.TestCase):
}, },
} }
frigate_config = FrigateConfig(**config) frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True) assert config == frigate_config.model_dump(exclude_unset=True)
runtime_config = frigate_config.runtime_config() runtime_config = frigate_config.runtime_config()
assert runtime_config.cameras["back"].snapshots.bounding_box assert runtime_config.cameras["back"].snapshots.bounding_box
@ -1163,7 +1247,7 @@ class TestConfig(unittest.TestCase):
}, },
} }
frigate_config = FrigateConfig(**config) frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True) assert config == frigate_config.model_dump(exclude_unset=True)
runtime_config = frigate_config.runtime_config() runtime_config = frigate_config.runtime_config()
assert runtime_config.cameras["back"].snapshots.bounding_box is False assert runtime_config.cameras["back"].snapshots.bounding_box is False
@ -1193,7 +1277,7 @@ class TestConfig(unittest.TestCase):
}, },
} }
frigate_config = FrigateConfig(**config) frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True) assert config == frigate_config.model_dump(exclude_unset=True)
runtime_config = frigate_config.runtime_config() runtime_config = frigate_config.runtime_config()
assert runtime_config.cameras["back"].live.quality == 4 assert runtime_config.cameras["back"].live.quality == 4
@ -1220,7 +1304,7 @@ class TestConfig(unittest.TestCase):
}, },
} }
frigate_config = FrigateConfig(**config) frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True) assert config == frigate_config.model_dump(exclude_unset=True)
runtime_config = frigate_config.runtime_config() runtime_config = frigate_config.runtime_config()
assert runtime_config.cameras["back"].live.quality == 8 assert runtime_config.cameras["back"].live.quality == 8
@ -1251,7 +1335,7 @@ class TestConfig(unittest.TestCase):
}, },
} }
frigate_config = FrigateConfig(**config) frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True) assert config == frigate_config.model_dump(exclude_unset=True)
runtime_config = frigate_config.runtime_config() runtime_config = frigate_config.runtime_config()
assert runtime_config.cameras["back"].live.quality == 7 assert runtime_config.cameras["back"].live.quality == 7
@ -1280,7 +1364,7 @@ class TestConfig(unittest.TestCase):
}, },
} }
frigate_config = FrigateConfig(**config) frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True) assert config == frigate_config.model_dump(exclude_unset=True)
runtime_config = frigate_config.runtime_config() runtime_config = frigate_config.runtime_config()
assert runtime_config.cameras["back"].timestamp_style.position == "bl" assert runtime_config.cameras["back"].timestamp_style.position == "bl"
@ -1307,7 +1391,7 @@ class TestConfig(unittest.TestCase):
}, },
} }
frigate_config = FrigateConfig(**config) frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True) assert config == frigate_config.model_dump(exclude_unset=True)
runtime_config = frigate_config.runtime_config() runtime_config = frigate_config.runtime_config()
assert runtime_config.cameras["back"].timestamp_style.position == "tl" assert runtime_config.cameras["back"].timestamp_style.position == "tl"
@ -1336,7 +1420,7 @@ class TestConfig(unittest.TestCase):
}, },
} }
frigate_config = FrigateConfig(**config) frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True) assert config == frigate_config.model_dump(exclude_unset=True)
runtime_config = frigate_config.runtime_config() runtime_config = frigate_config.runtime_config()
assert runtime_config.cameras["back"].timestamp_style.position == "bl" assert runtime_config.cameras["back"].timestamp_style.position == "bl"
@ -1365,7 +1449,7 @@ class TestConfig(unittest.TestCase):
}, },
} }
frigate_config = FrigateConfig(**config) frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True) assert config == frigate_config.model_dump(exclude_unset=True)
runtime_config = frigate_config.runtime_config() runtime_config = frigate_config.runtime_config()
assert runtime_config.cameras["back"].snapshots.retain.default == 1.5 assert runtime_config.cameras["back"].snapshots.retain.default == 1.5
@ -1505,7 +1589,7 @@ class TestConfig(unittest.TestCase):
}, },
} }
frigate_config = FrigateConfig(**config) frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True) assert config == frigate_config.model_dump(exclude_unset=True)
runtime_config = frigate_config.runtime_config() runtime_config = frigate_config.runtime_config()
assert "dog" in runtime_config.cameras["back"].objects.filters assert "dog" in runtime_config.cameras["back"].objects.filters

127
frigate/util/config.py Normal file
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 return mask_img
def add_mask(mask, mask_img): def add_mask(mask: str, mask_img: np.ndarray):
points = mask.split(",") points = mask.split(",")
# masks and zones are saved as relative coordinates
# we know if any points are > 1 then it is using the
# old native resolution coordinates
if any(x > "1.0" for x in points):
raise Exception("add mask expects relative coordinates only")
contour = np.array( contour = np.array(
[[int(points[i]), int(points[i + 1])] for i in range(0, len(points), 2)] [
[
int(float(points[i]) * mask_img.shape[1]),
int(float(points[i + 1]) * mask_img.shape[0]),
]
for i in range(0, len(points), 2)
]
) )
cv2.fillPoly(mask_img, pts=[contour], color=(0)) cv2.fillPoly(mask_img, pts=[contour], color=(0))

328
web/package-lock.json generated
View File

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

View File

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

View File

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

View File

@ -32,7 +32,7 @@ export default function Statusbar() {
const { potentialProblems } = useStats(stats); const { potentialProblems } = useStats(stats);
return ( return (
<div className="absolute left-0 bottom-0 right-0 w-full h-8 flex justify-between items-center px-4 bg-primary z-10 text-secondary-foreground border-t border-secondary-highlight"> <div className="absolute left-0 bottom-0 right-0 w-full h-8 flex justify-between items-center px-4 bg-background_alt z-10 dark:text-secondary-foreground border-t border-secondary-highlight">
<div className="h-full flex items-center gap-2"> <div className="h-full flex items-center gap-2">
{cpuPercent && ( {cpuPercent && (
<div className="flex items-center text-sm gap-2"> <div className="flex items-center text-sm gap-2">

View File

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

View File

@ -12,6 +12,7 @@ import { Input } from "../ui/input";
import useKeyboardListener from "@/hooks/use-keyboard-listener"; import useKeyboardListener from "@/hooks/use-keyboard-listener";
type ExportProps = { type ExportProps = {
className: string;
file: { file: {
name: string; name: string;
}; };
@ -19,7 +20,12 @@ type ExportProps = {
onDelete: (file: string) => void; onDelete: (file: string) => void;
}; };
export default function ExportCard({ file, onRename, onDelete }: ExportProps) { export default function ExportCard({
className,
file,
onRename,
onDelete,
}: ExportProps) {
const videoRef = useRef<HTMLVideoElement | null>(null); const videoRef = useRef<HTMLVideoElement | null>(null);
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const [playing, setPlaying] = useState(false); const [playing, setPlaying] = useState(false);
@ -94,7 +100,7 @@ export default function ExportCard({ file, onRename, onDelete }: ExportProps) {
</Dialog> </Dialog>
<div <div
className="relative aspect-video bg-black rounded-2xl flex justify-center items-center" className={`relative aspect-video bg-black rounded-2xl flex justify-center items-center ${className}`}
onMouseEnter={ onMouseEnter={
isDesktop && !inProgress ? () => setHovered(true) : undefined isDesktop && !inProgress ? () => setHovered(true) : undefined
} }

View File

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

View File

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

View File

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

View File

@ -19,7 +19,7 @@ export default function FilterCheckBox({
}: FilterCheckBoxProps) { }: FilterCheckBoxProps) {
return ( return (
<Button <Button
className="capitalize flex justify-between items-center cursor-pointer w-full text-primary-foreground" className="capitalize flex justify-between items-center cursor-pointer w-full text-primary"
variant="ghost" variant="ghost"
onClick={() => onCheckedChange(!isChecked)} onClick={() => onCheckedChange(!isChecked)}
> >

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

View File

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

View File

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

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"; import { CSSTransition } from "react-transition-group";
type ChipProps = { type ChipProps = {
@ -39,3 +40,35 @@ export default function Chip({
</CSSTransition> </CSSTransition>
); );
} }
type LogChipProps = {
severity: LogSeverity;
onClickSeverity?: () => void;
};
export function LogChip({ severity, onClickSeverity }: LogChipProps) {
const severityClassName = useMemo(() => {
switch (severity) {
case "info":
return "text-primary/60 bg-secondary hover:bg-secondary/60";
case "warning":
return "text-warning-foreground bg-warning hover:bg-warning/80";
case "error":
return "text-destructive-foreground bg-destructive hover:bg-destructive/80";
}
}, [severity]);
return (
<div
className={`py-[1px] px-1 capitalize text-xs rounded-md ${onClickSeverity ? "cursor-pointer" : ""} ${severityClassName}`}
onClick={(e) => {
e.stopPropagation();
if (onClickSeverity) {
onClickSeverity();
}
}}
>
{severity}
</div>
);
}

View File

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

View File

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

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 Logo from "../Logo";
import { navbarLinks } from "@/pages/site-navigation";
import NavItem from "./NavItem"; import NavItem from "./NavItem";
import { CameraGroupSelector } from "../filter/CameraGroupSelector"; import { CameraGroupSelector } from "../filter/CameraGroupSelector";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import GeneralSettings from "../settings/GeneralSettings"; import GeneralSettings from "../settings/GeneralSettings";
import AccountSettings from "../settings/AccountSettings"; import AccountSettings from "../settings/AccountSettings";
import useNavigation from "@/hooks/use-navigation";
function Sidebar() { function Sidebar() {
const location = useLocation(); const location = useLocation();
const navbarLinks = useNavigation();
return ( return (
<aside className="absolute w-[52px] z-10 left-o inset-y-0 overflow-y-auto scrollbar-hidden py-4 flex flex-col justify-between bg-primary border-r border-secondary-highlight"> <aside className="absolute w-[52px] z-10 left-o inset-y-0 overflow-y-auto scrollbar-hidden py-4 flex flex-col justify-between bg-background_alt border-r border-secondary-highlight">
<span tabIndex={0} className="sr-only" /> <span tabIndex={0} className="sr-only" />
<div className="w-full flex flex-col gap-0 items-center"> <div className="w-full flex flex-col gap-0 items-center">
<Logo className="w-8 h-8 mb-6" /> <Logo className="w-8 h-8 mb-6" />
@ -22,10 +24,8 @@ function Sidebar() {
<div key={item.id}> <div key={item.id}>
<NavItem <NavItem
className={`mx-[10px] ${showCameraGroups ? "mb-2" : "mb-4"}`} className={`mx-[10px] ${showCameraGroups ? "mb-2" : "mb-4"}`}
item={item}
Icon={item.icon} Icon={item.icon}
title={item.title}
url={item.url}
dev={item.dev}
/> />
{showCameraGroups && <CameraGroupSelector className="mb-4" />} {showCameraGroups && <CameraGroupSelector className="mb-4" />}
</div> </div>

View File

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

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 ( return (
<Drawer open={cameraDrawer} onOpenChange={setCameraDrawer}> <Drawer open={cameraDrawer} onOpenChange={setCameraDrawer}>
<DrawerTrigger asChild> <DrawerTrigger asChild>
<Button className="rounded-lg capitalize" size="sm" variant="secondary"> <Button className="rounded-lg capitalize" size="sm">
<FaVideo className="text-secondary-foreground" /> <FaVideo className="text-secondary-foreground" />
</Button> </Button>
</DrawerTrigger> </DrawerTrigger>

View File

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

View File

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

View File

@ -17,7 +17,7 @@ export default function SaveExportOverlay({
return ( return (
<div className={className}> <div className={className}>
<div <div
className={`flex justify-center px-2 gap-2 items-center pointer-events-auto rounded-lg *:text-white ${ className={`flex justify-center px-2 gap-2 items-center pointer-events-auto rounded-lg ${
show ? "animate-in slide-in-from-top duration-500" : "invisible" show ? "animate-in slide-in-from-top duration-500" : "invisible"
} text-center mt-5 mx-auto`} } text-center mt-5 mx-auto`}
> >
@ -31,9 +31,8 @@ export default function SaveExportOverlay({
Save Export Save Export
</Button> </Button>
<Button <Button
className="flex items-center gap-1" className="flex items-center gap-1 text-primary"
size="sm" size="sm"
variant="secondary"
onClick={onCancel} onClick={onCancel}
> >
<LuX /> <LuX />

View File

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

View File

@ -19,7 +19,6 @@ const unsupportedErrorCodes = [
]; ];
type HlsVideoPlayerProps = { type HlsVideoPlayerProps = {
className: string;
children?: ReactNode; children?: ReactNode;
videoRef: MutableRefObject<HTMLVideoElement | null>; videoRef: MutableRefObject<HTMLVideoElement | null>;
visible: boolean; visible: boolean;
@ -31,7 +30,6 @@ type HlsVideoPlayerProps = {
onPlaying?: () => void; onPlaying?: () => void;
}; };
export default function HlsVideoPlayer({ export default function HlsVideoPlayer({
className,
children, children,
videoRef, videoRef,
visible, visible,
@ -91,26 +89,10 @@ export default function HlsVideoPlayer({
return ( return (
<TransformWrapper minScale={1.0}> <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 <TransformComponent
wrapperStyle={{ wrapperStyle={{
position: "relative",
display: visible ? undefined : "none",
width: "100%", width: "100%",
height: "100%", height: "100%",
}} }}
@ -132,9 +114,7 @@ export default function HlsVideoPlayer({
if (isMobile) { if (isMobile) {
setControls(true); setControls(true);
setMobileCtrlTimeout( setMobileCtrlTimeout(setTimeout(() => setControls(false), 4000));
setTimeout(() => setControls(false), 4000),
);
} }
}} }}
onPlaying={onPlaying} onPlaying={onPlaying}
@ -165,7 +145,25 @@ export default function HlsVideoPlayer({
} }
}} }}
/> />
</TransformComponent> <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 <VideoControls
className="absolute bottom-5 left-1/2 -translate-x-1/2" className="absolute bottom-5 left-1/2 -translate-x-1/2"
video={videoRef.current} video={videoRef.current}
@ -201,6 +199,8 @@ export default function HlsVideoPlayer({
/> />
{children} {children}
</div> </div>
</div>
</TransformComponent>
</TransformWrapper> </TransformWrapper>
); );
} }

View File

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

View File

@ -235,7 +235,7 @@ function PreviewVideoPlayer({
return ( return (
<div <div
className={`relative rounded-2xl bg-black overflow-hidden ${onClick ? "cursor-pointer" : ""} ${className ?? ""}`} className={`relative rounded-2xl w-full flex justify-center bg-black overflow-hidden ${onClick ? "cursor-pointer" : ""} ${className ?? ""}`}
onClick={onClick} onClick={onClick}
> >
<img <img
@ -464,7 +464,7 @@ function PreviewFramesPlayer({
return ( return (
<div <div
className={`relative ${className ?? ""} ${onClick ? "cursor-pointer" : ""}`} className={`relative w-full flex justify-center ${className ?? ""} ${onClick ? "cursor-pointer" : ""}`}
onClick={onClick} onClick={onClick}
> >
<img <img

View File

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

View File

@ -142,10 +142,10 @@ export default function VideoControls({
return ( return (
<div <div
className={`px-4 py-2 flex justify-between items-center gap-8 text-white z-50 bg-secondary-foreground/60 dark:bg-secondary/60 rounded-lg ${className ?? ""}`} className={`px-4 py-2 flex justify-between items-center gap-8 text-primary z-50 bg-background/60 rounded-lg ${className ?? ""}`}
> >
{video && features.volume && ( {video && features.volume && (
<div className="flex justify-normal items-center gap-2"> <div className="flex justify-normal items-center gap-2 cursor-pointer">
<VolumeIcon <VolumeIcon
className="size-5" className="size-5"
onClick={(e: React.MouseEvent) => { onClick={(e: React.MouseEvent) => {
@ -170,9 +170,9 @@ export default function VideoControls({
)} )}
<div className="cursor-pointer" onClick={onTogglePlay}> <div className="cursor-pointer" onClick={onTogglePlay}>
{isPlaying ? ( {isPlaying ? (
<LuPause className="size-5 fill-white" /> <LuPause className="size-5 text-primary fill-primary" />
) : ( ) : (
<LuPlay className="size-5 fill-white" /> <LuPlay className="size-5 text-primary fill-primary" />
)} )}
</div> </div>
{features.seek && ( {features.seek && (

View File

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

View File

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

View File

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

View File

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

View File

@ -355,7 +355,7 @@ export function SummaryTimeline({
ref={visibleSectionRef} ref={visibleSectionRef}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onTouchStart={handleMouseDown} onTouchStart={handleMouseDown}
className={`bg-primary-foreground/30 z-20 absolute w-full touch-none ${ className={`bg-primary/30 z-20 absolute w-full touch-none ${
isDragging ? "cursor-grabbing" : "cursor-grab" isDragging ? "cursor-grabbing" : "cursor-grab"
}`} }`}
></div> ></div>

View File

@ -32,7 +32,7 @@ export function MinimapBounds({
<> <>
{isFirstSegmentInMinimap && ( {isFirstSegmentInMinimap && (
<div <div
className="absolute inset-0 -bottom-7 w-full flex items-center justify-center text-primary-foreground font-medium z-20 text-center text-[10px] scroll-mt-8 pointer-events-none select-none" className="absolute inset-0 -bottom-7 w-full flex items-center justify-center text-primary font-medium z-20 text-center text-[10px] scroll-mt-8 pointer-events-none select-none"
ref={firstMinimapSegmentRef} ref={firstMinimapSegmentRef}
> >
{new Date(alignedMinimapStartTime * 1000).toLocaleTimeString([], { {new Date(alignedMinimapStartTime * 1000).toLocaleTimeString([], {
@ -44,7 +44,7 @@ export function MinimapBounds({
)} )}
{isLastSegmentInMinimap && ( {isLastSegmentInMinimap && (
<div className="absolute inset-0 -top-3 w-full flex items-center justify-center text-primary-foreground font-medium z-20 text-center text-[10px] pointer-events-none select-none"> <div className="absolute inset-0 -top-3 w-full flex items-center justify-center text-primary font-medium z-20 text-center text-[10px] pointer-events-none select-none">
{new Date(alignedMinimapEndTime * 1000).toLocaleTimeString([], { {new Date(alignedMinimapEndTime * 1000).toLocaleTimeString([], {
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",

View File

@ -1,7 +1,7 @@
import * as React from "react" import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const badgeVariants = cva( const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
@ -9,7 +9,7 @@ const badgeVariants = cva(
variants: { variants: {
variant: { variant: {
default: default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80", "border-transparent bg-primary text-primary hover:bg-primary/80",
secondary: secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: destructive:
@ -20,8 +20,8 @@ const badgeVariants = cva(
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
}, },
} },
) );
export interface BadgeProps export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>,
@ -30,7 +30,7 @@ export interface BadgeProps
function Badge({ className, variant, ...props }: BadgeProps) { function Badge({ className, variant, ...props }: BadgeProps) {
return ( return (
<div className={cn(badgeVariants({ variant }), className)} {...props} /> <div className={cn(badgeVariants({ variant }), className)} {...props} />
) );
} }
export { Badge, badgeVariants } export { Badge, badgeVariants };

View File

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

View File

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

View File

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

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,22 +323,22 @@ function useDraggableElement({
} }
} }
const setTime = alignSetTimeToSegment
? targetSegmentId
: targetSegmentId + segmentDuration * (offset / segmentHeight);
updateDraggableElementPosition( updateDraggableElementPosition(
newElementPosition, newElementPosition,
targetSegmentId, setTime,
false, false,
false, false,
); );
if (setDraggableElementTime) { if (setDraggableElementTime) {
if (alignSetTimeToSegment) {
setDraggableElementTime(targetSegmentId);
} else {
setDraggableElementTime( setDraggableElementTime(
targetSegmentId + segmentDuration * (offset / segmentHeight), targetSegmentId + segmentDuration * (offset / segmentHeight),
); );
} }
}
if (draggingAtTopEdge || draggingAtBottomEdge) { if (draggingAtTopEdge || draggingAtBottomEdge) {
animationFrameId = requestAnimationFrame(handleScroll); animationFrameId = requestAnimationFrame(handleScroll);

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; return problems;
} }
// if frigate has just started
// don't look for issues
if (stats.service.uptime < 120) {
return problems;
}
// check detectors for high inference speeds // check detectors for high inference speeds
Object.entries(stats["detectors"]).forEach(([key, det]) => { Object.entries(stats["detectors"]).forEach(([key, det]) => {
if (det["inference_speed"] > InferenceThreshold.error) { if (det["inference_speed"] > InferenceThreshold.error) {

View File

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

View File

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

View File

@ -10,8 +10,9 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import axios from "axios"; import axios from "axios";
import { useCallback, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import useSWR from "swr"; import useSWR from "swr";
type ExportItem = { type ExportItem = {
@ -19,24 +20,34 @@ type ExportItem = {
}; };
function Export() { function Export() {
const { data: exports, mutate } = useSWR<ExportItem[]>( const { data: allExports, mutate } = useSWR<ExportItem[]>(
"exports/", "exports/",
(url: string) => axios({ baseURL: baseUrl, url }).then((res) => res.data), (url: string) => axios({ baseURL: baseUrl, url }).then((res) => res.data),
); );
const [deleteClip, setDeleteClip] = useState<string | undefined>(); useEffect(() => {
document.title = "Export - Frigate";
}, []);
const onHandleRename = useCallback( // Search
(original: string, update: string) => {
axios.patch(`export/${original}/${update}`).then((response) => { const [search, setSearch] = useState("");
if (response.status == 200) {
setDeleteClip(undefined); const exports = useMemo(() => {
mutate(); if (!search || !allExports) {
return allExports;
} }
});
}, return allExports.filter((exp) =>
[mutate], exp.name
.toLowerCase()
.includes(search.toLowerCase().replaceAll(" ", "_")),
); );
}, [allExports, search]);
// Deleting
const [deleteClip, setDeleteClip] = useState<string | undefined>();
const onHandleDelete = useCallback(() => { const onHandleDelete = useCallback(() => {
if (!deleteClip) { if (!deleteClip) {
@ -51,8 +62,22 @@ function Export() {
}); });
}, [deleteClip, mutate]); }, [deleteClip, mutate]);
// Renaming
const onHandleRename = useCallback(
(original: string, update: string) => {
axios.patch(`export/${original}/${update}`).then((response) => {
if (response.status == 200) {
setDeleteClip(undefined);
mutate();
}
});
},
[mutate],
);
return ( return (
<div className="size-full p-2 overflow-hidden flex flex-col"> <div className="size-full p-2 overflow-hidden flex flex-col gap-2">
<AlertDialog <AlertDialog
open={deleteClip != undefined} open={deleteClip != undefined}
onOpenChange={() => setDeleteClip(undefined)} onOpenChange={() => setDeleteClip(undefined)}
@ -73,12 +98,24 @@ function Export() {
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
<div className="w-full p-2 flex items-center justify-center">
<Input
className="w-full md:w-1/3 bg-muted"
placeholder="Search"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="w-full overflow-hidden"> <div className="w-full overflow-hidden">
{exports && ( {allExports && exports && (
<div className="size-full grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 overflow-y-auto"> <div className="size-full grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 overflow-y-auto">
{Object.values(exports).map((item) => ( {Object.values(allExports).map((item) => (
<ExportCard <ExportCard
key={item.name} key={item.name}
className={
search == "" || exports.includes(item) ? "" : "hidden"
}
file={item} file={item}
onRename={onHandleRename} onRename={onHandleRename}
onDelete={(file) => setDeleteClip(file)} onDelete={(file) => setDeleteClip(file)}

View File

@ -6,18 +6,37 @@ import { FrigateConfig } from "@/types/frigateConfig";
import LiveBirdseyeView from "@/views/live/LiveBirdseyeView"; import LiveBirdseyeView from "@/views/live/LiveBirdseyeView";
import LiveCameraView from "@/views/live/LiveCameraView"; import LiveCameraView from "@/views/live/LiveCameraView";
import LiveDashboardView from "@/views/live/LiveDashboardView"; import LiveDashboardView from "@/views/live/LiveDashboardView";
import { useMemo } from "react"; import { useEffect, useMemo } from "react";
import useSWR from "swr"; import useSWR from "swr";
function Live() { function Live() {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
// selection
const [selectedCameraName, setSelectedCameraName] = useHashState(); const [selectedCameraName, setSelectedCameraName] = useHashState();
const [cameraGroup] = usePersistedOverlayState( const [cameraGroup] = usePersistedOverlayState(
"cameraGroup", "cameraGroup",
"default" as string, "default" as string,
); );
// document title
useEffect(() => {
if (selectedCameraName) {
const capitalized = selectedCameraName
.split("_")
.map((text) => text[0].toUpperCase() + text.substring(1));
document.title = `${capitalized.join(" ")} - Live - Frigate`;
} else if (cameraGroup && cameraGroup != "default") {
document.title = `${cameraGroup[0].toUpperCase()}${cameraGroup.substring(1)} - Live - Frigate`;
} else {
document.title = "Live - Frigate";
}
}, [cameraGroup, selectedCameraName]);
// settings
const includesBirdseye = useMemo(() => { const includesBirdseye = useMemo(() => {
if (config && cameraGroup && cameraGroup != "default") { if (config && cameraGroup && cameraGroup != "default") {
return config.camera_groups[cameraGroup].cameras.includes("birdseye"); return config.camera_groups[cameraGroup].cameras.includes("birdseye");

View File

@ -3,10 +3,14 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { LogData, LogLine, LogSeverity } from "@/types/log"; import { LogData, LogLine, LogSeverity } from "@/types/log";
import copy from "copy-to-clipboard"; import copy from "copy-to-clipboard";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { IoIosAlert } from "react-icons/io";
import { GoAlertFill } from "react-icons/go";
import { LuCopy } from "react-icons/lu";
import axios from "axios"; import axios from "axios";
import LogInfoDialog from "@/components/overlay/LogInfoDialog";
import { LogChip } from "@/components/indicators/Chip";
import { LogLevelFilterButton } from "@/components/filter/LogLevelFilter";
import { FaCopy } from "react-icons/fa6";
import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner";
import { isDesktop } from "react-device-detect";
const logTypes = ["frigate", "go2rtc", "nginx"] as const; const logTypes = ["frigate", "go2rtc", "nginx"] as const;
type LogType = (typeof logTypes)[number]; type LogType = (typeof logTypes)[number];
@ -17,7 +21,7 @@ const frigateDateStamp = /\[[\d\s-:]*]/;
const frigateSeverity = /(DEBUG)|(INFO)|(WARNING)|(ERROR)/; const frigateSeverity = /(DEBUG)|(INFO)|(WARNING)|(ERROR)/;
const frigateSection = /[\w.]*/; const frigateSection = /[\w.]*/;
const goSeverity = /(DEB )|(INF )|(WARN )|(ERR )/; const goSeverity = /(DEB )|(INF )|(WRN )|(ERR )/;
const goSection = /\[[\w]*]/; const goSection = /\[[\w]*]/;
const ngSeverity = /(GET)|(POST)|(PUT)|(PATCH)|(DELETE)/; const ngSeverity = /(GET)|(POST)|(PUT)|(PATCH)|(DELETE)/;
@ -25,6 +29,10 @@ const ngSeverity = /(GET)|(POST)|(PUT)|(PATCH)|(DELETE)/;
function Logs() { function Logs() {
const [logService, setLogService] = useState<LogType>("frigate"); const [logService, setLogService] = useState<LogType>("frigate");
useEffect(() => {
document.title = `${logService[0].toUpperCase()}${logService.substring(1)} Stats - Frigate`;
}, [logService]);
// log data handling // log data handling
const [logRange, setLogRange] = useState<LogRange>({ start: 0, end: 0 }); const [logRange, setLogRange] = useState<LogRange>({ start: 0, end: 0 });
@ -101,7 +109,12 @@ function Logs() {
}; };
} }
return null; return {
dateStamp: line.substring(0, 19),
severity: "unknown",
section: "unknown",
content: line.substring(30).trim(),
};
} }
const sectionMatch = frigateSection.exec( const sectionMatch = frigateSection.exec(
@ -154,9 +167,28 @@ function Logs() {
contentStart = line.indexOf(section) + section.length + 2; contentStart = line.indexOf(section) + section.length + 2;
} }
let severityCat: LogSeverity;
switch (severity?.at(0)?.toString().trim()) {
case "INF":
severityCat = "info";
break;
case "WRN":
severityCat = "warning";
break;
case "ERR":
severityCat = "error";
break;
case "DBG":
case "TRC":
severityCat = "debug";
break;
default:
severityCat = "info";
}
return { return {
dateStamp: line.substring(0, 19), dateStamp: line.substring(0, 19),
severity: "INFO", severity: severityCat,
section: section, section: section,
content: line.substring(contentStart).trim(), content: line.substring(contentStart).trim(),
}; };
@ -171,7 +203,7 @@ function Logs() {
return { return {
dateStamp: line.substring(0, 19), dateStamp: line.substring(0, 19),
severity: "INFO", severity: "info",
section: ngSeverity.exec(line)?.at(0)?.toString() ?? "META", section: ngSeverity.exec(line)?.at(0)?.toString() ?? "META",
content: line.substring(line.indexOf(" ", 20)).trim(), content: line.substring(line.indexOf(" ", 20)).trim(),
}; };
@ -185,8 +217,15 @@ function Logs() {
const handleCopyLogs = useCallback(() => { const handleCopyLogs = useCallback(() => {
if (logs) { if (logs) {
copy(logs.join("\n")); copy(logs.join("\n"));
toast.success(
logRange.start == 0
? "Coplied logs to clipboard"
: "Copied visible logs to clipboard",
);
} else {
toast.error("Could not copy logs to clipboard");
} }
}, [logs]); }, [logs, logRange]);
// scroll to bottom // scroll to bottom
@ -279,8 +318,19 @@ function Logs() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [logLines, logService]); }, [logLines, logService]);
// log filtering
const [filterSeverity, setFilterSeverity] = useState<LogSeverity[]>();
// log selection
const [selectedLog, setSelectedLog] = useState<LogLine>();
return ( return (
<div className="size-full p-2 flex flex-col"> <div className="size-full p-2 flex flex-col">
<Toaster position="top-center" />
<LogInfoDialog logLine={selectedLog} setLogLine={setSelectedLog} />
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<ToggleGroup <ToggleGroup
className="*:px-3 *:py-4 *:rounded-md" className="*:px-3 *:py-4 *:rounded-md"
@ -290,6 +340,7 @@ function Logs() {
onValueChange={(value: LogType) => { onValueChange={(value: LogType) => {
if (value) { if (value) {
setLogs([]); setLogs([]);
setFilterSeverity(undefined);
setLogService(value); setLogService(value);
} }
}} // don't allow the severity to be unselected }} // don't allow the severity to be unselected
@ -301,26 +352,31 @@ function Logs() {
value={item} value={item}
aria-label={`Select ${item}`} aria-label={`Select ${item}`}
> >
<div className="capitalize">{`${item} Logs`}</div> <div className="capitalize">{item}</div>
</ToggleGroupItem> </ToggleGroupItem>
))} ))}
</ToggleGroup> </ToggleGroup>
<div> <div className="flex items-center gap-2">
<Button <Button
className="flex justify-between items-center gap-2" className="flex justify-between items-center gap-2"
size="sm" size="sm"
onClick={handleCopyLogs} onClick={handleCopyLogs}
> >
<LuCopy /> <FaCopy />
<div className="hidden md:block">Copy to Clipboard</div> <div className="hidden md:block text-primary">
Copy to Clipboard
</div>
</Button> </Button>
<LogLevelFilterButton
selectedLabels={filterSeverity}
updateLabelFilter={setFilterSeverity}
/>
</div> </div>
</div> </div>
{initialScroll && !endVisible && ( {initialScroll && !endVisible && (
<Button <Button
className="absolute bottom-8 left-[50%] -translate-x-[50%] rounded-xl bg-accent-foreground text-white bg-gray-400 z-20 p-2" className="absolute bottom-8 left-[50%] -translate-x-[50%] rounded-md text-primary bg-secondary-foreground z-20 p-2"
variant="secondary"
onClick={() => onClick={() =>
contentRef.current?.scrollTo({ contentRef.current?.scrollTo({
top: contentRef.current?.scrollHeight, top: contentRef.current?.scrollHeight,
@ -332,24 +388,21 @@ function Logs() {
</Button> </Button>
)} )}
<div <div className="size-full flex flex-col my-2 font-mono text-sm sm:p-2 whitespace-pre-wrap bg-background_alt border border-secondary rounded-md overflow-hidden">
ref={contentRef} <div className="grid grid-cols-5 sm:grid-cols-8 md:grid-cols-12 *:px-2 *:py-3 *:text-sm *:text-primary/40">
className="w-full h-min my-2 font-mono text-sm rounded py-4 sm:py-2 whitespace-pre-wrap overflow-auto no-scrollbar" <div className="p-1 flex items-center capitalize">Type</div>
> <div className="col-span-2 sm:col-span-1 flex items-center">
<div className="py-2 sticky top-0 -translate-y-1/4 grid grid-cols-5 sm:grid-cols-8 md:grid-cols-12 bg-background *:p-2">
<div className="p-1 flex items-center capitalize border-y border-l">
Type
</div>
<div className="col-span-2 sm:col-span-1 flex items-center border-y border-l">
Timestamp Timestamp
</div> </div>
<div className="col-span-2 flex items-center border-y border-l border-r sm:border-r-0"> <div className="col-span-2 flex items-center">Tag</div>
Tag <div className="col-span-5 sm:col-span-4 md:col-span-8 flex items-center">
</div>
<div className="col-span-5 sm:col-span-4 md:col-span-8 flex items-center border">
Message Message
</div> </div>
</div> </div>
<div
ref={contentRef}
className="w-full flex flex-col overflow-y-auto no-scrollbar"
>
{logLines.length > 0 && {logLines.length > 0 &&
[...Array(logRange.end).keys()].map((idx) => { [...Array(logRange.end).keys()].map((idx) => {
const logLine = const logLine =
@ -358,6 +411,15 @@ function Logs() {
: undefined; : undefined;
if (logLine) { if (logLine) {
const line = logLines[idx - logRange.start];
if (filterSeverity && !filterSeverity.includes(line.severity)) {
return (
<div
ref={idx == logRange.start + 10 ? startLogRef : undefined}
/>
);
}
return ( return (
<LogLineData <LogLineData
key={`${idx}-${logService}`} key={`${idx}-${logService}`}
@ -365,17 +427,24 @@ function Logs() {
idx == logRange.start + 10 ? startLogRef : undefined idx == logRange.start + 10 ? startLogRef : undefined
} }
className={initialScroll ? "" : "invisible"} className={initialScroll ? "" : "invisible"}
offset={idx} line={line}
line={logLines[idx - logRange.start]} onClickSeverity={() => setFilterSeverity([line.severity])}
onSelect={() => setSelectedLog(line)}
/> />
); );
} }
return <div key={`${idx}-${logService}`} className="h-12" />; return (
<div
key={`${idx}-${logService}`}
className={isDesktop ? "h-12" : "h-16"}
/>
);
})} })}
{logLines.length > 0 && <div id="page-bottom" ref={endLogRef} />} {logLines.length > 0 && <div id="page-bottom" ref={endLogRef} />}
</div> </div>
</div> </div>
</div>
); );
} }
@ -383,70 +452,37 @@ type LogLineDataProps = {
startRef?: (node: HTMLDivElement | null) => void; startRef?: (node: HTMLDivElement | null) => void;
className: string; className: string;
line: LogLine; line: LogLine;
offset: number; onClickSeverity: () => void;
onSelect: () => void;
}; };
function LogLineData({ startRef, className, line, offset }: LogLineDataProps) { function LogLineData({
// long log message startRef,
className,
const contentRef = useRef<HTMLDivElement | null>(null); line,
const [expanded, setExpanded] = useState(false); onClickSeverity,
onSelect,
const contentOverflows = useMemo(() => { }: LogLineDataProps) {
if (!contentRef.current) {
return false;
}
return contentRef.current.scrollWidth > contentRef.current.clientWidth;
// update on ref change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [contentRef.current]);
// severity coloring
const severityClassName = useMemo(() => {
switch (line.severity) {
case "info":
return "text-secondary-foreground rounded-md";
case "warning":
return "text-yellow-400 rounded-md";
case "error":
return "text-danger rounded-md";
}
}, [line]);
return ( return (
<div <div
ref={startRef} ref={startRef}
className={`py-2 grid grid-cols-5 sm:grid-cols-8 md:grid-cols-12 gap-2 ${offset % 2 == 0 ? "bg-secondary" : "bg-secondary/80"} border-t border-x ${className}`} className={`w-full py-2 grid grid-cols-5 sm:grid-cols-8 md:grid-cols-12 gap-2 border-secondary border-t cursor-pointer hover:bg-muted ${className} *:text-sm`}
onClick={onSelect}
> >
<div <div className="h-full p-1 flex items-center gap-2">
className={`h-full p-1 flex items-center gap-2 capitalize ${severityClassName}`} <LogChip severity={line.severity} onClickSeverity={onClickSeverity} />
>
{line.severity == "error" ? (
<GoAlertFill className="size-5" />
) : (
<IoIosAlert className="size-5" />
)}
{line.severity}
</div> </div>
<div className="h-full col-span-2 sm:col-span-1 flex items-center"> <div className="h-full col-span-2 sm:col-span-1 flex items-center">
{line.dateStamp} {line.dateStamp}
</div> </div>
<div className="h-full col-span-2 flex items-center overflow-hidden text-ellipsis"> <div className="size-full pr-2 col-span-2 flex items-center">
<div className="w-full overflow-hidden whitespace-nowrap text-ellipsis">
{line.section} {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>
<div <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">
ref={contentRef} <div className="w-full overflow-hidden whitespace-nowrap text-ellipsis">
className={`w-[94%] flex items-center" ${expanded ? "" : "overflow-hidden whitespace-nowrap text-ellipsis"}`}
>
{line.content} {line.content}
</div> </div>
{contentOverflows && (
<Button className="mr-4" onClick={() => setExpanded(!expanded)}>
...
</Button>
)}
</div> </div>
</div> </div>
); );

View File

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

View File

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

View File

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

View File

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

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; camera: string;
severity: ReviewSeverity; severity: ReviewSeverity;
start_time: number; start_time: number;
end_time: number; end_time?: number;
thumb_path: string; thumb_path: string;
has_been_reviewed: boolean; has_been_reviewed: boolean;
data: ReviewData; data: ReviewData;

View File

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

View File

@ -43,6 +43,7 @@ import { TimeRange } from "@/types/timeline";
import { useCameraMotionNextTimestamp } from "@/hooks/use-camera-activity"; import { useCameraMotionNextTimestamp } from "@/hooks/use-camera-activity";
import useOptimisticState from "@/hooks/use-optimistic-state"; import useOptimisticState from "@/hooks/use-optimistic-state";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import scrollIntoView from "scroll-into-view-if-needed";
type EventViewProps = { type EventViewProps = {
reviews?: ReviewSegment[]; reviews?: ReviewSegment[];
@ -289,10 +290,12 @@ export default function EventView({
reviewItems={reviewItems} reviewItems={reviewItems}
relevantPreviews={relevantPreviews} relevantPreviews={relevantPreviews}
selectedReviews={selectedReviews} selectedReviews={selectedReviews}
itemsToReview={reviewCounts[severity]} itemsToReview={reviewCounts[severityToggle]}
severity={severity} severity={severity}
filter={filter} filter={filter}
timeRange={timeRange} timeRange={timeRange}
startTime={startTime}
loading={severity != severityToggle}
markItemAsReviewed={markItemAsReviewed} markItemAsReviewed={markItemAsReviewed}
markAllItemsAsReviewed={markAllItemsAsReviewed} markAllItemsAsReviewed={markAllItemsAsReviewed}
onSelectReview={onSelectReview} onSelectReview={onSelectReview}
@ -331,6 +334,8 @@ type DetectionReviewProps = {
severity: ReviewSeverity; severity: ReviewSeverity;
filter?: ReviewFilter; filter?: ReviewFilter;
timeRange: { before: number; after: number }; timeRange: { before: number; after: number };
startTime?: number;
loading: boolean;
markItemAsReviewed: (review: ReviewSegment) => void; markItemAsReviewed: (review: ReviewSegment) => void;
markAllItemsAsReviewed: (currentItems: ReviewSegment[]) => void; markAllItemsAsReviewed: (currentItems: ReviewSegment[]) => void;
onSelectReview: (review: ReviewSegment, ctrl: boolean) => void; onSelectReview: (review: ReviewSegment, ctrl: boolean) => void;
@ -345,6 +350,8 @@ function DetectionReview({
severity, severity,
filter, filter,
timeRange, timeRange,
startTime,
loading,
markItemAsReviewed, markItemAsReviewed,
markAllItemsAsReviewed, markAllItemsAsReviewed,
onSelectReview, onSelectReview,
@ -495,6 +502,26 @@ function DetectionReview({
[minimap], [minimap],
); );
// existing review item
useEffect(() => {
if (!startTime || !currentItems || currentItems.length == 0) {
return;
}
const element = contentRef.current?.querySelector(
`[data-start="${startTime}"]`,
);
if (element) {
scrollIntoView(element, {
scrollMode: "if-needed",
behavior: "smooth",
});
}
// only run when start time changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [startTime]);
return ( return (
<> <>
<div <div
@ -506,7 +533,7 @@ function DetectionReview({
className="absolute left-1/2 -translate-x-1/2 z-50 pointer-events-none" className="absolute left-1/2 -translate-x-1/2 z-50 pointer-events-none"
contentRef={contentRef} contentRef={contentRef}
reviewItems={currentItems} reviewItems={currentItems}
itemsToReview={itemsToReview} itemsToReview={loading ? 0 : itemsToReview}
pullLatestData={pullLatestData} pullLatestData={pullLatestData}
/> />
)} )}
@ -517,7 +544,7 @@ function DetectionReview({
</div> </div>
)} )}
{currentItems?.length === 0 && ( {!loading && currentItems?.length === 0 && (
<div className="absolute left-1/2 -translate-x-1/2 top-1/2 -translate-y-1/2 flex flex-col justify-center items-center text-center"> <div className="absolute left-1/2 -translate-x-1/2 top-1/2 -translate-y-1/2 flex flex-col justify-center items-center text-center">
<LuFolderCheck className="size-16" /> <LuFolderCheck className="size-16" />
There are no {severity.replace(/_/g, " ")}s to review There are no {severity.replace(/_/g, " ")}s to review
@ -528,8 +555,8 @@ function DetectionReview({
className="w-full mx-2 px-1 grid sm:grid-cols-2 md:grid-cols-3 3xl:grid-cols-4 gap-2 md:gap-4" className="w-full mx-2 px-1 grid sm:grid-cols-2 md:grid-cols-3 3xl:grid-cols-4 gap-2 md:gap-4"
ref={contentRef} ref={contentRef}
> >
{currentItems && {!loading && currentItems
currentItems.map((value) => { ? currentItems.map((value) => {
const selected = selectedReviews.includes(value.id); const selected = selectedReviews.includes(value.id);
return ( return (
@ -538,7 +565,8 @@ function DetectionReview({
ref={minimapRef} ref={minimapRef}
data-start={value.start_time} data-start={value.start_time}
data-segment-start={ data-segment-start={
alignStartDateToTimeline(value.start_time) - segmentDuration alignStartDateToTimeline(value.start_time) -
segmentDuration
} }
className="review-item relative rounded-lg" className="review-item relative rounded-lg"
> >
@ -546,6 +574,7 @@ function DetectionReview({
<PreviewThumbnailPlayer <PreviewThumbnailPlayer
review={value} review={value}
allPreviews={relevantPreviews} allPreviews={relevantPreviews}
timeRange={timeRange}
setReviewed={markItemAsReviewed} setReviewed={markItemAsReviewed}
scrollLock={scrollLock} scrollLock={scrollLock}
onTimeUpdate={onPreviewTimeUpdate} onTimeUpdate={onPreviewTimeUpdate}
@ -557,8 +586,13 @@ function DetectionReview({
/> />
</div> </div>
); );
})} })
{(currentItems?.length ?? 0) > 0 && (itemsToReview ?? 0) > 0 && ( : 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"> <div className="col-span-full flex justify-center items-center">
<Button <Button
className="text-white" className="text-white"
@ -575,6 +609,9 @@ function DetectionReview({
</div> </div>
<div className="w-[65px] md:w-[110px] flex flex-row"> <div className="w-[65px] md:w-[110px] flex flex-row">
<div className="w-[55px] md:w-[100px] overflow-y-auto no-scrollbar"> <div className="w-[55px] md:w-[100px] overflow-y-auto no-scrollbar">
{loading ? (
<Skeleton className="size-full" />
) : (
<EventReviewTimeline <EventReviewTimeline
segmentDuration={segmentDuration} segmentDuration={segmentDuration}
timestampSpread={15} timestampSpread={15}
@ -592,8 +629,12 @@ function DetectionReview({
timelineRef={reviewTimelineRef} timelineRef={reviewTimelineRef}
dense={isMobile} dense={isMobile}
/> />
)}
</div> </div>
<div className="w-[10px]"> <div className="w-[10px]">
{loading ? (
<Skeleton className="w-full" />
) : (
<SummaryTimeline <SummaryTimeline
reviewTimelineRef={reviewTimelineRef} reviewTimelineRef={reviewTimelineRef}
timelineStart={timeRange.before} timelineStart={timeRange.before}
@ -602,6 +643,7 @@ function DetectionReview({
events={reviewItems?.all ?? []} events={reviewItems?.all ?? []}
severityType={severity} severityType={severity}
/> />
)}
</div> </div>
</div> </div>
</> </>
@ -787,16 +829,18 @@ function MotionReview({
} else { } else {
const segmentStartTime = alignStartDateToTimeline(currentTime); const segmentStartTime = alignStartDateToTimeline(currentTime);
const segmentEndTime = segmentStartTime + segmentDuration; const segmentEndTime = segmentStartTime + segmentDuration;
const matchingItem = reviewItems?.all.find( const matchingItem = reviewItems?.all.find((item) => {
(item) => const endTime = item.end_time ?? timeRange.before;
return (
((item.start_time >= segmentStartTime && ((item.start_time >= segmentStartTime &&
item.start_time < segmentEndTime) || item.start_time < segmentEndTime) ||
(item.end_time > segmentStartTime && (endTime > segmentStartTime && endTime <= segmentEndTime) ||
item.end_time <= segmentEndTime) ||
(item.start_time <= segmentStartTime && (item.start_time <= segmentStartTime &&
item.end_time >= segmentEndTime)) && endTime >= segmentEndTime)) &&
item.camera === cameraName, item.camera === cameraName
); );
});
return matchingItem ? matchingItem.severity : null; return matchingItem ? matchingItem.severity : null;
} }
@ -805,6 +849,7 @@ function MotionReview({
reviewItems, reviewItems,
motionData, motionData,
currentTime, currentTime,
timeRange,
motionOnly, motionOnly,
alignStartDateToTimeline, alignStartDateToTimeline,
], ],
@ -853,7 +898,10 @@ function MotionReview({
onClick={() => onClick={() =>
onOpenRecording({ onOpenRecording({
camera: camera.name, camera: camera.name,
startTime: currentTime, startTime: Math.min(
currentTime,
Date.now() / 1000 - 30,
),
severity: "significant_motion", severity: "significant_motion",
}) })
} }

View File

@ -38,6 +38,8 @@ import MobileCameraDrawer from "@/components/overlay/MobileCameraDrawer";
import MobileTimelineDrawer from "@/components/overlay/MobileTimelineDrawer"; import MobileTimelineDrawer from "@/components/overlay/MobileTimelineDrawer";
import MobileReviewSettingsDrawer from "@/components/overlay/MobileReviewSettingsDrawer"; import MobileReviewSettingsDrawer from "@/components/overlay/MobileReviewSettingsDrawer";
import Logo from "@/components/Logo"; import Logo from "@/components/Logo";
import { Skeleton } from "@/components/ui/skeleton";
import { FaVideo } from "react-icons/fa";
const SEGMENT_DURATION = 30; const SEGMENT_DURATION = 30;
@ -250,15 +252,28 @@ export function RecordingView({
{isMobile && ( {isMobile && (
<Logo className="absolute inset-x-1/2 -translate-x-1/2 h-8" /> <Logo className="absolute inset-x-1/2 -translate-x-1/2 h-8" />
)} )}
<div
className={`flex items-center gap-2 ${isMobile ? "landscape:flex-col" : ""}`}
>
<Button <Button
className="flex items-center gap-2 rounded-lg" className={`flex items-center gap-2.5 rounded-lg`}
size="sm" size="sm"
variant="secondary"
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
> >
<IoMdArrowRoundBack className="size-5" size="small" /> <IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
{isDesktop && <div className="text-primary-foreground">Back</div>} {isDesktop && <div className="text-primary">Back</div>}
</Button> </Button>
<Button
className="flex items-center gap-2.5 rounded-lg"
size="sm"
onClick={() => {
navigate(`/#${mainCamera}`);
}}
>
<FaVideo className="size-5 text-secondary-foreground" />
{isDesktop && <div className="text-primary">Live</div>}
</Button>
</div>
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
<MobileCameraDrawer <MobileCameraDrawer
allCameras={allCameras} allCameras={allCameras}
@ -341,7 +356,7 @@ export function RecordingView({
</div> </div>
<div <div
className={`h-full flex justify-center overflow-hidden ${isDesktop ? "" : "flex-col gap-2"}`} className={`h-full flex justify-center overflow-hidden ${isDesktop ? "" : "flex-col landscape:flex-row gap-2"}`}
> >
<div className={`${isDesktop ? "w-[80%]" : ""} flex flex-1 flex-wrap`}> <div className={`${isDesktop ? "w-[80%]" : ""} flex flex-1 flex-wrap`}>
<div <div
@ -351,8 +366,8 @@ export function RecordingView({
key={mainCamera} key={mainCamera}
className={ className={
isDesktop isDesktop
? `${mainCameraAspect == "tall" ? "h-[90%]" : mainCameraAspect == "wide" ? "w-full" : "w-[78%]"} px-4 flex justify-center` ? `${mainCameraAspect == "tall" ? "xl:h-[90%]" : mainCameraAspect == "wide" ? "w-full" : "w-[78%]"} px-4 flex justify-center`
: `w-full pt-2 ${mainCameraAspect == "wide" ? "aspect-wide" : "aspect-video"}` : `portrait:w-full pt-2 ${mainCameraAspect == "wide" ? "landscape:w-full aspect-wide" : "landscape:h-[94%] aspect-video"}`
} }
style={{ style={{
aspectRatio: isDesktop aspectRatio: isDesktop
@ -497,12 +512,13 @@ function Timeline({
className={`${ className={`${
isDesktop isDesktop
? `${timelineType == "timeline" ? "w-[100px]" : "w-60"} overflow-y-auto no-scrollbar` ? `${timelineType == "timeline" ? "w-[100px]" : "w-60"} overflow-y-auto no-scrollbar`
: "flex-grow overflow-hidden" : "portrait:flex-grow landscape:w-[20%] overflow-hidden"
} relative`} } relative`}
> >
<div className="absolute top-0 inset-x-0 z-20 w-full h-[30px] bg-gradient-to-b from-secondary to-transparent pointer-events-none"></div> <div className="absolute top-0 inset-x-0 z-20 w-full h-[30px] bg-gradient-to-b from-secondary to-transparent pointer-events-none"></div>
<div className="absolute bottom-0 inset-x-0 z-20 w-full h-[30px] bg-gradient-to-t from-secondary to-transparent pointer-events-none"></div> <div className="absolute bottom-0 inset-x-0 z-20 w-full h-[30px] bg-gradient-to-t from-secondary to-transparent pointer-events-none"></div>
{timelineType == "timeline" ? ( {timelineType == "timeline" ? (
motionData ? (
<MotionReviewTimeline <MotionReviewTimeline
segmentDuration={30} segmentDuration={30}
timestampSpread={15} timestampSpread={15}
@ -523,6 +539,9 @@ function Timeline({
contentRef={contentRef} contentRef={contentRef}
onHandlebarDraggingChange={(scrubbing) => setScrubbing(scrubbing)} onHandlebarDraggingChange={(scrubbing) => setScrubbing(scrubbing)}
/> />
) : (
<Skeleton className="size-full" />
)
) : ( ) : (
<div <div
className={`h-full grid grid-cols-1 gap-4 overflow-auto p-4 bg-secondary ${isDesktop ? "" : "sm:grid-cols-2"}`} className={`h-full grid grid-cols-1 gap-4 overflow-auto p-4 bg-secondary ${isDesktop ? "" : "sm:grid-cols-2"}`}

View File

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

View File

@ -158,9 +158,9 @@ export default function LiveCameraView({ camera }: LiveCameraViewProps) {
return "absolute left-2 right-2 top-[50%] -translate-y-[50%]"; return "absolute left-2 right-2 top-[50%] -translate-y-[50%]";
} else { } else {
if (aspect > 16 / 9) { if (aspect > 16 / 9) {
return "absolute left-0 top-[50%] -translate-y-[50%]"; return "p-2 absolute left-0 top-[50%] -translate-y-[50%]";
} else { } else {
return "absolute top-2 bottom-2 left-[50%] -translate-x-[50%]"; return "p-2 absolute top-2 bottom-2 left-[50%] -translate-x-[50%]";
} }
} }
} }
@ -209,35 +209,33 @@ export default function LiveCameraView({ camera }: LiveCameraViewProps) {
className={ className={
fullscreen fullscreen
? `fixed inset-0 bg-black z-30` ? `fixed inset-0 bg-black z-30`
: `size-full p-2 flex flex-col ${isMobile ? "landscape:flex-row" : ""}` : `size-full p-2 flex flex-col ${isMobile ? "landscape:flex-row landscape:gap-1" : ""}`
} }
> >
<div <div
className={ className={
fullscreen fullscreen
? `absolute right-32 top-1 z-40 ${isMobile ? "landscape:left-2 landscape:right-auto landscape:bottom-1 landscape:top-auto" : ""}` ? `absolute right-32 top-1 z-40 ${isMobile ? "landscape:left-2 landscape:right-auto landscape:bottom-1 landscape:top-auto" : ""}`
: `w-full h-12 flex flex-row items-center justify-between ${isMobile ? "landscape:w-min landscape:h-full landscape:flex-col" : ""}` : `w-full h-12 flex flex-row items-center justify-between ${isMobile ? "landscape:w-12 landscape:h-full landscape:flex-col" : ""}`
} }
> >
{!fullscreen ? ( {!fullscreen ? (
<div className="flex items-center gap-2"> <div
className={`flex items-center gap-2 ${isMobile ? "landscape:flex-col" : ""}`}
>
<Button <Button
className={`flex items-center gap-2.5 rounded-lg`} className={`flex items-center gap-2.5 rounded-lg`}
size="sm" size="sm"
variant="secondary"
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
> >
<IoMdArrowRoundBack className="size-5" /> <IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
{isDesktop && ( {isDesktop && <div className="text-primary">Back</div>}
<div className="text-primary-foreground">Back</div>
)}
</Button> </Button>
<Button <Button
className="flex items-center gap-2.5 rounded-lg" className="flex items-center gap-2.5 rounded-lg"
size="sm" size="sm"
variant="secondary"
onClick={() => { onClick={() => {
navigate("events", { navigate("review", {
state: { state: {
severity: "alert", severity: "alert",
recording: { recording: {
@ -249,10 +247,8 @@ export default function LiveCameraView({ camera }: LiveCameraViewProps) {
}); });
}} }}
> >
<LuHistory className="size-5" /> <LuHistory className="size-5 text-secondary-foreground" />
{isDesktop && ( {isDesktop && <div className="text-primary">History</div>}
<div className="text-primary-foreground">History</div>
)}
</Button> </Button>
</div> </div>
) : ( ) : (
@ -520,7 +516,7 @@ function PtzControlPanel({
{ptz?.features?.includes("pt-r-fov") && ( {ptz?.features?.includes("pt-r-fov") && (
<> <>
<Button <Button
className={`${clickOverlay ? "text-selected" : "text-primary-foreground"}`} className={`${clickOverlay ? "text-selected" : "text-primary"}`}
onClick={() => setClickOverlay(!clickOverlay)} onClick={() => setClickOverlay(!clickOverlay)}
> >
<HiViewfinderCircle /> <HiViewfinderCircle />
@ -619,7 +615,7 @@ function FrigateCameraFeatures({
<Drawer> <Drawer>
<DrawerTrigger> <DrawerTrigger>
<CameraFeatureToggle <CameraFeatureToggle
className="p-2" className="p-2 landscape:size-9"
variant="primary" variant="primary"
Icon={FaCog} Icon={FaCog}
isActive={false} isActive={false}

View File

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

View File

@ -19,7 +19,7 @@ export default function CameraMetrics({
// stats // stats
const { data: initialStats } = useSWR<FrigateStats[]>( const { data: initialStats } = useSWR<FrigateStats[]>(
["stats/history", { keys: "cpu_usages,cameras,service" }], ["stats/history", { keys: "cpu_usages,cameras,detection_fps,service" }],
{ {
revalidateOnFocus: false, revalidateOnFocus: false,
}, },
@ -57,6 +57,44 @@ export default function CameraMetrics({
// stats data // stats data
const overallFpsSeries = useMemo(() => {
if (!statsHistory) {
return [];
}
const series: {
[key: string]: { name: string; data: { x: number; y: number }[] };
} = {};
series["overall_dps"] = { name: "overall detections per second", data: [] };
series["overall_skipped_dps"] = {
name: "overall skipped detections per second",
data: [],
};
statsHistory.forEach((stats, statsIdx) => {
if (!stats) {
return;
}
series["overall_dps"].data.push({
x: statsIdx,
y: stats.detection_fps,
});
let skipped = 0;
Object.values(stats.cameras).forEach(
(camStat) => (skipped += camStat.skipped_fps),
);
series["overall_skipped_dps"].data.push({
x: statsIdx,
y: skipped,
});
});
return Object.values(series);
}, [statsHistory]);
const cameraCpuSeries = useMemo(() => { const cameraCpuSeries = useMemo(() => {
if (!statsHistory || statsHistory.length == 0) { if (!statsHistory || statsHistory.length == 0) {
return {}; return {};
@ -147,19 +185,36 @@ export default function CameraMetrics({
}, [statsHistory]); }, [statsHistory]);
return ( return (
<div className="size-full mt-4 flex flex-col overflow-y-auto"> <div className="size-full mt-4 flex flex-col gap-3 overflow-y-auto">
<div className="text-muted-foreground text-sm font-medium">Overview</div>
<div className="grid grid-cols-1 md:grid-cols-3">
{statsHistory.length != 0 ? (
<div className="p-2.5 bg-background_alt rounded-2xl">
<div className="mb-5">DPS</div>
<CameraLineGraph
graphId="overall-stats"
unit=" DPS"
dataLabels={["detect", "skipped"]}
updateTimes={updateTimes}
data={overallFpsSeries}
/>
</div>
) : (
<Skeleton className="w-full h-32" />
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{config && {config &&
Object.values(config.cameras).map((camera) => { Object.values(config.cameras).map((camera) => {
if (camera.enabled) { if (camera.enabled) {
return ( return (
<div className="w-full flex flex-col"> <div className="w-full flex flex-col gap-3">
<div className="mb-6 capitalize"> <div className="capitalize text-muted-foreground text-sm font-medium">
{camera.name.replaceAll("_", " ")} {camera.name.replaceAll("_", " ")}
</div> </div>
<div key={camera.name} className="grid sm:grid-cols-2 gap-2"> <div key={camera.name} className="grid sm:grid-cols-2 gap-2">
{Object.keys(cameraCpuSeries).includes(camera.name) ? ( {Object.keys(cameraCpuSeries).includes(camera.name) ? (
<div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col"> <div className="p-2.5 bg-background_alt rounded-2xl">
<div className="mb-5">CPU</div> <div className="mb-5">CPU</div>
<CameraLineGraph <CameraLineGraph
graphId={`${camera.name}-cpu`} graphId={`${camera.name}-cpu`}
@ -175,7 +230,7 @@ export default function CameraMetrics({
<Skeleton className="size-full aspect-video" /> <Skeleton className="size-full aspect-video" />
)} )}
{Object.keys(cameraFpsSeries).includes(camera.name) ? ( {Object.keys(cameraFpsSeries).includes(camera.name) ? (
<div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col"> <div className="p-2.5 bg-background_alt rounded-2xl">
<div className="mb-5">DPS</div> <div className="mb-5">DPS</div>
<CameraLineGraph <CameraLineGraph
graphId={`${camera.name}-dps`} graphId={`${camera.name}-dps`}

View File

@ -99,7 +99,7 @@ export default function GeneralMetrics({
series[key] = { name: key, data: [] }; series[key] = { name: key, data: [] };
} }
series[key].data.push({ x: statsIdx, y: stats.inference_speed }); series[key].data.push({ x: statsIdx + 1, y: stats.inference_speed });
}); });
}); });
return Object.values(series); return Object.values(series);
@ -125,7 +125,7 @@ export default function GeneralMetrics({
} }
series[key].data.push({ series[key].data.push({
x: statsIdx, x: statsIdx + 1,
y: stats.cpu_usages[detStats.pid.toString()].cpu, y: stats.cpu_usages[detStats.pid.toString()].cpu,
}); });
}); });
@ -153,7 +153,7 @@ export default function GeneralMetrics({
} }
series[key].data.push({ series[key].data.push({
x: statsIdx, x: statsIdx + 1,
y: stats.cpu_usages[detStats.pid.toString()].mem, y: stats.cpu_usages[detStats.pid.toString()].mem,
}); });
}); });
@ -182,7 +182,7 @@ export default function GeneralMetrics({
series[key] = { name: key, data: [] }; series[key] = { name: key, data: [] };
} }
series[key].data.push({ x: statsIdx, y: stats.gpu }); series[key].data.push({ x: statsIdx + 1, y: stats.gpu });
}); });
}); });
return Object.keys(series).length > 0 ? Object.values(series) : []; return Object.keys(series).length > 0 ? Object.values(series) : [];
@ -193,6 +193,14 @@ export default function GeneralMetrics({
return []; return [];
} }
if (
Object.keys(statsHistory?.at(0)?.gpu_usages ?? {}).length == 1 &&
Object.keys(statsHistory?.at(0)?.gpu_usages ?? {})[0].includes("intel")
) {
// intel gpu stats do not support memory
return undefined;
}
const series: { const series: {
[key: string]: { name: string; data: { x: number; y: string }[] }; [key: string]: { name: string; data: { x: number; y: string }[] };
} = {}; } = {};
@ -207,7 +215,7 @@ export default function GeneralMetrics({
series[key] = { name: key, data: [] }; series[key] = { name: key, data: [] };
} }
series[key].data.push({ x: statsIdx, y: stats.mem }); series[key].data.push({ x: statsIdx + 1, y: stats.mem });
}); });
}); });
return Object.values(series); return Object.values(series);
@ -236,7 +244,7 @@ export default function GeneralMetrics({
} }
series[key].data.push({ series[key].data.push({
x: statsIdx, x: statsIdx + 1,
y: stats.cpu_usages[procStats.pid.toString()].cpu, y: stats.cpu_usages[procStats.pid.toString()].cpu,
}); });
} }
@ -266,7 +274,7 @@ export default function GeneralMetrics({
} }
series[key].data.push({ series[key].data.push({
x: statsIdx, x: statsIdx + 1,
y: stats.cpu_usages[procStats.pid.toString()].mem, y: stats.cpu_usages[procStats.pid.toString()].mem,
}); });
} }
@ -285,7 +293,7 @@ export default function GeneralMetrics({
</div> </div>
<div className="w-full mt-4 grid grid-cols-1 sm:grid-cols-3 gap-2"> <div className="w-full mt-4 grid grid-cols-1 sm:grid-cols-3 gap-2">
{statsHistory.length != 0 ? ( {statsHistory.length != 0 ? (
<div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col"> <div className="p-2.5 bg-background_alt rounded-2xl">
<div className="mb-5">Detector Inference Speed</div> <div className="mb-5">Detector Inference Speed</div>
{detInferenceTimeSeries.map((series) => ( {detInferenceTimeSeries.map((series) => (
<ThresholdBarGraph <ThresholdBarGraph
@ -303,7 +311,7 @@ export default function GeneralMetrics({
<Skeleton className="w-full aspect-video" /> <Skeleton className="w-full aspect-video" />
)} )}
{statsHistory.length != 0 ? ( {statsHistory.length != 0 ? (
<div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col"> <div className="p-2.5 bg-background_alt rounded-2xl">
<div className="mb-5">Detector CPU Usage</div> <div className="mb-5">Detector CPU Usage</div>
{detCpuSeries.map((series) => ( {detCpuSeries.map((series) => (
<ThresholdBarGraph <ThresholdBarGraph
@ -321,7 +329,7 @@ export default function GeneralMetrics({
<Skeleton className="w-full aspect-video" /> <Skeleton className="w-full aspect-video" />
)} )}
{statsHistory.length != 0 ? ( {statsHistory.length != 0 ? (
<div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col"> <div className="p-2.5 bg-background_alt rounded-2xl">
<div className="mb-5">Detector Memory Usage</div> <div className="mb-5">Detector Memory Usage</div>
{detMemSeries.map((series) => ( {detMemSeries.map((series) => (
<ThresholdBarGraph <ThresholdBarGraph
@ -349,7 +357,6 @@ export default function GeneralMetrics({
{canGetGpuInfo && ( {canGetGpuInfo && (
<Button <Button
className="cursor-pointer" className="cursor-pointer"
variant="secondary"
size="sm" size="sm"
onClick={() => setShowVainfo(true)} onClick={() => setShowVainfo(true)}
> >
@ -359,7 +366,7 @@ export default function GeneralMetrics({
</div> </div>
<div className=" mt-4 grid grid-cols-1 sm:grid-cols-2 gap-2"> <div className=" mt-4 grid grid-cols-1 sm:grid-cols-2 gap-2">
{statsHistory.length != 0 ? ( {statsHistory.length != 0 ? (
<div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col"> <div className="p-2.5 bg-background_alt rounded-2xl">
<div className="mb-5">GPU Usage</div> <div className="mb-5">GPU Usage</div>
{gpuSeries.map((series) => ( {gpuSeries.map((series) => (
<ThresholdBarGraph <ThresholdBarGraph
@ -377,7 +384,9 @@ export default function GeneralMetrics({
<Skeleton className="w-full aspect-video" /> <Skeleton className="w-full aspect-video" />
)} )}
{statsHistory.length != 0 ? ( {statsHistory.length != 0 ? (
<div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col"> <>
{gpuMemSeries && (
<div className="p-2.5 bg-background_alt rounded-2xl">
<div className="mb-5">GPU Memory</div> <div className="mb-5">GPU Memory</div>
{gpuMemSeries.map((series) => ( {gpuMemSeries.map((series) => (
<ThresholdBarGraph <ThresholdBarGraph
@ -391,6 +400,8 @@ export default function GeneralMetrics({
/> />
))} ))}
</div> </div>
)}
</>
) : ( ) : (
<Skeleton className="w-full aspect-video" /> <Skeleton className="w-full aspect-video" />
)} )}
@ -403,7 +414,7 @@ export default function GeneralMetrics({
</div> </div>
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-2"> <div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-2">
{statsHistory.length != 0 ? ( {statsHistory.length != 0 ? (
<div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col"> <div className="p-2.5 bg-background_alt rounded-2xl">
<div className="mb-5">Process CPU Usage</div> <div className="mb-5">Process CPU Usage</div>
{otherProcessCpuSeries.map((series) => ( {otherProcessCpuSeries.map((series) => (
<ThresholdBarGraph <ThresholdBarGraph
@ -421,7 +432,7 @@ export default function GeneralMetrics({
<Skeleton className="w-full aspect-tall" /> <Skeleton className="w-full aspect-tall" />
)} )}
{statsHistory.length != 0 ? ( {statsHistory.length != 0 ? (
<div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col"> <div className="p-2.5 bg-background_alt rounded-2xl">
<div className="mb-5">Process Memory Usage</div> <div className="mb-5">Process Memory Usage</div>
{otherProcessMemSeries.map((series) => ( {otherProcessMemSeries.map((series) => (
<ThresholdBarGraph <ThresholdBarGraph

View File

@ -43,11 +43,9 @@ export default function StorageMetrics({
return ( return (
<div className="size-full mt-4 flex flex-col overflow-y-auto"> <div className="size-full mt-4 flex flex-col overflow-y-auto">
<div className="text-muted-foreground text-sm font-medium"> <div className="text-muted-foreground text-sm font-medium">Overview</div>
General Storage
</div>
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-2"> <div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-2">
<div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col"> <div className="p-2.5 bg-background_alt rounded-2xl flex-col">
<div className="mb-5">Recordings</div> <div className="mb-5">Recordings</div>
<StorageGraph <StorageGraph
graphId="general-recordings" graphId="general-recordings"
@ -55,7 +53,7 @@ export default function StorageMetrics({
total={totalStorage.total} total={totalStorage.total}
/> />
</div> </div>
<div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col"> <div className="p-2.5 bg-background_alt rounded-2xl flex-col">
<div className="mb-5">/tmp/cache</div> <div className="mb-5">/tmp/cache</div>
<StorageGraph <StorageGraph
graphId="general-cache" graphId="general-cache"
@ -63,7 +61,7 @@ export default function StorageMetrics({
total={stats.service.storage["/tmp/cache"]["total"]} total={stats.service.storage["/tmp/cache"]["total"]}
/> />
</div> </div>
<div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col"> <div className="p-2.5 bg-background_alt rounded-2xl flex-col">
<div className="mb-5">/dev/shm</div> <div className="mb-5">/dev/shm</div>
<StorageGraph <StorageGraph
graphId="general-shared-memory" graphId="general-shared-memory"
@ -77,7 +75,7 @@ export default function StorageMetrics({
</div> </div>
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-2"> <div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-2">
{Object.keys(cameraStorage).map((camera) => ( {Object.keys(cameraStorage).map((camera) => (
<div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col"> <div className="p-2.5 bg-background_alt rounded-2xl flex-col">
<div className="mb-5 capitalize">{camera.replaceAll("_", " ")}</div> <div className="mb-5 capitalize">{camera.replaceAll("_", " ")}</div>
<StorageGraph <StorageGraph
graphId={`${camera}-storage`} graphId={`${camera}-storage`}

View File

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

View File

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