mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-11 05:35:25 +03:00
Merge branch 'blakeblackshear:dev' into dev
This commit is contained in:
commit
29f87cc839
2
.github/workflows/pull_request.yml
vendored
2
.github/workflows/pull_request.yml
vendored
@ -65,7 +65,7 @@ jobs:
|
||||
- name: Check out the repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v5.0.0
|
||||
uses: actions/setup-python@v5.1.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
- name: Install requirements
|
||||
|
||||
@ -4,15 +4,15 @@ imutils == 0.5.*
|
||||
markupsafe == 2.1.*
|
||||
matplotlib == 3.7.*
|
||||
mypy == 1.6.1
|
||||
numpy == 1.23.*
|
||||
numpy == 1.26.*
|
||||
onvif_zeep == 0.2.12
|
||||
opencv-python-headless == 4.7.0.*
|
||||
paho-mqtt == 2.0.*
|
||||
pandas == 2.1.4
|
||||
pandas == 2.2.*
|
||||
peewee == 3.17.*
|
||||
peewee_migrate == 1.12.*
|
||||
psutil == 5.9.*
|
||||
pydantic == 2.6.*
|
||||
pydantic == 2.7.*
|
||||
git+https://github.com/fbcotter/py3nvml#egg=py3nvml
|
||||
PyYAML == 6.0.*
|
||||
pytz == 2024.1
|
||||
|
||||
@ -62,7 +62,7 @@ http {
|
||||
}
|
||||
|
||||
server {
|
||||
listen 5000;
|
||||
listen [::]:5000 ipv6only=off;
|
||||
|
||||
# vod settings
|
||||
vod_base_url '';
|
||||
@ -238,14 +238,14 @@ http {
|
||||
|
||||
location /api/stats {
|
||||
access_log off;
|
||||
rewrite ^/api/(.*)$ $1 break;
|
||||
rewrite ^/api(/.*)$ $1 break;
|
||||
proxy_pass http://frigate_api;
|
||||
include proxy.conf;
|
||||
}
|
||||
|
||||
location /api/version {
|
||||
access_log off;
|
||||
rewrite ^/api/(.*)$ $1 break;
|
||||
rewrite ^/api(/.*)$ $1 break;
|
||||
proxy_pass http://frigate_api;
|
||||
include proxy.conf;
|
||||
}
|
||||
|
||||
@ -257,6 +257,28 @@ objects:
|
||||
# Checks based on the bottom center of the bounding box of the object
|
||||
mask: 0,0,1000,0,1000,200,0,200
|
||||
|
||||
# Optional: Review configuration
|
||||
# NOTE: Can be overridden at the camera level
|
||||
review:
|
||||
# Optional: alerts configuration
|
||||
alerts:
|
||||
# Optional: labels that qualify as an alert (default: shown below)
|
||||
labels:
|
||||
- car
|
||||
- person
|
||||
# Optional: required zones for an object to be marked as an alert (default: none)
|
||||
required_zones:
|
||||
- driveway
|
||||
# Optional: detections configuration
|
||||
detections:
|
||||
# Optional: labels that qualify as a detection (default: all labels that are tracked / listened to)
|
||||
labels:
|
||||
- car
|
||||
- person
|
||||
# Optional: required zones for an object to be marked as a detection (default: none)
|
||||
required_zones:
|
||||
- driveway
|
||||
|
||||
# Optional: Motion configuration
|
||||
# NOTE: Can be overridden at the camera level
|
||||
motion:
|
||||
@ -345,8 +367,6 @@ record:
|
||||
# Optional: Objects to save recordings for. (default: all tracked objects)
|
||||
objects:
|
||||
- person
|
||||
# Optional: Restrict recordings to objects that entered any of the listed zones (default: no required zones)
|
||||
required_zones: []
|
||||
# Optional: Retention settings for recordings of events
|
||||
retain:
|
||||
# Required: Default retention days (default: shown below)
|
||||
|
||||
@ -19,17 +19,18 @@ To create a zone, follow [the steps for a "Motion mask"](masks.md), but use the
|
||||
Often you will only want events to be created when an object enters areas of interest. This is done using zones along with setting required_zones. Let's say you only want to be notified when an object enters your entire_yard zone, the config would be:
|
||||
|
||||
```yaml
|
||||
camera:
|
||||
record:
|
||||
events:
|
||||
cameras:
|
||||
name_of_your_camera:
|
||||
record:
|
||||
events:
|
||||
required_zones:
|
||||
- entire_yard
|
||||
snapshots:
|
||||
required_zones:
|
||||
- entire_yard
|
||||
snapshots:
|
||||
required_zones:
|
||||
- entire_yard
|
||||
zones:
|
||||
entire_yard:
|
||||
coordinates: ...
|
||||
zones:
|
||||
entire_yard:
|
||||
coordinates: ...
|
||||
```
|
||||
|
||||
### Restricting zones to specific objects
|
||||
@ -37,25 +38,26 @@ camera:
|
||||
Sometimes you want to limit a zone to specific object types to have more granular control of when events/snapshots are saved. The following example will limit one zone to person objects and the other to cars.
|
||||
|
||||
```yaml
|
||||
camera:
|
||||
record:
|
||||
events:
|
||||
cameras:
|
||||
name_of_your_camera:
|
||||
record:
|
||||
events:
|
||||
required_zones:
|
||||
- entire_yard
|
||||
- front_yard_street
|
||||
snapshots:
|
||||
required_zones:
|
||||
- entire_yard
|
||||
- front_yard_street
|
||||
snapshots:
|
||||
required_zones:
|
||||
- entire_yard
|
||||
- front_yard_street
|
||||
zones:
|
||||
entire_yard:
|
||||
coordinates: ... (everywhere you want a person)
|
||||
objects:
|
||||
- person
|
||||
front_yard_street:
|
||||
coordinates: ... (just the street)
|
||||
objects:
|
||||
- car
|
||||
zones:
|
||||
entire_yard:
|
||||
coordinates: ... (everywhere you want a person)
|
||||
objects:
|
||||
- person
|
||||
front_yard_street:
|
||||
coordinates: ... (just the street)
|
||||
objects:
|
||||
- car
|
||||
```
|
||||
|
||||
Only car objects can trigger the `front_yard_street` zone and only person can trigger the `entire_yard`. You will get events for person objects that enter anywhere in the yard, and events for cars only if they enter the street.
|
||||
@ -65,12 +67,13 @@ Only car objects can trigger the `front_yard_street` zone and only person can tr
|
||||
Sometimes objects are expected to be passing through a zone, but an object loitering in an area is unexpected. Zones can be configured to have a minimum loitering time before the object will be considered in the zone.
|
||||
|
||||
```yaml
|
||||
camera:
|
||||
zones:
|
||||
sidewalk:
|
||||
loitering_time: 4 # unit is in seconds
|
||||
objects:
|
||||
- person
|
||||
cameras:
|
||||
name_of_your_camera:
|
||||
zones:
|
||||
sidewalk:
|
||||
loitering_time: 4 # unit is in seconds
|
||||
objects:
|
||||
- person
|
||||
```
|
||||
|
||||
### Zone Inertia
|
||||
@ -78,21 +81,23 @@ camera:
|
||||
Sometimes an objects bounding box may be slightly incorrect and the bottom center of the bounding box is inside the zone while the object is not actually in the zone. Zone inertia helps guard against this by requiring an object's bounding box to be within the zone for multiple consecutive frames. This value can be configured:
|
||||
|
||||
```yaml
|
||||
camera:
|
||||
zones:
|
||||
front_yard:
|
||||
inertia: 3
|
||||
objects:
|
||||
- person
|
||||
cameras:
|
||||
name_of_your_camera:
|
||||
zones:
|
||||
front_yard:
|
||||
inertia: 3
|
||||
objects:
|
||||
- person
|
||||
```
|
||||
|
||||
There may also be cases where you expect an object to quickly enter and exit a zone, like when a car is pulling into the driveway, and you may want to have the object be considered present in the zone immediately:
|
||||
|
||||
```yaml
|
||||
camera:
|
||||
zones:
|
||||
driveway_entrance:
|
||||
inertia: 1
|
||||
objects:
|
||||
- car
|
||||
cameras:
|
||||
name_of_your_camera:
|
||||
zones:
|
||||
driveway_entrance:
|
||||
inertia: 1
|
||||
objects:
|
||||
- car
|
||||
```
|
||||
|
||||
@ -137,7 +137,10 @@ def stats_history():
|
||||
|
||||
@bp.route("/config")
|
||||
def config():
|
||||
config = current_app.frigate_config.model_dump(mode="json", exclude_none=True)
|
||||
config_obj: FrigateConfig = current_app.frigate_config
|
||||
config: dict[str, dict[str, any]] = config_obj.model_dump(
|
||||
mode="json", exclude_none=True
|
||||
)
|
||||
|
||||
# remove the mqtt password
|
||||
config["mqtt"].pop("password", None)
|
||||
@ -154,9 +157,13 @@ def config():
|
||||
for cmd in camera_dict["ffmpeg_cmds"]:
|
||||
cmd["cmd"] = clean_camera_user_pass(" ".join(cmd["cmd"]))
|
||||
|
||||
# ensure that zones are relative
|
||||
for zone_name, zone in config_obj.cameras[camera_name].zones.items():
|
||||
camera_dict["zones"][zone_name]["color"] = zone.color
|
||||
|
||||
config["plus"] = {"enabled": current_app.plus_api.is_active()}
|
||||
|
||||
for detector, detector_config in config["detectors"].items():
|
||||
for detector_config in config["detectors"].values():
|
||||
detector_config["model"]["labelmap"] = (
|
||||
current_app.frigate_config.model.merged_labelmap
|
||||
)
|
||||
|
||||
@ -1333,7 +1333,9 @@ def review_preview(id: str):
|
||||
|
||||
padding = 8
|
||||
start_ts = review.start_time - padding
|
||||
end_ts = review.end_time + padding
|
||||
end_ts = (
|
||||
review.end_time + padding if review.end_time else datetime.now().timestamp()
|
||||
)
|
||||
return preview_gif(review.camera, start_ts, end_ts)
|
||||
|
||||
|
||||
@ -1344,8 +1346,15 @@ def preview_thumbnail(file_name: str):
|
||||
safe_file_name_current = secure_filename(file_name)
|
||||
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
|
||||
|
||||
with open(os.path.join(preview_dir, safe_file_name_current), "rb") as image_file:
|
||||
jpg_bytes = image_file.read()
|
||||
try:
|
||||
with open(
|
||||
os.path.join(preview_dir, safe_file_name_current), "rb"
|
||||
) as image_file:
|
||||
jpg_bytes = image_file.read()
|
||||
except FileNotFoundError:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Image file not found"}), 404
|
||||
)
|
||||
|
||||
response = make_response(jpg_bytes)
|
||||
response.headers["Content-Type"] = "image/jpeg"
|
||||
|
||||
@ -27,10 +27,18 @@ def review():
|
||||
|
||||
before = request.args.get("before", type=float, default=datetime.now().timestamp())
|
||||
after = request.args.get(
|
||||
"after", type=float, default=(datetime.now() - timedelta(hours=18)).timestamp()
|
||||
"after", type=float, default=(datetime.now() - timedelta(hours=24)).timestamp()
|
||||
)
|
||||
|
||||
clauses = [((ReviewSegment.start_time > after) & (ReviewSegment.end_time < before))]
|
||||
clauses = [
|
||||
(
|
||||
(ReviewSegment.start_time > after)
|
||||
& (
|
||||
(ReviewSegment.end_time.is_null(True))
|
||||
| (ReviewSegment.end_time < before)
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
if cameras != "all":
|
||||
camera_list = cameras.split(",")
|
||||
@ -45,6 +53,7 @@ def review():
|
||||
for label in filtered_labels:
|
||||
label_clauses.append(
|
||||
(ReviewSegment.data["objects"].cast("text") % f'*"{label}"*')
|
||||
| (ReviewSegment.data["audio"].cast("text") % f'*"{label}"*')
|
||||
)
|
||||
|
||||
label_clause = reduce(operator.or_, label_clauses)
|
||||
@ -94,6 +103,7 @@ def review_summary():
|
||||
for label in filtered_labels:
|
||||
label_clauses.append(
|
||||
(ReviewSegment.data["objects"].cast("text") % f'*"{label}"*')
|
||||
| (ReviewSegment.data["audio"].cast("text") % f'*"{label}"*')
|
||||
)
|
||||
|
||||
label_clause = reduce(operator.or_, label_clauses)
|
||||
@ -429,12 +439,12 @@ def motion_activity():
|
||||
# normalize data
|
||||
motion = (
|
||||
df["motion"]
|
||||
.resample(f"{scale}S")
|
||||
.resample(f"{scale}s")
|
||||
.apply(lambda x: max(x, key=abs, default=0.0))
|
||||
.fillna(0.0)
|
||||
.to_frame()
|
||||
)
|
||||
cameras = df["camera"].resample(f"{scale}S").agg(lambda x: ",".join(set(x)))
|
||||
cameras = df["camera"].resample(f"{scale}s").agg(lambda x: ",".join(set(x)))
|
||||
df = motion.join(cameras)
|
||||
|
||||
length = df.shape[0]
|
||||
|
||||
@ -63,6 +63,7 @@ from frigate.storage import StorageMaintainer
|
||||
from frigate.timeline import TimelineProcessor
|
||||
from frigate.types import CameraMetricsTypes, PTZMetricsTypes
|
||||
from frigate.util.builtin import save_default_config
|
||||
from frigate.util.config import migrate_frigate_config
|
||||
from frigate.util.object import get_camera_regions_grid
|
||||
from frigate.version import VERSION
|
||||
from frigate.video import capture_camera, track_camera
|
||||
@ -126,6 +127,9 @@ class FrigateApp:
|
||||
config_file = config_file_yaml
|
||||
save_default_config(config_file)
|
||||
|
||||
# check if the config file needs to be migrated
|
||||
migrate_frigate_config(config_file)
|
||||
|
||||
user_config = FrigateConfig.parse_file(config_file)
|
||||
self.config = user_config.runtime_config(self.plus_api)
|
||||
|
||||
@ -200,9 +204,6 @@ class FrigateApp:
|
||||
logging.getLogger("ws4py").setLevel("ERROR")
|
||||
|
||||
def init_queues(self) -> None:
|
||||
# Queues for clip processing
|
||||
self.event_processed_queue: Queue = mp.Queue()
|
||||
|
||||
# Queue for cameras to push tracked objects to
|
||||
self.detected_frames_queue: Queue = mp.Queue(
|
||||
maxsize=sum(camera.enabled for camera in self.config.cameras.values()) * 2
|
||||
@ -420,7 +421,6 @@ class FrigateApp:
|
||||
self.config,
|
||||
self.dispatcher,
|
||||
self.detected_frames_queue,
|
||||
self.event_processed_queue,
|
||||
self.ptz_autotracker_thread,
|
||||
self.stop_event,
|
||||
)
|
||||
@ -517,7 +517,6 @@ class FrigateApp:
|
||||
def start_event_processor(self) -> None:
|
||||
self.event_processor = EventProcessor(
|
||||
self.config,
|
||||
self.event_processed_queue,
|
||||
self.timeline_queue,
|
||||
self.stop_event,
|
||||
)
|
||||
@ -672,6 +671,14 @@ class FrigateApp:
|
||||
logger.info("Stopping...")
|
||||
self.stop_event.set()
|
||||
|
||||
# set an end_time on entries without an end_time before exiting
|
||||
Event.update(end_time=datetime.datetime.now().timestamp()).where(
|
||||
Event.end_time == None
|
||||
).execute()
|
||||
ReviewSegment.update(end_time=datetime.datetime.now().timestamp()).where(
|
||||
ReviewSegment.end_time == None
|
||||
).execute()
|
||||
|
||||
# Stop Communicators
|
||||
self.inter_process_communicator.stop()
|
||||
self.inter_config_updater.stop()
|
||||
@ -704,7 +711,6 @@ class FrigateApp:
|
||||
shm.unlink()
|
||||
|
||||
for queue in [
|
||||
self.event_processed_queue,
|
||||
self.detected_frames_queue,
|
||||
self.log_queue,
|
||||
]:
|
||||
|
||||
@ -8,7 +8,7 @@ import zmq
|
||||
|
||||
SOCKET_CONTROL = "inproc://control.detections_updater"
|
||||
SOCKET_PUB = "ipc:///tmp/cache/detect_pub"
|
||||
SOCKET_SUB = "ipc:///tmp/cache/detect_sun"
|
||||
SOCKET_SUB = "ipc:///tmp/cache/detect_sub"
|
||||
|
||||
|
||||
class DetectionTypeEnum(str, Enum):
|
||||
|
||||
@ -5,6 +5,7 @@ import zmq
|
||||
from frigate.events.types import EventStateEnum, EventTypeEnum
|
||||
|
||||
SOCKET_PUSH_PULL = "ipc:///tmp/cache/events"
|
||||
SOCKET_PUSH_PULL_END = "ipc:///tmp/cache/events_ended"
|
||||
|
||||
|
||||
class EventUpdatePublisher:
|
||||
@ -37,7 +38,53 @@ class EventUpdateSubscriber:
|
||||
def check_for_update(
|
||||
self, timeout=1
|
||||
) -> tuple[EventTypeEnum, EventStateEnum, str, dict[str, any]]:
|
||||
"""Returns updated config or None if no update."""
|
||||
"""Returns events or None if no update."""
|
||||
try:
|
||||
has_update, _, _ = zmq.select([self.socket], [], [], timeout)
|
||||
|
||||
if has_update:
|
||||
return self.socket.recv_pyobj()
|
||||
except zmq.ZMQError:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def stop(self) -> None:
|
||||
self.socket.close()
|
||||
self.context.destroy()
|
||||
|
||||
|
||||
class EventEndPublisher:
|
||||
"""Publishes events that have ended."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.context = zmq.Context()
|
||||
self.socket = self.context.socket(zmq.PUSH)
|
||||
self.socket.connect(SOCKET_PUSH_PULL_END)
|
||||
|
||||
def publish(
|
||||
self, payload: tuple[EventTypeEnum, EventStateEnum, str, dict[str, any]]
|
||||
) -> None:
|
||||
"""There is no communication back to the processes."""
|
||||
self.socket.send_pyobj(payload)
|
||||
|
||||
def stop(self) -> None:
|
||||
self.socket.close()
|
||||
self.context.destroy()
|
||||
|
||||
|
||||
class EventEndSubscriber:
|
||||
"""Receives events that have ended."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.context = zmq.Context()
|
||||
self.socket = self.context.socket(zmq.PULL)
|
||||
self.socket.bind(SOCKET_PUSH_PULL_END)
|
||||
|
||||
def check_for_update(
|
||||
self, timeout=1
|
||||
) -> tuple[EventTypeEnum, EventStateEnum, str, dict[str, any]]:
|
||||
"""Returns events ended or None if no update."""
|
||||
try:
|
||||
has_update, _, _ = zmq.select([self.socket], [], [], timeout)
|
||||
|
||||
|
||||
@ -245,10 +245,6 @@ class EventsConfig(FrigateBaseModel):
|
||||
default=5, title="Seconds to retain before event starts.", le=MAX_PRE_CAPTURE
|
||||
)
|
||||
post_capture: int = Field(default=5, title="Seconds to retain after event ends.")
|
||||
required_zones: List[str] = Field(
|
||||
default_factory=list,
|
||||
title="List of required zones to be entered in order to save the event.",
|
||||
)
|
||||
objects: Optional[List[str]] = Field(
|
||||
None,
|
||||
title="List of objects to be detected in order to save the event.",
|
||||
@ -354,6 +350,34 @@ class RuntimeMotionConfig(MotionConfig):
|
||||
frame_shape = config.get("frame_shape", (1, 1))
|
||||
|
||||
mask = config.get("mask", "")
|
||||
|
||||
# masks and zones are saved as relative coordinates
|
||||
# we know if any points are > 1 then it is using the
|
||||
# old native resolution coordinates
|
||||
if mask:
|
||||
if isinstance(mask, list) and any(x > "1.0" for x in mask[0].split(",")):
|
||||
relative_masks = []
|
||||
for m in mask:
|
||||
points = m.split(",")
|
||||
relative_masks.append(
|
||||
",".join(
|
||||
[
|
||||
f"{round(int(points[i]) / frame_shape[1], 3)},{round(int(points[i + 1]) / frame_shape[0], 3)}"
|
||||
for i in range(0, len(points), 2)
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
mask = relative_masks
|
||||
elif isinstance(mask, str) and any(x > "1.0" for x in mask.split(",")):
|
||||
points = mask.split(",")
|
||||
mask = ",".join(
|
||||
[
|
||||
f"{round(int(points[i]) / frame_shape[1], 3)},{round(int(points[i + 1]) / frame_shape[0], 3)}"
|
||||
for i in range(0, len(points), 2)
|
||||
]
|
||||
)
|
||||
|
||||
config["raw_mask"] = mask
|
||||
|
||||
if mask:
|
||||
@ -484,11 +508,40 @@ class RuntimeFilterConfig(FilterConfig):
|
||||
raw_mask: Optional[Union[str, List[str]]] = None
|
||||
|
||||
def __init__(self, **config):
|
||||
frame_shape = config.get("frame_shape", (1, 1))
|
||||
mask = config.get("mask")
|
||||
|
||||
# masks and zones are saved as relative coordinates
|
||||
# we know if any points are > 1 then it is using the
|
||||
# old native resolution coordinates
|
||||
if mask:
|
||||
if isinstance(mask, list) and any(x > "1.0" for x in mask[0].split(",")):
|
||||
relative_masks = []
|
||||
for m in mask:
|
||||
points = m.split(",")
|
||||
relative_masks.append(
|
||||
",".join(
|
||||
[
|
||||
f"{round(int(points[i]) / frame_shape[1], 3)},{round(int(points[i + 1]) / frame_shape[0], 3)}"
|
||||
for i in range(0, len(points), 2)
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
mask = relative_masks
|
||||
elif isinstance(mask, str) and any(x > "1.0" for x in mask.split(",")):
|
||||
points = mask.split(",")
|
||||
mask = ",".join(
|
||||
[
|
||||
f"{round(int(points[i]) / frame_shape[1], 3)},{round(int(points[i + 1]) / frame_shape[0], 3)}"
|
||||
for i in range(0, len(points), 2)
|
||||
]
|
||||
)
|
||||
|
||||
config["raw_mask"] = mask
|
||||
|
||||
if mask is not None:
|
||||
config["mask"] = create_mask(config.get("frame_shape", (1, 1)), mask)
|
||||
config["mask"] = create_mask(frame_shape, mask)
|
||||
|
||||
super().__init__(**config)
|
||||
|
||||
@ -539,30 +592,106 @@ class ZoneConfig(BaseModel):
|
||||
super().__init__(**config)
|
||||
|
||||
self._color = config.get("color", (0, 0, 0))
|
||||
coordinates = config["coordinates"]
|
||||
self._contour = config.get("contour", np.array([]))
|
||||
|
||||
def generate_contour(self, frame_shape: tuple[int, int]):
|
||||
coordinates = self.coordinates
|
||||
|
||||
# masks and zones are saved as relative coordinates
|
||||
# we know if any points are > 1 then it is using the
|
||||
# old native resolution coordinates
|
||||
if isinstance(coordinates, list):
|
||||
explicit = any(p.split(",")[0] > "1.0" for p in coordinates)
|
||||
self._contour = np.array(
|
||||
[[int(p.split(",")[0]), int(p.split(",")[1])] for p in coordinates]
|
||||
[
|
||||
(
|
||||
[int(p.split(",")[0]), int(p.split(",")[1])]
|
||||
if explicit
|
||||
else [
|
||||
int(float(p.split(",")[0]) * frame_shape[1]),
|
||||
int(float(p.split(",")[1]) * frame_shape[0]),
|
||||
]
|
||||
)
|
||||
for p in coordinates
|
||||
]
|
||||
)
|
||||
|
||||
if explicit:
|
||||
self.coordinates = ",".join(
|
||||
[
|
||||
f'{round(int(p.split(",")[0]) / frame_shape[1], 3)},{round(int(p.split(",")[1]) / frame_shape[0], 3)}'
|
||||
for p in coordinates
|
||||
]
|
||||
)
|
||||
elif isinstance(coordinates, str):
|
||||
points = coordinates.split(",")
|
||||
explicit = any(p > "1.0" for p in points)
|
||||
self._contour = np.array(
|
||||
[[int(points[i]), int(points[i + 1])] for i in range(0, len(points), 2)]
|
||||
[
|
||||
(
|
||||
[int(points[i]), int(points[i + 1])]
|
||||
if explicit
|
||||
else [
|
||||
int(float(points[i]) * frame_shape[1]),
|
||||
int(float(points[i + 1]) * frame_shape[0]),
|
||||
]
|
||||
)
|
||||
for i in range(0, len(points), 2)
|
||||
]
|
||||
)
|
||||
|
||||
if explicit:
|
||||
self.coordinates = ",".join(
|
||||
[
|
||||
f"{round(int(points[i]) / frame_shape[1], 3)},{round(int(points[i + 1]) / frame_shape[0], 3)}"
|
||||
for i in range(0, len(points), 2)
|
||||
]
|
||||
)
|
||||
else:
|
||||
self._contour = np.array([])
|
||||
|
||||
|
||||
class ObjectConfig(FrigateBaseModel):
|
||||
track: List[str] = Field(default=DEFAULT_TRACKED_OBJECTS, title="Objects to track.")
|
||||
alert: List[str] = Field(
|
||||
default=DEFAULT_ALERT_OBJECTS, title="Objects to create alerts for."
|
||||
)
|
||||
filters: Dict[str, FilterConfig] = Field(default={}, title="Object filters.")
|
||||
mask: Union[str, List[str]] = Field(default="", title="Object mask.")
|
||||
|
||||
|
||||
class AlertsConfig(FrigateBaseModel):
|
||||
"""Configure alerts"""
|
||||
|
||||
labels: List[str] = Field(
|
||||
default=DEFAULT_ALERT_OBJECTS, title="Labels to create alerts for."
|
||||
)
|
||||
required_zones: List[str] = Field(
|
||||
default_factory=list,
|
||||
title="List of required zones to be entered in order to save the event as an alert.",
|
||||
)
|
||||
|
||||
|
||||
class DetectionsConfig(FrigateBaseModel):
|
||||
"""Configure detections"""
|
||||
|
||||
labels: Optional[List[str]] = Field(
|
||||
default=None, title="Labels to create detections for."
|
||||
)
|
||||
required_zones: List[str] = Field(
|
||||
default_factory=list,
|
||||
title="List of required zones to be entered in order to save the event as a detection.",
|
||||
)
|
||||
|
||||
|
||||
class ReviewConfig(FrigateBaseModel):
|
||||
"""Configure reviews"""
|
||||
|
||||
alerts: AlertsConfig = Field(
|
||||
default_factory=AlertsConfig, title="Review alerts config."
|
||||
)
|
||||
detections: DetectionsConfig = Field(
|
||||
default_factory=DetectionsConfig, title="Review detections config."
|
||||
)
|
||||
|
||||
|
||||
class AudioConfig(FrigateBaseModel):
|
||||
enabled: bool = Field(default=False, title="Enable audio events.")
|
||||
max_not_heard: int = Field(
|
||||
@ -841,6 +970,9 @@ class CameraConfig(FrigateBaseModel):
|
||||
objects: ObjectConfig = Field(
|
||||
default_factory=ObjectConfig, title="Object configuration."
|
||||
)
|
||||
review: ReviewConfig = Field(
|
||||
default_factory=ReviewConfig, title="Review configuration."
|
||||
)
|
||||
audio: AudioConfig = Field(
|
||||
default_factory=AudioConfig, title="Audio events configuration."
|
||||
)
|
||||
@ -1162,6 +1294,9 @@ class FrigateConfig(FrigateBaseModel):
|
||||
objects: ObjectConfig = Field(
|
||||
default_factory=ObjectConfig, title="Global object configuration."
|
||||
)
|
||||
review: ReviewConfig = Field(
|
||||
default_factory=ReviewConfig, title="Review configuration."
|
||||
)
|
||||
audio: AudioConfig = Field(
|
||||
default_factory=AudioConfig, title="Global Audio events configuration."
|
||||
)
|
||||
@ -1209,6 +1344,7 @@ class FrigateConfig(FrigateBaseModel):
|
||||
"snapshots": ...,
|
||||
"live": ...,
|
||||
"objects": ...,
|
||||
"review": ...,
|
||||
"motion": ...,
|
||||
"detect": ...,
|
||||
"ffmpeg": ...,
|
||||
@ -1346,6 +1482,11 @@ class FrigateConfig(FrigateBaseModel):
|
||||
)
|
||||
camera_config.motion.enabled_in_config = camera_config.motion.enabled
|
||||
|
||||
# generate zone contours
|
||||
if len(camera_config.zones) > 0:
|
||||
for zone in camera_config.zones.values():
|
||||
zone.generate_contour(camera_config.frame_shape)
|
||||
|
||||
# Set live view stream if none is set
|
||||
if not camera_config.live.stream_name:
|
||||
camera_config.live.stream_name = name
|
||||
|
||||
@ -39,6 +39,10 @@ AUDIO_MAX_BIT_RANGE = 32768.0
|
||||
AUDIO_SAMPLE_RATE = 16000
|
||||
AUDIO_MIN_CONFIDENCE = 0.5
|
||||
|
||||
# DB Consts
|
||||
|
||||
MAX_WAL_SIZE = 10 # MB
|
||||
|
||||
# Ffmpeg Presets
|
||||
|
||||
FFMPEG_HWACCEL_NVIDIA = "preset-nvidia"
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
import datetime
|
||||
import logging
|
||||
import threading
|
||||
from multiprocessing import Queue
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
from typing import Dict
|
||||
|
||||
from frigate.comms.events_updater import EventUpdateSubscriber
|
||||
from frigate.comms.events_updater import EventEndPublisher, EventUpdateSubscriber
|
||||
from frigate.config import EventsConfig, FrigateConfig
|
||||
from frigate.events.types import EventStateEnum, EventTypeEnum
|
||||
from frigate.models import Event
|
||||
@ -52,19 +51,18 @@ class EventProcessor(threading.Thread):
|
||||
def __init__(
|
||||
self,
|
||||
config: FrigateConfig,
|
||||
event_processed_queue: Queue,
|
||||
timeline_queue: Queue,
|
||||
stop_event: MpEvent,
|
||||
):
|
||||
threading.Thread.__init__(self)
|
||||
self.name = "event_processor"
|
||||
self.config = config
|
||||
self.event_processed_queue = event_processed_queue
|
||||
self.timeline_queue = timeline_queue
|
||||
self.events_in_process: Dict[str, Event] = {}
|
||||
self.stop_event = stop_event
|
||||
|
||||
self.event_receiver = EventUpdateSubscriber()
|
||||
self.event_end_publisher = EventEndPublisher()
|
||||
|
||||
def run(self) -> None:
|
||||
# set an end_time on events without an end_time on startup
|
||||
@ -113,11 +111,8 @@ class EventProcessor(threading.Thread):
|
||||
|
||||
self.handle_external_detection(event_type, event_data)
|
||||
|
||||
# set an end_time on events without an end_time before exiting
|
||||
Event.update(end_time=datetime.datetime.now().timestamp()).where(
|
||||
Event.end_time == None
|
||||
).execute()
|
||||
self.event_receiver.stop()
|
||||
self.event_end_publisher.stop()
|
||||
logger.info("Exiting event processor...")
|
||||
|
||||
def handle_object_detection(
|
||||
@ -242,7 +237,7 @@ class EventProcessor(threading.Thread):
|
||||
|
||||
if event_type == EventStateEnum.end:
|
||||
del self.events_in_process[event_data["id"]]
|
||||
self.event_processed_queue.put((event_data["id"], camera))
|
||||
self.event_end_publisher.publish((event_data["id"], camera))
|
||||
|
||||
def handle_external_detection(
|
||||
self, event_type: EventStateEnum, event_data: Event
|
||||
|
||||
@ -6,6 +6,7 @@ import os
|
||||
import queue
|
||||
import threading
|
||||
from collections import Counter, defaultdict
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
from statistics import median
|
||||
from typing import Callable
|
||||
|
||||
@ -14,7 +15,7 @@ import numpy as np
|
||||
|
||||
from frigate.comms.detections_updater import DetectionPublisher, DetectionTypeEnum
|
||||
from frigate.comms.dispatcher import Dispatcher
|
||||
from frigate.comms.events_updater import EventUpdatePublisher
|
||||
from frigate.comms.events_updater import EventEndSubscriber, EventUpdatePublisher
|
||||
from frigate.config import (
|
||||
CameraConfig,
|
||||
FrigateConfig,
|
||||
@ -827,7 +828,6 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
config: FrigateConfig,
|
||||
dispatcher: Dispatcher,
|
||||
tracked_objects_queue,
|
||||
event_processed_queue,
|
||||
ptz_autotracker_thread,
|
||||
stop_event,
|
||||
):
|
||||
@ -836,14 +836,14 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
self.config = config
|
||||
self.dispatcher = dispatcher
|
||||
self.tracked_objects_queue = tracked_objects_queue
|
||||
self.event_processed_queue = event_processed_queue
|
||||
self.stop_event = stop_event
|
||||
self.stop_event: MpEvent = stop_event
|
||||
self.camera_states: dict[str, CameraState] = {}
|
||||
self.frame_manager = SharedMemoryFrameManager()
|
||||
self.last_motion_detected: dict[str, float] = {}
|
||||
self.ptz_autotracker_thread = ptz_autotracker_thread
|
||||
self.detection_publisher = DetectionPublisher(DetectionTypeEnum.video)
|
||||
self.event_sender = EventUpdatePublisher()
|
||||
self.event_end_subscriber = EventEndSubscriber()
|
||||
|
||||
def start(camera, obj: TrackedObject, current_frame_time):
|
||||
self.event_sender.publish(
|
||||
@ -1007,7 +1007,7 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
|
||||
return True
|
||||
|
||||
def should_retain_recording(self, camera, obj: TrackedObject):
|
||||
def should_retain_recording(self, camera: str, obj: TrackedObject):
|
||||
if obj.false_positive:
|
||||
return False
|
||||
|
||||
@ -1022,7 +1022,11 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
return False
|
||||
|
||||
# If there are required zones and there is no overlap
|
||||
required_zones = record_config.events.required_zones
|
||||
review_config = self.config.cameras[camera].review
|
||||
required_zones = (
|
||||
review_config.alerts.required_zones
|
||||
+ review_config.detections.required_zones
|
||||
)
|
||||
if len(required_zones) > 0 and not set(obj.entered_zones) & set(required_zones):
|
||||
logger.debug(
|
||||
f"Not creating clip for {obj.obj_data['id']} because it did not enter required zones"
|
||||
@ -1215,10 +1219,16 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
)
|
||||
|
||||
# cleanup event finished queue
|
||||
while not self.event_processed_queue.empty():
|
||||
event_id, camera = self.event_processed_queue.get()
|
||||
while not self.stop_event.is_set():
|
||||
update = self.event_end_subscriber.check_for_update(timeout=0.01)
|
||||
|
||||
if not update:
|
||||
break
|
||||
|
||||
event_id, camera = update
|
||||
self.camera_states[camera].finished(event_id)
|
||||
|
||||
self.detection_publisher.stop()
|
||||
self.event_sender.stop()
|
||||
self.event_end_subscriber.stop()
|
||||
logger.info("Exiting object processor...")
|
||||
|
||||
@ -3,12 +3,15 @@
|
||||
import datetime
|
||||
import itertools
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
from pathlib import Path
|
||||
|
||||
from playhouse.sqlite_ext import SqliteExtDatabase
|
||||
|
||||
from frigate.config import CameraConfig, FrigateConfig, RetainModeEnum
|
||||
from frigate.const import CACHE_DIR, RECORD_DIR
|
||||
from frigate.const import CACHE_DIR, MAX_WAL_SIZE, RECORD_DIR
|
||||
from frigate.models import Event, Previews, Recordings, ReviewSegment
|
||||
from frigate.record.util import remove_empty_directories, sync_recordings
|
||||
from frigate.util.builtin import clear_and_unlink, get_tomorrow_at_time
|
||||
@ -33,6 +36,23 @@ class RecordingCleanup(threading.Thread):
|
||||
logger.debug("Deleting tmp clip.")
|
||||
clear_and_unlink(p)
|
||||
|
||||
def truncate_wal(self) -> None:
|
||||
"""check if the WAL needs to be manually truncated."""
|
||||
|
||||
# by default the WAL should be check-pointed automatically
|
||||
# however, high levels of activity can prevent an opportunity
|
||||
# for the checkpoint to be finished which means the WAL will grow
|
||||
# without bound
|
||||
|
||||
# with auto checkpoint most users should never hit this
|
||||
|
||||
if (
|
||||
os.stat(f"{self.config.database.path}-wal").st_size / (1024 * 1024)
|
||||
) > MAX_WAL_SIZE:
|
||||
db = SqliteExtDatabase(self.config.database.path)
|
||||
db.execute_sql("PRAGMA wal_checkpoint(TRUNCATE);")
|
||||
db.close()
|
||||
|
||||
def expire_existing_camera_recordings(
|
||||
self, expire_date: float, config: CameraConfig, events: Event
|
||||
) -> None:
|
||||
@ -328,3 +348,4 @@ class RecordingCleanup(threading.Thread):
|
||||
if counter == 0:
|
||||
self.expire_recordings()
|
||||
remove_empty_directories(RECORD_DIR)
|
||||
self.truncate_wal()
|
||||
|
||||
@ -103,14 +103,14 @@ class RecordingExporter(threading.Thread):
|
||||
|
||||
if self.playback_factor == PlaybackFactorEnum.realtime:
|
||||
ffmpeg_cmd = (
|
||||
f"ffmpeg -hide_banner {ffmpeg_input} -c copy {file_path}"
|
||||
f"ffmpeg -hide_banner {ffmpeg_input} -c copy -movflags +faststart {file_path}"
|
||||
).split(" ")
|
||||
elif self.playback_factor == PlaybackFactorEnum.timelapse_25x:
|
||||
ffmpeg_cmd = (
|
||||
parse_preset_hardware_acceleration_encode(
|
||||
self.config.ffmpeg.hwaccel_args,
|
||||
f"{TIMELAPSE_DATA_INPUT_ARGS} {ffmpeg_input}",
|
||||
f"{self.config.cameras[self.camera].record.export.timelapse_args} {file_path}",
|
||||
f"{self.config.cameras[self.camera].record.export.timelapse_args} -movflags +faststart {file_path}",
|
||||
EncodeTypeEnum.timelapse,
|
||||
)
|
||||
).split(" ")
|
||||
|
||||
@ -32,13 +32,11 @@ THUMB_WIDTH = 320
|
||||
|
||||
THRESHOLD_ALERT_ACTIVITY = 120
|
||||
THRESHOLD_DETECTION_ACTIVITY = 30
|
||||
THRESHOLD_MOTION_ACTIVITY = 30
|
||||
|
||||
|
||||
class SeverityEnum(str, Enum):
|
||||
alert = "alert"
|
||||
detection = "detection"
|
||||
signification_motion = "significant_motion"
|
||||
|
||||
|
||||
class PendingReviewSegment:
|
||||
@ -50,7 +48,6 @@ class PendingReviewSegment:
|
||||
detections: dict[str, str],
|
||||
zones: set[str] = set(),
|
||||
audio: set[str] = set(),
|
||||
motion: list[int] = [],
|
||||
):
|
||||
rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
|
||||
self.id = f"{frame_time}-{rand_id}"
|
||||
@ -60,12 +57,12 @@ class PendingReviewSegment:
|
||||
self.detections = detections
|
||||
self.zones = zones
|
||||
self.audio = audio
|
||||
self.sig_motion_areas = motion
|
||||
self.last_update = frame_time
|
||||
|
||||
# thumbnail
|
||||
self.frame = np.zeros((THUMB_HEIGHT * 3 // 2, THUMB_WIDTH), np.uint8)
|
||||
self.frame_active_count = 0
|
||||
self.frame_path = os.path.join(CLIPS_DIR, f"thumb-{self.camera}-{self.id}.jpg")
|
||||
|
||||
def update_frame(
|
||||
self, camera_config: CameraConfig, frame, objects: list[TrackedObject]
|
||||
@ -98,25 +95,24 @@ class PendingReviewSegment:
|
||||
color_frame, dsize=(width, THUMB_HEIGHT), interpolation=cv2.INTER_AREA
|
||||
)
|
||||
|
||||
def end(self) -> dict:
|
||||
path = os.path.join(CLIPS_DIR, f"thumb-{self.camera}-{self.id}.jpg")
|
||||
|
||||
if self.frame is not None:
|
||||
cv2.imwrite(path, self.frame, [int(cv2.IMWRITE_WEBP_QUALITY), 60])
|
||||
cv2.imwrite(
|
||||
self.frame_path, self.frame, [int(cv2.IMWRITE_WEBP_QUALITY), 60]
|
||||
)
|
||||
|
||||
def get_data(self, ended: bool) -> dict:
|
||||
return {
|
||||
ReviewSegment.id: self.id,
|
||||
ReviewSegment.camera: self.camera,
|
||||
ReviewSegment.start_time: self.start_time,
|
||||
ReviewSegment.end_time: self.last_update,
|
||||
ReviewSegment.end_time: self.last_update if ended else None,
|
||||
ReviewSegment.severity: self.severity.value,
|
||||
ReviewSegment.thumb_path: path,
|
||||
ReviewSegment.thumb_path: self.frame_path,
|
||||
ReviewSegment.data: {
|
||||
"detections": list(set(self.detections.keys())),
|
||||
"objects": list(set(self.detections.values())),
|
||||
"zones": list(self.zones),
|
||||
"audio": list(self.audio),
|
||||
"significant_motion_areas": self.sig_motion_areas,
|
||||
},
|
||||
}
|
||||
|
||||
@ -141,9 +137,20 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
|
||||
self.stop_event = stop_event
|
||||
|
||||
def update_segment(self, segment: PendingReviewSegment) -> None:
|
||||
"""Update segment."""
|
||||
seg_data = segment.get_data(ended=False)
|
||||
self.requestor.send_data(UPSERT_REVIEW_SEGMENT, seg_data)
|
||||
self.requestor.send_data(
|
||||
"reviews",
|
||||
json.dumps(
|
||||
{"type": "update", "review": {k.name: v for k, v in seg_data.items()}}
|
||||
),
|
||||
)
|
||||
|
||||
def end_segment(self, segment: PendingReviewSegment) -> None:
|
||||
"""End segment."""
|
||||
seg_data = segment.end()
|
||||
seg_data = segment.get_data(ended=True)
|
||||
self.requestor.send_data(UPSERT_REVIEW_SEGMENT, seg_data)
|
||||
self.requestor.send_data(
|
||||
"reviews",
|
||||
@ -158,7 +165,6 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
segment: PendingReviewSegment,
|
||||
frame_time: float,
|
||||
objects: list[TrackedObject],
|
||||
motion: list,
|
||||
) -> None:
|
||||
"""Validate if existing review segment should continue."""
|
||||
camera_config = self.config.cameras[segment.camera]
|
||||
@ -168,18 +174,6 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
if frame_time > segment.last_update:
|
||||
segment.last_update = frame_time
|
||||
|
||||
# update type for this segment now that active objects are detected
|
||||
if segment.severity == SeverityEnum.signification_motion:
|
||||
segment.severity = SeverityEnum.detection
|
||||
|
||||
if len(active_objects) > segment.frame_active_count:
|
||||
frame_id = f"{camera_config.name}{frame_time}"
|
||||
yuv_frame = self.frame_manager.get(
|
||||
frame_id, camera_config.frame_shape_yuv
|
||||
)
|
||||
segment.update_frame(camera_config, yuv_frame, active_objects)
|
||||
self.frame_manager.close(frame_id)
|
||||
|
||||
for object in active_objects:
|
||||
if not object["sub_label"]:
|
||||
segment.detections[object["id"]] = object["label"]
|
||||
@ -188,24 +182,38 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
else:
|
||||
segment.detections[object["id"]] = f'{object["label"]}-verified'
|
||||
|
||||
# if object is alert label and has qualified for recording
|
||||
# if object is alert label
|
||||
# and has entered required zones or required zones is not set
|
||||
# mark this review as alert
|
||||
if (
|
||||
segment.severity == SeverityEnum.detection
|
||||
and object["has_clip"]
|
||||
and object["label"] in camera_config.objects.alert
|
||||
segment.severity != SeverityEnum.alert
|
||||
and object["label"] in camera_config.review.alerts.labels
|
||||
and (
|
||||
not camera_config.review.alerts.required_zones
|
||||
or (
|
||||
len(object["current_zones"]) > 0
|
||||
and set(object["current_zones"])
|
||||
& set(camera_config.review.alerts.required_zones)
|
||||
)
|
||||
)
|
||||
):
|
||||
segment.severity = SeverityEnum.alert
|
||||
|
||||
# keep zones up to date
|
||||
if len(object["current_zones"]) > 0:
|
||||
segment.zones.update(object["current_zones"])
|
||||
elif (
|
||||
segment.severity == SeverityEnum.signification_motion
|
||||
and len(motion) >= THRESHOLD_MOTION_ACTIVITY
|
||||
):
|
||||
if frame_time > segment.last_update:
|
||||
segment.last_update = frame_time
|
||||
|
||||
if len(active_objects) > segment.frame_active_count:
|
||||
try:
|
||||
frame_id = f"{camera_config.name}{frame_time}"
|
||||
yuv_frame = self.frame_manager.get(
|
||||
frame_id, camera_config.frame_shape_yuv
|
||||
)
|
||||
segment.update_frame(camera_config, yuv_frame, active_objects)
|
||||
self.frame_manager.close(frame_id)
|
||||
self.update_segment(segment)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
else:
|
||||
if segment.severity == SeverityEnum.alert and frame_time > (
|
||||
segment.last_update + THRESHOLD_ALERT_ACTIVITY
|
||||
@ -219,7 +227,6 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
camera: str,
|
||||
frame_time: float,
|
||||
objects: list[TrackedObject],
|
||||
motion: list,
|
||||
) -> None:
|
||||
"""Check if a new review segment should be created."""
|
||||
camera_config = self.config.cameras[camera]
|
||||
@ -229,15 +236,9 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
has_sig_object = False
|
||||
detections: dict[str, str] = {}
|
||||
zones: set = set()
|
||||
severity = None
|
||||
|
||||
for object in active_objects:
|
||||
if (
|
||||
not has_sig_object
|
||||
and object["has_clip"]
|
||||
and object["label"] in camera_config.objects.alert
|
||||
):
|
||||
has_sig_object = True
|
||||
|
||||
if not object["sub_label"]:
|
||||
detections[object["id"]] = object["label"]
|
||||
elif object["sub_label"][0] in ALL_ATTRIBUTE_LABELS:
|
||||
@ -245,34 +246,68 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
else:
|
||||
detections[object["id"]] = f'{object["label"]}-verified'
|
||||
|
||||
# if object is alert label
|
||||
# and has entered required zones or required zones is not set
|
||||
# mark this review as alert
|
||||
if (
|
||||
severity != SeverityEnum.alert
|
||||
and object["label"] in camera_config.review.alerts.labels
|
||||
and (
|
||||
not camera_config.review.alerts.required_zones
|
||||
or (
|
||||
len(object["current_zones"]) > 0
|
||||
and set(object["current_zones"])
|
||||
& set(camera_config.review.alerts.required_zones)
|
||||
)
|
||||
)
|
||||
):
|
||||
severity = SeverityEnum.alert
|
||||
|
||||
# if object is detection label
|
||||
# and review is not already a detection or alert
|
||||
# and has entered required zones or required zones is not set
|
||||
# mark this review as alert
|
||||
if (
|
||||
not severity
|
||||
and (
|
||||
not camera_config.review.detections.labels
|
||||
or object["label"] in (camera_config.review.detections.labels)
|
||||
)
|
||||
and (
|
||||
not camera_config.review.detections.required_zones
|
||||
or (
|
||||
len(object["current_zones"]) > 0
|
||||
and set(object["current_zones"])
|
||||
& set(camera_config.review.detections.required_zones)
|
||||
)
|
||||
)
|
||||
):
|
||||
severity = SeverityEnum.detection
|
||||
|
||||
zones.update(object["current_zones"])
|
||||
|
||||
self.active_review_segments[camera] = PendingReviewSegment(
|
||||
camera,
|
||||
frame_time,
|
||||
SeverityEnum.alert if has_sig_object else SeverityEnum.detection,
|
||||
detections,
|
||||
audio=set(),
|
||||
zones=zones,
|
||||
motion=[],
|
||||
)
|
||||
if severity:
|
||||
self.active_review_segments[camera] = PendingReviewSegment(
|
||||
camera,
|
||||
frame_time,
|
||||
SeverityEnum.alert if has_sig_object else SeverityEnum.detection,
|
||||
detections,
|
||||
audio=set(),
|
||||
zones=zones,
|
||||
)
|
||||
|
||||
frame_id = f"{camera_config.name}{frame_time}"
|
||||
yuv_frame = self.frame_manager.get(frame_id, camera_config.frame_shape_yuv)
|
||||
self.active_review_segments[camera].update_frame(
|
||||
camera_config, yuv_frame, active_objects
|
||||
)
|
||||
self.frame_manager.close(frame_id)
|
||||
elif len(motion) >= 20:
|
||||
self.active_review_segments[camera] = PendingReviewSegment(
|
||||
camera,
|
||||
frame_time,
|
||||
SeverityEnum.signification_motion,
|
||||
detections={},
|
||||
audio=set(),
|
||||
motion=motion,
|
||||
zones=set(),
|
||||
)
|
||||
try:
|
||||
frame_id = f"{camera_config.name}{frame_time}"
|
||||
yuv_frame = self.frame_manager.get(
|
||||
frame_id, camera_config.frame_shape_yuv
|
||||
)
|
||||
self.active_review_segments[camera].update_frame(
|
||||
camera_config, yuv_frame, active_objects
|
||||
)
|
||||
self.frame_manager.close(frame_id)
|
||||
self.update_segment(self.active_review_segments[camera])
|
||||
except FileNotFoundError:
|
||||
return
|
||||
|
||||
def run(self) -> None:
|
||||
while not self.stop_event.is_set():
|
||||
@ -330,13 +365,22 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
current_segment,
|
||||
frame_time,
|
||||
current_tracked_objects,
|
||||
motion_boxes,
|
||||
)
|
||||
elif topic == DetectionTypeEnum.audio and len(audio_detections) > 0:
|
||||
camera_config = self.config.cameras[camera]
|
||||
|
||||
if frame_time > current_segment.last_update:
|
||||
current_segment.last_update = frame_time
|
||||
|
||||
current_segment.audio.update(audio_detections)
|
||||
for audio in audio_detections:
|
||||
if audio in camera_config.review.alerts.labels:
|
||||
current_segment.audio.add(audio)
|
||||
current_segment.severity = SeverityEnum.alert
|
||||
elif (
|
||||
not camera_config.review.detections.labels
|
||||
or audio in camera_config.review.detections.labels
|
||||
):
|
||||
current_segment.audio.add(audio)
|
||||
elif topic == DetectionTypeEnum.api:
|
||||
if manual_info["state"] == ManualEventState.complete:
|
||||
current_segment.detections[manual_info["event_id"]] = (
|
||||
@ -364,18 +408,35 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
camera,
|
||||
frame_time,
|
||||
current_tracked_objects,
|
||||
motion_boxes,
|
||||
)
|
||||
elif topic == DetectionTypeEnum.audio and len(audio_detections) > 0:
|
||||
self.active_review_segments[camera] = PendingReviewSegment(
|
||||
camera,
|
||||
frame_time,
|
||||
SeverityEnum.detection,
|
||||
{},
|
||||
set(),
|
||||
set(audio_detections),
|
||||
[],
|
||||
)
|
||||
severity = None
|
||||
|
||||
camera_config = self.config.cameras[camera]
|
||||
detections = set()
|
||||
|
||||
for audio in audio_detections:
|
||||
if audio in camera_config.review.alerts.labels:
|
||||
detections.add(audio)
|
||||
severity = SeverityEnum.alert
|
||||
elif (
|
||||
not camera_config.review.detections.labels
|
||||
or audio in camera_config.review.detections.labels
|
||||
):
|
||||
detections.add(audio)
|
||||
|
||||
if not severity:
|
||||
severity = SeverityEnum.detection
|
||||
|
||||
if severity:
|
||||
self.active_review_segments[camera] = PendingReviewSegment(
|
||||
camera,
|
||||
frame_time,
|
||||
severity,
|
||||
{},
|
||||
set(),
|
||||
detections,
|
||||
)
|
||||
elif topic == DetectionTypeEnum.api:
|
||||
self.active_review_segments[camera] = PendingReviewSegment(
|
||||
camera,
|
||||
@ -384,7 +445,6 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
{manual_info["event_id"]: manual_info["label"]},
|
||||
set(),
|
||||
set(),
|
||||
[],
|
||||
)
|
||||
|
||||
if manual_info["state"] == ManualEventState.start:
|
||||
@ -398,6 +458,11 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
"end_time"
|
||||
]
|
||||
|
||||
self.config_subscriber.stop()
|
||||
self.requestor.stop()
|
||||
self.detection_subscriber.stop()
|
||||
logger.info("Exiting review maintainer...")
|
||||
|
||||
|
||||
def get_active_objects(
|
||||
frame_time: float, camera_config: CameraConfig, all_objects: list[TrackedObject]
|
||||
@ -406,8 +471,16 @@ def get_active_objects(
|
||||
return [
|
||||
o
|
||||
for o in all_objects
|
||||
if o["motionless_count"] < camera_config.detect.stationary.threshold
|
||||
and o["position_changes"] > 0
|
||||
and o["frame_time"] == frame_time
|
||||
and not o["false_positive"]
|
||||
if o["motionless_count"]
|
||||
< camera_config.detect.stationary.threshold # no stationary objects
|
||||
and o["position_changes"] > 0 # object must have moved at least once
|
||||
and o["frame_time"] == frame_time # object must be detected in this frame
|
||||
and not o["false_positive"] # object must not be a false positive
|
||||
and (
|
||||
o["label"] in camera_config.review.alerts.labels
|
||||
or (
|
||||
not camera_config.review.detections.labels
|
||||
or o["label"] in camera_config.review.detections.labels
|
||||
)
|
||||
) # object must be in the alerts or detections label list
|
||||
]
|
||||
|
||||
@ -64,7 +64,7 @@ class TestConfig(unittest.TestCase):
|
||||
|
||||
def test_config_class(self):
|
||||
frigate_config = FrigateConfig(**self.minimal)
|
||||
assert self.minimal == frigate_config.dict(exclude_unset=True)
|
||||
assert self.minimal == frigate_config.model_dump(exclude_unset=True)
|
||||
|
||||
runtime_config = frigate_config.runtime_config()
|
||||
assert "cpu" in runtime_config.detectors.keys()
|
||||
@ -157,7 +157,7 @@ class TestConfig(unittest.TestCase):
|
||||
},
|
||||
}
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert config == frigate_config.dict(exclude_unset=True)
|
||||
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||
|
||||
runtime_config = frigate_config.runtime_config()
|
||||
assert "dog" in runtime_config.cameras["back"].objects.track
|
||||
@ -183,7 +183,7 @@ class TestConfig(unittest.TestCase):
|
||||
},
|
||||
}
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert config == frigate_config.dict(exclude_unset=True)
|
||||
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||
|
||||
runtime_config = frigate_config.runtime_config()
|
||||
assert not runtime_config.cameras["back"].birdseye.enabled
|
||||
@ -209,7 +209,7 @@ class TestConfig(unittest.TestCase):
|
||||
},
|
||||
}
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert config == frigate_config.dict(exclude_unset=True)
|
||||
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||
|
||||
runtime_config = frigate_config.runtime_config()
|
||||
assert runtime_config.cameras["back"].birdseye.enabled
|
||||
@ -234,7 +234,7 @@ class TestConfig(unittest.TestCase):
|
||||
},
|
||||
}
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert config == frigate_config.dict(exclude_unset=True)
|
||||
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||
|
||||
runtime_config = frigate_config.runtime_config()
|
||||
assert runtime_config.cameras["back"].birdseye.enabled
|
||||
@ -263,7 +263,7 @@ class TestConfig(unittest.TestCase):
|
||||
},
|
||||
}
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert config == frigate_config.dict(exclude_unset=True)
|
||||
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||
|
||||
runtime_config = frigate_config.runtime_config()
|
||||
assert "cat" in runtime_config.cameras["back"].objects.track
|
||||
@ -288,7 +288,7 @@ class TestConfig(unittest.TestCase):
|
||||
},
|
||||
}
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert config == frigate_config.dict(exclude_unset=True)
|
||||
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||
|
||||
runtime_config = frigate_config.runtime_config()
|
||||
assert "dog" in runtime_config.cameras["back"].objects.filters
|
||||
@ -316,7 +316,7 @@ class TestConfig(unittest.TestCase):
|
||||
},
|
||||
}
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert config == frigate_config.dict(exclude_unset=True)
|
||||
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||
|
||||
runtime_config = frigate_config.runtime_config()
|
||||
assert "dog" in runtime_config.cameras["back"].objects.filters
|
||||
@ -345,7 +345,7 @@ class TestConfig(unittest.TestCase):
|
||||
},
|
||||
}
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert config == frigate_config.dict(exclude_unset=True)
|
||||
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||
|
||||
runtime_config = frigate_config.runtime_config()
|
||||
assert "dog" in runtime_config.cameras["back"].objects.filters
|
||||
@ -375,7 +375,7 @@ class TestConfig(unittest.TestCase):
|
||||
},
|
||||
}
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert config == frigate_config.dict(exclude_unset=True)
|
||||
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||
|
||||
runtime_config = frigate_config.runtime_config()
|
||||
back_camera = runtime_config.cameras["back"]
|
||||
@ -383,6 +383,55 @@ class TestConfig(unittest.TestCase):
|
||||
assert len(back_camera.objects.filters["dog"].raw_mask) == 2
|
||||
assert len(back_camera.objects.filters["person"].raw_mask) == 1
|
||||
|
||||
def test_motion_mask_relative_matches_explicit(self):
|
||||
config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"record": {
|
||||
"events": {"retain": {"default": 20, "objects": {"person": 30}}}
|
||||
},
|
||||
"cameras": {
|
||||
"explicit": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||
]
|
||||
},
|
||||
"detect": {
|
||||
"height": 400,
|
||||
"width": 800,
|
||||
"fps": 5,
|
||||
},
|
||||
"motion": {
|
||||
"mask": [
|
||||
"0,0,200,100,600,300,800,400",
|
||||
]
|
||||
},
|
||||
},
|
||||
"relative": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||
]
|
||||
},
|
||||
"detect": {
|
||||
"height": 400,
|
||||
"width": 800,
|
||||
"fps": 5,
|
||||
},
|
||||
"motion": {
|
||||
"mask": [
|
||||
"0.0,0.0,0.25,0.25,0.75,0.75,1.0,1.0",
|
||||
]
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
frigate_config = FrigateConfig(**config).runtime_config()
|
||||
assert np.array_equal(
|
||||
frigate_config.cameras["explicit"].motion.mask,
|
||||
frigate_config.cameras["relative"].motion.mask,
|
||||
)
|
||||
|
||||
def test_default_input_args(self):
|
||||
config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
@ -406,7 +455,7 @@ class TestConfig(unittest.TestCase):
|
||||
}
|
||||
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert config == frigate_config.dict(exclude_unset=True)
|
||||
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||
|
||||
runtime_config = frigate_config.runtime_config()
|
||||
assert "-rtsp_transport" in runtime_config.cameras["back"].ffmpeg_cmds[0]["cmd"]
|
||||
@ -435,7 +484,7 @@ class TestConfig(unittest.TestCase):
|
||||
},
|
||||
}
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert config == frigate_config.dict(exclude_unset=True)
|
||||
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||
|
||||
runtime_config = frigate_config.runtime_config()
|
||||
assert "-re" in runtime_config.cameras["back"].ffmpeg_cmds[0]["cmd"]
|
||||
@ -465,7 +514,7 @@ class TestConfig(unittest.TestCase):
|
||||
},
|
||||
}
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert config == frigate_config.dict(exclude_unset=True)
|
||||
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||
|
||||
runtime_config = frigate_config.runtime_config()
|
||||
assert "-re" in runtime_config.cameras["back"].ffmpeg_cmds[0]["cmd"]
|
||||
@ -500,7 +549,7 @@ class TestConfig(unittest.TestCase):
|
||||
},
|
||||
}
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert config == frigate_config.dict(exclude_unset=True)
|
||||
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||
|
||||
runtime_config = frigate_config.runtime_config()
|
||||
assert "-re" in runtime_config.cameras["back"].ffmpeg_cmds[0]["cmd"]
|
||||
@ -530,7 +579,7 @@ class TestConfig(unittest.TestCase):
|
||||
},
|
||||
}
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert config == frigate_config.dict(exclude_unset=True)
|
||||
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||
|
||||
runtime_config = frigate_config.runtime_config()
|
||||
assert (
|
||||
@ -608,7 +657,7 @@ class TestConfig(unittest.TestCase):
|
||||
},
|
||||
}
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert config == frigate_config.dict(exclude_unset=True)
|
||||
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||
|
||||
runtime_config = frigate_config.runtime_config()
|
||||
assert isinstance(
|
||||
@ -616,6 +665,41 @@ class TestConfig(unittest.TestCase):
|
||||
)
|
||||
assert runtime_config.cameras["back"].zones["test"].color != (0, 0, 0)
|
||||
|
||||
def test_zone_relative_matches_explicit(self):
|
||||
config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"record": {
|
||||
"events": {"retain": {"default": 20, "objects": {"person": 30}}}
|
||||
},
|
||||
"cameras": {
|
||||
"back": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||
]
|
||||
},
|
||||
"detect": {
|
||||
"height": 400,
|
||||
"width": 800,
|
||||
"fps": 5,
|
||||
},
|
||||
"zones": {
|
||||
"explicit": {
|
||||
"coordinates": "0,0,200,100,600,300,800,400",
|
||||
},
|
||||
"relative": {
|
||||
"coordinates": "0.0,0.0,0.25,0.25,0.75,0.75,1.0,1.0",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
frigate_config = FrigateConfig(**config).runtime_config()
|
||||
assert np.array_equal(
|
||||
frigate_config.cameras["back"].zones["explicit"].contour,
|
||||
frigate_config.cameras["back"].zones["relative"].contour,
|
||||
)
|
||||
|
||||
def test_clips_should_default_to_global_objects(self):
|
||||
config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
@ -640,7 +724,7 @@ class TestConfig(unittest.TestCase):
|
||||
},
|
||||
}
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert config == frigate_config.dict(exclude_unset=True)
|
||||
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||
|
||||
runtime_config = frigate_config.runtime_config()
|
||||
back_camera = runtime_config.cameras["back"]
|
||||
@ -671,7 +755,7 @@ class TestConfig(unittest.TestCase):
|
||||
}
|
||||
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert config == frigate_config.dict(exclude_unset=True)
|
||||
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||
|
||||
runtime_config = frigate_config.runtime_config()
|
||||
ffmpeg_cmds = runtime_config.cameras["back"].ffmpeg_cmds
|
||||
@ -702,7 +786,7 @@ class TestConfig(unittest.TestCase):
|
||||
}
|
||||
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert config == frigate_config.dict(exclude_unset=True)
|
||||
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||
|
||||
runtime_config = frigate_config.runtime_config()
|
||||
assert runtime_config.cameras["back"].detect.max_disappeared == 5 * 5
|
||||
@ -730,7 +814,7 @@ class TestConfig(unittest.TestCase):
|
||||
}
|
||||
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert config == frigate_config.dict(exclude_unset=True)
|
||||
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||
|
||||
runtime_config = frigate_config.runtime_config()
|
||||
assert runtime_config.cameras["back"].motion.frame_height == 100
|
||||
@ -758,7 +842,7 @@ class TestConfig(unittest.TestCase):
|
||||
}
|
||||
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert config == frigate_config.dict(exclude_unset=True)
|
||||
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||
|
||||
runtime_config = frigate_config.runtime_config()
|
||||
assert round(runtime_config.cameras["back"].motion.contour_area) == 10
|
||||
@ -787,7 +871,7 @@ class TestConfig(unittest.TestCase):
|
||||
}
|
||||
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert config == frigate_config.dict(exclude_unset=True)
|
||||
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||
|
||||
runtime_config = frigate_config.runtime_config()
|
||||
assert runtime_config.model.merged_labelmap[7] == "truck"
|
||||
@ -815,7 +899,7 @@ class TestConfig(unittest.TestCase):
|
||||
}
|
||||
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert config == frigate_config.dict(exclude_unset=True)
|
||||
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||
|
||||
runtime_config = frigate_config.runtime_config()
|
||||
assert runtime_config.model.merged_labelmap[0] == "person"
|
||||
@ -844,7 +928,7 @@ class TestConfig(unittest.TestCase):
|
||||
}
|
||||
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert config == frigate_config.dict(exclude_unset=True)
|
||||
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||
|
||||
runtime_config = frigate_config.runtime_config()
|
||||
assert runtime_config.model.merged_labelmap[0] == "person"
|
||||
@ -878,7 +962,7 @@ class TestConfig(unittest.TestCase):
|
||||
}
|
||||
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert config == frigate_config.dict(exclude_unset=True)
|
||||
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||
|
||||
runtime_config = frigate_config.runtime_config(PlusApi())
|
||||
assert runtime_config.model.merged_labelmap[0] == "amazon"
|
||||
@ -1012,7 +1096,7 @@ class TestConfig(unittest.TestCase):
|
||||
},
|
||||
}
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert config == frigate_config.dict(exclude_unset=True)
|
||||
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||
|
||||
runtime_config = frigate_config.runtime_config()
|
||||
assert runtime_config.cameras["back"].detect.max_disappeared == 1
|
||||
@ -1040,7 +1124,7 @@ class TestConfig(unittest.TestCase):
|
||||
},
|
||||
}
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert config == frigate_config.dict(exclude_unset=True)
|
||||
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||
|
||||
runtime_config = frigate_config.runtime_config()
|
||||
assert runtime_config.cameras["back"].detect.max_disappeared == 25
|
||||
@ -1069,7 +1153,7 @@ class TestConfig(unittest.TestCase):
|
||||
},
|
||||
}
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert config == frigate_config.dict(exclude_unset=True)
|
||||
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||
|
||||
runtime_config = frigate_config.runtime_config()
|
||||
assert runtime_config.cameras["back"].detect.max_disappeared == 1
|
||||
@ -1102,7 +1186,7 @@ class TestConfig(unittest.TestCase):
|
||||
},
|
||||
}
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert config == frigate_config.dict(exclude_unset=True)
|
||||
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||
|
||||
runtime_config = frigate_config.runtime_config()
|
||||
assert runtime_config.cameras["back"].snapshots.enabled
|
||||
@ -1130,7 +1214,7 @@ class TestConfig(unittest.TestCase):
|
||||
},
|
||||
}
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert config == frigate_config.dict(exclude_unset=True)
|
||||
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||
|
||||
runtime_config = frigate_config.runtime_config()
|
||||
assert runtime_config.cameras["back"].snapshots.bounding_box
|
||||
@ -1163,7 +1247,7 @@ class TestConfig(unittest.TestCase):
|
||||
},
|
||||
}
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert config == frigate_config.dict(exclude_unset=True)
|
||||
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||
|
||||
runtime_config = frigate_config.runtime_config()
|
||||
assert runtime_config.cameras["back"].snapshots.bounding_box is False
|
||||
@ -1193,7 +1277,7 @@ class TestConfig(unittest.TestCase):
|
||||
},
|
||||
}
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert config == frigate_config.dict(exclude_unset=True)
|
||||
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||
|
||||
runtime_config = frigate_config.runtime_config()
|
||||
assert runtime_config.cameras["back"].live.quality == 4
|
||||
@ -1220,7 +1304,7 @@ class TestConfig(unittest.TestCase):
|
||||
},
|
||||
}
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert config == frigate_config.dict(exclude_unset=True)
|
||||
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||
|
||||
runtime_config = frigate_config.runtime_config()
|
||||
assert runtime_config.cameras["back"].live.quality == 8
|
||||
@ -1251,7 +1335,7 @@ class TestConfig(unittest.TestCase):
|
||||
},
|
||||
}
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert config == frigate_config.dict(exclude_unset=True)
|
||||
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||
|
||||
runtime_config = frigate_config.runtime_config()
|
||||
assert runtime_config.cameras["back"].live.quality == 7
|
||||
@ -1280,7 +1364,7 @@ class TestConfig(unittest.TestCase):
|
||||
},
|
||||
}
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert config == frigate_config.dict(exclude_unset=True)
|
||||
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||
|
||||
runtime_config = frigate_config.runtime_config()
|
||||
assert runtime_config.cameras["back"].timestamp_style.position == "bl"
|
||||
@ -1307,7 +1391,7 @@ class TestConfig(unittest.TestCase):
|
||||
},
|
||||
}
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert config == frigate_config.dict(exclude_unset=True)
|
||||
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||
|
||||
runtime_config = frigate_config.runtime_config()
|
||||
assert runtime_config.cameras["back"].timestamp_style.position == "tl"
|
||||
@ -1336,7 +1420,7 @@ class TestConfig(unittest.TestCase):
|
||||
},
|
||||
}
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert config == frigate_config.dict(exclude_unset=True)
|
||||
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||
|
||||
runtime_config = frigate_config.runtime_config()
|
||||
assert runtime_config.cameras["back"].timestamp_style.position == "bl"
|
||||
@ -1365,7 +1449,7 @@ class TestConfig(unittest.TestCase):
|
||||
},
|
||||
}
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert config == frigate_config.dict(exclude_unset=True)
|
||||
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||
|
||||
runtime_config = frigate_config.runtime_config()
|
||||
assert runtime_config.cameras["back"].snapshots.retain.default == 1.5
|
||||
@ -1505,7 +1589,7 @@ class TestConfig(unittest.TestCase):
|
||||
},
|
||||
}
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert config == frigate_config.dict(exclude_unset=True)
|
||||
assert config == frigate_config.model_dump(exclude_unset=True)
|
||||
|
||||
runtime_config = frigate_config.runtime_config()
|
||||
assert "dog" in runtime_config.cameras["back"].objects.filters
|
||||
|
||||
127
frigate/util/config.py
Normal file
127
frigate/util/config.py
Normal file
@ -0,0 +1,127 @@
|
||||
"""configuration utils."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
from frigate.const import CONFIG_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CURRENT_CONFIG_VERSION = 0.14
|
||||
|
||||
|
||||
def migrate_frigate_config(config_file: str):
|
||||
"""handle migrating the frigate config."""
|
||||
logger.info("Checking if frigate config needs migration...")
|
||||
version_file = os.path.join(CONFIG_DIR, ".version")
|
||||
|
||||
if not os.path.isfile(version_file):
|
||||
previous_version = 0.13
|
||||
else:
|
||||
with open(version_file) as f:
|
||||
try:
|
||||
previous_version = float(f.readline())
|
||||
except Exception:
|
||||
previous_version = 0.13
|
||||
|
||||
if previous_version == CURRENT_CONFIG_VERSION:
|
||||
logger.info("frigate config does not need migration...")
|
||||
return
|
||||
|
||||
logger.info("copying config as backup...")
|
||||
shutil.copy(config_file, os.path.join(CONFIG_DIR, "backup_config.yaml"))
|
||||
|
||||
yaml = YAML()
|
||||
yaml.indent(mapping=2, sequence=4, offset=2)
|
||||
with open(config_file, "r") as f:
|
||||
config: dict[str, dict[str, any]] = yaml.load(f)
|
||||
|
||||
if previous_version < 0.14:
|
||||
logger.info(f"Migrating frigate config from {previous_version} to 0.14...")
|
||||
new_config = migrate_014(config)
|
||||
with open(config_file, "w") as f:
|
||||
yaml.dump(new_config, f)
|
||||
previous_version = 0.14
|
||||
|
||||
with open(version_file, "w") as f:
|
||||
f.write(str(CURRENT_CONFIG_VERSION))
|
||||
|
||||
logger.info("Finished frigate config migration...")
|
||||
|
||||
|
||||
def migrate_014(config: dict[str, dict[str, any]]) -> dict[str, dict[str, any]]:
|
||||
"""Handle migrating frigate config to 0.14"""
|
||||
# migrate record.events.required_zones to review.alerts.required_zones
|
||||
new_config = config.copy()
|
||||
global_required_zones = (
|
||||
config.get("record", {}).get("events", {}).get("required_zones", [])
|
||||
)
|
||||
|
||||
if global_required_zones:
|
||||
# migrate to new review config
|
||||
if not new_config.get("review"):
|
||||
new_config["review"] = {}
|
||||
|
||||
if not new_config["review"].get("alerts"):
|
||||
new_config["review"]["alerts"] = {}
|
||||
|
||||
if not new_config["review"]["alerts"].get("required_zones"):
|
||||
new_config["review"]["alerts"]["required_zones"] = global_required_zones
|
||||
|
||||
# remove record required zones config
|
||||
del new_config["record"]["events"]["required_zones"]
|
||||
|
||||
# remove record altogether if there is not other config
|
||||
if not new_config["record"]["events"]:
|
||||
del new_config["record"]["events"]
|
||||
|
||||
if not new_config["record"]:
|
||||
del new_config["record"]
|
||||
|
||||
# remove rtmp
|
||||
if new_config.get("ffmpeg", {}).get("output_args", {}).get("rtmp"):
|
||||
del new_config["ffmpeg"]["output_args"]["rtmp"]
|
||||
|
||||
if new_config.get("rtmp"):
|
||||
del new_config["rtmp"]
|
||||
|
||||
for name, camera in config.get("cameras", {}).items():
|
||||
camera_config: dict[str, dict[str, any]] = camera.copy()
|
||||
required_zones = (
|
||||
camera_config.get("record", {}).get("events", {}).get("required_zones", [])
|
||||
)
|
||||
|
||||
if required_zones:
|
||||
# migrate to new review config
|
||||
if not camera_config.get("review"):
|
||||
camera_config["review"] = {}
|
||||
|
||||
if not camera_config["review"].get("alerts"):
|
||||
camera_config["review"]["alerts"] = {}
|
||||
|
||||
if not camera_config["review"]["alerts"].get("required_zones"):
|
||||
camera_config["review"]["alerts"]["required_zones"] = required_zones
|
||||
|
||||
# remove record required zones config
|
||||
del camera_config["record"]["events"]["required_zones"]
|
||||
|
||||
# remove record altogether if there is not other config
|
||||
if not camera_config["record"]["events"]:
|
||||
del camera_config["record"]["events"]
|
||||
|
||||
if not camera_config["record"]:
|
||||
del camera_config["record"]
|
||||
|
||||
# remove rtmp
|
||||
if camera_config.get("ffmpeg", {}).get("output_args", {}).get("rtmp"):
|
||||
del camera_config["ffmpeg"]["output_args"]["rtmp"]
|
||||
|
||||
if camera_config.get("rtmp"):
|
||||
del camera_config["rtmp"]
|
||||
|
||||
new_config["cameras"][name] = camera_config
|
||||
|
||||
return new_config
|
||||
@ -727,9 +727,22 @@ def create_mask(frame_shape, mask):
|
||||
return mask_img
|
||||
|
||||
|
||||
def add_mask(mask, mask_img):
|
||||
def add_mask(mask: str, mask_img: np.ndarray):
|
||||
points = mask.split(",")
|
||||
|
||||
# masks and zones are saved as relative coordinates
|
||||
# we know if any points are > 1 then it is using the
|
||||
# old native resolution coordinates
|
||||
if any(x > "1.0" for x in points):
|
||||
raise Exception("add mask expects relative coordinates only")
|
||||
|
||||
contour = np.array(
|
||||
[[int(points[i]), int(points[i + 1])] for i in range(0, len(points), 2)]
|
||||
[
|
||||
[
|
||||
int(float(points[i]) * mask_img.shape[1]),
|
||||
int(float(points[i + 1]) * mask_img.shape[0]),
|
||||
]
|
||||
for i in range(0, len(points), 2)
|
||||
]
|
||||
)
|
||||
cv2.fillPoly(mask_img, pts=[contour], color=(0))
|
||||
|
||||
328
web/package-lock.json
generated
328
web/package-lock.json
generated
@ -37,7 +37,7 @@
|
||||
"hls.js": "^1.5.7",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"immer": "^10.0.4",
|
||||
"lucide-react": "^0.360.0",
|
||||
"lucide-react": "^0.365.0",
|
||||
"monaco-yaml": "^5.1.1",
|
||||
"next-themes": "^0.3.0",
|
||||
"react": "^18.2.0",
|
||||
@ -45,14 +45,14 @@
|
||||
"react-day-picker": "^8.9.1",
|
||||
"react-device-detect": "^2.2.3",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.51.1",
|
||||
"react-hook-form": "^7.51.2",
|
||||
"react-icons": "^5.0.1",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"react-swipeable": "^7.0.1",
|
||||
"react-tracked": "^1.7.14",
|
||||
"react-transition-group": "^4.4.5",
|
||||
"react-use-websocket": "^4.8.1",
|
||||
"react-zoom-pan-pinch": "^3.4.3",
|
||||
"react-zoom-pan-pinch": "^3.4.4",
|
||||
"recoil": "^0.7.7",
|
||||
"scroll-into-view-if-needed": "^3.1.0",
|
||||
"sonner": "^1.4.41",
|
||||
@ -68,20 +68,20 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@testing-library/jest-dom": "^6.1.5",
|
||||
"@types/node": "^20.11.30",
|
||||
"@types/react": "^18.2.67",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"@types/node": "^20.12.5",
|
||||
"@types/react": "^18.2.74",
|
||||
"@types/react-dom": "^18.2.24",
|
||||
"@types/react-icons": "^3.0.0",
|
||||
"@types/react-transition-group": "^4.4.10",
|
||||
"@types/strftime": "^0.9.8",
|
||||
"@typescript-eslint/eslint-plugin": "^7.3.1",
|
||||
"@typescript-eslint/parser": "^7.3.1",
|
||||
"@typescript-eslint/eslint-plugin": "^7.5.0",
|
||||
"@typescript-eslint/parser": "^7.5.0",
|
||||
"@vitejs/plugin-react-swc": "^3.6.0",
|
||||
"@vitest/coverage-v8": "^1.4.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^8.55.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-jest": "^27.6.0",
|
||||
"eslint-plugin-jest": "^28.2.0",
|
||||
"eslint-plugin-prettier": "^5.0.1",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.6",
|
||||
@ -89,12 +89,12 @@
|
||||
"fake-indexeddb": "^5.0.2",
|
||||
"jest-websocket-mock": "^2.5.0",
|
||||
"jsdom": "^24.0.0",
|
||||
"msw": "^2.2.9",
|
||||
"msw": "^2.2.13",
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "^3.2.5",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.4.3",
|
||||
"vite": "^5.2.2",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^5.4.4",
|
||||
"vite": "^5.2.8",
|
||||
"vitest": "^1.3.1"
|
||||
}
|
||||
},
|
||||
@ -841,9 +841,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@mswjs/interceptors": {
|
||||
"version": "0.25.16",
|
||||
"resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.25.16.tgz",
|
||||
"integrity": "sha512-8QC8JyKztvoGAdPgyZy49c9vSHHAZjHagwl4RY9E8carULk8ym3iTaiawrT1YoLF/qb449h48f71XDPgkUSOUg==",
|
||||
"version": "0.26.15",
|
||||
"resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.26.15.tgz",
|
||||
"integrity": "sha512-HM47Lu1YFmnYHKMBynFfjCp0U/yRskHj/8QEJW0CBEPOlw8Gkmjfll+S9b8M7V5CNDw2/ciRxjjnWeaCiblSIQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@open-draft/deferred-promise": "^2.2.0",
|
||||
@ -2507,9 +2507,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.11.30",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.30.tgz",
|
||||
"integrity": "sha512-dHM6ZxwlmuZaRmUPfv1p+KrdD1Dci04FbdEm/9wEMouFqxYoFl5aMkt0VMAUtYRQDyYvD41WJLukhq/ha3YuTw==",
|
||||
"version": "20.12.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.5.tgz",
|
||||
"integrity": "sha512-BD+BjQ9LS/D8ST9p5uqBxghlN+S42iuNxjsUGjeZobe/ciXzk2qb1B6IXc6AnRLS+yFJRpN2IPEHMzwspfDJNw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~5.26.4"
|
||||
@ -2522,20 +2522,19 @@
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.2.67",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.67.tgz",
|
||||
"integrity": "sha512-vkIE2vTIMHQ/xL0rgmuoECBCkZFZeHr49HeWSc24AptMbNRo7pwSBvj73rlJJs9fGKj0koS+V7kQB1jHS0uCgw==",
|
||||
"version": "18.2.74",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.74.tgz",
|
||||
"integrity": "sha512-9AEqNZZyBx8OdZpxzQlaFEVCSFUM2YXJH46yPOiOpm078k6ZLOCcuAzGum/zK8YBwY+dbahVNbHrbgrAwIRlqw==",
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"@types/scheduler": "*",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "18.2.22",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.22.tgz",
|
||||
"integrity": "sha512-fHkBXPeNtfvri6gdsMYyW+dW7RXFo6Ad09nLFK0VQWR7yGLai/Cyvyj696gbwYvBnhGtevUG9cET0pmUbMtoPQ==",
|
||||
"version": "18.2.24",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.24.tgz",
|
||||
"integrity": "sha512-cN6upcKd8zkGy4HU9F1+/s98Hrp6D4MOcippK4PoE8OZRngohHZpbJn1GsaDLz87MqvHNoT13nHvNqM9ocRHZg==",
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
@ -2560,12 +2559,6 @@
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/scheduler": {
|
||||
"version": "0.16.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz",
|
||||
"integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==",
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/@types/semver": {
|
||||
"version": "7.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz",
|
||||
@ -2591,16 +2584,16 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.3.1.tgz",
|
||||
"integrity": "sha512-STEDMVQGww5lhCuNXVSQfbfuNII5E08QWkvAw5Qwf+bj2WT+JkG1uc+5/vXA3AOYMDHVOSpL+9rcbEUiHIm2dw==",
|
||||
"version": "7.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.5.0.tgz",
|
||||
"integrity": "sha512-HpqNTH8Du34nLxbKgVMGljZMG0rJd2O9ecvr2QLYp+7512ty1j42KnsFwspPXg1Vh8an9YImf6CokUBltisZFQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.5.1",
|
||||
"@typescript-eslint/scope-manager": "7.3.1",
|
||||
"@typescript-eslint/type-utils": "7.3.1",
|
||||
"@typescript-eslint/utils": "7.3.1",
|
||||
"@typescript-eslint/visitor-keys": "7.3.1",
|
||||
"@typescript-eslint/scope-manager": "7.5.0",
|
||||
"@typescript-eslint/type-utils": "7.5.0",
|
||||
"@typescript-eslint/utils": "7.5.0",
|
||||
"@typescript-eslint/visitor-keys": "7.5.0",
|
||||
"debug": "^4.3.4",
|
||||
"graphemer": "^1.4.0",
|
||||
"ignore": "^5.2.4",
|
||||
@ -2626,15 +2619,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.3.1.tgz",
|
||||
"integrity": "sha512-Rq49+pq7viTRCH48XAbTA+wdLRrB/3sRq4Lpk0oGDm0VmnjBrAOVXH/Laalmwsv2VpekiEfVFwJYVk6/e8uvQw==",
|
||||
"version": "7.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.5.0.tgz",
|
||||
"integrity": "sha512-cj+XGhNujfD2/wzR1tabNsidnYRaFfEkcULdcIyVBYcXjBvBKOes+mpMBP7hMpOyk+gBcfXsrg4NBGAStQyxjQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "7.3.1",
|
||||
"@typescript-eslint/types": "7.3.1",
|
||||
"@typescript-eslint/typescript-estree": "7.3.1",
|
||||
"@typescript-eslint/visitor-keys": "7.3.1",
|
||||
"@typescript-eslint/scope-manager": "7.5.0",
|
||||
"@typescript-eslint/types": "7.5.0",
|
||||
"@typescript-eslint/typescript-estree": "7.5.0",
|
||||
"@typescript-eslint/visitor-keys": "7.5.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
@ -2654,13 +2647,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.3.1.tgz",
|
||||
"integrity": "sha512-fVS6fPxldsKY2nFvyT7IP78UO1/I2huG+AYu5AMjCT9wtl6JFiDnsv4uad4jQ0GTFzcUV5HShVeN96/17bTBag==",
|
||||
"version": "7.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.5.0.tgz",
|
||||
"integrity": "sha512-Z1r7uJY0MDeUlql9XJ6kRVgk/sP11sr3HKXn268HZyqL7i4cEfrdFuSSY/0tUqT37l5zT0tJOsuDP16kio85iA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "7.3.1",
|
||||
"@typescript-eslint/visitor-keys": "7.3.1"
|
||||
"@typescript-eslint/types": "7.5.0",
|
||||
"@typescript-eslint/visitor-keys": "7.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || >=20.0.0"
|
||||
@ -2671,13 +2664,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.3.1.tgz",
|
||||
"integrity": "sha512-iFhaysxFsMDQlzJn+vr3OrxN8NmdQkHks4WaqD4QBnt5hsq234wcYdyQ9uquzJJIDAj5W4wQne3yEsYA6OmXGw==",
|
||||
"version": "7.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.5.0.tgz",
|
||||
"integrity": "sha512-A021Rj33+G8mx2Dqh0nMO9GyjjIBK3MqgVgZ2qlKf6CJy51wY/lkkFqq3TqqnH34XyAHUkq27IjlUkWlQRpLHw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/typescript-estree": "7.3.1",
|
||||
"@typescript-eslint/utils": "7.3.1",
|
||||
"@typescript-eslint/typescript-estree": "7.5.0",
|
||||
"@typescript-eslint/utils": "7.5.0",
|
||||
"debug": "^4.3.4",
|
||||
"ts-api-utils": "^1.0.1"
|
||||
},
|
||||
@ -2698,9 +2691,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.3.1.tgz",
|
||||
"integrity": "sha512-2tUf3uWggBDl4S4183nivWQ2HqceOZh1U4hhu4p1tPiIJoRRXrab7Y+Y0p+dozYwZVvLPRI6r5wKe9kToF9FIw==",
|
||||
"version": "7.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.5.0.tgz",
|
||||
"integrity": "sha512-tv5B4IHeAdhR7uS4+bf8Ov3k793VEVHd45viRRkehIUZxm0WF82VPiLgHzA/Xl4TGPg1ZD49vfxBKFPecD5/mg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": "^18.18.0 || >=20.0.0"
|
||||
@ -2711,13 +2704,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.3.1.tgz",
|
||||
"integrity": "sha512-tLpuqM46LVkduWP7JO7yVoWshpJuJzxDOPYIVWUUZbW+4dBpgGeUdl/fQkhuV0A8eGnphYw3pp8d2EnvPOfxmQ==",
|
||||
"version": "7.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.5.0.tgz",
|
||||
"integrity": "sha512-YklQQfe0Rv2PZEueLTUffiQGKQneiIEKKnfIqPIOxgM9lKSZFCjT5Ad4VqRKj/U4+kQE3fa8YQpskViL7WjdPQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "7.3.1",
|
||||
"@typescript-eslint/visitor-keys": "7.3.1",
|
||||
"@typescript-eslint/types": "7.5.0",
|
||||
"@typescript-eslint/visitor-keys": "7.5.0",
|
||||
"debug": "^4.3.4",
|
||||
"globby": "^11.1.0",
|
||||
"is-glob": "^4.0.3",
|
||||
@ -2763,17 +2756,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.3.1.tgz",
|
||||
"integrity": "sha512-jIERm/6bYQ9HkynYlNZvXpzmXWZGhMbrOvq3jJzOSOlKXsVjrrolzWBjDW6/TvT5Q3WqaN4EkmcfdQwi9tDjBQ==",
|
||||
"version": "7.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.5.0.tgz",
|
||||
"integrity": "sha512-3vZl9u0R+/FLQcpy2EHyRGNqAS/ofJ3Ji8aebilfJe+fobK8+LbIFmrHciLVDxjDoONmufDcnVSF38KwMEOjzw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.4.0",
|
||||
"@types/json-schema": "^7.0.12",
|
||||
"@types/semver": "^7.5.0",
|
||||
"@typescript-eslint/scope-manager": "7.3.1",
|
||||
"@typescript-eslint/types": "7.3.1",
|
||||
"@typescript-eslint/typescript-estree": "7.3.1",
|
||||
"@typescript-eslint/scope-manager": "7.5.0",
|
||||
"@typescript-eslint/types": "7.5.0",
|
||||
"@typescript-eslint/typescript-estree": "7.5.0",
|
||||
"semver": "^7.5.4"
|
||||
},
|
||||
"engines": {
|
||||
@ -2788,12 +2781,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.3.1.tgz",
|
||||
"integrity": "sha512-9RMXwQF8knsZvfv9tdi+4D/j7dMG28X/wMJ8Jj6eOHyHWwDW4ngQJcqEczSsqIKKjFiLFr40Mnr7a5ulDD3vmw==",
|
||||
"version": "7.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.5.0.tgz",
|
||||
"integrity": "sha512-mcuHM/QircmA6O7fy6nn2w/3ditQkj+SgtOc8DW3uQ10Yfj42amm2i+6F2K4YAOPNNTmE6iM1ynM6lrSwdendA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "7.3.1",
|
||||
"@typescript-eslint/types": "7.5.0",
|
||||
"eslint-visitor-keys": "^3.4.1"
|
||||
},
|
||||
"engines": {
|
||||
@ -4014,19 +4007,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-jest": {
|
||||
"version": "27.9.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-27.9.0.tgz",
|
||||
"integrity": "sha512-QIT7FH7fNmd9n4se7FFKHbsLKGQiw885Ds6Y/sxKgCZ6natwCsXdgPOADnYVxN2QrRweF0FZWbJ6S7Rsn7llug==",
|
||||
"version": "28.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-28.2.0.tgz",
|
||||
"integrity": "sha512-yRDti/a+f+SMSmNTiT9/M/MzXGkitl8CfzUxnpoQcTyfq8gUrXMriVcWU36W1X6BZSUoyUCJrDAWWUA2N4hE5g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/utils": "^5.10.0"
|
||||
"@typescript-eslint/utils": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
"node": "^16.10.0 || ^18.12.0 || >=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^5.0.0 || ^6.0.0 || ^7.0.0",
|
||||
"eslint": "^7.0.0 || ^8.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0 || ^7.0.0",
|
||||
"eslint": "^7.0.0 || ^8.0.0 || ^9.0.0",
|
||||
"jest": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
@ -4039,16 +4032,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-jest/node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "5.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz",
|
||||
"integrity": "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==",
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz",
|
||||
"integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "5.62.0",
|
||||
"@typescript-eslint/visitor-keys": "5.62.0"
|
||||
"@typescript-eslint/types": "6.21.0",
|
||||
"@typescript-eslint/visitor-keys": "6.21.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
"node": "^16.0.0 || >=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@ -4056,12 +4049,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-jest/node_modules/@typescript-eslint/types": {
|
||||
"version": "5.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz",
|
||||
"integrity": "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==",
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz",
|
||||
"integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
"node": "^16.0.0 || >=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@ -4069,21 +4062,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-jest/node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "5.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz",
|
||||
"integrity": "sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==",
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz",
|
||||
"integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "5.62.0",
|
||||
"@typescript-eslint/visitor-keys": "5.62.0",
|
||||
"@typescript-eslint/types": "6.21.0",
|
||||
"@typescript-eslint/visitor-keys": "6.21.0",
|
||||
"debug": "^4.3.4",
|
||||
"globby": "^11.1.0",
|
||||
"is-glob": "^4.0.3",
|
||||
"semver": "^7.3.7",
|
||||
"tsutils": "^3.21.0"
|
||||
"minimatch": "9.0.3",
|
||||
"semver": "^7.5.4",
|
||||
"ts-api-utils": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
"node": "^16.0.0 || >=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@ -4096,68 +4090,69 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-jest/node_modules/@typescript-eslint/utils": {
|
||||
"version": "5.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.62.0.tgz",
|
||||
"integrity": "sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==",
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz",
|
||||
"integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@types/json-schema": "^7.0.9",
|
||||
"@types/semver": "^7.3.12",
|
||||
"@typescript-eslint/scope-manager": "5.62.0",
|
||||
"@typescript-eslint/types": "5.62.0",
|
||||
"@typescript-eslint/typescript-estree": "5.62.0",
|
||||
"eslint-scope": "^5.1.1",
|
||||
"semver": "^7.3.7"
|
||||
"@eslint-community/eslint-utils": "^4.4.0",
|
||||
"@types/json-schema": "^7.0.12",
|
||||
"@types/semver": "^7.5.0",
|
||||
"@typescript-eslint/scope-manager": "6.21.0",
|
||||
"@typescript-eslint/types": "6.21.0",
|
||||
"@typescript-eslint/typescript-estree": "6.21.0",
|
||||
"semver": "^7.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
"node": "^16.0.0 || >=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
"eslint": "^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-jest/node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "5.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz",
|
||||
"integrity": "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==",
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz",
|
||||
"integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "5.62.0",
|
||||
"eslint-visitor-keys": "^3.3.0"
|
||||
"@typescript-eslint/types": "6.21.0",
|
||||
"eslint-visitor-keys": "^3.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
"node": "^16.0.0 || >=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-jest/node_modules/eslint-scope": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
|
||||
"integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
|
||||
"node_modules/eslint-plugin-jest/node_modules/brace-expansion": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
|
||||
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"esrecurse": "^4.3.0",
|
||||
"estraverse": "^4.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-jest/node_modules/estraverse": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
|
||||
"integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
|
||||
"node_modules/eslint-plugin-jest/node_modules/minimatch": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
|
||||
"integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-prettier": {
|
||||
@ -5284,9 +5279,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.360.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.360.0.tgz",
|
||||
"integrity": "sha512-MskvbEsAhD2zxgx/I05vXq1cjFQXrmhL97YFIi4wSaKH793ZMvU/Com4d+DE7OB3QMmZig1fY1q94aTX5skozw==",
|
||||
"version": "0.365.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.365.0.tgz",
|
||||
"integrity": "sha512-sJYpPyyzGHI4B3pys+XSFnE4qtSWc68rFnDLxbNNKjkLST5XSx9DNn5+1Z3eFgFiw39PphNRiVBSVb+AL3oKwA==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
@ -5528,9 +5523,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/msw": {
|
||||
"version": "2.2.9",
|
||||
"resolved": "https://registry.npmjs.org/msw/-/msw-2.2.9.tgz",
|
||||
"integrity": "sha512-MLIFufBe6m9c5rZKlmGl6jl1pjn7cTNiDGEgn5v2iVRs0mz+neE2r7lRyYNzvcp6FbdiUEIRp/Y2O2gRMjO8yQ==",
|
||||
"version": "2.2.13",
|
||||
"resolved": "https://registry.npmjs.org/msw/-/msw-2.2.13.tgz",
|
||||
"integrity": "sha512-ljFf1xZsU0b4zv1l7xzEmC6OZA6yD06hcx0H+dc8V0VypaP3HGYJa1rMLjQbBWl32ptGhcfwcPCWDB1wjmsftw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
@ -5538,7 +5533,7 @@
|
||||
"@bundled-es-modules/statuses": "^1.0.1",
|
||||
"@inquirer/confirm": "^3.0.0",
|
||||
"@mswjs/cookies": "^1.1.0",
|
||||
"@mswjs/interceptors": "^0.25.16",
|
||||
"@mswjs/interceptors": "^0.26.14",
|
||||
"@open-draft/until": "^2.1.0",
|
||||
"@types/cookie": "^0.6.0",
|
||||
"@types/statuses": "^2.0.4",
|
||||
@ -6266,9 +6261,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-hook-form": {
|
||||
"version": "7.51.1",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.51.1.tgz",
|
||||
"integrity": "sha512-ifnBjl+kW0ksINHd+8C/Gp6a4eZOdWyvRv0UBaByShwU8JbVx5hTcTWEcd5VdybvmPTATkVVXk9npXArHmo56w==",
|
||||
"version": "7.51.2",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.51.2.tgz",
|
||||
"integrity": "sha512-y++lwaWjtzDt/XNnyGDQy6goHskFualmDlf+jzEZvjvz6KWDf7EboL7pUvRCzPTJd0EOPpdekYaQLEvvG6m6HA==",
|
||||
"engines": {
|
||||
"node": ">=12.22.0"
|
||||
},
|
||||
@ -6447,9 +6442,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-zoom-pan-pinch": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-3.4.3.tgz",
|
||||
"integrity": "sha512-x5MFlfAx2D6NTpZu8OISqc2nYn4p+YEaM1p21w7S/VE1wbVzK8vRzTo9Bj1ydufa649MuP7JBRM3vvj1RftFZw==",
|
||||
"version": "3.4.4",
|
||||
"resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-3.4.4.tgz",
|
||||
"integrity": "sha512-lGTu7D9lQpYEQ6sH+NSlLA7gicgKRW8j+D/4HO1AbSV2POvKRFzdWQ8eI0r3xmOsl4dYQcY+teV6MhULeg1xBw==",
|
||||
"engines": {
|
||||
"node": ">=8",
|
||||
"npm": ">=5"
|
||||
@ -7200,9 +7195,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.1.tgz",
|
||||
"integrity": "sha512-qAYmXRfk3ENzuPBakNK0SRrUDipP8NQnEY6772uDhflcQz5EhRdD7JNZxyrFHVQNCwULPBn6FNPp9brpO7ctcA==",
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.3.tgz",
|
||||
"integrity": "sha512-U7sxQk/n397Bmx4JHbJx/iSOOv5G+II3f1kpLpY2QeUv5DcPdcTsYLlusZfq1NthHS1c1cZoyFmmkex1rzke0A==",
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"arg": "^5.0.2",
|
||||
@ -7212,7 +7207,7 @@
|
||||
"fast-glob": "^3.3.0",
|
||||
"glob-parent": "^6.0.2",
|
||||
"is-glob": "^4.0.3",
|
||||
"jiti": "^1.19.1",
|
||||
"jiti": "^1.21.0",
|
||||
"lilconfig": "^2.1.0",
|
||||
"micromatch": "^4.0.5",
|
||||
"normalize-path": "^3.0.0",
|
||||
@ -7392,27 +7387,6 @@
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
|
||||
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
|
||||
},
|
||||
"node_modules/tsutils": {
|
||||
"version": "3.21.0",
|
||||
"resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz",
|
||||
"integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"tslib": "^1.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta"
|
||||
}
|
||||
},
|
||||
"node_modules/tsutils/node_modules/tslib": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
@ -7447,9 +7421,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.4.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.3.tgz",
|
||||
"integrity": "sha512-KrPd3PKaCLr78MalgiwJnA25Nm8HAmdwN3mYUYZgG/wizIo9EainNVQI9/yDavtVFRN2h3k8uf3GLHuhDMgEHg==",
|
||||
"version": "5.4.4",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.4.tgz",
|
||||
"integrity": "sha512-dGE2Vv8cpVvw28v8HCPqyb08EzbBURxDpuhJvTrusShUfGnhHBafDsLdS1EhhxyL6BJQE+2cT3dDPAv+MQ6oLw==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
@ -7660,13 +7634,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.2.2.tgz",
|
||||
"integrity": "sha512-FWZbz0oSdLq5snUI0b6sULbz58iXFXdvkZfZWR/F0ZJuKTSPO7v72QPXt6KqYeMFb0yytNp6kZosxJ96Nr/wDQ==",
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.2.8.tgz",
|
||||
"integrity": "sha512-OyZR+c1CE8yeHw5V5t59aXsUPPVTHMDjEZz8MgguLL/Q7NblxhZUlTu9xSPqlsUO/y+X7dlU05jdhvyycD55DA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.20.1",
|
||||
"postcss": "^8.4.36",
|
||||
"postcss": "^8.4.38",
|
||||
"rollup": "^4.13.0"
|
||||
},
|
||||
"bin": {
|
||||
|
||||
@ -42,7 +42,7 @@
|
||||
"hls.js": "^1.5.7",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"immer": "^10.0.4",
|
||||
"lucide-react": "^0.360.0",
|
||||
"lucide-react": "^0.365.0",
|
||||
"monaco-yaml": "^5.1.1",
|
||||
"next-themes": "^0.3.0",
|
||||
"react": "^18.2.0",
|
||||
@ -50,14 +50,14 @@
|
||||
"react-day-picker": "^8.9.1",
|
||||
"react-device-detect": "^2.2.3",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.51.1",
|
||||
"react-hook-form": "^7.51.2",
|
||||
"react-icons": "^5.0.1",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"react-swipeable": "^7.0.1",
|
||||
"react-tracked": "^1.7.14",
|
||||
"react-transition-group": "^4.4.5",
|
||||
"react-use-websocket": "^4.8.1",
|
||||
"react-zoom-pan-pinch": "^3.4.3",
|
||||
"react-zoom-pan-pinch": "^3.4.4",
|
||||
"recoil": "^0.7.7",
|
||||
"scroll-into-view-if-needed": "^3.1.0",
|
||||
"sonner": "^1.4.41",
|
||||
@ -73,20 +73,20 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@testing-library/jest-dom": "^6.1.5",
|
||||
"@types/node": "^20.11.30",
|
||||
"@types/react": "^18.2.67",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"@types/node": "^20.12.5",
|
||||
"@types/react": "^18.2.74",
|
||||
"@types/react-dom": "^18.2.24",
|
||||
"@types/react-icons": "^3.0.0",
|
||||
"@types/react-transition-group": "^4.4.10",
|
||||
"@types/strftime": "^0.9.8",
|
||||
"@typescript-eslint/eslint-plugin": "^7.3.1",
|
||||
"@typescript-eslint/parser": "^7.3.1",
|
||||
"@typescript-eslint/eslint-plugin": "^7.5.0",
|
||||
"@typescript-eslint/parser": "^7.5.0",
|
||||
"@vitejs/plugin-react-swc": "^3.6.0",
|
||||
"@vitest/coverage-v8": "^1.4.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^8.55.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-jest": "^27.6.0",
|
||||
"eslint-plugin-jest": "^28.2.0",
|
||||
"eslint-plugin-prettier": "^5.0.1",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.6",
|
||||
@ -94,12 +94,12 @@
|
||||
"fake-indexeddb": "^5.0.2",
|
||||
"jest-websocket-mock": "^2.5.0",
|
||||
"jsdom": "^24.0.0",
|
||||
"msw": "^2.2.9",
|
||||
"msw": "^2.2.13",
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "^3.2.5",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.4.3",
|
||||
"vite": "^5.2.2",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^5.4.4",
|
||||
"vite": "^5.2.8",
|
||||
"vitest": "^1.3.1"
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import { isDesktop, isMobile } from "react-device-detect";
|
||||
import Statusbar from "./components/Statusbar";
|
||||
import Bottombar from "./components/navigation/Bottombar";
|
||||
import { Suspense, lazy } from "react";
|
||||
import { Redirect } from "./components/navigation/Redirect";
|
||||
|
||||
const Live = lazy(() => import("@/pages/Live"));
|
||||
const Events = lazy(() => import("@/pages/Events"));
|
||||
@ -35,7 +36,8 @@ function App() {
|
||||
<Suspense>
|
||||
<Routes>
|
||||
<Route path="/" element={<Live />} />
|
||||
<Route path="/events" element={<Events />} />
|
||||
<Route path="/events" element={<Redirect to="/review" />} />
|
||||
<Route path="/review" element={<Events />} />
|
||||
<Route path="/export" element={<Export />} />
|
||||
<Route path="/plus" element={<SubmitPlus />} />
|
||||
<Route path="/system" element={<System />} />
|
||||
|
||||
@ -32,7 +32,7 @@ export default function Statusbar() {
|
||||
const { potentialProblems } = useStats(stats);
|
||||
|
||||
return (
|
||||
<div className="absolute left-0 bottom-0 right-0 w-full h-8 flex justify-between items-center px-4 bg-primary z-10 text-secondary-foreground border-t border-secondary-highlight">
|
||||
<div className="absolute left-0 bottom-0 right-0 w-full h-8 flex justify-between items-center px-4 bg-background_alt z-10 dark:text-secondary-foreground border-t border-secondary-highlight">
|
||||
<div className="h-full flex items-center gap-2">
|
||||
{cpuPercent && (
|
||||
<div className="flex items-center text-sm gap-2">
|
||||
|
||||
@ -20,7 +20,7 @@ export function AnimatedEventCard({ event }: AnimatedEventCardProps) {
|
||||
|
||||
const navigate = useNavigate();
|
||||
const onOpenReview = useCallback(() => {
|
||||
navigate("events", {
|
||||
navigate("review", {
|
||||
state: {
|
||||
severity: event.severity,
|
||||
recording: {
|
||||
|
||||
@ -12,6 +12,7 @@ import { Input } from "../ui/input";
|
||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||
|
||||
type ExportProps = {
|
||||
className: string;
|
||||
file: {
|
||||
name: string;
|
||||
};
|
||||
@ -19,7 +20,12 @@ type ExportProps = {
|
||||
onDelete: (file: string) => void;
|
||||
};
|
||||
|
||||
export default function ExportCard({ file, onRename, onDelete }: ExportProps) {
|
||||
export default function ExportCard({
|
||||
className,
|
||||
file,
|
||||
onRename,
|
||||
onDelete,
|
||||
}: ExportProps) {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
@ -94,7 +100,7 @@ export default function ExportCard({ file, onRename, onDelete }: ExportProps) {
|
||||
</Dialog>
|
||||
|
||||
<div
|
||||
className="relative aspect-video bg-black rounded-2xl flex justify-center items-center"
|
||||
className={`relative aspect-video bg-black rounded-2xl flex justify-center items-center ${className}`}
|
||||
onMouseEnter={
|
||||
isDesktop && !inProgress ? () => setHovered(true) : undefined
|
||||
}
|
||||
|
||||
@ -27,7 +27,9 @@ export default function ReviewCard({
|
||||
config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p",
|
||||
);
|
||||
const isSelected = useMemo(
|
||||
() => event.start_time <= currentTime && event.end_time >= currentTime,
|
||||
() =>
|
||||
event.start_time <= currentTime &&
|
||||
(event.end_time ?? Date.now() / 1000) >= currentTime,
|
||||
[event, currentTime],
|
||||
);
|
||||
|
||||
|
||||
@ -34,7 +34,6 @@ export default function NewReviewData({
|
||||
? "animate-in slide-in-from-top duration-500"
|
||||
: "invisible"
|
||||
} text-center mt-5 mx-auto bg-gray-400 text-white`}
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
pullLatestData();
|
||||
if (contentRef.current) {
|
||||
|
||||
@ -131,7 +131,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
||||
size="xs"
|
||||
onClick={() => setAddGroup(true)}
|
||||
>
|
||||
<LuPlus className="size-4 text-primary-foreground" />
|
||||
<LuPlus className="size-4 text-primary" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@ -253,7 +253,7 @@ function NewGroupDialog({ open, setOpen, currentGroups }: NewGroupDialogProps) {
|
||||
{currentGroups.length > 0 && <DropdownMenuSeparator />}
|
||||
{editState == "none" && (
|
||||
<Button
|
||||
className="text-primary-foreground justify-start"
|
||||
className="text-primary justify-start"
|
||||
variant="ghost"
|
||||
onClick={() => setEditState("add")}
|
||||
>
|
||||
|
||||
@ -19,7 +19,7 @@ export default function FilterCheckBox({
|
||||
}: FilterCheckBoxProps) {
|
||||
return (
|
||||
<Button
|
||||
className="capitalize flex justify-between items-center cursor-pointer w-full text-primary-foreground"
|
||||
className="capitalize flex justify-between items-center cursor-pointer w-full text-primary"
|
||||
variant="ghost"
|
||||
onClick={() => onCheckedChange(!isChecked)}
|
||||
>
|
||||
|
||||
126
web/src/components/filter/LogLevelFilter.tsx
Normal file
126
web/src/components/filter/LogLevelFilter.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
import { Button } from "../ui/button";
|
||||
import { FaFilter } from "react-icons/fa";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
import { LogSeverity } from "@/types/log";
|
||||
import { Label } from "../ui/label";
|
||||
import { Switch } from "../ui/switch";
|
||||
import { DropdownMenuSeparator } from "../ui/dropdown-menu";
|
||||
|
||||
type LogLevelFilterButtonProps = {
|
||||
selectedLabels?: LogSeverity[];
|
||||
updateLabelFilter: (labels: LogSeverity[] | undefined) => void;
|
||||
};
|
||||
export function LogLevelFilterButton({
|
||||
selectedLabels,
|
||||
updateLabelFilter,
|
||||
}: LogLevelFilterButtonProps) {
|
||||
const trigger = (
|
||||
<Button size="sm" className="flex items-center gap-2">
|
||||
<FaFilter className="text-secondary-foreground" />
|
||||
<div className="hidden md:block text-primary">Filter</div>
|
||||
</Button>
|
||||
);
|
||||
const content = (
|
||||
<GeneralFilterContent
|
||||
selectedLabels={selectedLabels}
|
||||
updateLabelFilter={updateLabelFilter}
|
||||
/>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
|
||||
<DrawerContent className="max-h-[75dvh] p-3 mx-1 overflow-hidden">
|
||||
{content}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
||||
<PopoverContent>{content}</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
type GeneralFilterContentProps = {
|
||||
selectedLabels: LogSeverity[] | undefined;
|
||||
updateLabelFilter: (labels: LogSeverity[] | undefined) => void;
|
||||
};
|
||||
export function GeneralFilterContent({
|
||||
selectedLabels,
|
||||
updateLabelFilter,
|
||||
}: GeneralFilterContentProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="h-auto overflow-y-auto overflow-x-hidden">
|
||||
<div className="flex justify-between items-center my-2.5">
|
||||
<Label
|
||||
className="mx-2 text-primary cursor-pointer"
|
||||
htmlFor="allLabels"
|
||||
>
|
||||
All Logs
|
||||
</Label>
|
||||
<Switch
|
||||
className="ml-1"
|
||||
id="allLabels"
|
||||
checked={selectedLabels == undefined}
|
||||
onCheckedChange={(isChecked) => {
|
||||
if (isChecked) {
|
||||
updateLabelFilter(undefined);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="my-2.5 flex flex-col gap-2.5">
|
||||
{["debug", "info", "warning", "error"].map((item) => (
|
||||
<div className="flex justify-between items-center">
|
||||
<Label
|
||||
className="w-full mx-2 text-primary capitalize cursor-pointer"
|
||||
htmlFor={item}
|
||||
>
|
||||
{item.replaceAll("_", " ")}
|
||||
</Label>
|
||||
<Switch
|
||||
key={item}
|
||||
className="ml-1"
|
||||
id={item}
|
||||
checked={selectedLabels?.includes(item as LogSeverity) ?? false}
|
||||
onCheckedChange={(isChecked) => {
|
||||
if (isChecked) {
|
||||
const updatedLabels = selectedLabels
|
||||
? [...selectedLabels]
|
||||
: [];
|
||||
|
||||
updatedLabels.push(item as LogSeverity);
|
||||
updateLabelFilter(updatedLabels);
|
||||
} else {
|
||||
const updatedLabels = selectedLabels
|
||||
? [...selectedLabels]
|
||||
: [];
|
||||
|
||||
// can not deselect the last item
|
||||
if (updatedLabels.length > 1) {
|
||||
updatedLabels.splice(
|
||||
updatedLabels.indexOf(item as LogSeverity),
|
||||
1,
|
||||
);
|
||||
updateLabelFilter(updatedLabels);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -40,7 +40,7 @@ export default function ReviewActionGroup({
|
||||
<div className="p-1">{`${selectedReviews.length} selected`}</div>
|
||||
<div className="p-1">{"|"}</div>
|
||||
<div
|
||||
className="p-2 text-primary-foreground cursor-pointer hover:bg-secondary hover:rounded-lg"
|
||||
className="p-2 text-primary cursor-pointer hover:bg-secondary hover:rounded-lg"
|
||||
onClick={onClearSelected}
|
||||
>
|
||||
Unselect
|
||||
@ -50,7 +50,6 @@ export default function ReviewActionGroup({
|
||||
{selectedReviews.length == 1 && (
|
||||
<Button
|
||||
className="p-2 flex items-center gap-2"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onExport(selectedReviews[0]);
|
||||
@ -58,28 +57,24 @@ export default function ReviewActionGroup({
|
||||
}}
|
||||
>
|
||||
<FaCompactDisc />
|
||||
{isDesktop && <div className="text-primary-foreground">Export</div>}
|
||||
{isDesktop && <div className="text-primary">Export</div>}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className="p-2 flex items-center gap-2"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onMarkAsReviewed}
|
||||
>
|
||||
<FaCircleCheck />
|
||||
{isDesktop && (
|
||||
<div className="text-primary-foreground">Mark as reviewed</div>
|
||||
)}
|
||||
{isDesktop && <div className="text-primary">Mark as reviewed</div>}
|
||||
</Button>
|
||||
<Button
|
||||
className="p-2 flex items-center gap-1"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<HiTrash />
|
||||
{isDesktop && <div className="text-primary-foreground">Delete</div>}
|
||||
{isDesktop && <div className="text-primary">Delete</div>}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -75,6 +75,9 @@ export default function ReviewFilterGroup({
|
||||
const cameras = filter?.cameras || Object.keys(config.cameras);
|
||||
|
||||
cameras.forEach((camera) => {
|
||||
if (camera == "birdseye") {
|
||||
return;
|
||||
}
|
||||
const cameraConfig = config.cameras[camera];
|
||||
cameraConfig.objects.track.forEach((label) => {
|
||||
labels.add(label);
|
||||
@ -220,23 +223,31 @@ function CamerasFilterButton({
|
||||
const trigger = (
|
||||
<Button
|
||||
className="flex items-center gap-2 capitalize"
|
||||
variant="secondary"
|
||||
variant={selectedCameras?.length == undefined ? "default" : "select"}
|
||||
size="sm"
|
||||
>
|
||||
<FaVideo className="text-secondary-foreground" />
|
||||
<div className="hidden md:block text-primary-foreground">
|
||||
<FaVideo
|
||||
className={`${selectedCameras?.length == 1 ? "text-selected-foreground" : "text-secondary-foreground"}`}
|
||||
/>
|
||||
<div
|
||||
className={`hidden md:block ${selectedCameras?.length ? "text-selected-foreground" : "text-primary"}`}
|
||||
>
|
||||
{selectedCameras == undefined
|
||||
? "All Cameras"
|
||||
: `${selectedCameras.length} Cameras`}
|
||||
: `${selectedCameras.includes("birdseye") ? selectedCameras.length - 1 : selectedCameras.length} Camera${selectedCameras.length !== 1 ? "s" : ""}`}
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
const content = (
|
||||
<>
|
||||
<DropdownMenuLabel className="flex justify-center">
|
||||
Filter Cameras
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{isMobile && (
|
||||
<>
|
||||
<DropdownMenuLabel className="flex justify-center">
|
||||
Cameras
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<div className="h-auto overflow-y-auto overflow-x-hidden">
|
||||
<FilterCheckBox
|
||||
isChecked={currentCameras == undefined}
|
||||
@ -305,7 +316,6 @@ function CamerasFilterButton({
|
||||
Apply
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setCurrentCameras(undefined);
|
||||
updateCameraFilter(undefined);
|
||||
@ -368,7 +378,7 @@ function ShowReviewFilter({
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div className="hidden h-9 md:flex p-2 justify-start items-center text-sm bg-secondary hover:bg-secondary/80 text-primary-foreground rounded-md cursor-pointer">
|
||||
<div className="hidden h-9 md:flex p-2 justify-start items-center text-sm bg-secondary hover:bg-secondary/80 rounded-md cursor-pointer">
|
||||
<Switch
|
||||
id="reviewed"
|
||||
checked={showReviewedSwitch == 1}
|
||||
@ -376,19 +386,19 @@ function ShowReviewFilter({
|
||||
setShowReviewedSwitch(showReviewedSwitch == 0 ? 1 : 0)
|
||||
}
|
||||
/>
|
||||
<Label className="ml-2 cursor-pointer" htmlFor="reviewed">
|
||||
<Label className="ml-2 cursor-pointer text-primary" htmlFor="reviewed">
|
||||
Show Reviewed
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="block md:hidden"
|
||||
className="block md:hidden duration-0"
|
||||
variant={showReviewedSwitch == 1 ? "select" : "default"}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setShowReviewedSwitch(showReviewedSwitch == 0 ? 1 : 0)}
|
||||
>
|
||||
<FaCheckCircle
|
||||
className={`${showReviewedSwitch == 1 ? "text-selected" : "text-muted-foreground"}`}
|
||||
className={`${showReviewedSwitch == 1 ? "text-selected-foreground" : "text-secondary-foreground"}`}
|
||||
/>
|
||||
</Button>
|
||||
</>
|
||||
@ -411,9 +421,17 @@ function CalendarFilterButton({
|
||||
);
|
||||
|
||||
const trigger = (
|
||||
<Button size="sm" className="flex items-center gap-2" variant="secondary">
|
||||
<FaCalendarAlt className="text-secondary-foreground" />
|
||||
<div className="hidden md:block text-primary-foreground">
|
||||
<Button
|
||||
className="flex items-center gap-2"
|
||||
variant={day == undefined ? "default" : "select"}
|
||||
size="sm"
|
||||
>
|
||||
<FaCalendarAlt
|
||||
className={`${day == undefined ? "text-secondary-foreground" : "text-selected-foreground"}`}
|
||||
/>
|
||||
<div
|
||||
className={`hidden md:block ${day == undefined ? "text-primary" : "text-selected-foreground"}`}
|
||||
>
|
||||
{day == undefined ? "Last 24 Hours" : selectedDate}
|
||||
</div>
|
||||
</Button>
|
||||
@ -428,7 +446,6 @@ function CalendarFilterButton({
|
||||
<DropdownMenuSeparator />
|
||||
<div className="p-2 flex justify-center items-center">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
updateSelectedDay(undefined);
|
||||
}}
|
||||
@ -472,9 +489,19 @@ function GeneralFilterButton({
|
||||
);
|
||||
|
||||
const trigger = (
|
||||
<Button size="sm" className="flex items-center gap-2" variant="secondary">
|
||||
<FaFilter className="text-secondary-foreground" />
|
||||
<div className="hidden md:block text-primary-foreground">Filter</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={selectedLabels?.length ? "select" : "default"}
|
||||
className="flex items-center gap-2 capitalize"
|
||||
>
|
||||
<FaFilter
|
||||
className={`${selectedLabels?.length ? "text-selected-foreground" : "text-secondary-foreground"}`}
|
||||
/>
|
||||
<div
|
||||
className={`hidden md:block ${selectedLabels?.length ? "text-selected-foreground" : "text-primary"}`}
|
||||
>
|
||||
Filter
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
const content = (
|
||||
@ -546,7 +573,7 @@ export function GeneralFilterContent({
|
||||
<div className="h-auto overflow-y-auto overflow-x-hidden">
|
||||
<div className="flex justify-between items-center my-2.5">
|
||||
<Label
|
||||
className="mx-2 text-primary-foreground cursor-pointer"
|
||||
className="mx-2 text-primary cursor-pointer"
|
||||
htmlFor="allLabels"
|
||||
>
|
||||
All Labels
|
||||
@ -567,7 +594,7 @@ export function GeneralFilterContent({
|
||||
{allLabels.map((item) => (
|
||||
<div className="flex justify-between items-center">
|
||||
<Label
|
||||
className="w-full mx-2 text-secondary-foreground capitalize cursor-pointer"
|
||||
className="w-full mx-2 text-primary capitalize cursor-pointer"
|
||||
htmlFor={item}
|
||||
>
|
||||
{item.replaceAll("_", " ")}
|
||||
@ -617,7 +644,6 @@ export function GeneralFilterContent({
|
||||
Apply
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setCurrentLabels(undefined);
|
||||
updateLabelFilter(undefined);
|
||||
@ -645,7 +671,7 @@ function ShowMotionOnlyButton({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="hidden md:inline-flex items-center justify-center whitespace-nowrap text-sm bg-secondary hover:bg-secondary/80 text-secondary-foreground h-9 rounded-md px-3 mx-1 cursor-pointer">
|
||||
<div className="hidden md:inline-flex items-center justify-center whitespace-nowrap text-sm bg-secondary hover:bg-secondary/80 text-primary h-9 rounded-md px-3 mx-1 cursor-pointer">
|
||||
<Switch
|
||||
className="ml-1"
|
||||
id="collapse-motion"
|
||||
@ -653,7 +679,7 @@ function ShowMotionOnlyButton({
|
||||
onCheckedChange={setMotionOnlyButton}
|
||||
/>
|
||||
<Label
|
||||
className="mx-2 text-primary-foreground cursor-pointer"
|
||||
className="mx-2 text-primary cursor-pointer"
|
||||
htmlFor="collapse-motion"
|
||||
>
|
||||
Motion only
|
||||
@ -663,11 +689,12 @@ function ShowMotionOnlyButton({
|
||||
<div className="block md:hidden">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="duration-0"
|
||||
variant={motionOnlyButton ? "select" : "default"}
|
||||
onClick={() => setMotionOnlyButton(!motionOnlyButton)}
|
||||
>
|
||||
<FaRunning
|
||||
className={`${motionOnlyButton ? "text-selected" : "text-muted-foreground"}`}
|
||||
className={`${motionOnlyButton ? "text-selected-foreground" : "text-secondary-foreground"}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -3,6 +3,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { Threshold } from "@/types/graph";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import Chart from "react-apexcharts";
|
||||
import { isMobileOnly } from "react-device-detect";
|
||||
import { MdCircle } from "react-icons/md";
|
||||
import useSWR from "swr";
|
||||
|
||||
@ -36,11 +37,11 @@ export function ThresholdBarGraph({
|
||||
|
||||
const formatTime = useCallback(
|
||||
(val: unknown) => {
|
||||
if (val == 0) {
|
||||
if (val == 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const date = new Date(updateTimes[Math.round(val as number)] * 1000);
|
||||
const date = new Date(updateTimes[Math.round(val as number) - 1] * 1000);
|
||||
return date.toLocaleTimeString([], {
|
||||
hour12: config?.ui.time_format != "24hour",
|
||||
hour: "2-digit",
|
||||
@ -96,10 +97,10 @@ export function ThresholdBarGraph({
|
||||
size: 0,
|
||||
},
|
||||
xaxis: {
|
||||
tickAmount: 4,
|
||||
tickAmount: isMobileOnly ? 3 : 4,
|
||||
tickPlacement: "on",
|
||||
labels: {
|
||||
offsetX: -30,
|
||||
offsetX: -18,
|
||||
formatter: formatTime,
|
||||
},
|
||||
axisBorder: {
|
||||
@ -110,9 +111,11 @@ export function ThresholdBarGraph({
|
||||
},
|
||||
},
|
||||
yaxis: {
|
||||
show: false,
|
||||
show: true,
|
||||
labels: {
|
||||
formatter: (val: number) => Math.ceil(val).toString(),
|
||||
},
|
||||
min: 0,
|
||||
max: threshold.warning + 10,
|
||||
},
|
||||
} as ApexCharts.ApexOptions;
|
||||
}, [graphId, threshold, systemTheme, theme, formatTime]);
|
||||
@ -125,7 +128,7 @@ export function ThresholdBarGraph({
|
||||
<div className="w-full flex flex-col">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="text-xs text-muted-foreground">{name}</div>
|
||||
<div className="text-xs text-primary-foreground">
|
||||
<div className="text-xs text-primary">
|
||||
{lastValue}
|
||||
{unit}
|
||||
</div>
|
||||
@ -216,15 +219,13 @@ export function StorageGraph({ graphId, used, total }: StorageGraphProps) {
|
||||
<div className="w-full flex flex-col gap-2.5">
|
||||
<div className="w-full flex justify-between items-center gap-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="text-xs text-primary-foreground">
|
||||
{getUnitSize(used)}
|
||||
</div>
|
||||
<div className="text-xs text-primary-foreground">/</div>
|
||||
<div className="text-xs text-primary">{getUnitSize(used)}</div>
|
||||
<div className="text-xs text-primary">/</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{getUnitSize(total)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-primary-foreground">
|
||||
<div className="text-xs text-primary">
|
||||
{Math.round((used / total) * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
@ -278,7 +279,7 @@ export function CameraLineGraph({
|
||||
|
||||
const formatTime = useCallback(
|
||||
(val: unknown) => {
|
||||
if (val == 0) {
|
||||
if (val == 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -326,10 +327,10 @@ export function CameraLineGraph({
|
||||
size: 0,
|
||||
},
|
||||
xaxis: {
|
||||
tickAmount: 4,
|
||||
tickPlacement: "between",
|
||||
tickAmount: isMobileOnly ? 3 : 4,
|
||||
tickPlacement: "on",
|
||||
labels: {
|
||||
offsetX: -30,
|
||||
offsetX: isMobileOnly ? -18 : 0,
|
||||
formatter: formatTime,
|
||||
},
|
||||
axisBorder: {
|
||||
@ -340,7 +341,10 @@ export function CameraLineGraph({
|
||||
},
|
||||
},
|
||||
yaxis: {
|
||||
show: false,
|
||||
show: true,
|
||||
labels: {
|
||||
formatter: (val: number) => Math.ceil(val).toString(),
|
||||
},
|
||||
min: 0,
|
||||
},
|
||||
} as ApexCharts.ApexOptions;
|
||||
@ -361,7 +365,7 @@ export function CameraLineGraph({
|
||||
style={{ color: GRAPH_COLORS[labelIdx] }}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className="text-xs text-primary-foreground">
|
||||
<div className="text-xs text-primary">
|
||||
{lastValues[labelIdx]}
|
||||
{unit}
|
||||
</div>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { ReactNode, useRef } from "react";
|
||||
import { LogSeverity } from "@/types/log";
|
||||
import { ReactNode, useMemo, useRef } from "react";
|
||||
import { CSSTransition } from "react-transition-group";
|
||||
|
||||
type ChipProps = {
|
||||
@ -39,3 +40,35 @@ export default function Chip({
|
||||
</CSSTransition>
|
||||
);
|
||||
}
|
||||
|
||||
type LogChipProps = {
|
||||
severity: LogSeverity;
|
||||
onClickSeverity?: () => void;
|
||||
};
|
||||
export function LogChip({ severity, onClickSeverity }: LogChipProps) {
|
||||
const severityClassName = useMemo(() => {
|
||||
switch (severity) {
|
||||
case "info":
|
||||
return "text-primary/60 bg-secondary hover:bg-secondary/60";
|
||||
case "warning":
|
||||
return "text-warning-foreground bg-warning hover:bg-warning/80";
|
||||
case "error":
|
||||
return "text-destructive-foreground bg-destructive hover:bg-destructive/80";
|
||||
}
|
||||
}, [severity]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`py-[1px] px-1 capitalize text-xs rounded-md ${onClickSeverity ? "cursor-pointer" : ""} ${severityClassName}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (onClickSeverity) {
|
||||
onClickSeverity();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{severity}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import { navbarLinks } from "@/pages/site-navigation";
|
||||
import NavItem from "./NavItem";
|
||||
import { IoIosWarning } from "react-icons/io";
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
|
||||
@ -9,20 +8,15 @@ import { useMemo } from "react";
|
||||
import useStats from "@/hooks/use-stats";
|
||||
import GeneralSettings from "../settings/GeneralSettings";
|
||||
import AccountSettings from "../settings/AccountSettings";
|
||||
import useNavigation from "@/hooks/use-navigation";
|
||||
|
||||
function Bottombar() {
|
||||
const navItems = useNavigation("secondary");
|
||||
|
||||
return (
|
||||
<div className="absolute h-16 inset-x-4 bottom-0 flex flex-row items-center justify-between">
|
||||
{navbarLinks.map((item) => (
|
||||
<NavItem
|
||||
className=""
|
||||
variant="secondary"
|
||||
key={item.id}
|
||||
Icon={item.icon}
|
||||
title={item.title}
|
||||
url={item.url}
|
||||
dev={item.dev}
|
||||
/>
|
||||
{navItems.map((item) => (
|
||||
<NavItem key={item.id} item={item} Icon={item.icon} />
|
||||
))}
|
||||
<GeneralSettings />
|
||||
<AccountSettings />
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
import { IconType } from "react-icons";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { ENV } from "@/env";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@ -8,6 +6,8 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import { isDesktop } from "react-device-detect";
|
||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
import { NavData } from "@/types/navigation";
|
||||
import { IconType } from "react-icons";
|
||||
|
||||
const variants = {
|
||||
primary: {
|
||||
@ -21,37 +21,29 @@ const variants = {
|
||||
};
|
||||
|
||||
type NavItemProps = {
|
||||
className: string;
|
||||
variant?: "primary" | "secondary";
|
||||
className?: string;
|
||||
item: NavData;
|
||||
Icon: IconType;
|
||||
title: string;
|
||||
url: string;
|
||||
dev?: boolean;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
export default function NavItem({
|
||||
className,
|
||||
variant = "primary",
|
||||
item,
|
||||
Icon,
|
||||
title,
|
||||
url,
|
||||
dev,
|
||||
onClick,
|
||||
}: NavItemProps) {
|
||||
const shouldRender = dev ? ENV !== "production" : true;
|
||||
|
||||
if (!shouldRender) {
|
||||
if (item.enabled == false) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = (
|
||||
<NavLink
|
||||
to={url}
|
||||
to={item.url}
|
||||
onClick={onClick}
|
||||
className={({ isActive }) =>
|
||||
`${className} flex flex-col justify-center items-center rounded-lg ${
|
||||
variants[variant][isActive ? "active" : "inactive"]
|
||||
`flex flex-col justify-center items-center rounded-lg ${className ?? ""} ${
|
||||
variants[item.variant ?? "primary"][isActive ? "active" : "inactive"]
|
||||
}`
|
||||
}
|
||||
>
|
||||
@ -65,7 +57,7 @@ export default function NavItem({
|
||||
<TooltipTrigger>{content}</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent side="right">
|
||||
<p>{title}</p>
|
||||
<p>{item.title}</p>
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
|
||||
14
web/src/components/navigation/Redirect.tsx
Normal file
14
web/src/components/navigation/Redirect.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
type RedirectProps = {
|
||||
to: string;
|
||||
};
|
||||
export function Redirect({ to }: RedirectProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
navigate(to);
|
||||
}, [to, navigate]);
|
||||
return <div />;
|
||||
}
|
||||
@ -1,16 +1,18 @@
|
||||
import Logo from "../Logo";
|
||||
import { navbarLinks } from "@/pages/site-navigation";
|
||||
import NavItem from "./NavItem";
|
||||
import { CameraGroupSelector } from "../filter/CameraGroupSelector";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import GeneralSettings from "../settings/GeneralSettings";
|
||||
import AccountSettings from "../settings/AccountSettings";
|
||||
import useNavigation from "@/hooks/use-navigation";
|
||||
|
||||
function Sidebar() {
|
||||
const location = useLocation();
|
||||
|
||||
const navbarLinks = useNavigation();
|
||||
|
||||
return (
|
||||
<aside className="absolute w-[52px] z-10 left-o inset-y-0 overflow-y-auto scrollbar-hidden py-4 flex flex-col justify-between bg-primary border-r border-secondary-highlight">
|
||||
<aside className="absolute w-[52px] z-10 left-o inset-y-0 overflow-y-auto scrollbar-hidden py-4 flex flex-col justify-between bg-background_alt border-r border-secondary-highlight">
|
||||
<span tabIndex={0} className="sr-only" />
|
||||
<div className="w-full flex flex-col gap-0 items-center">
|
||||
<Logo className="w-8 h-8 mb-6" />
|
||||
@ -22,10 +24,8 @@ function Sidebar() {
|
||||
<div key={item.id}>
|
||||
<NavItem
|
||||
className={`mx-[10px] ${showCameraGroups ? "mb-2" : "mb-4"}`}
|
||||
item={item}
|
||||
Icon={item.icon}
|
||||
title={item.title}
|
||||
url={item.url}
|
||||
dev={item.dev}
|
||||
/>
|
||||
{showCameraGroups && <CameraGroupSelector className="mb-4" />}
|
||||
</div>
|
||||
|
||||
@ -64,10 +64,13 @@ export default function ExportDialog({
|
||||
}
|
||||
|
||||
axios
|
||||
.post(`export/${camera}/start/${range.after}/end/${range.before}`, {
|
||||
playback: "realtime",
|
||||
name,
|
||||
})
|
||||
.post(
|
||||
`export/${camera}/start/${Math.round(range.after)}/end/${Math.round(range.before)}`,
|
||||
{
|
||||
playback: "realtime",
|
||||
name,
|
||||
},
|
||||
)
|
||||
.then((response) => {
|
||||
if (response.status == 200) {
|
||||
toast.success(
|
||||
@ -116,14 +119,13 @@ export default function ExportDialog({
|
||||
<Trigger asChild>
|
||||
<Button
|
||||
className="flex items-center gap-2"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setMode("select");
|
||||
}}
|
||||
>
|
||||
<FaArrowDown className="p-1 fill-secondary bg-secondary-foreground rounded-md" />
|
||||
{isDesktop && <div className="text-primary-foreground">Export</div>}
|
||||
{isDesktop && <div className="text-primary">Export</div>}
|
||||
</Button>
|
||||
</Trigger>
|
||||
<Content
|
||||
@ -371,7 +373,7 @@ function CustomTimeSelector({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mt-3 flex items-center bg-secondary rounded-lg ${isDesktop ? "mx-8 px-2 gap-2" : "pl-2"}`}
|
||||
className={`mt-3 flex items-center bg-secondary text-secondary-foreground rounded-lg ${isDesktop ? "mx-8 px-2 gap-2" : "pl-2"}`}
|
||||
>
|
||||
<FaCalendarAlt />
|
||||
<Popover
|
||||
@ -384,8 +386,8 @@ function CustomTimeSelector({
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
className={isDesktop ? "" : "text-xs"}
|
||||
variant={startOpen ? "select" : "secondary"}
|
||||
className={`text-primary ${isDesktop ? "" : "text-xs"}`}
|
||||
variant={startOpen ? "select" : "default"}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setStartOpen(true);
|
||||
@ -435,7 +437,7 @@ function CustomTimeSelector({
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FaArrowRight className="size-4" />
|
||||
<FaArrowRight className="size-4 text-primary" />
|
||||
<Popover
|
||||
open={endOpen}
|
||||
onOpenChange={(open) => {
|
||||
@ -446,8 +448,8 @@ function CustomTimeSelector({
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
className={isDesktop ? "" : "text-xs"}
|
||||
variant={endOpen ? "select" : "secondary"}
|
||||
className={`text-primary ${isDesktop ? "" : "text-xs"}`}
|
||||
variant={endOpen ? "select" : "default"}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEndOpen(true);
|
||||
|
||||
125
web/src/components/overlay/LogInfoDialog.tsx
Normal file
125
web/src/components/overlay/LogInfoDialog.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
import { LogLine } from "@/types/log";
|
||||
import { isDesktop } from "react-device-detect";
|
||||
import { Sheet, SheetContent } from "../ui/sheet";
|
||||
import { Drawer, DrawerContent } from "../ui/drawer";
|
||||
import { LogChip } from "../indicators/Chip";
|
||||
import { useMemo } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
type LogInfoDialogProps = {
|
||||
logLine?: LogLine;
|
||||
setLogLine: (log: LogLine | undefined) => void;
|
||||
};
|
||||
export default function LogInfoDialog({
|
||||
logLine,
|
||||
setLogLine,
|
||||
}: LogInfoDialogProps) {
|
||||
const Overlay = isDesktop ? Sheet : Drawer;
|
||||
const Content = isDesktop ? SheetContent : DrawerContent;
|
||||
|
||||
const helpfulLinks = useHelpfulLinks(logLine?.content);
|
||||
|
||||
return (
|
||||
<Overlay
|
||||
open={logLine != undefined}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setLogLine(undefined);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Content className={isDesktop ? "" : "max-h-[75dvh] p-2 overflow-hidden"}>
|
||||
{logLine && (
|
||||
<div className="size-full flex flex-col gap-5">
|
||||
<div className="w-min flex flex-col gap-1.5">
|
||||
<div className="text-sm text-primary/40">Type</div>
|
||||
<LogChip severity={logLine.severity} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="text-sm text-primary/40">Timestamp</div>
|
||||
<div className="text-sm">{logLine.dateStamp}</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="text-sm text-primary/40">Tag</div>
|
||||
<div className="text-sm">{logLine.section}</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="text-sm text-primary/40">Message</div>
|
||||
<div className="text-sm">{logLine.content}</div>
|
||||
</div>
|
||||
{helpfulLinks.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="text-sm text-primary/40">Helpful Links</div>
|
||||
{helpfulLinks.map((tip) => (
|
||||
<Link to={tip.link} target="_blank" rel="noopener noreferrer">
|
||||
<div className="text-sm text-selected hover:underline">
|
||||
{tip.text}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Content>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
|
||||
function useHelpfulLinks(content: string | undefined) {
|
||||
return useMemo(() => {
|
||||
if (!content) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const links = [];
|
||||
|
||||
if (/Could not clear [\d.]* currently [\d.]*/.exec(content)) {
|
||||
links.push({
|
||||
link: "https://docs.frigate.video/configuration/record#will-frigate-delete-old-recordings-if-my-storage-runs-out",
|
||||
text: "Frigate Automatic Storage Cleanup",
|
||||
});
|
||||
}
|
||||
|
||||
if (/Did not detect hwaccel/.exec(content)) {
|
||||
links.push({
|
||||
link: "https://docs.frigate.video/configuration/hardware_acceleration",
|
||||
text: "Setup Hardware Acceleration",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
content.includes(
|
||||
"/usr/lib/x86_64-linux-gnu/dri/iHD_drv_video.so init failed",
|
||||
) ||
|
||||
content.includes(
|
||||
"/usr/lib/x86_64-linux-gnu/dri/i965_drv_video.so init failed",
|
||||
) ||
|
||||
content.includes(
|
||||
"/usr/lib/x86_64-linux-gnu/dri/i965_drv_video.so init failed",
|
||||
) ||
|
||||
content.includes("No VA display found for device /dev/dri/renderD128")
|
||||
) {
|
||||
links.push({
|
||||
link: "https://docs.frigate.video/configuration/hardware_acceleration",
|
||||
text: "Verify Hardware Acceleration Setup",
|
||||
});
|
||||
}
|
||||
|
||||
if (content.includes("No EdgeTPU was detected")) {
|
||||
links.push({
|
||||
link: "https://docs.frigate.video/troubleshooting/edgetpu",
|
||||
text: "Troubleshoot Coral",
|
||||
});
|
||||
}
|
||||
|
||||
if (content.includes("The current SHM size of")) {
|
||||
links.push({
|
||||
link: "https://docs.frigate.video/frigate/installation/#calculating-required-shm-size",
|
||||
text: "Calculate Correct SHM Size",
|
||||
});
|
||||
}
|
||||
|
||||
return links;
|
||||
}, [content]);
|
||||
}
|
||||
@ -23,7 +23,7 @@ export default function MobileCameraDrawer({
|
||||
return (
|
||||
<Drawer open={cameraDrawer} onOpenChange={setCameraDrawer}>
|
||||
<DrawerTrigger asChild>
|
||||
<Button className="rounded-lg capitalize" size="sm" variant="secondary">
|
||||
<Button className="rounded-lg capitalize" size="sm">
|
||||
<FaVideo className="text-secondary-foreground" />
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
|
||||
@ -66,10 +66,13 @@ export default function MobileReviewSettingsDrawer({
|
||||
}
|
||||
|
||||
axios
|
||||
.post(`export/${camera}/start/${range.after}/end/${range.before}`, {
|
||||
playback: "realtime",
|
||||
name,
|
||||
})
|
||||
.post(
|
||||
`export/${camera}/start/${Math.round(range.after)}/end/${Math.round(range.before)}`,
|
||||
{
|
||||
playback: "realtime",
|
||||
name,
|
||||
},
|
||||
)
|
||||
.then((response) => {
|
||||
if (response.status == 200) {
|
||||
toast.success(
|
||||
@ -144,18 +147,24 @@ export default function MobileReviewSettingsDrawer({
|
||||
{features.includes("calendar") && (
|
||||
<Button
|
||||
className="w-full flex justify-center items-center gap-2"
|
||||
variant={filter?.after ? "select" : "default"}
|
||||
onClick={() => setDrawerMode("calendar")}
|
||||
>
|
||||
<FaCalendarAlt className="fill-secondary-foreground" />
|
||||
<FaCalendarAlt
|
||||
className={`${filter?.after ? "text-selected-foreground" : "text-secondary-foreground"}`}
|
||||
/>
|
||||
Calendar
|
||||
</Button>
|
||||
)}
|
||||
{features.includes("filter") && (
|
||||
<Button
|
||||
className="w-full flex justify-center items-center gap-2"
|
||||
variant={filter?.labels ? "select" : "default"}
|
||||
onClick={() => setDrawerMode("filter")}
|
||||
>
|
||||
<FaFilter className="fill-secondary-foreground" />
|
||||
<FaFilter
|
||||
className={`${filter?.labels ? "text-selected-foreground" : "text-secondary-foreground"}`}
|
||||
/>
|
||||
Filter
|
||||
</Button>
|
||||
)}
|
||||
@ -217,7 +226,6 @@ export default function MobileReviewSettingsDrawer({
|
||||
<SelectSeparator />
|
||||
<div className="p-2 flex justify-center items-center">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
onUpdateFilter({
|
||||
...filter,
|
||||
@ -278,11 +286,13 @@ export default function MobileReviewSettingsDrawer({
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
className="rounded-lg capitalize"
|
||||
variant={filter?.labels || filter?.after ? "select" : "default"}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setDrawerMode("select")}
|
||||
>
|
||||
<FaCog className="text-secondary-foreground" />
|
||||
<FaCog
|
||||
className={`${filter?.labels || filter?.after ? "text-selected-foreground" : "text-secondary-foreground"}`}
|
||||
/>
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent className="max-h-[80dvh] overflow-hidden flex flex-col items-center gap-2 px-4 pb-4 mx-1 rounded-t-2xl">
|
||||
|
||||
@ -22,7 +22,7 @@ export default function MobileTimelineDrawer({
|
||||
return (
|
||||
<Drawer open={drawer} onOpenChange={setDrawer}>
|
||||
<DrawerTrigger asChild>
|
||||
<Button className="rounded-lg capitalize" size="sm" variant="secondary">
|
||||
<Button className="rounded-lg capitalize" size="sm">
|
||||
<FaFlag className="text-secondary-foreground" />
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
|
||||
@ -17,7 +17,7 @@ export default function SaveExportOverlay({
|
||||
return (
|
||||
<div className={className}>
|
||||
<div
|
||||
className={`flex justify-center px-2 gap-2 items-center pointer-events-auto rounded-lg *:text-white ${
|
||||
className={`flex justify-center px-2 gap-2 items-center pointer-events-auto rounded-lg ${
|
||||
show ? "animate-in slide-in-from-top duration-500" : "invisible"
|
||||
} text-center mt-5 mx-auto`}
|
||||
>
|
||||
@ -31,9 +31,8 @@ export default function SaveExportOverlay({
|
||||
Save Export
|
||||
</Button>
|
||||
<Button
|
||||
className="flex items-center gap-1"
|
||||
className="flex items-center gap-1 text-primary"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<LuX />
|
||||
|
||||
@ -44,9 +44,7 @@ export default function VainfoDialog({
|
||||
<ActivityIndicator />
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={() => setShowVainfo(false)}>
|
||||
Close
|
||||
</Button>
|
||||
<Button onClick={() => setShowVainfo(false)}>Close</Button>
|
||||
<Button variant="select" onClick={() => onCopyVainfo()}>
|
||||
Copy
|
||||
</Button>
|
||||
|
||||
@ -19,7 +19,6 @@ const unsupportedErrorCodes = [
|
||||
];
|
||||
|
||||
type HlsVideoPlayerProps = {
|
||||
className: string;
|
||||
children?: ReactNode;
|
||||
videoRef: MutableRefObject<HTMLVideoElement | null>;
|
||||
visible: boolean;
|
||||
@ -31,7 +30,6 @@ type HlsVideoPlayerProps = {
|
||||
onPlaying?: () => void;
|
||||
};
|
||||
export default function HlsVideoPlayer({
|
||||
className,
|
||||
children,
|
||||
videoRef,
|
||||
visible,
|
||||
@ -91,116 +89,118 @@ export default function HlsVideoPlayer({
|
||||
|
||||
return (
|
||||
<TransformWrapper minScale={1.0}>
|
||||
<div
|
||||
className={`relative ${className ?? ""} ${visible ? "visible" : "hidden"}`}
|
||||
onMouseOver={
|
||||
isDesktop
|
||||
? () => {
|
||||
setControls(true);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onMouseOut={
|
||||
isDesktop
|
||||
? () => {
|
||||
setControls(controlsOpen);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onClick={isDesktop ? undefined : () => setControls(!controls)}
|
||||
<TransformComponent
|
||||
wrapperStyle={{
|
||||
position: "relative",
|
||||
display: visible ? undefined : "none",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
contentStyle={{
|
||||
width: "100%",
|
||||
height: isMobile ? "100%" : undefined,
|
||||
}}
|
||||
>
|
||||
<TransformComponent
|
||||
wrapperStyle={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
contentStyle={{
|
||||
width: "100%",
|
||||
height: isMobile ? "100%" : undefined,
|
||||
}}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
className={`size-full bg-black rounded-2xl ${loadedMetadata ? "" : "invisible"}`}
|
||||
preload="auto"
|
||||
autoPlay
|
||||
controls={false}
|
||||
playsInline
|
||||
muted
|
||||
onPlay={() => {
|
||||
setIsPlaying(true);
|
||||
<video
|
||||
ref={videoRef}
|
||||
className={`size-full bg-black rounded-2xl ${loadedMetadata ? "" : "invisible"}`}
|
||||
preload="auto"
|
||||
autoPlay
|
||||
controls={false}
|
||||
playsInline
|
||||
muted
|
||||
onPlay={() => {
|
||||
setIsPlaying(true);
|
||||
|
||||
if (isMobile) {
|
||||
setControls(true);
|
||||
setMobileCtrlTimeout(
|
||||
setTimeout(() => setControls(false), 4000),
|
||||
);
|
||||
}
|
||||
}}
|
||||
onPlaying={onPlaying}
|
||||
onPause={() => {
|
||||
setIsPlaying(false);
|
||||
|
||||
if (isMobile && mobileCtrlTimeout) {
|
||||
clearTimeout(mobileCtrlTimeout);
|
||||
}
|
||||
}}
|
||||
onTimeUpdate={() =>
|
||||
onTimeUpdate && videoRef.current
|
||||
? onTimeUpdate(videoRef.current.currentTime)
|
||||
: undefined
|
||||
}
|
||||
onLoadedData={onPlayerLoaded}
|
||||
onLoadedMetadata={() => setLoadedMetadata(true)}
|
||||
onEnded={onClipEnded}
|
||||
onError={(e) => {
|
||||
if (
|
||||
!hlsRef.current &&
|
||||
// @ts-expect-error code does exist
|
||||
unsupportedErrorCodes.includes(e.target.error.code) &&
|
||||
videoRef.current
|
||||
) {
|
||||
setLoadedMetadata(false);
|
||||
setUseHlsCompat(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TransformComponent>
|
||||
<VideoControls
|
||||
className="absolute bottom-5 left-1/2 -translate-x-1/2"
|
||||
video={videoRef.current}
|
||||
isPlaying={isPlaying}
|
||||
show={controls}
|
||||
controlsOpen={controlsOpen}
|
||||
setControlsOpen={setControlsOpen}
|
||||
playbackRate={videoRef.current?.playbackRate ?? 1}
|
||||
hotKeys={hotKeys}
|
||||
onPlayPause={(play) => {
|
||||
if (!videoRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (play) {
|
||||
videoRef.current.play();
|
||||
} else {
|
||||
videoRef.current.pause();
|
||||
if (isMobile) {
|
||||
setControls(true);
|
||||
setMobileCtrlTimeout(setTimeout(() => setControls(false), 4000));
|
||||
}
|
||||
}}
|
||||
onSeek={(diff) => {
|
||||
const currentTime = videoRef.current?.currentTime;
|
||||
onPlaying={onPlaying}
|
||||
onPause={() => {
|
||||
setIsPlaying(false);
|
||||
|
||||
if (!videoRef.current || !currentTime) {
|
||||
return;
|
||||
if (isMobile && mobileCtrlTimeout) {
|
||||
clearTimeout(mobileCtrlTimeout);
|
||||
}
|
||||
|
||||
videoRef.current.currentTime = Math.max(0, currentTime + diff);
|
||||
}}
|
||||
onSetPlaybackRate={(rate) =>
|
||||
videoRef.current ? (videoRef.current.playbackRate = rate) : null
|
||||
onTimeUpdate={() =>
|
||||
onTimeUpdate && videoRef.current
|
||||
? onTimeUpdate(videoRef.current.currentTime)
|
||||
: undefined
|
||||
}
|
||||
onLoadedData={onPlayerLoaded}
|
||||
onLoadedMetadata={() => setLoadedMetadata(true)}
|
||||
onEnded={onClipEnded}
|
||||
onError={(e) => {
|
||||
if (
|
||||
!hlsRef.current &&
|
||||
// @ts-expect-error code does exist
|
||||
unsupportedErrorCodes.includes(e.target.error.code) &&
|
||||
videoRef.current
|
||||
) {
|
||||
setLoadedMetadata(false);
|
||||
setUseHlsCompat(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
onMouseOver={
|
||||
isDesktop
|
||||
? () => {
|
||||
setControls(true);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onMouseOut={
|
||||
isDesktop
|
||||
? () => {
|
||||
setControls(controlsOpen);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onClick={isDesktop ? undefined : () => setControls(!controls)}
|
||||
>
|
||||
<div className={`size-full relative ${visible ? "" : "hidden"}`}>
|
||||
<VideoControls
|
||||
className="absolute bottom-5 left-1/2 -translate-x-1/2"
|
||||
video={videoRef.current}
|
||||
isPlaying={isPlaying}
|
||||
show={controls}
|
||||
controlsOpen={controlsOpen}
|
||||
setControlsOpen={setControlsOpen}
|
||||
playbackRate={videoRef.current?.playbackRate ?? 1}
|
||||
hotKeys={hotKeys}
|
||||
onPlayPause={(play) => {
|
||||
if (!videoRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (play) {
|
||||
videoRef.current.play();
|
||||
} else {
|
||||
videoRef.current.pause();
|
||||
}
|
||||
}}
|
||||
onSeek={(diff) => {
|
||||
const currentTime = videoRef.current?.currentTime;
|
||||
|
||||
if (!videoRef.current || !currentTime) {
|
||||
return;
|
||||
}
|
||||
|
||||
videoRef.current.currentTime = Math.max(0, currentTime + diff);
|
||||
}}
|
||||
onSetPlaybackRate={(rate) =>
|
||||
videoRef.current ? (videoRef.current.playbackRate = rate) : null
|
||||
}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</TransformComponent>
|
||||
</TransformWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@ -7,10 +7,8 @@ import MSEPlayer from "./MsePlayer";
|
||||
import JSMpegPlayer from "./JSMpegPlayer";
|
||||
import { MdCircle } from "react-icons/md";
|
||||
import { useCameraActivity } from "@/hooks/use-camera-activity";
|
||||
import { useRecordingsState } from "@/api/ws";
|
||||
import { LivePlayerMode } from "@/types/live";
|
||||
import useCameraLiveMode from "@/hooks/use-camera-live-mode";
|
||||
import CameraActivityIndicator from "../indicators/CameraActivityIndicator";
|
||||
|
||||
type LivePlayerProps = {
|
||||
cameraRef?: (ref: HTMLDivElement | null) => void;
|
||||
@ -41,8 +39,7 @@ export default function LivePlayer({
|
||||
}: LivePlayerProps) {
|
||||
// camera activity
|
||||
|
||||
const { activeMotion, activeAudio, activeTracking } =
|
||||
useCameraActivity(cameraConfig);
|
||||
const { activeMotion, activeTracking } = useCameraActivity(cameraConfig);
|
||||
|
||||
const cameraActive = useMemo(
|
||||
() =>
|
||||
@ -72,8 +69,6 @@ export default function LivePlayer({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cameraActive, liveReady]);
|
||||
|
||||
const { payload: recording } = useRecordingsState(cameraConfig.name);
|
||||
|
||||
// camera still state
|
||||
|
||||
const stillReloadInterval = useMemo(() => {
|
||||
@ -171,15 +166,8 @@ export default function LivePlayer({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="absolute right-2 bottom-2 w-[40px]">
|
||||
{(activeMotion ||
|
||||
(cameraConfig.audio.enabled_in_config && activeAudio)) && (
|
||||
<CameraActivityIndicator />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="absolute right-2 top-2 size-4">
|
||||
{recording == "ON" && (
|
||||
{activeMotion && (
|
||||
<MdCircle className="size-2 drop-shadow-md shadow-danger text-danger animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -235,7 +235,7 @@ function PreviewVideoPlayer({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative rounded-2xl bg-black overflow-hidden ${onClick ? "cursor-pointer" : ""} ${className ?? ""}`}
|
||||
className={`relative rounded-2xl w-full flex justify-center bg-black overflow-hidden ${onClick ? "cursor-pointer" : ""} ${className ?? ""}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<img
|
||||
@ -464,7 +464,7 @@ function PreviewFramesPlayer({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative ${className ?? ""} ${onClick ? "cursor-pointer" : ""}`}
|
||||
className={`relative w-full flex justify-center ${className ?? ""} ${onClick ? "cursor-pointer" : ""}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<img
|
||||
|
||||
@ -13,18 +13,22 @@ import { getIconForLabel } from "@/utils/iconUtil";
|
||||
import TimeAgo from "../dynamic/TimeAgo";
|
||||
import useSWR from "swr";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { isFirefox, isMobile, isSafari } from "react-device-detect";
|
||||
import { isFirefox, isIOS, isMobile, isSafari } from "react-device-detect";
|
||||
import Chip from "@/components/indicators/Chip";
|
||||
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
|
||||
import useImageLoaded from "@/hooks/use-image-loaded";
|
||||
import { useSwipeable } from "react-swipeable";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator";
|
||||
import useContextMenu from "@/hooks/use-contextmenu";
|
||||
import ActivityIndicator from "../indicators/activity-indicator";
|
||||
import { TimeRange } from "@/types/timeline";
|
||||
|
||||
type PreviewPlayerProps = {
|
||||
review: ReviewSegment;
|
||||
allPreviews?: Preview[];
|
||||
scrollLock?: boolean;
|
||||
timeRange: TimeRange;
|
||||
onTimeUpdate?: (time: number | undefined) => void;
|
||||
setReviewed: (review: ReviewSegment) => void;
|
||||
onClick: (review: ReviewSegment, ctrl: boolean) => void;
|
||||
@ -42,6 +46,7 @@ export default function PreviewThumbnailPlayer({
|
||||
review,
|
||||
allPreviews,
|
||||
scrollLock = false,
|
||||
timeRange,
|
||||
setReviewed,
|
||||
onClick,
|
||||
onTimeUpdate,
|
||||
@ -69,10 +74,16 @@ export default function PreviewThumbnailPlayer({
|
||||
});
|
||||
|
||||
const handleSetReviewed = useCallback(() => {
|
||||
review.has_been_reviewed = true;
|
||||
setReviewed(review);
|
||||
if (review.end_time && !review.has_been_reviewed) {
|
||||
review.has_been_reviewed = true;
|
||||
setReviewed(review);
|
||||
}
|
||||
}, [review, setReviewed]);
|
||||
|
||||
useContextMenu(imgRef, () => {
|
||||
onClick(review, true);
|
||||
});
|
||||
|
||||
// playback
|
||||
|
||||
const relevantPreview = useMemo(() => {
|
||||
@ -86,7 +97,7 @@ export default function PreviewThumbnailPlayer({
|
||||
return false;
|
||||
}
|
||||
|
||||
if (review.end_time > preview.end) {
|
||||
if ((review.end_time ?? timeRange.before) > preview.end) {
|
||||
multiHour = true;
|
||||
}
|
||||
|
||||
@ -103,7 +114,8 @@ export default function PreviewThumbnailPlayer({
|
||||
|
||||
const firstPrev = allPreviews[firstIndex];
|
||||
const firstDuration = firstPrev.end - review.start_time;
|
||||
const secondDuration = review.end_time - firstPrev.end;
|
||||
const secondDuration =
|
||||
(review.end_time ?? timeRange.before) - firstPrev.end;
|
||||
|
||||
if (firstDuration > secondDuration) {
|
||||
// the first preview is longer than the second, return the first
|
||||
@ -118,7 +130,7 @@ export default function PreviewThumbnailPlayer({
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}, [allPreviews, review]);
|
||||
}, [allPreviews, review, timeRange]);
|
||||
|
||||
// Hover Playback
|
||||
|
||||
@ -170,10 +182,6 @@ export default function PreviewThumbnailPlayer({
|
||||
className="relative size-full cursor-pointer"
|
||||
onMouseOver={isMobile ? undefined : () => setIsHovered(true)}
|
||||
onMouseLeave={isMobile ? undefined : () => setIsHovered(false)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
onClick(review, true);
|
||||
}}
|
||||
onClick={handleOnClick}
|
||||
{...swipeHandlers}
|
||||
>
|
||||
@ -182,6 +190,7 @@ export default function PreviewThumbnailPlayer({
|
||||
<PreviewContent
|
||||
review={review}
|
||||
relevantPreview={relevantPreview}
|
||||
timeRange={timeRange}
|
||||
setReviewed={handleSetReviewed}
|
||||
setIgnoreClick={setIgnoreClick}
|
||||
isPlayingBack={setPlayback}
|
||||
@ -196,9 +205,18 @@ export default function PreviewThumbnailPlayer({
|
||||
<div className={`${imgLoaded ? "visible" : "invisible"}`}>
|
||||
<img
|
||||
ref={imgRef}
|
||||
className={`size-full transition-opacity ${
|
||||
className={`size-full transition-opacity select-none ${
|
||||
playingBack ? "opacity-0" : "opacity-100"
|
||||
}`}
|
||||
style={
|
||||
isIOS
|
||||
? {
|
||||
WebkitUserSelect: "none",
|
||||
WebkitTouchCallout: "none",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
draggable={false}
|
||||
src={`${apiHost}${review.thumb_path.replace("/media/frigate/", "")}`}
|
||||
loading={isSafari ? "eager" : "lazy"}
|
||||
onLoad={() => {
|
||||
@ -246,7 +264,13 @@ export default function PreviewThumbnailPlayer({
|
||||
<div className="absolute top-0 inset-x-0 rounded-t-l z-10 w-full h-[30%] bg-gradient-to-b from-black/60 to-transparent pointer-events-none"></div>
|
||||
<div className="absolute bottom-0 inset-x-0 rounded-b-l z-10 w-full h-[20%] bg-gradient-to-t from-black/60 to-transparent pointer-events-none">
|
||||
<div className="flex h-full justify-between items-end mx-3 pb-1 text-white text-sm">
|
||||
<TimeAgo time={review.start_time * 1000} dense />
|
||||
{review.end_time ? (
|
||||
<TimeAgo time={review.start_time * 1000} dense />
|
||||
) : (
|
||||
<div>
|
||||
<ActivityIndicator size={24} />
|
||||
</div>
|
||||
)}
|
||||
{formattedDate}
|
||||
</div>
|
||||
</div>
|
||||
@ -260,6 +284,7 @@ export default function PreviewThumbnailPlayer({
|
||||
type PreviewContentProps = {
|
||||
review: ReviewSegment;
|
||||
relevantPreview: Preview | undefined;
|
||||
timeRange: TimeRange;
|
||||
setReviewed: () => void;
|
||||
setIgnoreClick: (ignore: boolean) => void;
|
||||
isPlayingBack: (ended: boolean) => void;
|
||||
@ -268,6 +293,7 @@ type PreviewContentProps = {
|
||||
function PreviewContent({
|
||||
review,
|
||||
relevantPreview,
|
||||
timeRange,
|
||||
setReviewed,
|
||||
setIgnoreClick,
|
||||
isPlayingBack,
|
||||
@ -278,8 +304,9 @@ function PreviewContent({
|
||||
if (relevantPreview) {
|
||||
return (
|
||||
<VideoPreview
|
||||
review={review}
|
||||
relevantPreview={relevantPreview}
|
||||
startTime={review.start_time}
|
||||
endTime={review.end_time}
|
||||
setReviewed={setReviewed}
|
||||
setIgnoreClick={setIgnoreClick}
|
||||
isPlayingBack={isPlayingBack}
|
||||
@ -290,6 +317,7 @@ function PreviewContent({
|
||||
return (
|
||||
<InProgressPreview
|
||||
review={review}
|
||||
timeRange={timeRange}
|
||||
setReviewed={setReviewed}
|
||||
setIgnoreClick={setIgnoreClick}
|
||||
isPlayingBack={isPlayingBack}
|
||||
@ -301,16 +329,18 @@ function PreviewContent({
|
||||
|
||||
const PREVIEW_PADDING = 16;
|
||||
type VideoPreviewProps = {
|
||||
review: ReviewSegment;
|
||||
relevantPreview: Preview;
|
||||
startTime: number;
|
||||
endTime?: number;
|
||||
setReviewed: () => void;
|
||||
setIgnoreClick: (ignore: boolean) => void;
|
||||
isPlayingBack: (ended: boolean) => void;
|
||||
onTimeUpdate?: (time: number | undefined) => void;
|
||||
};
|
||||
function VideoPreview({
|
||||
review,
|
||||
relevantPreview,
|
||||
startTime,
|
||||
endTime,
|
||||
setReviewed,
|
||||
setIgnoreClick,
|
||||
isPlayingBack,
|
||||
@ -329,16 +359,13 @@ function VideoPreview({
|
||||
}
|
||||
|
||||
// start with a bit of padding
|
||||
return Math.max(
|
||||
0,
|
||||
review.start_time - relevantPreview.start - PREVIEW_PADDING,
|
||||
);
|
||||
return Math.max(0, startTime - relevantPreview.start - PREVIEW_PADDING);
|
||||
|
||||
// we know that these deps are correct
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
const playerDuration = useMemo(
|
||||
() => review.end_time - review.start_time + PREVIEW_PADDING,
|
||||
() => (endTime ?? relevantPreview.end) - startTime + PREVIEW_PADDING,
|
||||
// we know that these deps are correct
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
@ -379,21 +406,14 @@ function VideoPreview({
|
||||
// end with a bit of padding
|
||||
const playerPercent = (playerProgress / playerDuration) * 100;
|
||||
|
||||
if (
|
||||
setReviewed &&
|
||||
!review.has_been_reviewed &&
|
||||
lastPercent < 50 &&
|
||||
playerPercent > 50
|
||||
) {
|
||||
if (setReviewed && lastPercent < 50 && playerPercent > 50) {
|
||||
setReviewed();
|
||||
}
|
||||
|
||||
setLastPercent(playerPercent);
|
||||
|
||||
if (playerPercent > 100) {
|
||||
if (!review.has_been_reviewed) {
|
||||
setReviewed();
|
||||
}
|
||||
setReviewed();
|
||||
|
||||
if (isMobile) {
|
||||
isPlayingBack(false);
|
||||
@ -458,7 +478,7 @@ function VideoPreview({
|
||||
setIgnoreClick(true);
|
||||
}
|
||||
|
||||
if (setReviewed && !review.has_been_reviewed) {
|
||||
if (setReviewed) {
|
||||
setReviewed();
|
||||
}
|
||||
|
||||
@ -541,6 +561,7 @@ function VideoPreview({
|
||||
const MIN_LOAD_TIMEOUT_MS = 200;
|
||||
type InProgressPreviewProps = {
|
||||
review: ReviewSegment;
|
||||
timeRange: TimeRange;
|
||||
setReviewed: (reviewId: string) => void;
|
||||
setIgnoreClick: (ignore: boolean) => void;
|
||||
isPlayingBack: (ended: boolean) => void;
|
||||
@ -548,6 +569,7 @@ type InProgressPreviewProps = {
|
||||
};
|
||||
function InProgressPreview({
|
||||
review,
|
||||
timeRange,
|
||||
setReviewed,
|
||||
setIgnoreClick,
|
||||
isPlayingBack,
|
||||
@ -557,7 +579,7 @@ function InProgressPreview({
|
||||
const sliderRef = useRef<HTMLDivElement | null>(null);
|
||||
const { data: previewFrames } = useSWR<string[]>(
|
||||
`preview/${review.camera}/start/${Math.floor(review.start_time) - PREVIEW_PADDING}/end/${
|
||||
Math.ceil(review.end_time) + PREVIEW_PADDING
|
||||
Math.ceil(review.end_time ?? timeRange.before) + PREVIEW_PADDING
|
||||
}/frames`,
|
||||
{ revalidateOnFocus: false },
|
||||
);
|
||||
|
||||
@ -142,10 +142,10 @@ export default function VideoControls({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`px-4 py-2 flex justify-between items-center gap-8 text-white z-50 bg-secondary-foreground/60 dark:bg-secondary/60 rounded-lg ${className ?? ""}`}
|
||||
className={`px-4 py-2 flex justify-between items-center gap-8 text-primary z-50 bg-background/60 rounded-lg ${className ?? ""}`}
|
||||
>
|
||||
{video && features.volume && (
|
||||
<div className="flex justify-normal items-center gap-2">
|
||||
<div className="flex justify-normal items-center gap-2 cursor-pointer">
|
||||
<VolumeIcon
|
||||
className="size-5"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
@ -170,9 +170,9 @@ export default function VideoControls({
|
||||
)}
|
||||
<div className="cursor-pointer" onClick={onTogglePlay}>
|
||||
{isPlaying ? (
|
||||
<LuPause className="size-5 fill-white" />
|
||||
<LuPause className="size-5 text-primary fill-primary" />
|
||||
) : (
|
||||
<LuPlay className="size-5 fill-white" />
|
||||
<LuPlay className="size-5 text-primary fill-primary" />
|
||||
)}
|
||||
</div>
|
||||
{features.seek && (
|
||||
|
||||
@ -150,7 +150,6 @@ export default function DynamicVideoPlayer({
|
||||
return (
|
||||
<>
|
||||
<HlsVideoPlayer
|
||||
className={className ?? ""}
|
||||
videoRef={playerRef}
|
||||
visible={!(isScrubbing || isLoading)}
|
||||
currentSource={source}
|
||||
|
||||
@ -142,7 +142,7 @@ export default function GeneralSettings({ className }: GeneralSettings) {
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "p-2 flex items-center text-sm"
|
||||
: "w-full p-2 flex items-center text-sm"
|
||||
}
|
||||
>
|
||||
<LuActivity className="mr-2 size-4" />
|
||||
@ -154,7 +154,7 @@ export default function GeneralSettings({ className }: GeneralSettings) {
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "p-2 flex items-center text-sm"
|
||||
: "w-full p-2 flex items-center text-sm"
|
||||
}
|
||||
>
|
||||
<LuList className="mr-2 size-4" />
|
||||
@ -172,7 +172,7 @@ export default function GeneralSettings({ className }: GeneralSettings) {
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "p-2 flex items-center text-sm"
|
||||
: "w-full p-2 flex items-center text-sm"
|
||||
}
|
||||
>
|
||||
<LuSettings className="mr-2 size-4" />
|
||||
@ -184,7 +184,7 @@ export default function GeneralSettings({ className }: GeneralSettings) {
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "p-2 flex items-center text-sm"
|
||||
: "w-full p-2 flex items-center text-sm"
|
||||
}
|
||||
>
|
||||
<LuPenSquare className="mr-2 size-4" />
|
||||
|
||||
@ -100,8 +100,10 @@ export function MotionReviewTimeline({
|
||||
const overlappingReviewItems = events.some(
|
||||
(item) =>
|
||||
(item.start_time >= motionStart && item.start_time < motionEnd) ||
|
||||
(item.end_time > motionStart && item.end_time <= motionEnd) ||
|
||||
(item.start_time <= motionStart && item.end_time >= motionEnd),
|
||||
((item.end_time ?? timelineStart) > motionStart &&
|
||||
(item.end_time ?? timelineStart) <= motionEnd) ||
|
||||
(item.start_time <= motionStart &&
|
||||
(item.end_time ?? timelineStart) >= motionEnd),
|
||||
);
|
||||
|
||||
if ((!segmentMotion || overlappingReviewItems) && motionOnly) {
|
||||
|
||||
@ -107,6 +107,7 @@ export function ReviewTimeline({
|
||||
showDraggableElement: showHandlebar,
|
||||
draggableElementTime: handlebarTime,
|
||||
setDraggableElementTime: setHandlebarTime,
|
||||
alignSetTimeToSegment: true,
|
||||
initialScrollIntoViewOnly: onlyInitialHandlebarScroll,
|
||||
timelineDuration,
|
||||
timelineCollapsed: timelineCollapsed,
|
||||
@ -132,7 +133,6 @@ export function ReviewTimeline({
|
||||
draggableElementTime: exportStartTime,
|
||||
draggableElementLatestTime: paddedExportEndTime,
|
||||
setDraggableElementTime: setExportStartTime,
|
||||
alignSetTimeToSegment: true,
|
||||
timelineDuration,
|
||||
timelineStartAligned,
|
||||
isDragging: isDraggingExportStart,
|
||||
@ -157,7 +157,6 @@ export function ReviewTimeline({
|
||||
draggableElementTime: exportEndTime,
|
||||
draggableElementEarliestTime: paddedExportStartTime,
|
||||
setDraggableElementTime: setExportEndTime,
|
||||
alignSetTimeToSegment: true,
|
||||
timelineDuration,
|
||||
timelineStartAligned,
|
||||
isDragging: isDraggingExportEnd,
|
||||
|
||||
@ -355,7 +355,7 @@ export function SummaryTimeline({
|
||||
ref={visibleSectionRef}
|
||||
onMouseDown={handleMouseDown}
|
||||
onTouchStart={handleMouseDown}
|
||||
className={`bg-primary-foreground/30 z-20 absolute w-full touch-none ${
|
||||
className={`bg-primary/30 z-20 absolute w-full touch-none ${
|
||||
isDragging ? "cursor-grabbing" : "cursor-grab"
|
||||
}`}
|
||||
></div>
|
||||
|
||||
@ -32,7 +32,7 @@ export function MinimapBounds({
|
||||
<>
|
||||
{isFirstSegmentInMinimap && (
|
||||
<div
|
||||
className="absolute inset-0 -bottom-7 w-full flex items-center justify-center text-primary-foreground font-medium z-20 text-center text-[10px] scroll-mt-8 pointer-events-none select-none"
|
||||
className="absolute inset-0 -bottom-7 w-full flex items-center justify-center text-primary font-medium z-20 text-center text-[10px] scroll-mt-8 pointer-events-none select-none"
|
||||
ref={firstMinimapSegmentRef}
|
||||
>
|
||||
{new Date(alignedMinimapStartTime * 1000).toLocaleTimeString([], {
|
||||
@ -44,7 +44,7 @@ export function MinimapBounds({
|
||||
)}
|
||||
|
||||
{isLastSegmentInMinimap && (
|
||||
<div className="absolute inset-0 -top-3 w-full flex items-center justify-center text-primary-foreground font-medium z-20 text-center text-[10px] pointer-events-none select-none">
|
||||
<div className="absolute inset-0 -top-3 w-full flex items-center justify-center text-primary font-medium z-20 text-center text-[10px] pointer-events-none select-none">
|
||||
{new Date(alignedMinimapEndTime * 1000).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
@ -9,7 +9,7 @@ const badgeVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
"border-transparent bg-primary text-primary hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
@ -20,8 +20,8 @@ const badgeVariants = cva(
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
@ -30,7 +30,7 @@ export interface BadgeProps
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
export { Badge, badgeVariants };
|
||||
|
||||
@ -9,14 +9,13 @@ const buttonVariants = cva(
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
select: "bg-selected text-white hover:bg-opacity-90",
|
||||
default: "bg-secondary text-primary hover:bg-secondary/80",
|
||||
select: "bg-selected text-selected-foreground hover:bg-opacity-90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
secondary: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
ghost:
|
||||
"text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
@ -33,7 +32,7 @@ const buttonVariants = cva(
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
@ -52,7 +51,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
|
||||
@ -15,13 +15,13 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
actionButton:
|
||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||
actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary",
|
||||
cancelButton:
|
||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
success:
|
||||
"group toast group-[.toaster]:bg-success group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
error: "group toast group-[.toaster]:bg-danger group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
error:
|
||||
"group toast group-[.toaster]:bg-danger group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
|
||||
@ -1,8 +1,4 @@
|
||||
import {
|
||||
useAudioActivity,
|
||||
useFrigateEvents,
|
||||
useMotionActivity,
|
||||
} from "@/api/ws";
|
||||
import { useFrigateEvents, useMotionActivity } from "@/api/ws";
|
||||
import { CameraConfig } from "@/types/frigateConfig";
|
||||
import { MotionData, ReviewSegment } from "@/types/review";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
@ -11,7 +7,6 @@ import { useTimelineUtils } from "./use-timeline-utils";
|
||||
type useCameraActivityReturn = {
|
||||
activeTracking: boolean;
|
||||
activeMotion: boolean;
|
||||
activeAudio: boolean;
|
||||
};
|
||||
|
||||
export function useCameraActivity(
|
||||
@ -25,7 +20,6 @@ export function useCameraActivity(
|
||||
|
||||
const { payload: detectingMotion } = useMotionActivity(camera.name);
|
||||
const { payload: event } = useFrigateEvents();
|
||||
const { payload: audioRms } = useAudioActivity(camera.name);
|
||||
|
||||
useEffect(() => {
|
||||
if (!event) {
|
||||
@ -63,9 +57,6 @@ export function useCameraActivity(
|
||||
return {
|
||||
activeTracking: hasActiveObjects,
|
||||
activeMotion: detectingMotion == "ON",
|
||||
activeAudio: camera.audio.enabled_in_config
|
||||
? audioRms >= camera.audio.min_volume
|
||||
: false,
|
||||
};
|
||||
}
|
||||
|
||||
@ -116,8 +107,10 @@ export function useCameraMotionNextTimestamp(
|
||||
const overlappingReviewItems = reviewItems.some(
|
||||
(item) =>
|
||||
(item.start_time >= motionStart && item.start_time < motionEnd) ||
|
||||
(item.end_time > motionStart && item.end_time <= motionEnd) ||
|
||||
(item.start_time <= motionStart && item.end_time >= motionEnd),
|
||||
((item.end_time ?? Date.now() / 1000) > motionStart &&
|
||||
(item.end_time ?? Date.now() / 1000) <= motionEnd) ||
|
||||
(item.start_time <= motionStart &&
|
||||
(item.end_time ?? Date.now() / 1000) >= motionEnd),
|
||||
);
|
||||
|
||||
if (!segmentMotion || overlappingReviewItems) {
|
||||
|
||||
46
web/src/hooks/use-contextmenu.ts
Normal file
46
web/src/hooks/use-contextmenu.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { MutableRefObject, useEffect } from "react";
|
||||
import { isIOS } from "react-device-detect";
|
||||
|
||||
export default function useContextMenu(
|
||||
ref: MutableRefObject<HTMLDivElement | null>,
|
||||
callback: () => void,
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (!ref.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const elem = ref.current;
|
||||
|
||||
if (isIOS) {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
const touchStart = () => {
|
||||
timeoutId = setTimeout(() => {
|
||||
callback();
|
||||
}, 610);
|
||||
};
|
||||
const touchClear = () => {
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
elem.addEventListener("touchstart", touchStart);
|
||||
elem.addEventListener("touchmove", touchClear);
|
||||
elem.addEventListener("touchend", touchClear);
|
||||
|
||||
return () => {
|
||||
elem.removeEventListener("touchstart", touchStart);
|
||||
elem.removeEventListener("touchmove", touchClear);
|
||||
elem.removeEventListener("touchend", touchClear);
|
||||
};
|
||||
} else {
|
||||
const context = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
callback();
|
||||
};
|
||||
elem.addEventListener("contextmenu", context);
|
||||
|
||||
return () => {
|
||||
elem.removeEventListener("contextmenu", context);
|
||||
};
|
||||
}
|
||||
}, [callback, ref]);
|
||||
}
|
||||
@ -323,21 +323,21 @@ function useDraggableElement({
|
||||
}
|
||||
}
|
||||
|
||||
const setTime = alignSetTimeToSegment
|
||||
? targetSegmentId
|
||||
: targetSegmentId + segmentDuration * (offset / segmentHeight);
|
||||
|
||||
updateDraggableElementPosition(
|
||||
newElementPosition,
|
||||
targetSegmentId,
|
||||
setTime,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
if (setDraggableElementTime) {
|
||||
if (alignSetTimeToSegment) {
|
||||
setDraggableElementTime(targetSegmentId);
|
||||
} else {
|
||||
setDraggableElementTime(
|
||||
targetSegmentId + segmentDuration * (offset / segmentHeight),
|
||||
);
|
||||
}
|
||||
setDraggableElementTime(
|
||||
targetSegmentId + segmentDuration * (offset / segmentHeight),
|
||||
);
|
||||
}
|
||||
|
||||
if (draggingAtTopEdge || draggingAtBottomEdge) {
|
||||
|
||||
59
web/src/hooks/use-navigation.ts
Normal file
59
web/src/hooks/use-navigation.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import Logo from "@/components/Logo";
|
||||
import { ENV } from "@/env";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { NavData } from "@/types/navigation";
|
||||
import { useMemo } from "react";
|
||||
import { FaCompactDisc, FaVideo } from "react-icons/fa";
|
||||
import { LuConstruction } from "react-icons/lu";
|
||||
import { MdVideoLibrary } from "react-icons/md";
|
||||
import useSWR from "swr";
|
||||
|
||||
export default function useNavigation(
|
||||
variant: "primary" | "secondary" = "primary",
|
||||
) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
id: 1,
|
||||
variant,
|
||||
icon: FaVideo,
|
||||
title: "Live",
|
||||
url: "/",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
variant,
|
||||
icon: MdVideoLibrary,
|
||||
title: "Review",
|
||||
url: "/review",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
variant,
|
||||
icon: FaCompactDisc,
|
||||
title: "Export",
|
||||
url: "/export",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
variant,
|
||||
icon: Logo,
|
||||
title: "Frigate+",
|
||||
url: "/plus",
|
||||
enabled: config?.plus?.enabled == true,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
variant,
|
||||
icon: LuConstruction,
|
||||
title: "UI Playground",
|
||||
url: "/playground",
|
||||
enabled: ENV !== "production",
|
||||
},
|
||||
] as NavData[],
|
||||
[config?.plus.enabled, variant],
|
||||
);
|
||||
}
|
||||
@ -18,6 +18,12 @@ export default function useStats(stats: FrigateStats | undefined) {
|
||||
return problems;
|
||||
}
|
||||
|
||||
// if frigate has just started
|
||||
// don't look for issues
|
||||
if (stats.service.uptime < 120) {
|
||||
return problems;
|
||||
}
|
||||
|
||||
// check detectors for high inference speeds
|
||||
Object.entries(stats["detectors"]).forEach(([key, det]) => {
|
||||
if (det["inference_speed"] > InferenceThreshold.error) {
|
||||
|
||||
@ -17,6 +17,10 @@ type SaveOptions = "saveonly" | "restart";
|
||||
function ConfigEditor() {
|
||||
const apiHost = useApiHost();
|
||||
|
||||
useEffect(() => {
|
||||
document.title = "Config Editor - Frigate";
|
||||
}, []);
|
||||
|
||||
const { data: config } = useSWR<string>("config/raw");
|
||||
|
||||
const { theme, systemTheme } = useTheme();
|
||||
|
||||
@ -29,10 +29,20 @@ export default function Events() {
|
||||
"severity",
|
||||
"alert",
|
||||
);
|
||||
|
||||
const [recording, setRecording] =
|
||||
useOverlayState<RecordingStartingPoint>("recording");
|
||||
|
||||
const [startTime, setStartTime] = useState<number>();
|
||||
|
||||
useEffect(() => {
|
||||
if (recording) {
|
||||
document.title = "Recordings - Frigate";
|
||||
} else {
|
||||
document.title = `Review - Frigate`;
|
||||
}
|
||||
}, [recording, severity]);
|
||||
|
||||
// review filter
|
||||
|
||||
const [reviewFilter, setReviewFilter, reviewSearchParams] =
|
||||
@ -204,7 +214,7 @@ export default function Events() {
|
||||
const newData = [...data];
|
||||
|
||||
newData.forEach((seg) => {
|
||||
if (seg.severity == severity) {
|
||||
if (seg.end_time && seg.severity == severity) {
|
||||
seg.has_been_reviewed = true;
|
||||
}
|
||||
});
|
||||
@ -214,10 +224,16 @@ export default function Events() {
|
||||
{ revalidate: false, populateCache: true },
|
||||
);
|
||||
|
||||
await axios.post(`reviews/viewed`, {
|
||||
ids: currentItems?.map((seg) => seg.id),
|
||||
});
|
||||
reloadData();
|
||||
const itemsToMarkReviewed = currentItems
|
||||
?.filter((seg) => seg.end_time)
|
||||
?.map((seg) => seg.id);
|
||||
|
||||
if (itemsToMarkReviewed.length > 0) {
|
||||
await axios.post(`reviews/viewed`, {
|
||||
ids: itemsToMarkReviewed,
|
||||
});
|
||||
reloadData();
|
||||
}
|
||||
},
|
||||
[reloadData, updateSegments],
|
||||
);
|
||||
|
||||
@ -10,8 +10,9 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import axios from "axios";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import useSWR from "swr";
|
||||
|
||||
type ExportItem = {
|
||||
@ -19,24 +20,34 @@ type ExportItem = {
|
||||
};
|
||||
|
||||
function Export() {
|
||||
const { data: exports, mutate } = useSWR<ExportItem[]>(
|
||||
const { data: allExports, mutate } = useSWR<ExportItem[]>(
|
||||
"exports/",
|
||||
(url: string) => axios({ baseURL: baseUrl, url }).then((res) => res.data),
|
||||
);
|
||||
|
||||
const [deleteClip, setDeleteClip] = useState<string | undefined>();
|
||||
useEffect(() => {
|
||||
document.title = "Export - Frigate";
|
||||
}, []);
|
||||
|
||||
const onHandleRename = useCallback(
|
||||
(original: string, update: string) => {
|
||||
axios.patch(`export/${original}/${update}`).then((response) => {
|
||||
if (response.status == 200) {
|
||||
setDeleteClip(undefined);
|
||||
mutate();
|
||||
}
|
||||
});
|
||||
},
|
||||
[mutate],
|
||||
);
|
||||
// Search
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const exports = useMemo(() => {
|
||||
if (!search || !allExports) {
|
||||
return allExports;
|
||||
}
|
||||
|
||||
return allExports.filter((exp) =>
|
||||
exp.name
|
||||
.toLowerCase()
|
||||
.includes(search.toLowerCase().replaceAll(" ", "_")),
|
||||
);
|
||||
}, [allExports, search]);
|
||||
|
||||
// Deleting
|
||||
|
||||
const [deleteClip, setDeleteClip] = useState<string | undefined>();
|
||||
|
||||
const onHandleDelete = useCallback(() => {
|
||||
if (!deleteClip) {
|
||||
@ -51,8 +62,22 @@ function Export() {
|
||||
});
|
||||
}, [deleteClip, mutate]);
|
||||
|
||||
// Renaming
|
||||
|
||||
const onHandleRename = useCallback(
|
||||
(original: string, update: string) => {
|
||||
axios.patch(`export/${original}/${update}`).then((response) => {
|
||||
if (response.status == 200) {
|
||||
setDeleteClip(undefined);
|
||||
mutate();
|
||||
}
|
||||
});
|
||||
},
|
||||
[mutate],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="size-full p-2 overflow-hidden flex flex-col">
|
||||
<div className="size-full p-2 overflow-hidden flex flex-col gap-2">
|
||||
<AlertDialog
|
||||
open={deleteClip != undefined}
|
||||
onOpenChange={() => setDeleteClip(undefined)}
|
||||
@ -73,12 +98,24 @@ function Export() {
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<div className="w-full p-2 flex items-center justify-center">
|
||||
<Input
|
||||
className="w-full md:w-1/3 bg-muted"
|
||||
placeholder="Search"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full overflow-hidden">
|
||||
{exports && (
|
||||
{allExports && exports && (
|
||||
<div className="size-full grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 overflow-y-auto">
|
||||
{Object.values(exports).map((item) => (
|
||||
{Object.values(allExports).map((item) => (
|
||||
<ExportCard
|
||||
key={item.name}
|
||||
className={
|
||||
search == "" || exports.includes(item) ? "" : "hidden"
|
||||
}
|
||||
file={item}
|
||||
onRename={onHandleRename}
|
||||
onDelete={(file) => setDeleteClip(file)}
|
||||
|
||||
@ -6,18 +6,37 @@ import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import LiveBirdseyeView from "@/views/live/LiveBirdseyeView";
|
||||
import LiveCameraView from "@/views/live/LiveCameraView";
|
||||
import LiveDashboardView from "@/views/live/LiveDashboardView";
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import useSWR from "swr";
|
||||
|
||||
function Live() {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
// selection
|
||||
|
||||
const [selectedCameraName, setSelectedCameraName] = useHashState();
|
||||
const [cameraGroup] = usePersistedOverlayState(
|
||||
"cameraGroup",
|
||||
"default" as string,
|
||||
);
|
||||
|
||||
// document title
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCameraName) {
|
||||
const capitalized = selectedCameraName
|
||||
.split("_")
|
||||
.map((text) => text[0].toUpperCase() + text.substring(1));
|
||||
document.title = `${capitalized.join(" ")} - Live - Frigate`;
|
||||
} else if (cameraGroup && cameraGroup != "default") {
|
||||
document.title = `${cameraGroup[0].toUpperCase()}${cameraGroup.substring(1)} - Live - Frigate`;
|
||||
} else {
|
||||
document.title = "Live - Frigate";
|
||||
}
|
||||
}, [cameraGroup, selectedCameraName]);
|
||||
|
||||
// settings
|
||||
|
||||
const includesBirdseye = useMemo(() => {
|
||||
if (config && cameraGroup && cameraGroup != "default") {
|
||||
return config.camera_groups[cameraGroup].cameras.includes("birdseye");
|
||||
|
||||
@ -3,10 +3,14 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { LogData, LogLine, LogSeverity } from "@/types/log";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { IoIosAlert } from "react-icons/io";
|
||||
import { GoAlertFill } from "react-icons/go";
|
||||
import { LuCopy } from "react-icons/lu";
|
||||
import axios from "axios";
|
||||
import LogInfoDialog from "@/components/overlay/LogInfoDialog";
|
||||
import { LogChip } from "@/components/indicators/Chip";
|
||||
import { LogLevelFilterButton } from "@/components/filter/LogLevelFilter";
|
||||
import { FaCopy } from "react-icons/fa6";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { toast } from "sonner";
|
||||
import { isDesktop } from "react-device-detect";
|
||||
|
||||
const logTypes = ["frigate", "go2rtc", "nginx"] as const;
|
||||
type LogType = (typeof logTypes)[number];
|
||||
@ -17,7 +21,7 @@ const frigateDateStamp = /\[[\d\s-:]*]/;
|
||||
const frigateSeverity = /(DEBUG)|(INFO)|(WARNING)|(ERROR)/;
|
||||
const frigateSection = /[\w.]*/;
|
||||
|
||||
const goSeverity = /(DEB )|(INF )|(WARN )|(ERR )/;
|
||||
const goSeverity = /(DEB )|(INF )|(WRN )|(ERR )/;
|
||||
const goSection = /\[[\w]*]/;
|
||||
|
||||
const ngSeverity = /(GET)|(POST)|(PUT)|(PATCH)|(DELETE)/;
|
||||
@ -25,6 +29,10 @@ const ngSeverity = /(GET)|(POST)|(PUT)|(PATCH)|(DELETE)/;
|
||||
function Logs() {
|
||||
const [logService, setLogService] = useState<LogType>("frigate");
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `${logService[0].toUpperCase()}${logService.substring(1)} Stats - Frigate`;
|
||||
}, [logService]);
|
||||
|
||||
// log data handling
|
||||
|
||||
const [logRange, setLogRange] = useState<LogRange>({ start: 0, end: 0 });
|
||||
@ -101,7 +109,12 @@ function Logs() {
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
return {
|
||||
dateStamp: line.substring(0, 19),
|
||||
severity: "unknown",
|
||||
section: "unknown",
|
||||
content: line.substring(30).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
const sectionMatch = frigateSection.exec(
|
||||
@ -154,9 +167,28 @@ function Logs() {
|
||||
contentStart = line.indexOf(section) + section.length + 2;
|
||||
}
|
||||
|
||||
let severityCat: LogSeverity;
|
||||
switch (severity?.at(0)?.toString().trim()) {
|
||||
case "INF":
|
||||
severityCat = "info";
|
||||
break;
|
||||
case "WRN":
|
||||
severityCat = "warning";
|
||||
break;
|
||||
case "ERR":
|
||||
severityCat = "error";
|
||||
break;
|
||||
case "DBG":
|
||||
case "TRC":
|
||||
severityCat = "debug";
|
||||
break;
|
||||
default:
|
||||
severityCat = "info";
|
||||
}
|
||||
|
||||
return {
|
||||
dateStamp: line.substring(0, 19),
|
||||
severity: "INFO",
|
||||
severity: severityCat,
|
||||
section: section,
|
||||
content: line.substring(contentStart).trim(),
|
||||
};
|
||||
@ -171,7 +203,7 @@ function Logs() {
|
||||
|
||||
return {
|
||||
dateStamp: line.substring(0, 19),
|
||||
severity: "INFO",
|
||||
severity: "info",
|
||||
section: ngSeverity.exec(line)?.at(0)?.toString() ?? "META",
|
||||
content: line.substring(line.indexOf(" ", 20)).trim(),
|
||||
};
|
||||
@ -185,8 +217,15 @@ function Logs() {
|
||||
const handleCopyLogs = useCallback(() => {
|
||||
if (logs) {
|
||||
copy(logs.join("\n"));
|
||||
toast.success(
|
||||
logRange.start == 0
|
||||
? "Coplied logs to clipboard"
|
||||
: "Copied visible logs to clipboard",
|
||||
);
|
||||
} else {
|
||||
toast.error("Could not copy logs to clipboard");
|
||||
}
|
||||
}, [logs]);
|
||||
}, [logs, logRange]);
|
||||
|
||||
// scroll to bottom
|
||||
|
||||
@ -279,8 +318,19 @@ function Logs() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [logLines, logService]);
|
||||
|
||||
// log filtering
|
||||
|
||||
const [filterSeverity, setFilterSeverity] = useState<LogSeverity[]>();
|
||||
|
||||
// log selection
|
||||
|
||||
const [selectedLog, setSelectedLog] = useState<LogLine>();
|
||||
|
||||
return (
|
||||
<div className="size-full p-2 flex flex-col">
|
||||
<Toaster position="top-center" />
|
||||
<LogInfoDialog logLine={selectedLog} setLogLine={setSelectedLog} />
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<ToggleGroup
|
||||
className="*:px-3 *:py-4 *:rounded-md"
|
||||
@ -290,6 +340,7 @@ function Logs() {
|
||||
onValueChange={(value: LogType) => {
|
||||
if (value) {
|
||||
setLogs([]);
|
||||
setFilterSeverity(undefined);
|
||||
setLogService(value);
|
||||
}
|
||||
}} // don't allow the severity to be unselected
|
||||
@ -301,26 +352,31 @@ function Logs() {
|
||||
value={item}
|
||||
aria-label={`Select ${item}`}
|
||||
>
|
||||
<div className="capitalize">{`${item} Logs`}</div>
|
||||
<div className="capitalize">{item}</div>
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
className="flex justify-between items-center gap-2"
|
||||
size="sm"
|
||||
onClick={handleCopyLogs}
|
||||
>
|
||||
<LuCopy />
|
||||
<div className="hidden md:block">Copy to Clipboard</div>
|
||||
<FaCopy />
|
||||
<div className="hidden md:block text-primary">
|
||||
Copy to Clipboard
|
||||
</div>
|
||||
</Button>
|
||||
<LogLevelFilterButton
|
||||
selectedLabels={filterSeverity}
|
||||
updateLabelFilter={setFilterSeverity}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{initialScroll && !endVisible && (
|
||||
<Button
|
||||
className="absolute bottom-8 left-[50%] -translate-x-[50%] rounded-xl bg-accent-foreground text-white bg-gray-400 z-20 p-2"
|
||||
variant="secondary"
|
||||
className="absolute bottom-8 left-[50%] -translate-x-[50%] rounded-md text-primary bg-secondary-foreground z-20 p-2"
|
||||
onClick={() =>
|
||||
contentRef.current?.scrollTo({
|
||||
top: contentRef.current?.scrollHeight,
|
||||
@ -332,48 +388,61 @@ function Logs() {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="w-full h-min my-2 font-mono text-sm rounded py-4 sm:py-2 whitespace-pre-wrap overflow-auto no-scrollbar"
|
||||
>
|
||||
<div className="py-2 sticky top-0 -translate-y-1/4 grid grid-cols-5 sm:grid-cols-8 md:grid-cols-12 bg-background *:p-2">
|
||||
<div className="p-1 flex items-center capitalize border-y border-l">
|
||||
Type
|
||||
</div>
|
||||
<div className="col-span-2 sm:col-span-1 flex items-center border-y border-l">
|
||||
<div className="size-full flex flex-col my-2 font-mono text-sm sm:p-2 whitespace-pre-wrap bg-background_alt border border-secondary rounded-md overflow-hidden">
|
||||
<div className="grid grid-cols-5 sm:grid-cols-8 md:grid-cols-12 *:px-2 *:py-3 *:text-sm *:text-primary/40">
|
||||
<div className="p-1 flex items-center capitalize">Type</div>
|
||||
<div className="col-span-2 sm:col-span-1 flex items-center">
|
||||
Timestamp
|
||||
</div>
|
||||
<div className="col-span-2 flex items-center border-y border-l border-r sm:border-r-0">
|
||||
Tag
|
||||
</div>
|
||||
<div className="col-span-5 sm:col-span-4 md:col-span-8 flex items-center border">
|
||||
<div className="col-span-2 flex items-center">Tag</div>
|
||||
<div className="col-span-5 sm:col-span-4 md:col-span-8 flex items-center">
|
||||
Message
|
||||
</div>
|
||||
</div>
|
||||
{logLines.length > 0 &&
|
||||
[...Array(logRange.end).keys()].map((idx) => {
|
||||
const logLine =
|
||||
idx >= logRange.start
|
||||
? logLines[idx - logRange.start]
|
||||
: undefined;
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="w-full flex flex-col overflow-y-auto no-scrollbar"
|
||||
>
|
||||
{logLines.length > 0 &&
|
||||
[...Array(logRange.end).keys()].map((idx) => {
|
||||
const logLine =
|
||||
idx >= logRange.start
|
||||
? logLines[idx - logRange.start]
|
||||
: undefined;
|
||||
|
||||
if (logLine) {
|
||||
const line = logLines[idx - logRange.start];
|
||||
if (filterSeverity && !filterSeverity.includes(line.severity)) {
|
||||
return (
|
||||
<div
|
||||
ref={idx == logRange.start + 10 ? startLogRef : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LogLineData
|
||||
key={`${idx}-${logService}`}
|
||||
startRef={
|
||||
idx == logRange.start + 10 ? startLogRef : undefined
|
||||
}
|
||||
className={initialScroll ? "" : "invisible"}
|
||||
line={line}
|
||||
onClickSeverity={() => setFilterSeverity([line.severity])}
|
||||
onSelect={() => setSelectedLog(line)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (logLine) {
|
||||
return (
|
||||
<LogLineData
|
||||
<div
|
||||
key={`${idx}-${logService}`}
|
||||
startRef={
|
||||
idx == logRange.start + 10 ? startLogRef : undefined
|
||||
}
|
||||
className={initialScroll ? "" : "invisible"}
|
||||
offset={idx}
|
||||
line={logLines[idx - logRange.start]}
|
||||
className={isDesktop ? "h-12" : "h-16"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <div key={`${idx}-${logService}`} className="h-12" />;
|
||||
})}
|
||||
{logLines.length > 0 && <div id="page-bottom" ref={endLogRef} />}
|
||||
})}
|
||||
{logLines.length > 0 && <div id="page-bottom" ref={endLogRef} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -383,70 +452,37 @@ type LogLineDataProps = {
|
||||
startRef?: (node: HTMLDivElement | null) => void;
|
||||
className: string;
|
||||
line: LogLine;
|
||||
offset: number;
|
||||
onClickSeverity: () => void;
|
||||
onSelect: () => void;
|
||||
};
|
||||
function LogLineData({ startRef, className, line, offset }: LogLineDataProps) {
|
||||
// long log message
|
||||
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const contentOverflows = useMemo(() => {
|
||||
if (!contentRef.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return contentRef.current.scrollWidth > contentRef.current.clientWidth;
|
||||
// update on ref change
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [contentRef.current]);
|
||||
|
||||
// severity coloring
|
||||
|
||||
const severityClassName = useMemo(() => {
|
||||
switch (line.severity) {
|
||||
case "info":
|
||||
return "text-secondary-foreground rounded-md";
|
||||
case "warning":
|
||||
return "text-yellow-400 rounded-md";
|
||||
case "error":
|
||||
return "text-danger rounded-md";
|
||||
}
|
||||
}, [line]);
|
||||
|
||||
function LogLineData({
|
||||
startRef,
|
||||
className,
|
||||
line,
|
||||
onClickSeverity,
|
||||
onSelect,
|
||||
}: LogLineDataProps) {
|
||||
return (
|
||||
<div
|
||||
ref={startRef}
|
||||
className={`py-2 grid grid-cols-5 sm:grid-cols-8 md:grid-cols-12 gap-2 ${offset % 2 == 0 ? "bg-secondary" : "bg-secondary/80"} border-t border-x ${className}`}
|
||||
className={`w-full py-2 grid grid-cols-5 sm:grid-cols-8 md:grid-cols-12 gap-2 border-secondary border-t cursor-pointer hover:bg-muted ${className} *:text-sm`}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<div
|
||||
className={`h-full p-1 flex items-center gap-2 capitalize ${severityClassName}`}
|
||||
>
|
||||
{line.severity == "error" ? (
|
||||
<GoAlertFill className="size-5" />
|
||||
) : (
|
||||
<IoIosAlert className="size-5" />
|
||||
)}
|
||||
{line.severity}
|
||||
<div className="h-full p-1 flex items-center gap-2">
|
||||
<LogChip severity={line.severity} onClickSeverity={onClickSeverity} />
|
||||
</div>
|
||||
<div className="h-full col-span-2 sm:col-span-1 flex items-center">
|
||||
{line.dateStamp}
|
||||
</div>
|
||||
<div className="h-full col-span-2 flex items-center overflow-hidden text-ellipsis">
|
||||
{line.section}
|
||||
<div className="size-full pr-2 col-span-2 flex items-center">
|
||||
<div className="w-full overflow-hidden whitespace-nowrap text-ellipsis">
|
||||
{line.section}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full col-span-5 sm:col-span-4 md:col-span-8 flex justify-between items-center">
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={`w-[94%] flex items-center" ${expanded ? "" : "overflow-hidden whitespace-nowrap text-ellipsis"}`}
|
||||
>
|
||||
<div className="size-full pl-2 sm:pl-0 pr-2 col-span-5 sm:col-span-4 md:col-span-8 flex justify-between items-center">
|
||||
<div className="w-full overflow-hidden whitespace-nowrap text-ellipsis">
|
||||
{line.content}
|
||||
</div>
|
||||
{contentOverflows && (
|
||||
<Button className="mr-4" onClick={() => setExpanded(!expanded)}>
|
||||
...
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
import Heading from "@/components/ui/heading";
|
||||
import { useEffect } from "react";
|
||||
|
||||
function NoMatch() {
|
||||
useEffect(() => {
|
||||
document.title = "Not Found - Frigate";
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Heading as="h2">404</Heading>
|
||||
|
||||
@ -20,7 +20,7 @@ import {
|
||||
import { Event } from "@/types/event";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import axios from "axios";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { FaList, FaVideo } from "react-icons/fa";
|
||||
import useSWR from "swr";
|
||||
@ -28,6 +28,10 @@ import useSWR from "swr";
|
||||
export default function SubmitPlus() {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
useEffect(() => {
|
||||
document.title = "Plus - Frigate";
|
||||
}, []);
|
||||
|
||||
// filters
|
||||
|
||||
const [selectedCameras, setSelectedCameras] = useState<string[]>();
|
||||
@ -135,6 +139,7 @@ export default function SubmitPlus() {
|
||||
This is a {upload?.label}
|
||||
</Button>
|
||||
<Button
|
||||
className="text-white"
|
||||
variant="destructive"
|
||||
onClick={() => onSubmitToPlus(true)}
|
||||
>
|
||||
@ -236,9 +241,9 @@ function PlusFilterGroup({
|
||||
}}
|
||||
>
|
||||
<Trigger asChild>
|
||||
<Button size="sm" className="mx-1 capitalize" variant="secondary">
|
||||
<Button size="sm" className="mx-1 capitalize">
|
||||
<FaVideo className="md:mr-[10px] text-secondary-foreground" />
|
||||
<div className="hidden md:block text-primary-foreground">
|
||||
<div className="hidden md:block text-primary">
|
||||
{selectedCameras == undefined
|
||||
? "All Cameras"
|
||||
: `${selectedCameras.length} Cameras`}
|
||||
@ -313,9 +318,9 @@ function PlusFilterGroup({
|
||||
}}
|
||||
>
|
||||
<Trigger asChild>
|
||||
<Button size="sm" className="mx-1 capitalize" variant="secondary">
|
||||
<Button size="sm" className="mx-1 capitalize">
|
||||
<FaList className="md:mr-[10px] text-secondary-foreground" />
|
||||
<div className="hidden md:block text-primary-foreground">
|
||||
<div className="hidden md:block text-primary">
|
||||
{selectedLabels == undefined
|
||||
? "All Labels"
|
||||
: `${selectedLabels.length} Labels`}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import useSWR from "swr";
|
||||
import { FrigateStats } from "@/types/stats";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import TimeAgo from "@/components/dynamic/TimeAgo";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { isDesktop, isMobile } from "react-device-detect";
|
||||
@ -22,6 +22,10 @@ function System() {
|
||||
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
|
||||
const [lastUpdated, setLastUpdated] = useState<number>(Date.now() / 1000);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `${pageToggle[0].toUpperCase()}${pageToggle.substring(1)} Stats - Frigate`;
|
||||
}, [pageToggle]);
|
||||
|
||||
// stats collection
|
||||
|
||||
const { data: statsSnapshot } = useSWR<FrigateStats>("stats", {
|
||||
|
||||
@ -318,7 +318,11 @@ function UIPlayground() {
|
||||
<CameraActivityIndicator />
|
||||
</div>
|
||||
<p>
|
||||
<Button onClick={handleZoomOut} disabled={zoomLevel === 0}>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleZoomOut}
|
||||
disabled={zoomLevel === 0}
|
||||
>
|
||||
Zoom Out
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@ -1,37 +0,0 @@
|
||||
import Logo from "@/components/Logo";
|
||||
import { FaCompactDisc, FaFlag, FaVideo } from "react-icons/fa";
|
||||
import { LuConstruction } from "react-icons/lu";
|
||||
|
||||
export const navbarLinks = [
|
||||
{
|
||||
id: 1,
|
||||
icon: FaVideo,
|
||||
title: "Live",
|
||||
url: "/",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
icon: FaFlag,
|
||||
title: "Events",
|
||||
url: "/events",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
icon: FaCompactDisc,
|
||||
title: "Export",
|
||||
url: "/export",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
icon: Logo,
|
||||
title: "Frigate+",
|
||||
url: "/plus",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
icon: LuConstruction,
|
||||
title: "UI Playground",
|
||||
url: "/playground",
|
||||
dev: true,
|
||||
},
|
||||
];
|
||||
10
web/src/types/navigation.ts
Normal file
10
web/src/types/navigation.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { IconType } from "react-icons";
|
||||
|
||||
export type NavData = {
|
||||
id: number;
|
||||
variant?: "primary" | "secondary";
|
||||
icon: IconType;
|
||||
title: string;
|
||||
url: string;
|
||||
enabled?: boolean;
|
||||
};
|
||||
@ -3,7 +3,7 @@ export interface ReviewSegment {
|
||||
camera: string;
|
||||
severity: ReviewSeverity;
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
end_time?: number;
|
||||
thumb_path: string;
|
||||
has_been_reviewed: boolean;
|
||||
data: ReviewData;
|
||||
|
||||
@ -47,7 +47,7 @@ export type ServiceStats = {
|
||||
last_updated: number;
|
||||
storage: { [path: string]: StorageStats };
|
||||
temperatures: { [apex: string]: number };
|
||||
update: number;
|
||||
uptime: number;
|
||||
latest_version: string;
|
||||
version: string;
|
||||
};
|
||||
|
||||
@ -43,6 +43,7 @@ import { TimeRange } from "@/types/timeline";
|
||||
import { useCameraMotionNextTimestamp } from "@/hooks/use-camera-activity";
|
||||
import useOptimisticState from "@/hooks/use-optimistic-state";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
|
||||
type EventViewProps = {
|
||||
reviews?: ReviewSegment[];
|
||||
@ -289,10 +290,12 @@ export default function EventView({
|
||||
reviewItems={reviewItems}
|
||||
relevantPreviews={relevantPreviews}
|
||||
selectedReviews={selectedReviews}
|
||||
itemsToReview={reviewCounts[severity]}
|
||||
itemsToReview={reviewCounts[severityToggle]}
|
||||
severity={severity}
|
||||
filter={filter}
|
||||
timeRange={timeRange}
|
||||
startTime={startTime}
|
||||
loading={severity != severityToggle}
|
||||
markItemAsReviewed={markItemAsReviewed}
|
||||
markAllItemsAsReviewed={markAllItemsAsReviewed}
|
||||
onSelectReview={onSelectReview}
|
||||
@ -331,6 +334,8 @@ type DetectionReviewProps = {
|
||||
severity: ReviewSeverity;
|
||||
filter?: ReviewFilter;
|
||||
timeRange: { before: number; after: number };
|
||||
startTime?: number;
|
||||
loading: boolean;
|
||||
markItemAsReviewed: (review: ReviewSegment) => void;
|
||||
markAllItemsAsReviewed: (currentItems: ReviewSegment[]) => void;
|
||||
onSelectReview: (review: ReviewSegment, ctrl: boolean) => void;
|
||||
@ -345,6 +350,8 @@ function DetectionReview({
|
||||
severity,
|
||||
filter,
|
||||
timeRange,
|
||||
startTime,
|
||||
loading,
|
||||
markItemAsReviewed,
|
||||
markAllItemsAsReviewed,
|
||||
onSelectReview,
|
||||
@ -495,6 +502,26 @@ function DetectionReview({
|
||||
[minimap],
|
||||
);
|
||||
|
||||
// existing review item
|
||||
|
||||
useEffect(() => {
|
||||
if (!startTime || !currentItems || currentItems.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = contentRef.current?.querySelector(
|
||||
`[data-start="${startTime}"]`,
|
||||
);
|
||||
if (element) {
|
||||
scrollIntoView(element, {
|
||||
scrollMode: "if-needed",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
// only run when start time changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [startTime]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@ -506,7 +533,7 @@ function DetectionReview({
|
||||
className="absolute left-1/2 -translate-x-1/2 z-50 pointer-events-none"
|
||||
contentRef={contentRef}
|
||||
reviewItems={currentItems}
|
||||
itemsToReview={itemsToReview}
|
||||
itemsToReview={loading ? 0 : itemsToReview}
|
||||
pullLatestData={pullLatestData}
|
||||
/>
|
||||
)}
|
||||
@ -517,7 +544,7 @@ function DetectionReview({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentItems?.length === 0 && (
|
||||
{!loading && currentItems?.length === 0 && (
|
||||
<div className="absolute left-1/2 -translate-x-1/2 top-1/2 -translate-y-1/2 flex flex-col justify-center items-center text-center">
|
||||
<LuFolderCheck className="size-16" />
|
||||
There are no {severity.replace(/_/g, " ")}s to review
|
||||
@ -528,80 +555,95 @@ function DetectionReview({
|
||||
className="w-full mx-2 px-1 grid sm:grid-cols-2 md:grid-cols-3 3xl:grid-cols-4 gap-2 md:gap-4"
|
||||
ref={contentRef}
|
||||
>
|
||||
{currentItems &&
|
||||
currentItems.map((value) => {
|
||||
const selected = selectedReviews.includes(value.id);
|
||||
{!loading && currentItems
|
||||
? currentItems.map((value) => {
|
||||
const selected = selectedReviews.includes(value.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={value.id}
|
||||
ref={minimapRef}
|
||||
data-start={value.start_time}
|
||||
data-segment-start={
|
||||
alignStartDateToTimeline(value.start_time) - segmentDuration
|
||||
}
|
||||
className="review-item relative rounded-lg"
|
||||
>
|
||||
<div className="aspect-video rounded-lg overflow-hidden">
|
||||
<PreviewThumbnailPlayer
|
||||
review={value}
|
||||
allPreviews={relevantPreviews}
|
||||
setReviewed={markItemAsReviewed}
|
||||
scrollLock={scrollLock}
|
||||
onTimeUpdate={onPreviewTimeUpdate}
|
||||
onClick={onSelectReview}
|
||||
return (
|
||||
<div
|
||||
key={value.id}
|
||||
ref={minimapRef}
|
||||
data-start={value.start_time}
|
||||
data-segment-start={
|
||||
alignStartDateToTimeline(value.start_time) -
|
||||
segmentDuration
|
||||
}
|
||||
className="review-item relative rounded-lg"
|
||||
>
|
||||
<div className="aspect-video rounded-lg overflow-hidden">
|
||||
<PreviewThumbnailPlayer
|
||||
review={value}
|
||||
allPreviews={relevantPreviews}
|
||||
timeRange={timeRange}
|
||||
setReviewed={markItemAsReviewed}
|
||||
scrollLock={scrollLock}
|
||||
onTimeUpdate={onPreviewTimeUpdate}
|
||||
onClick={onSelectReview}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`review-item-ring pointer-events-none z-10 absolute rounded-lg inset-0 size-full -outline-offset-[2.8px] outline outline-[3px] ${selected ? `outline-severity_${value.severity} shadow-severity_${value.severity}` : "outline-transparent duration-500"}`}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`review-item-ring pointer-events-none z-10 absolute rounded-lg inset-0 size-full -outline-offset-[2.8px] outline outline-[3px] ${selected ? `outline-severity_${value.severity} shadow-severity_${value.severity}` : "outline-transparent duration-500"}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(currentItems?.length ?? 0) > 0 && (itemsToReview ?? 0) > 0 && (
|
||||
<div className="col-span-full flex justify-center items-center">
|
||||
<Button
|
||||
className="text-white"
|
||||
variant="select"
|
||||
onClick={() => {
|
||||
markAllItemsAsReviewed(currentItems ?? []);
|
||||
}}
|
||||
>
|
||||
Mark these items as reviewed
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})
|
||||
: Array(itemsToReview)
|
||||
.fill(0)
|
||||
.map(() => <Skeleton className="size-full aspect-video" />)}
|
||||
{!loading &&
|
||||
(currentItems?.length ?? 0) > 0 &&
|
||||
(itemsToReview ?? 0) > 0 && (
|
||||
<div className="col-span-full flex justify-center items-center">
|
||||
<Button
|
||||
className="text-white"
|
||||
variant="select"
|
||||
onClick={() => {
|
||||
markAllItemsAsReviewed(currentItems ?? []);
|
||||
}}
|
||||
>
|
||||
Mark these items as reviewed
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-[65px] md:w-[110px] flex flex-row">
|
||||
<div className="w-[55px] md:w-[100px] overflow-y-auto no-scrollbar">
|
||||
<EventReviewTimeline
|
||||
segmentDuration={segmentDuration}
|
||||
timestampSpread={15}
|
||||
timelineStart={timeRange.before}
|
||||
timelineEnd={timeRange.after}
|
||||
showMinimap={showMinimap && !previewTime}
|
||||
minimapStartTime={minimapBounds.start}
|
||||
minimapEndTime={minimapBounds.end}
|
||||
showHandlebar={previewTime != undefined}
|
||||
handlebarTime={previewTime}
|
||||
visibleTimestamps={visibleTimestamps}
|
||||
events={reviewItems?.all ?? []}
|
||||
severityType={severity}
|
||||
contentRef={contentRef}
|
||||
timelineRef={reviewTimelineRef}
|
||||
dense={isMobile}
|
||||
/>
|
||||
{loading ? (
|
||||
<Skeleton className="size-full" />
|
||||
) : (
|
||||
<EventReviewTimeline
|
||||
segmentDuration={segmentDuration}
|
||||
timestampSpread={15}
|
||||
timelineStart={timeRange.before}
|
||||
timelineEnd={timeRange.after}
|
||||
showMinimap={showMinimap && !previewTime}
|
||||
minimapStartTime={minimapBounds.start}
|
||||
minimapEndTime={minimapBounds.end}
|
||||
showHandlebar={previewTime != undefined}
|
||||
handlebarTime={previewTime}
|
||||
visibleTimestamps={visibleTimestamps}
|
||||
events={reviewItems?.all ?? []}
|
||||
severityType={severity}
|
||||
contentRef={contentRef}
|
||||
timelineRef={reviewTimelineRef}
|
||||
dense={isMobile}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-[10px]">
|
||||
<SummaryTimeline
|
||||
reviewTimelineRef={reviewTimelineRef}
|
||||
timelineStart={timeRange.before}
|
||||
timelineEnd={timeRange.after}
|
||||
segmentDuration={segmentDuration}
|
||||
events={reviewItems?.all ?? []}
|
||||
severityType={severity}
|
||||
/>
|
||||
{loading ? (
|
||||
<Skeleton className="w-full" />
|
||||
) : (
|
||||
<SummaryTimeline
|
||||
reviewTimelineRef={reviewTimelineRef}
|
||||
timelineStart={timeRange.before}
|
||||
timelineEnd={timeRange.after}
|
||||
segmentDuration={segmentDuration}
|
||||
events={reviewItems?.all ?? []}
|
||||
severityType={severity}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@ -787,16 +829,18 @@ function MotionReview({
|
||||
} else {
|
||||
const segmentStartTime = alignStartDateToTimeline(currentTime);
|
||||
const segmentEndTime = segmentStartTime + segmentDuration;
|
||||
const matchingItem = reviewItems?.all.find(
|
||||
(item) =>
|
||||
const matchingItem = reviewItems?.all.find((item) => {
|
||||
const endTime = item.end_time ?? timeRange.before;
|
||||
|
||||
return (
|
||||
((item.start_time >= segmentStartTime &&
|
||||
item.start_time < segmentEndTime) ||
|
||||
(item.end_time > segmentStartTime &&
|
||||
item.end_time <= segmentEndTime) ||
|
||||
(endTime > segmentStartTime && endTime <= segmentEndTime) ||
|
||||
(item.start_time <= segmentStartTime &&
|
||||
item.end_time >= segmentEndTime)) &&
|
||||
item.camera === cameraName,
|
||||
);
|
||||
endTime >= segmentEndTime)) &&
|
||||
item.camera === cameraName
|
||||
);
|
||||
});
|
||||
|
||||
return matchingItem ? matchingItem.severity : null;
|
||||
}
|
||||
@ -805,6 +849,7 @@ function MotionReview({
|
||||
reviewItems,
|
||||
motionData,
|
||||
currentTime,
|
||||
timeRange,
|
||||
motionOnly,
|
||||
alignStartDateToTimeline,
|
||||
],
|
||||
@ -853,7 +898,10 @@ function MotionReview({
|
||||
onClick={() =>
|
||||
onOpenRecording({
|
||||
camera: camera.name,
|
||||
startTime: currentTime,
|
||||
startTime: Math.min(
|
||||
currentTime,
|
||||
Date.now() / 1000 - 30,
|
||||
),
|
||||
severity: "significant_motion",
|
||||
})
|
||||
}
|
||||
|
||||
@ -38,6 +38,8 @@ import MobileCameraDrawer from "@/components/overlay/MobileCameraDrawer";
|
||||
import MobileTimelineDrawer from "@/components/overlay/MobileTimelineDrawer";
|
||||
import MobileReviewSettingsDrawer from "@/components/overlay/MobileReviewSettingsDrawer";
|
||||
import Logo from "@/components/Logo";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { FaVideo } from "react-icons/fa";
|
||||
|
||||
const SEGMENT_DURATION = 30;
|
||||
|
||||
@ -250,15 +252,28 @@ export function RecordingView({
|
||||
{isMobile && (
|
||||
<Logo className="absolute inset-x-1/2 -translate-x-1/2 h-8" />
|
||||
)}
|
||||
<Button
|
||||
className="flex items-center gap-2 rounded-lg"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => navigate(-1)}
|
||||
<div
|
||||
className={`flex items-center gap-2 ${isMobile ? "landscape:flex-col" : ""}`}
|
||||
>
|
||||
<IoMdArrowRoundBack className="size-5" size="small" />
|
||||
{isDesktop && <div className="text-primary-foreground">Back</div>}
|
||||
</Button>
|
||||
<Button
|
||||
className={`flex items-center gap-2.5 rounded-lg`}
|
||||
size="sm"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
||||
{isDesktop && <div className="text-primary">Back</div>}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex items-center gap-2.5 rounded-lg"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
navigate(`/#${mainCamera}`);
|
||||
}}
|
||||
>
|
||||
<FaVideo className="size-5 text-secondary-foreground" />
|
||||
{isDesktop && <div className="text-primary">Live</div>}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<MobileCameraDrawer
|
||||
allCameras={allCameras}
|
||||
@ -341,7 +356,7 @@ export function RecordingView({
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`h-full flex justify-center overflow-hidden ${isDesktop ? "" : "flex-col gap-2"}`}
|
||||
className={`h-full flex justify-center overflow-hidden ${isDesktop ? "" : "flex-col landscape:flex-row gap-2"}`}
|
||||
>
|
||||
<div className={`${isDesktop ? "w-[80%]" : ""} flex flex-1 flex-wrap`}>
|
||||
<div
|
||||
@ -351,8 +366,8 @@ export function RecordingView({
|
||||
key={mainCamera}
|
||||
className={
|
||||
isDesktop
|
||||
? `${mainCameraAspect == "tall" ? "h-[90%]" : mainCameraAspect == "wide" ? "w-full" : "w-[78%]"} px-4 flex justify-center`
|
||||
: `w-full pt-2 ${mainCameraAspect == "wide" ? "aspect-wide" : "aspect-video"}`
|
||||
? `${mainCameraAspect == "tall" ? "xl:h-[90%]" : mainCameraAspect == "wide" ? "w-full" : "w-[78%]"} px-4 flex justify-center`
|
||||
: `portrait:w-full pt-2 ${mainCameraAspect == "wide" ? "landscape:w-full aspect-wide" : "landscape:h-[94%] aspect-video"}`
|
||||
}
|
||||
style={{
|
||||
aspectRatio: isDesktop
|
||||
@ -497,32 +512,36 @@ function Timeline({
|
||||
className={`${
|
||||
isDesktop
|
||||
? `${timelineType == "timeline" ? "w-[100px]" : "w-60"} overflow-y-auto no-scrollbar`
|
||||
: "flex-grow overflow-hidden"
|
||||
: "portrait:flex-grow landscape:w-[20%] overflow-hidden"
|
||||
} relative`}
|
||||
>
|
||||
<div className="absolute top-0 inset-x-0 z-20 w-full h-[30px] bg-gradient-to-b from-secondary to-transparent pointer-events-none"></div>
|
||||
<div className="absolute bottom-0 inset-x-0 z-20 w-full h-[30px] bg-gradient-to-t from-secondary to-transparent pointer-events-none"></div>
|
||||
{timelineType == "timeline" ? (
|
||||
<MotionReviewTimeline
|
||||
segmentDuration={30}
|
||||
timestampSpread={15}
|
||||
timelineStart={timeRange.before}
|
||||
timelineEnd={timeRange.after}
|
||||
showHandlebar={exportRange == undefined}
|
||||
showExportHandles={exportRange != undefined}
|
||||
exportStartTime={exportRange?.after}
|
||||
exportEndTime={exportRange?.before}
|
||||
setExportStartTime={setExportStartTime}
|
||||
setExportEndTime={setExportEndTime}
|
||||
handlebarTime={currentTime}
|
||||
setHandlebarTime={setCurrentTime}
|
||||
onlyInitialHandlebarScroll={true}
|
||||
events={mainCameraReviewItems}
|
||||
motion_events={motionData ?? []}
|
||||
severityType="significant_motion"
|
||||
contentRef={contentRef}
|
||||
onHandlebarDraggingChange={(scrubbing) => setScrubbing(scrubbing)}
|
||||
/>
|
||||
motionData ? (
|
||||
<MotionReviewTimeline
|
||||
segmentDuration={30}
|
||||
timestampSpread={15}
|
||||
timelineStart={timeRange.before}
|
||||
timelineEnd={timeRange.after}
|
||||
showHandlebar={exportRange == undefined}
|
||||
showExportHandles={exportRange != undefined}
|
||||
exportStartTime={exportRange?.after}
|
||||
exportEndTime={exportRange?.before}
|
||||
setExportStartTime={setExportStartTime}
|
||||
setExportEndTime={setExportEndTime}
|
||||
handlebarTime={currentTime}
|
||||
setHandlebarTime={setCurrentTime}
|
||||
onlyInitialHandlebarScroll={true}
|
||||
events={mainCameraReviewItems}
|
||||
motion_events={motionData ?? []}
|
||||
severityType="significant_motion"
|
||||
contentRef={contentRef}
|
||||
onHandlebarDraggingChange={(scrubbing) => setScrubbing(scrubbing)}
|
||||
/>
|
||||
) : (
|
||||
<Skeleton className="size-full" />
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
className={`h-full grid grid-cols-1 gap-4 overflow-auto p-4 bg-secondary ${isDesktop ? "" : "sm:grid-cols-2"}`}
|
||||
|
||||
@ -133,7 +133,7 @@ export default function LiveBirdseyeView() {
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<IoMdArrowBack className="size-5" />
|
||||
{isDesktop && <div className="text-primary-foreground">Back</div>}
|
||||
{isDesktop && <div className="text-primary">Back</div>}
|
||||
</Button>
|
||||
) : (
|
||||
<div />
|
||||
|
||||
@ -158,9 +158,9 @@ export default function LiveCameraView({ camera }: LiveCameraViewProps) {
|
||||
return "absolute left-2 right-2 top-[50%] -translate-y-[50%]";
|
||||
} else {
|
||||
if (aspect > 16 / 9) {
|
||||
return "absolute left-0 top-[50%] -translate-y-[50%]";
|
||||
return "p-2 absolute left-0 top-[50%] -translate-y-[50%]";
|
||||
} else {
|
||||
return "absolute top-2 bottom-2 left-[50%] -translate-x-[50%]";
|
||||
return "p-2 absolute top-2 bottom-2 left-[50%] -translate-x-[50%]";
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -209,35 +209,33 @@ export default function LiveCameraView({ camera }: LiveCameraViewProps) {
|
||||
className={
|
||||
fullscreen
|
||||
? `fixed inset-0 bg-black z-30`
|
||||
: `size-full p-2 flex flex-col ${isMobile ? "landscape:flex-row" : ""}`
|
||||
: `size-full p-2 flex flex-col ${isMobile ? "landscape:flex-row landscape:gap-1" : ""}`
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
fullscreen
|
||||
? `absolute right-32 top-1 z-40 ${isMobile ? "landscape:left-2 landscape:right-auto landscape:bottom-1 landscape:top-auto" : ""}`
|
||||
: `w-full h-12 flex flex-row items-center justify-between ${isMobile ? "landscape:w-min landscape:h-full landscape:flex-col" : ""}`
|
||||
: `w-full h-12 flex flex-row items-center justify-between ${isMobile ? "landscape:w-12 landscape:h-full landscape:flex-col" : ""}`
|
||||
}
|
||||
>
|
||||
{!fullscreen ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`flex items-center gap-2 ${isMobile ? "landscape:flex-col" : ""}`}
|
||||
>
|
||||
<Button
|
||||
className={`flex items-center gap-2.5 rounded-lg`}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<IoMdArrowRoundBack className="size-5" />
|
||||
{isDesktop && (
|
||||
<div className="text-primary-foreground">Back</div>
|
||||
)}
|
||||
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
||||
{isDesktop && <div className="text-primary">Back</div>}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex items-center gap-2.5 rounded-lg"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
navigate("events", {
|
||||
navigate("review", {
|
||||
state: {
|
||||
severity: "alert",
|
||||
recording: {
|
||||
@ -249,10 +247,8 @@ export default function LiveCameraView({ camera }: LiveCameraViewProps) {
|
||||
});
|
||||
}}
|
||||
>
|
||||
<LuHistory className="size-5" />
|
||||
{isDesktop && (
|
||||
<div className="text-primary-foreground">History</div>
|
||||
)}
|
||||
<LuHistory className="size-5 text-secondary-foreground" />
|
||||
{isDesktop && <div className="text-primary">History</div>}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
@ -520,7 +516,7 @@ function PtzControlPanel({
|
||||
{ptz?.features?.includes("pt-r-fov") && (
|
||||
<>
|
||||
<Button
|
||||
className={`${clickOverlay ? "text-selected" : "text-primary-foreground"}`}
|
||||
className={`${clickOverlay ? "text-selected" : "text-primary"}`}
|
||||
onClick={() => setClickOverlay(!clickOverlay)}
|
||||
>
|
||||
<HiViewfinderCircle />
|
||||
@ -619,7 +615,7 @@ function FrigateCameraFeatures({
|
||||
<Drawer>
|
||||
<DrawerTrigger>
|
||||
<CameraFeatureToggle
|
||||
className="p-2"
|
||||
className="p-2 landscape:size-9"
|
||||
variant="primary"
|
||||
Icon={FaCog}
|
||||
isActive={false}
|
||||
|
||||
@ -47,8 +47,8 @@ export default function LiveDashboardView({
|
||||
}
|
||||
|
||||
// if event is ended and was saved, update events list
|
||||
if (eventUpdate.type == "end" && eventUpdate.review.severity == "alert") {
|
||||
setTimeout(() => updateEvents(), 1000);
|
||||
if (eventUpdate.review.severity == "alert") {
|
||||
setTimeout(() => updateEvents(), eventUpdate.type == "end" ? 1000 : 6000);
|
||||
return;
|
||||
}
|
||||
}, [eventUpdate, updateEvents]);
|
||||
|
||||
@ -19,7 +19,7 @@ export default function CameraMetrics({
|
||||
// stats
|
||||
|
||||
const { data: initialStats } = useSWR<FrigateStats[]>(
|
||||
["stats/history", { keys: "cpu_usages,cameras,service" }],
|
||||
["stats/history", { keys: "cpu_usages,cameras,detection_fps,service" }],
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
},
|
||||
@ -57,6 +57,44 @@ export default function CameraMetrics({
|
||||
|
||||
// stats data
|
||||
|
||||
const overallFpsSeries = useMemo(() => {
|
||||
if (!statsHistory) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const series: {
|
||||
[key: string]: { name: string; data: { x: number; y: number }[] };
|
||||
} = {};
|
||||
|
||||
series["overall_dps"] = { name: "overall detections per second", data: [] };
|
||||
series["overall_skipped_dps"] = {
|
||||
name: "overall skipped detections per second",
|
||||
data: [],
|
||||
};
|
||||
|
||||
statsHistory.forEach((stats, statsIdx) => {
|
||||
if (!stats) {
|
||||
return;
|
||||
}
|
||||
|
||||
series["overall_dps"].data.push({
|
||||
x: statsIdx,
|
||||
y: stats.detection_fps,
|
||||
});
|
||||
|
||||
let skipped = 0;
|
||||
Object.values(stats.cameras).forEach(
|
||||
(camStat) => (skipped += camStat.skipped_fps),
|
||||
);
|
||||
|
||||
series["overall_skipped_dps"].data.push({
|
||||
x: statsIdx,
|
||||
y: skipped,
|
||||
});
|
||||
});
|
||||
return Object.values(series);
|
||||
}, [statsHistory]);
|
||||
|
||||
const cameraCpuSeries = useMemo(() => {
|
||||
if (!statsHistory || statsHistory.length == 0) {
|
||||
return {};
|
||||
@ -147,19 +185,36 @@ export default function CameraMetrics({
|
||||
}, [statsHistory]);
|
||||
|
||||
return (
|
||||
<div className="size-full mt-4 flex flex-col overflow-y-auto">
|
||||
<div className="size-full mt-4 flex flex-col gap-3 overflow-y-auto">
|
||||
<div className="text-muted-foreground text-sm font-medium">Overview</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3">
|
||||
{statsHistory.length != 0 ? (
|
||||
<div className="p-2.5 bg-background_alt rounded-2xl">
|
||||
<div className="mb-5">DPS</div>
|
||||
<CameraLineGraph
|
||||
graphId="overall-stats"
|
||||
unit=" DPS"
|
||||
dataLabels={["detect", "skipped"]}
|
||||
updateTimes={updateTimes}
|
||||
data={overallFpsSeries}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Skeleton className="w-full h-32" />
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{config &&
|
||||
Object.values(config.cameras).map((camera) => {
|
||||
if (camera.enabled) {
|
||||
return (
|
||||
<div className="w-full flex flex-col">
|
||||
<div className="mb-6 capitalize">
|
||||
<div className="w-full flex flex-col gap-3">
|
||||
<div className="capitalize text-muted-foreground text-sm font-medium">
|
||||
{camera.name.replaceAll("_", " ")}
|
||||
</div>
|
||||
<div key={camera.name} className="grid sm:grid-cols-2 gap-2">
|
||||
{Object.keys(cameraCpuSeries).includes(camera.name) ? (
|
||||
<div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col">
|
||||
<div className="p-2.5 bg-background_alt rounded-2xl">
|
||||
<div className="mb-5">CPU</div>
|
||||
<CameraLineGraph
|
||||
graphId={`${camera.name}-cpu`}
|
||||
@ -175,7 +230,7 @@ export default function CameraMetrics({
|
||||
<Skeleton className="size-full aspect-video" />
|
||||
)}
|
||||
{Object.keys(cameraFpsSeries).includes(camera.name) ? (
|
||||
<div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col">
|
||||
<div className="p-2.5 bg-background_alt rounded-2xl">
|
||||
<div className="mb-5">DPS</div>
|
||||
<CameraLineGraph
|
||||
graphId={`${camera.name}-dps`}
|
||||
|
||||
@ -99,7 +99,7 @@ export default function GeneralMetrics({
|
||||
series[key] = { name: key, data: [] };
|
||||
}
|
||||
|
||||
series[key].data.push({ x: statsIdx, y: stats.inference_speed });
|
||||
series[key].data.push({ x: statsIdx + 1, y: stats.inference_speed });
|
||||
});
|
||||
});
|
||||
return Object.values(series);
|
||||
@ -125,7 +125,7 @@ export default function GeneralMetrics({
|
||||
}
|
||||
|
||||
series[key].data.push({
|
||||
x: statsIdx,
|
||||
x: statsIdx + 1,
|
||||
y: stats.cpu_usages[detStats.pid.toString()].cpu,
|
||||
});
|
||||
});
|
||||
@ -153,7 +153,7 @@ export default function GeneralMetrics({
|
||||
}
|
||||
|
||||
series[key].data.push({
|
||||
x: statsIdx,
|
||||
x: statsIdx + 1,
|
||||
y: stats.cpu_usages[detStats.pid.toString()].mem,
|
||||
});
|
||||
});
|
||||
@ -182,7 +182,7 @@ export default function GeneralMetrics({
|
||||
series[key] = { name: key, data: [] };
|
||||
}
|
||||
|
||||
series[key].data.push({ x: statsIdx, y: stats.gpu });
|
||||
series[key].data.push({ x: statsIdx + 1, y: stats.gpu });
|
||||
});
|
||||
});
|
||||
return Object.keys(series).length > 0 ? Object.values(series) : [];
|
||||
@ -193,6 +193,14 @@ export default function GeneralMetrics({
|
||||
return [];
|
||||
}
|
||||
|
||||
if (
|
||||
Object.keys(statsHistory?.at(0)?.gpu_usages ?? {}).length == 1 &&
|
||||
Object.keys(statsHistory?.at(0)?.gpu_usages ?? {})[0].includes("intel")
|
||||
) {
|
||||
// intel gpu stats do not support memory
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const series: {
|
||||
[key: string]: { name: string; data: { x: number; y: string }[] };
|
||||
} = {};
|
||||
@ -207,7 +215,7 @@ export default function GeneralMetrics({
|
||||
series[key] = { name: key, data: [] };
|
||||
}
|
||||
|
||||
series[key].data.push({ x: statsIdx, y: stats.mem });
|
||||
series[key].data.push({ x: statsIdx + 1, y: stats.mem });
|
||||
});
|
||||
});
|
||||
return Object.values(series);
|
||||
@ -236,7 +244,7 @@ export default function GeneralMetrics({
|
||||
}
|
||||
|
||||
series[key].data.push({
|
||||
x: statsIdx,
|
||||
x: statsIdx + 1,
|
||||
y: stats.cpu_usages[procStats.pid.toString()].cpu,
|
||||
});
|
||||
}
|
||||
@ -266,7 +274,7 @@ export default function GeneralMetrics({
|
||||
}
|
||||
|
||||
series[key].data.push({
|
||||
x: statsIdx,
|
||||
x: statsIdx + 1,
|
||||
y: stats.cpu_usages[procStats.pid.toString()].mem,
|
||||
});
|
||||
}
|
||||
@ -285,7 +293,7 @@ export default function GeneralMetrics({
|
||||
</div>
|
||||
<div className="w-full mt-4 grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
{statsHistory.length != 0 ? (
|
||||
<div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col">
|
||||
<div className="p-2.5 bg-background_alt rounded-2xl">
|
||||
<div className="mb-5">Detector Inference Speed</div>
|
||||
{detInferenceTimeSeries.map((series) => (
|
||||
<ThresholdBarGraph
|
||||
@ -303,7 +311,7 @@ export default function GeneralMetrics({
|
||||
<Skeleton className="w-full aspect-video" />
|
||||
)}
|
||||
{statsHistory.length != 0 ? (
|
||||
<div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col">
|
||||
<div className="p-2.5 bg-background_alt rounded-2xl">
|
||||
<div className="mb-5">Detector CPU Usage</div>
|
||||
{detCpuSeries.map((series) => (
|
||||
<ThresholdBarGraph
|
||||
@ -321,7 +329,7 @@ export default function GeneralMetrics({
|
||||
<Skeleton className="w-full aspect-video" />
|
||||
)}
|
||||
{statsHistory.length != 0 ? (
|
||||
<div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col">
|
||||
<div className="p-2.5 bg-background_alt rounded-2xl">
|
||||
<div className="mb-5">Detector Memory Usage</div>
|
||||
{detMemSeries.map((series) => (
|
||||
<ThresholdBarGraph
|
||||
@ -349,7 +357,6 @@ export default function GeneralMetrics({
|
||||
{canGetGpuInfo && (
|
||||
<Button
|
||||
className="cursor-pointer"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setShowVainfo(true)}
|
||||
>
|
||||
@ -359,7 +366,7 @@ export default function GeneralMetrics({
|
||||
</div>
|
||||
<div className=" mt-4 grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{statsHistory.length != 0 ? (
|
||||
<div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col">
|
||||
<div className="p-2.5 bg-background_alt rounded-2xl">
|
||||
<div className="mb-5">GPU Usage</div>
|
||||
{gpuSeries.map((series) => (
|
||||
<ThresholdBarGraph
|
||||
@ -377,20 +384,24 @@ export default function GeneralMetrics({
|
||||
<Skeleton className="w-full aspect-video" />
|
||||
)}
|
||||
{statsHistory.length != 0 ? (
|
||||
<div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col">
|
||||
<div className="mb-5">GPU Memory</div>
|
||||
{gpuMemSeries.map((series) => (
|
||||
<ThresholdBarGraph
|
||||
key={series.name}
|
||||
graphId={`${series.name}-mem`}
|
||||
unit=""
|
||||
name={series.name}
|
||||
threshold={GPUMemThreshold}
|
||||
updateTimes={updateTimes}
|
||||
data={[series]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
{gpuMemSeries && (
|
||||
<div className="p-2.5 bg-background_alt rounded-2xl">
|
||||
<div className="mb-5">GPU Memory</div>
|
||||
{gpuMemSeries.map((series) => (
|
||||
<ThresholdBarGraph
|
||||
key={series.name}
|
||||
graphId={`${series.name}-mem`}
|
||||
unit=""
|
||||
name={series.name}
|
||||
threshold={GPUMemThreshold}
|
||||
updateTimes={updateTimes}
|
||||
data={[series]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Skeleton className="w-full aspect-video" />
|
||||
)}
|
||||
@ -403,7 +414,7 @@ export default function GeneralMetrics({
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{statsHistory.length != 0 ? (
|
||||
<div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col">
|
||||
<div className="p-2.5 bg-background_alt rounded-2xl">
|
||||
<div className="mb-5">Process CPU Usage</div>
|
||||
{otherProcessCpuSeries.map((series) => (
|
||||
<ThresholdBarGraph
|
||||
@ -421,7 +432,7 @@ export default function GeneralMetrics({
|
||||
<Skeleton className="w-full aspect-tall" />
|
||||
)}
|
||||
{statsHistory.length != 0 ? (
|
||||
<div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col">
|
||||
<div className="p-2.5 bg-background_alt rounded-2xl">
|
||||
<div className="mb-5">Process Memory Usage</div>
|
||||
{otherProcessMemSeries.map((series) => (
|
||||
<ThresholdBarGraph
|
||||
|
||||
@ -43,11 +43,9 @@ export default function StorageMetrics({
|
||||
|
||||
return (
|
||||
<div className="size-full mt-4 flex flex-col overflow-y-auto">
|
||||
<div className="text-muted-foreground text-sm font-medium">
|
||||
General Storage
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm font-medium">Overview</div>
|
||||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col">
|
||||
<div className="p-2.5 bg-background_alt rounded-2xl flex-col">
|
||||
<div className="mb-5">Recordings</div>
|
||||
<StorageGraph
|
||||
graphId="general-recordings"
|
||||
@ -55,7 +53,7 @@ export default function StorageMetrics({
|
||||
total={totalStorage.total}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col">
|
||||
<div className="p-2.5 bg-background_alt rounded-2xl flex-col">
|
||||
<div className="mb-5">/tmp/cache</div>
|
||||
<StorageGraph
|
||||
graphId="general-cache"
|
||||
@ -63,7 +61,7 @@ export default function StorageMetrics({
|
||||
total={stats.service.storage["/tmp/cache"]["total"]}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col">
|
||||
<div className="p-2.5 bg-background_alt rounded-2xl flex-col">
|
||||
<div className="mb-5">/dev/shm</div>
|
||||
<StorageGraph
|
||||
graphId="general-shared-memory"
|
||||
@ -77,7 +75,7 @@ export default function StorageMetrics({
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
{Object.keys(cameraStorage).map((camera) => (
|
||||
<div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col">
|
||||
<div className="p-2.5 bg-background_alt rounded-2xl flex-col">
|
||||
<div className="mb-5 capitalize">{camera.replaceAll("_", " ")}</div>
|
||||
<StorageGraph
|
||||
graphId={`${camera}-storage`}
|
||||
|
||||
@ -43,13 +43,13 @@ module.exports = {
|
||||
ring: "hsl(var(--ring))",
|
||||
danger: "#ef4444",
|
||||
success: "#22c55e",
|
||||
// detection colors
|
||||
motion: "#991b1b",
|
||||
object: "#06b6d4",
|
||||
audio: "#ea580c",
|
||||
background: "hsl(var(--background))",
|
||||
background_alt: "hsl(var(--background-alt))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
selected: "hsl(var(--selected))",
|
||||
selected: {
|
||||
DEFAULT: "hsl(var(--selected))",
|
||||
foreground: "hsl(var(--selected-foreground))",
|
||||
},
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
@ -63,6 +63,10 @@ module.exports = {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
warning: {
|
||||
DEFAULT: "hsl(var(--warning))",
|
||||
foreground: "hsl(var(--warning-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
|
||||
@ -1,68 +1,80 @@
|
||||
@layer base {
|
||||
:root {
|
||||
--background-hsl: hsl(0 0% 100%);
|
||||
--background: hsl(0, 0%, 100%);
|
||||
--background: 0 0% 100%;
|
||||
|
||||
--foreground: hsl(222.2 84% 4.9%);
|
||||
--background-alt: hsl(0, 0%, 98.5%);
|
||||
--background-alt: 0 0% 98.5%;
|
||||
|
||||
--foreground: hsl(222.2, 84%, 4.9%);
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
|
||||
--card: hsl(0 0% 100%);
|
||||
--card: hsl(0, 0%, 100%);
|
||||
--card: 0 0% 100%;
|
||||
|
||||
--card-foreground: hsl(222.2 84% 4.9%);
|
||||
--card-foreground: hsl(222.2, 84%, 4.9%);
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
|
||||
--popover: hsl(0 0% 100%);
|
||||
--popover: hsl(0, 0%, 100%);
|
||||
--popover: 0 0% 100%;
|
||||
|
||||
--popover-foreground: hsl(222.2 84% 4.9%);
|
||||
--popover-foreground: hsl(222.2, 84%, 4.9%);
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
|
||||
--primary: hsl(0 0% 100%);
|
||||
--primary: 0 0% 100%;
|
||||
--primary: hsl(222.2, 37.4%, 11.2%);
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
|
||||
--primary-foreground: hsl(0, 0%, 0%);
|
||||
--primary-foreground: 0 0% 0%;
|
||||
--primary-foreground: hsl(210, 40%, 98%);
|
||||
--primary-foreground: 210 40% 98%;
|
||||
|
||||
--secondary: hsl(0, 0%, 96%);
|
||||
--secondary: 0 0% 96%;
|
||||
--secondary: hsl(210, 20%, 94.1%);
|
||||
--secondary: 210 20% 94.1%;
|
||||
|
||||
--secondary-foreground: hsl(0, 0%, 83%);
|
||||
--secondary-foreground: 0 0% 83%;
|
||||
--secondary-foreground: hsl(222.2, 17.4%, 36.2%);
|
||||
--secondary-foreground: 222.2 17.4% 36.2%;
|
||||
|
||||
--secondary-highlight: hsl(0, 0%, 94%);
|
||||
--secondary-highlight: 0 0% 94%;
|
||||
|
||||
--muted: hsl(210 40% 96.1%);
|
||||
--muted: hsl(210, 40%, 96.1%);
|
||||
--muted: 210 40% 96.1%;
|
||||
|
||||
--muted-foreground: hsl(0, 0%, 64%);
|
||||
--muted-foreground: 0, 0%, 64%;
|
||||
--muted-foreground: hsl(215.4, 6.3%, 46.9%);
|
||||
--muted-foreground: 215.4 6.3% 46.9%;
|
||||
|
||||
--accent: hsl(210 40% 96.1%);
|
||||
--accent: hsl(210, 40%, 96.1%);
|
||||
--accent: 210 40% 96.1%;
|
||||
|
||||
--accent-foreground: hsl(222.2 47.4% 11.2%);
|
||||
--accent-foreground: hsl(222.2, 47.4%, 11.2%);
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--destructive: hsl(0 84.2% 60.2%);
|
||||
--destructive: hsl(0, 84.2%, 60.2%);
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
|
||||
--destructive-foreground: hsl(210 40% 98%);
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--destructive-foreground: hsl(0, 100%, 83%);
|
||||
--destructive-foreground: 0 100% 83%;
|
||||
|
||||
--border: hsl(214.3 31.8% 91.4%);
|
||||
--warning: hsl(17, 87%, 18%);
|
||||
--warning: 17 87% 18%;
|
||||
|
||||
--warning-foreground: hsl(32, 100%, 74%);
|
||||
--warning-foreground: 32 100% 74%;
|
||||
|
||||
--border: hsl(214.3, 31.8%, 91.4%);
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
|
||||
--input: hsl(214.3 31.8% 91.4%);
|
||||
--input: 0 0 85%;
|
||||
--input: hsl(0, 0%, 85%);
|
||||
--input: 0 0% 85%;
|
||||
|
||||
--ring: hsl(222.2 84% 4.9%);
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--ring: hsla(0, 0%, 25%, 0%);
|
||||
--ring: 0 0% 25% 0%;
|
||||
|
||||
--selected: hsl(228, 89%, 63%);
|
||||
--selected: 228 89% 63%;
|
||||
|
||||
--selected-foreground: hsl(0 0% 100%);
|
||||
--selected-foreground: 0 0% 100%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
|
||||
--severity_alert: var(--red-800);
|
||||
@ -85,16 +97,19 @@
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background-hsl: hsl(0 0 0%);
|
||||
--background: hsl(0, 0, 0%);
|
||||
--background: 0 0% 0%;
|
||||
|
||||
--background-alt: hsl(0, 0, 9%);
|
||||
--background-alt: 0 0% 9%;
|
||||
|
||||
--foreground: hsl(0, 0%, 100%);
|
||||
--foreground: 0, 0%, 100%;
|
||||
|
||||
--card: hsl(0, 0%, 15%);
|
||||
--card: 0, 0%, 15%;
|
||||
|
||||
--card-foreground: hsl(210 40% 98%);
|
||||
--card-foreground: hsl(210, 40%, 98%);
|
||||
--card-foreground: 210 40% 98%;
|
||||
|
||||
--popover: hsl(0, 0%, 15%);
|
||||
@ -103,11 +118,11 @@
|
||||
--popover-foreground: hsl(0, 0%, 100%);
|
||||
--popover-foreground: 210 40% 98%;
|
||||
|
||||
--primary: hsl(0, 0%, 9%);
|
||||
--primary: 0 0% 9%;
|
||||
--primary: hsl(0, 0%, 91%);
|
||||
--primary: 0 0% 91%;
|
||||
|
||||
--primary-foreground: hsl(0, 0%, 100%);
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--primary-foreground: hsl(0, 0%, 9%);
|
||||
--primary-foreground: 0 0% 9%;
|
||||
|
||||
--secondary: hsl(0, 0%, 15%);
|
||||
--secondary: 0 0% 15%;
|
||||
@ -127,25 +142,28 @@
|
||||
--accent: hsl(0, 0%, 15%);
|
||||
--accent: 0 0% 15%;
|
||||
|
||||
--accent-foreground: hsl(210 40% 98%);
|
||||
--accent-foreground: hsl(210, 40%, 98%);
|
||||
--accent-foreground: 210 40% 98%;
|
||||
|
||||
--destructive: hsl(0 62.8% 30.6%);
|
||||
--destructive: hsl(0, 62.8%, 30.6%);
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
|
||||
--destructive-foreground: hsl(210 40% 98%);
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--destructive-foreground: hsl(0, 100%, 83%);
|
||||
--destructive-foreground: 0 100% 83%;
|
||||
|
||||
--warning: hsl(17, 87%, 18%);
|
||||
--warning: 17 87% 18%;
|
||||
|
||||
--warning-foreground: hsl(32, 100%, 74%);
|
||||
--warning-foreground: 32 100% 74%;
|
||||
|
||||
--border: hsl(0, 0%, 32%);
|
||||
--border: 0 0% 32%;
|
||||
|
||||
--input: hsl(217.2 32.6% 17.5%);
|
||||
--input: 0 0 25%;
|
||||
--input: hsl(0, 0%, 5%);
|
||||
--input: 0 0% 25%;
|
||||
|
||||
--ring: hsl(212.7 26.8% 83.9%);
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
|
||||
--selected: hsl(228, 89%, 63%);
|
||||
--selected: 228 89% 63%;
|
||||
--ring: hsla(0, 0%, 25%, 0%);
|
||||
--ring: 0 0% 25% 0%;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user