mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-03 05:16:50 +03:00
Compare commits
2
Commits
dev
..
overflow-menu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06ed433e9a | ||
|
|
5ab8666d4a |
@@ -571,8 +571,6 @@ notifications:
|
||||
enabled: False
|
||||
# Optional: Email for push service to reach out to
|
||||
# NOTE: This is required to use notifications
|
||||
# NOTE: Email can be specified with an environment variable or docker secrets that must begin with 'FRIGATE_'.
|
||||
# e.g. email: '{FRIGATE_NOTIFICATION_EMAIL}'
|
||||
email: "admin@example.com"
|
||||
# Optional: Cooldown time for notifications in seconds (default: shown below)
|
||||
cooldown: 0
|
||||
|
||||
+3
-14
@@ -311,12 +311,9 @@ def config(request: Request):
|
||||
mode="json", warnings="none", exclude_none=True
|
||||
)
|
||||
|
||||
is_admin = request.headers.get("remote-role") == "admin"
|
||||
|
||||
# hide environment_vars and the notification email from non-admin users
|
||||
if not is_admin:
|
||||
# remove environment_vars for non-admin users
|
||||
if request.headers.get("remote-role") != "admin":
|
||||
config.pop("environment_vars", None)
|
||||
redact_credential(config["notifications"], "email")
|
||||
|
||||
# redact mqtt credentials
|
||||
redact_credential(config["mqtt"], "password")
|
||||
@@ -373,15 +370,7 @@ def config(request: Request):
|
||||
camera_name
|
||||
)
|
||||
if base_sections:
|
||||
# copy so redaction below can't alter the profile manager's cache
|
||||
camera_dict["base_config"] = copy.deepcopy(base_sections)
|
||||
|
||||
# cameras inherit the global notification email
|
||||
if not is_admin:
|
||||
redact_credential(camera_dict["notifications"], "email")
|
||||
redact_credential(
|
||||
camera_dict.get("base_config", {}).get("notifications", {}), "email"
|
||||
)
|
||||
camera_dict["base_config"] = base_sections
|
||||
|
||||
# remove go2rtc stream passwords
|
||||
go2rtc: dict[str, Any] = config_obj.go2rtc.model_dump(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from pydantic import Field
|
||||
|
||||
from ..base import FrigateBaseModel
|
||||
from ..env import EnvString
|
||||
|
||||
__all__ = ["NotificationConfig"]
|
||||
|
||||
@@ -12,7 +11,7 @@ class NotificationConfig(FrigateBaseModel):
|
||||
title="Enable notifications",
|
||||
description="Enable or disable notifications for all cameras; can be overridden per-camera.",
|
||||
)
|
||||
email: EnvString | None = Field(
|
||||
email: str | None = Field(
|
||||
default=None,
|
||||
title="Notification email",
|
||||
description="Email address used for push notifications or required by certain notification providers.",
|
||||
|
||||
@@ -186,28 +186,15 @@ class ModelConfig(BaseModel):
|
||||
|
||||
# download the model if it doesn't exist
|
||||
if not os.path.isfile(self.path):
|
||||
try:
|
||||
download_url = plus_api.get_model_download_url(model_id)
|
||||
r = requests.get(download_url)
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
raise ValueError(
|
||||
f"Unable to connect to Frigate+ to download model {model_id}"
|
||||
) from e
|
||||
|
||||
download_url = plus_api.get_model_download_url(model_id)
|
||||
r = requests.get(download_url)
|
||||
with open(self.path, "wb") as f:
|
||||
f.write(r.content)
|
||||
|
||||
# download the model info if it doesn't exist
|
||||
if not os.path.isfile(model_info_path):
|
||||
try:
|
||||
model_info = plus_api.get_model_info(model_id)
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
raise ValueError(
|
||||
f"Unable to connect to Frigate+ to download model info for {model_id}"
|
||||
) from e
|
||||
|
||||
with open(model_info_path, "w") as f:
|
||||
json.dump(model_info, f)
|
||||
json.dump(plus_api.get_model_info(model_id), f)
|
||||
|
||||
model_info = load_plus_model_info(model_id)
|
||||
|
||||
|
||||
+4
-15
@@ -9,9 +9,7 @@ from typing import Any
|
||||
import cv2
|
||||
import requests
|
||||
from numpy import ndarray
|
||||
from requests.adapters import HTTPAdapter
|
||||
from requests.models import Response
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
from frigate.const import MODEL_CACHE_DIR, PLUS_API_HOST, PLUS_ENV_VAR
|
||||
|
||||
@@ -103,13 +101,6 @@ class PlusApi:
|
||||
self._is_active: bool = self.key is not None
|
||||
self._token_data: dict = {}
|
||||
|
||||
# Retry connection failures so a network that comes up late at startup
|
||||
# doesn't fail the Frigate+ model download
|
||||
self._session = requests.Session()
|
||||
self._session.mount(
|
||||
self.host, HTTPAdapter(max_retries=Retry(connect=5, backoff_factor=1))
|
||||
)
|
||||
|
||||
def _refresh_token_if_needed(self) -> None:
|
||||
if (
|
||||
self._token_data.get("expires") is None
|
||||
@@ -120,9 +111,7 @@ class PlusApi:
|
||||
"Plus API key not set. See https://docs.frigate.video/integrations/plus#set-your-api-key"
|
||||
)
|
||||
parts = self.key.split(":")
|
||||
r = self._session.get(
|
||||
f"{self.host}/v1/auth/token", auth=(parts[0], parts[1])
|
||||
)
|
||||
r = requests.get(f"{self.host}/v1/auth/token", auth=(parts[0], parts[1]))
|
||||
if not r.ok:
|
||||
raise Exception(f"Unable to refresh API token: {r.text}")
|
||||
self._token_data = r.json()
|
||||
@@ -132,19 +121,19 @@ class PlusApi:
|
||||
return {"authorization": f"Bearer {self._token_data.get('accessToken')}"}
|
||||
|
||||
def _get(self, path: str) -> Response:
|
||||
return self._session.get(
|
||||
return requests.get(
|
||||
f"{self.host}/v1/{path}", headers=self._get_authorization_header()
|
||||
)
|
||||
|
||||
def _post(self, path: str, data: dict) -> Response:
|
||||
return self._session.post(
|
||||
return requests.post(
|
||||
f"{self.host}/v1/{path}",
|
||||
headers=self._get_authorization_header(),
|
||||
json=data,
|
||||
)
|
||||
|
||||
def _put(self, path: str, data: dict) -> Response:
|
||||
return self._session.put(
|
||||
return requests.put(
|
||||
f"{self.host}/v1/{path}",
|
||||
headers=self._get_authorization_header(),
|
||||
json=data,
|
||||
|
||||
+1
-17
@@ -236,15 +236,6 @@ def skipped_percent(skipped_fps: float, camera_fps: float, enabled: bool) -> flo
|
||||
return round(skipped_fps / camera_fps * 100, 1)
|
||||
|
||||
|
||||
def get_go2rtc_pid(cpu_usages: dict[str, dict[str, Any]]) -> int | None:
|
||||
"""Find the pid of the running go2rtc process in the cpu usages."""
|
||||
for pid, usage in cpu_usages.items():
|
||||
if usage.get("cmdline", "").split(" ")[0].endswith("/go2rtc"):
|
||||
return int(pid)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def stats_snapshot(
|
||||
config: FrigateConfig,
|
||||
stats_tracking: StatsTrackingTypes,
|
||||
@@ -365,14 +356,6 @@ def stats_snapshot(
|
||||
|
||||
stats["service"]["storage"]["/dev/shm"] = calculate_shm_requirements(config)
|
||||
|
||||
cpu_usages = stats.get("cpu_usages", {})
|
||||
|
||||
# go2rtc is supervised by s6, so its pid changes when s6 restarts it
|
||||
go2rtc_pid = get_go2rtc_pid(cpu_usages)
|
||||
|
||||
if go2rtc_pid is not None:
|
||||
stats_tracking["processes"]["go2rtc"] = go2rtc_pid
|
||||
|
||||
stats["processes"] = {}
|
||||
for name, pid in stats_tracking["processes"].items():
|
||||
stats["processes"][name] = {
|
||||
@@ -381,6 +364,7 @@ def stats_snapshot(
|
||||
|
||||
# Embed cpu/mem stats into detectors, cameras, and processes
|
||||
# so history consumers don't need the full cpu_usages dict
|
||||
cpu_usages = stats.get("cpu_usages", {})
|
||||
|
||||
for det_stats in stats["detectors"].values():
|
||||
pid_str = str(det_stats.get("pid", ""))
|
||||
|
||||
@@ -4,7 +4,6 @@ from unittest.mock import Mock, patch
|
||||
|
||||
import frigate.genai
|
||||
from frigate.config import GenAIProviderEnum
|
||||
from frigate.config.env import FRIGATE_ENV_VARS
|
||||
from frigate.const import MODEL_CACHE_DIR, REDACTED_CREDENTIAL_SENTINEL
|
||||
from frigate.genai import GenAIClient
|
||||
from frigate.models import Event, Recordings, ReviewSegment
|
||||
@@ -112,30 +111,6 @@ class TestHttpApp(BaseTestHttp):
|
||||
mqtt = response.json()["mqtt"]
|
||||
assert mqtt["password"] == REDACTED_CREDENTIAL_SENTINEL
|
||||
|
||||
def test_config_response_hides_notification_email_from_viewers(self):
|
||||
self.minimal_config["notifications"] = {"email": "{FRIGATE_TEST_EMAIL}"}
|
||||
|
||||
with patch.dict(FRIGATE_ENV_VARS, {"FRIGATE_TEST_EMAIL": "me@example.com"}):
|
||||
app = super().create_app()
|
||||
|
||||
assert app.frigate_config.notifications.email == "me@example.com"
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
response = client.get(
|
||||
"/config",
|
||||
headers={"remote-user": "viewer", "remote-role": "viewer"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
config = response.json()
|
||||
assert config["notifications"]["email"] == REDACTED_CREDENTIAL_SENTINEL
|
||||
assert (
|
||||
config["cameras"]["front_door"]["notifications"]["email"]
|
||||
== REDACTED_CREDENTIAL_SENTINEL
|
||||
)
|
||||
|
||||
response = client.get("/config")
|
||||
assert response.json()["notifications"]["email"] == "me@example.com"
|
||||
|
||||
def test_config_response_keeps_plus_model_reference(self):
|
||||
model_id = "test_plus_reference"
|
||||
model_path = os.path.join(MODEL_CACHE_DIR, model_id)
|
||||
|
||||
@@ -59,6 +59,7 @@ def build_watchdog(
|
||||
MagicMock(),
|
||||
)
|
||||
|
||||
watchdog.requestor = MagicMock()
|
||||
return watchdog
|
||||
|
||||
|
||||
@@ -107,8 +108,8 @@ class TestCameraWatchdogStreamHealth(unittest.TestCase):
|
||||
def test_status_goes_to_the_matching_role_topic(self):
|
||||
watchdog = self._build_watchdog()
|
||||
|
||||
watchdog.record_status[STREAM_TYPE_MAIN].send("online", 100.0)
|
||||
watchdog.record_status[STREAM_TYPE_SUB].send("offline", 100.0)
|
||||
watchdog._send_record_status(STREAM_TYPE_MAIN, "online", 100.0)
|
||||
watchdog._send_record_status(STREAM_TYPE_SUB, "offline", 100.0)
|
||||
|
||||
watchdog.requestor.send_data.assert_any_call(
|
||||
"front_door/status/record", "online"
|
||||
@@ -120,9 +121,9 @@ class TestCameraWatchdogStreamHealth(unittest.TestCase):
|
||||
def test_status_is_cached_per_stream(self):
|
||||
watchdog = self._build_watchdog()
|
||||
|
||||
watchdog.record_status[STREAM_TYPE_MAIN].send("online", 100.0)
|
||||
watchdog.record_status[STREAM_TYPE_SUB].send("online", 100.0)
|
||||
watchdog.record_status[STREAM_TYPE_MAIN].send("online", 100.0)
|
||||
watchdog._send_record_status(STREAM_TYPE_MAIN, "online", 100.0)
|
||||
watchdog._send_record_status(STREAM_TYPE_SUB, "online", 100.0)
|
||||
watchdog._send_record_status(STREAM_TYPE_MAIN, "online", 100.0)
|
||||
|
||||
assert watchdog.requestor.send_data.call_count == 2
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ from copy import deepcopy
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
import requests
|
||||
from pydantic import ValidationError
|
||||
from ruamel.yaml.constructor import DuplicateKeyError
|
||||
|
||||
@@ -1596,31 +1595,6 @@ class TestConfig(unittest.TestCase):
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert frigate_config.primary_model.merged_labelmap[0] == "amazon"
|
||||
|
||||
@patch(
|
||||
"frigate.plus.PlusApi.get_model_download_url",
|
||||
side_effect=requests.exceptions.ConnectionError,
|
||||
)
|
||||
def test_plus_unreachable_is_validation_error(self, _):
|
||||
config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"models": [{"path": "plus://unreachable", "devices": ["cpu"]}],
|
||||
"cameras": {
|
||||
"back": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{
|
||||
"path": "rtsp://10.0.0.1:554/video",
|
||||
"roles": ["detect"],
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
with self.assertRaisesRegex(ValidationError, "Unable to connect to Frigate+"):
|
||||
FrigateConfig(**config)
|
||||
|
||||
def test_fails_on_invalid_role(self):
|
||||
config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
"""Tests for resolving the go2rtc pid from cpu usages."""
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from frigate.stats.util import get_go2rtc_pid, stats_snapshot
|
||||
|
||||
|
||||
class TestGo2rtcPid(unittest.TestCase):
|
||||
def test_finds_go2rtc_by_binary_path(self):
|
||||
cpu_usages = {
|
||||
"frigate.full_system": {"cpu": "1.0", "mem": "2.0"},
|
||||
"100": {"cmdline": "ffmpeg -i rtsp://127.0.0.1:8554/go2rtc_cam"},
|
||||
"200": {
|
||||
"cmdline": "/usr/local/go2rtc/bin/go2rtc -config=/dev/shm/go2rtc.yaml"
|
||||
},
|
||||
"300": {"cmdline": "frigate.recording"},
|
||||
}
|
||||
|
||||
self.assertEqual(get_go2rtc_pid(cpu_usages), 200)
|
||||
|
||||
def test_finds_custom_go2rtc_binary(self):
|
||||
self.assertEqual(get_go2rtc_pid({"42": {"cmdline": "/config/go2rtc"}}), 42)
|
||||
|
||||
def test_returns_none_when_go2rtc_is_not_running(self):
|
||||
self.assertIsNone(get_go2rtc_pid({"100": {"cmdline": "ffmpeg -i x"}}))
|
||||
self.assertIsNone(get_go2rtc_pid({}))
|
||||
|
||||
|
||||
class TestGo2rtcPidInSnapshot(unittest.TestCase):
|
||||
def snapshot(self, tracking: dict, go2rtc_pid: int) -> dict:
|
||||
def update_stats(stats: dict) -> None:
|
||||
stats["cpu_usages"] = {
|
||||
str(go2rtc_pid): {
|
||||
"cmdline": "/usr/local/go2rtc/bin/go2rtc -config=x",
|
||||
"cpu": str(go2rtc_pid / 100),
|
||||
"mem": str(go2rtc_pid / 10),
|
||||
}
|
||||
}
|
||||
|
||||
config = SimpleNamespace(
|
||||
cameras={},
|
||||
telemetry=SimpleNamespace(stats=SimpleNamespace(network_bandwidth=False)),
|
||||
)
|
||||
hardware_stats = Mock()
|
||||
hardware_stats.update_stats.side_effect = update_stats
|
||||
|
||||
with (
|
||||
patch("frigate.stats.util.get_detector_stats", return_value={}),
|
||||
patch("frigate.stats.util.embeddings_stats", return_value={}),
|
||||
patch("frigate.stats.util.calculate_shm_requirements", return_value={}),
|
||||
):
|
||||
return stats_snapshot(config, tracking, hardware_stats)
|
||||
|
||||
def test_snapshot_follows_go2rtc_restart(self):
|
||||
tracking = {
|
||||
"camera_metrics": {},
|
||||
"detectors": {},
|
||||
"started": 0,
|
||||
"latest_frigate_version": "",
|
||||
"processes": {"go2rtc": 200, "recording": 50},
|
||||
"storage_maintainer": None,
|
||||
}
|
||||
|
||||
first = self.snapshot(tracking, 200)["processes"]["go2rtc"]
|
||||
self.assertEqual(first, {"pid": 200, "cpu": "2.0", "mem": "20.0"})
|
||||
|
||||
restarted = self.snapshot(tracking, 300)["processes"]["go2rtc"]
|
||||
self.assertEqual(restarted, {"pid": 300, "cpu": "3.0", "mem": "30.0"})
|
||||
+43
-48
@@ -6,7 +6,6 @@ import subprocess as sp
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from multiprocessing import Queue, Value
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
@@ -109,27 +108,6 @@ def capture_frames(
|
||||
frame_index = 0 if frame_index == shm_frame_count - 1 else frame_index + 1
|
||||
|
||||
|
||||
@dataclass
|
||||
class RoleStatus:
|
||||
"""Publishes a role's status when it changes or the resend interval elapses."""
|
||||
|
||||
requestor: InterProcessRequestor
|
||||
topic: str
|
||||
resend_interval: float
|
||||
last_status: str | None = None
|
||||
last_update_time: float = 0.0
|
||||
|
||||
def send(self, status: str, now: float) -> None:
|
||||
"""Publish a changed status or resend it after the configured interval."""
|
||||
if (
|
||||
status != self.last_status
|
||||
or (now - self.last_update_time) >= self.resend_interval
|
||||
):
|
||||
self.requestor.send_data(self.topic, status)
|
||||
self.last_status = status
|
||||
self.last_update_time = now
|
||||
|
||||
|
||||
class CameraWatchdog(threading.Thread):
|
||||
def __init__(
|
||||
self,
|
||||
@@ -208,16 +186,33 @@ class CameraWatchdog(threading.Thread):
|
||||
self._stall_active: bool = False
|
||||
|
||||
# Status caching to reduce message volume
|
||||
self.detect_status = self._role_status("detect")
|
||||
self.record_status = {
|
||||
stream_type: self._role_status(role)
|
||||
for stream_type, role in STREAM_TYPE_TO_ROLE.items()
|
||||
}
|
||||
self._last_detect_status: str | None = None
|
||||
self._last_record_status: dict[str, str] = {}
|
||||
self._last_detect_status_update_time: float = 0.0
|
||||
self._last_record_status_update_time: dict[str, float] = defaultdict(float)
|
||||
|
||||
def _role_status(self, role: str) -> RoleStatus:
|
||||
return RoleStatus(
|
||||
self.requestor, f"{self.config.name}/status/{role}", self.sleeptime
|
||||
)
|
||||
def _send_detect_status(self, status: str, now: float) -> None:
|
||||
"""Send detect status only if changed or retry_interval has elapsed."""
|
||||
if (
|
||||
status != self._last_detect_status
|
||||
or (now - self._last_detect_status_update_time) >= self.sleeptime
|
||||
):
|
||||
self.requestor.send_data(f"{self.config.name}/status/detect", status)
|
||||
self._last_detect_status = status
|
||||
self._last_detect_status_update_time = now
|
||||
|
||||
def _send_record_status(self, stream_type: str, status: str, now: float) -> None:
|
||||
"""Send a record stream's status only if changed or retry_interval has elapsed."""
|
||||
if (
|
||||
status != self._last_record_status.get(stream_type)
|
||||
or (now - self._last_record_status_update_time[stream_type])
|
||||
>= self.sleeptime
|
||||
):
|
||||
self.requestor.send_data(
|
||||
f"{self.config.name}/status/{STREAM_TYPE_TO_ROLE[stream_type]}", status
|
||||
)
|
||||
self._last_record_status[stream_type] = status
|
||||
self._last_record_status_update_time[stream_type] = now
|
||||
|
||||
def _send_roles_offline(self, roles: list[CameraRoleEnum], now: float) -> None:
|
||||
"""Send offline status for each role of a restarted ffmpeg process."""
|
||||
@@ -227,7 +222,7 @@ class CameraWatchdog(threading.Thread):
|
||||
stream_type = ROLE_TO_STREAM_TYPE.get(role.value)
|
||||
|
||||
if stream_type is not None:
|
||||
self.record_status[stream_type].send("offline", now)
|
||||
self._send_record_status(stream_type, "offline", now)
|
||||
else:
|
||||
self.requestor.send_data(
|
||||
f"{self.config.name}/status/{role.value}", "offline"
|
||||
@@ -393,11 +388,11 @@ class CameraWatchdog(threading.Thread):
|
||||
|
||||
# update camera status
|
||||
now = datetime.now().timestamp()
|
||||
self.detect_status.send("disabled", now)
|
||||
self.record_status[STREAM_TYPE_MAIN].send("disabled", now)
|
||||
self._send_detect_status("disabled", now)
|
||||
self._send_record_status(STREAM_TYPE_MAIN, "disabled", now)
|
||||
# cameras without a sub stream never get a record_sub topic
|
||||
if self.config.record.sub.enabled:
|
||||
self.record_status[STREAM_TYPE_SUB].send("disabled", now)
|
||||
self._send_record_status(STREAM_TYPE_SUB, "disabled", now)
|
||||
self.was_enabled = enabled
|
||||
continue
|
||||
|
||||
@@ -470,7 +465,7 @@ class CameraWatchdog(threading.Thread):
|
||||
can_restart = time_since_last_restart >= self.sleeptime
|
||||
|
||||
if not self.capture_thread.is_alive():
|
||||
self.detect_status.send("offline", now)
|
||||
self._send_detect_status("offline", now)
|
||||
self.camera_fps.value = 0
|
||||
self.logger.error(
|
||||
f"Ffmpeg process crashed unexpectedly for {self.config.name}."
|
||||
@@ -482,7 +477,7 @@ class CameraWatchdog(threading.Thread):
|
||||
self.fps_overflow_count += 1
|
||||
|
||||
if self.fps_overflow_count == 3:
|
||||
self.detect_status.send("offline", now)
|
||||
self._send_detect_status("offline", now)
|
||||
self.fps_overflow_count = 0
|
||||
self.camera_fps.value = 0
|
||||
self.logger.info(
|
||||
@@ -492,7 +487,7 @@ class CameraWatchdog(threading.Thread):
|
||||
self.reset_capture_thread(drain_output=False)
|
||||
last_restart_time = now
|
||||
elif now - self.capture_thread.current_frame.value > 20:
|
||||
self.detect_status.send("offline", now)
|
||||
self._send_detect_status("offline", now)
|
||||
self.camera_fps.value = 0
|
||||
self.logger.info(
|
||||
f"No frames received from {self.config.name} in 20 seconds. Exiting ffmpeg..."
|
||||
@@ -502,7 +497,7 @@ class CameraWatchdog(threading.Thread):
|
||||
last_restart_time = now
|
||||
else:
|
||||
# process is running normally
|
||||
self.detect_status.send("online", now)
|
||||
self._send_detect_status("online", now)
|
||||
self.fps_overflow_count = 0
|
||||
|
||||
for p in self.ffmpeg_other_processes:
|
||||
@@ -545,7 +540,7 @@ class CameraWatchdog(threading.Thread):
|
||||
elif stale_stream is None:
|
||||
if poll is None:
|
||||
for stream_type in recorded_streams:
|
||||
self.record_status[stream_type].send("online", now)
|
||||
self._send_record_status(stream_type, "online", now)
|
||||
|
||||
p["latest_segment_time"] = max(
|
||||
self.latest_cache_segment_time[stream_type]
|
||||
@@ -561,22 +556,22 @@ class CameraWatchdog(threading.Thread):
|
||||
p["cmd"], self.logger, p["logpipe"], ffmpeg_process=p["process"]
|
||||
)
|
||||
|
||||
if self.detect_process_records_sub and self.config.record.stream_enabled(
|
||||
STREAM_TYPE_SUB
|
||||
if (
|
||||
self.detect_process_records_sub
|
||||
and self.config.record.stream_enabled(STREAM_TYPE_SUB)
|
||||
and self.capture_thread is not None
|
||||
and self.capture_thread.is_alive()
|
||||
):
|
||||
now_utc = datetime.now().astimezone(UTC)
|
||||
stale_reason = self._stream_staleness(STREAM_TYPE_SUB, now_utc)
|
||||
|
||||
if self.detect_status.last_status == "offline":
|
||||
# the sub stream is down whenever the detect process is
|
||||
self.record_status[STREAM_TYPE_SUB].send("offline", now)
|
||||
elif stale_reason is None:
|
||||
self.record_status[STREAM_TYPE_SUB].send("online", now)
|
||||
if stale_reason is None:
|
||||
self._send_record_status(STREAM_TYPE_SUB, "online", now)
|
||||
elif can_restart:
|
||||
self.logger.error(
|
||||
f"{stale_reason} for {self.config.name} (sub, shared with detect) in the last {self.record_stale_threshold[STREAM_TYPE_SUB]}s. Restarting ffmpeg..."
|
||||
)
|
||||
self.record_status[STREAM_TYPE_SUB].send("offline", now)
|
||||
self._send_record_status(STREAM_TYPE_SUB, "offline", now)
|
||||
self.reset_capture_thread()
|
||||
last_restart_time = now
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { FrigateApp } from "../fixtures/frigate-test";
|
||||
|
||||
// On mobile the System tabs sit in an OverflowStrip, which keeps an inert copy
|
||||
// of every tab for measurement and hides the ones that do not fit behind a
|
||||
// kebab. The selected tab always stays in the strip.
|
||||
|
||||
export function systemTab(frigateApp: FrigateApp, name: string) {
|
||||
return frigateApp.page
|
||||
.locator(`[aria-label="Select ${name}" i]:not([inert] *)`)
|
||||
.first();
|
||||
}
|
||||
@@ -7,12 +7,13 @@
|
||||
*/
|
||||
|
||||
import { test, expect } from "../fixtures/frigate-test";
|
||||
import { systemTab } from "../helpers/system-tabs";
|
||||
import { viewerProfile } from "../fixtures/mock-data/profile";
|
||||
|
||||
test.describe("Auth — admin access @high", () => {
|
||||
test("admin /system renders general tab", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/system");
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toBeVisible({
|
||||
await expect(systemTab(frigateApp, "general")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
@@ -28,7 +29,7 @@ test.describe("Auth — admin access @high", () => {
|
||||
|
||||
test("admin /logs renders frigate tab", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/logs");
|
||||
await expect(frigateApp.page.getByLabel("Select frigate")).toBeVisible({
|
||||
await expect(systemTab(frigateApp, "frigate")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -287,3 +287,22 @@ test.describe("Live mobile layout @critical @mobile", () => {
|
||||
await expect(frigateApp.page.locator("body")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Live camera groups @medium", () => {
|
||||
test("a group with an invalid icon renders a fallback icon", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
config: {
|
||||
camera_groups: {
|
||||
outdoor: { cameras: ["front_door"], icon: "generic" },
|
||||
},
|
||||
},
|
||||
});
|
||||
await frigateApp.goto("/");
|
||||
const group = frigateApp.page
|
||||
.locator('[aria-label="Camera Groups"]:not([inert] *)')
|
||||
.first();
|
||||
await expect(group.locator("svg")).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { test, expect } from "../fixtures/frigate-test";
|
||||
import { systemTab } from "../helpers/system-tabs";
|
||||
import { viewerProfile } from "../fixtures/mock-data/profile";
|
||||
|
||||
const NOW = Math.floor(Date.now() / 1000);
|
||||
@@ -55,7 +56,7 @@ test.describe("System — Health tab @medium", () => {
|
||||
});
|
||||
await frigateApp.goto("/system");
|
||||
|
||||
await expect(frigateApp.page.getByLabel("Select health")).toHaveAttribute(
|
||||
await expect(systemTab(frigateApp, "health")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
@@ -801,7 +802,7 @@ test.describe("System — Health notices sources @medium", () => {
|
||||
stats: QUIET_STATS,
|
||||
});
|
||||
await frigateApp.goto("/system#health");
|
||||
await expect(frigateApp.page.getByLabel("Select health")).toHaveAttribute(
|
||||
await expect(systemTab(frigateApp, "health")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
|
||||
@@ -6,42 +6,66 @@
|
||||
* RestartDialog cancel flow.
|
||||
*/
|
||||
|
||||
import { test, expect } from "../fixtures/frigate-test";
|
||||
import { test, expect, FrigateApp } from "../fixtures/frigate-test";
|
||||
import {
|
||||
expectBodyInteractive,
|
||||
waitForBodyInteractive,
|
||||
} from "../helpers/overlay-interaction";
|
||||
import { systemTab } from "../helpers/system-tabs";
|
||||
|
||||
async function selectTab(frigateApp: FrigateApp, name: string) {
|
||||
const kebab = frigateApp.page.getByLabel("Show all tabs");
|
||||
|
||||
if (frigateApp.isMobile && (await kebab.isVisible())) {
|
||||
await kebab.click();
|
||||
await frigateApp.page
|
||||
.locator(`[aria-label="Select ${name}" i]:not([inert] *)`)
|
||||
.last()
|
||||
.click();
|
||||
return;
|
||||
}
|
||||
|
||||
await systemTab(frigateApp, name).click();
|
||||
}
|
||||
|
||||
async function expectTabActive(frigateApp: FrigateApp, name: string) {
|
||||
await expect(systemTab(frigateApp, name)).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{
|
||||
timeout: 5_000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("System — tabs @medium", () => {
|
||||
test("general tab is active by default via #general hash", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto("/system#general");
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await expect(frigateApp.page.getByLabel("Select storage")).toBeVisible();
|
||||
await expect(frigateApp.page.getByLabel("Select cameras")).toBeVisible();
|
||||
if (!frigateApp.isMobile) {
|
||||
await expect(systemTab(frigateApp, "storage")).toBeVisible();
|
||||
await expect(systemTab(frigateApp, "cameras")).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("Storage tab activates and deactivates General", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto("/system#general");
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await frigateApp.page.getByLabel("Select storage").click();
|
||||
await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||
await selectTab(frigateApp, "storage");
|
||||
await expectTabActive(frigateApp, "storage");
|
||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||
"data-state",
|
||||
"off",
|
||||
);
|
||||
@@ -49,24 +73,20 @@ test.describe("System — tabs @medium", () => {
|
||||
|
||||
test("Cameras tab activates", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/system#general");
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await frigateApp.page.getByLabel("Select cameras").click();
|
||||
await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
await selectTab(frigateApp, "cameras");
|
||||
await expectTabActive(frigateApp, "cameras");
|
||||
});
|
||||
|
||||
test("general tab shows version and last-refreshed", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto("/system#general");
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
@@ -87,17 +107,13 @@ test.describe("System — tabs @medium", () => {
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto("/system#general");
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await frigateApp.page.getByLabel("Select storage").click();
|
||||
await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
await selectTab(frigateApp, "storage");
|
||||
await expectTabActive(frigateApp, "storage");
|
||||
// On desktop, tab buttons render text labels so the word "storage"
|
||||
// always appears in #pageRoot after switching. On mobile, tabs are
|
||||
// icon-only, so we verify the general-tab content disappears instead
|
||||
@@ -112,25 +128,23 @@ test.describe("System — tabs @medium", () => {
|
||||
} else {
|
||||
// Mobile: tab activation (data-state "on") already asserted above.
|
||||
// Additionally confirm general tab is no longer the active tab.
|
||||
await expect(
|
||||
frigateApp.page.getByLabel("Select general"),
|
||||
).toHaveAttribute("data-state", "off", { timeout: 5_000 });
|
||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||
"data-state",
|
||||
"off",
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("cameras tab renders each configured camera", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/system#general");
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await frigateApp.page.getByLabel("Select cameras").click();
|
||||
await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
await selectTab(frigateApp, "cameras");
|
||||
await expectTabActive(frigateApp, "cameras");
|
||||
// Cameras tab lists every camera from config/stats. The default
|
||||
// mock has front_door, backyard, garage.
|
||||
for (const cam of ["front_door", "backyard", "garage"]) {
|
||||
@@ -151,17 +165,13 @@ test.describe("System — tabs @medium", () => {
|
||||
config: { semantic_search: { enabled: true } },
|
||||
});
|
||||
await frigateApp.goto("/system#general");
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
const enrichTab = frigateApp.page.getByLabel(/select enrichments/i).first();
|
||||
await expect(enrichTab).toBeVisible({ timeout: 5_000 });
|
||||
await enrichTab.click();
|
||||
await expect(enrichTab).toHaveAttribute("data-state", "on", {
|
||||
timeout: 5_000,
|
||||
});
|
||||
await selectTab(frigateApp, "enrichments");
|
||||
await expectTabActive(frigateApp, "enrichments");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -223,31 +233,27 @@ test.describe("System — mobile @medium @mobile", () => {
|
||||
|
||||
test("tabs render at mobile viewport", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/system#general");
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toBeVisible({
|
||||
await expect(systemTab(frigateApp, "general")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("switching tabs works at mobile viewport", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/system#general");
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await frigateApp.page.getByLabel("Select storage").click();
|
||||
await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
await selectTab(frigateApp, "storage");
|
||||
await expectTabActive(frigateApp, "storage");
|
||||
});
|
||||
|
||||
test("header controls leave the logo uncovered on a narrow phone", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto("/system#general");
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
@@ -255,9 +261,9 @@ test.describe("System — mobile @medium @mobile", () => {
|
||||
await frigateApp.page.setViewportSize({ width: 320, height: 740 });
|
||||
|
||||
const logo = frigateApp.page.locator("svg.fill-current").first();
|
||||
const tabs = frigateApp.page
|
||||
.locator("[data-radix-scroll-area-viewport]")
|
||||
.filter({ has: frigateApp.page.getByLabel("Select general") });
|
||||
const kebab = frigateApp.page.getByLabel("Show all tabs");
|
||||
await expect(kebab).toBeVisible();
|
||||
const tabs = kebab.locator("..");
|
||||
const refreshed = frigateApp.page.getByText(/Just now|ago/);
|
||||
|
||||
const logoBox = await logo.boundingBox();
|
||||
@@ -267,20 +273,9 @@ test.describe("System — mobile @medium @mobile", () => {
|
||||
expect(tabsBox!.x + tabsBox!.width).toBeLessThanOrEqual(logoBox!.x + 1);
|
||||
expect(refreshedBox!.x).toBeGreaterThanOrEqual(logoBox!.x + logoBox!.width);
|
||||
|
||||
// the clipped tabs stay reachable by scrolling
|
||||
const overflow = await tabs.evaluate((el) => ({
|
||||
scroll: el.scrollWidth,
|
||||
client: el.clientWidth,
|
||||
}));
|
||||
expect(overflow.scroll).toBeGreaterThan(overflow.client);
|
||||
await tabs.evaluate((el) => {
|
||||
el.scrollLeft = el.scrollWidth;
|
||||
});
|
||||
await frigateApp.page.getByLabel("Select cameras").click();
|
||||
await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
// the tabs that do not fit stay reachable through the kebab
|
||||
await selectTab(frigateApp, "cameras");
|
||||
await expectTabActive(frigateApp, "cameras");
|
||||
await expect(frigateApp.page.getByLabel("Show less")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
},
|
||||
"title": "System",
|
||||
"metrics": "System metrics",
|
||||
"showAllTabs": "Show all tabs",
|
||||
"showLessTabs": "Show less",
|
||||
"health": {
|
||||
"title": "Health",
|
||||
"notices": {
|
||||
|
||||
@@ -8,18 +8,8 @@ import { isDesktop, isMobile } from "react-device-detect";
|
||||
import useSWR from "swr";
|
||||
import { MdHome } from "react-icons/md";
|
||||
import { Button, buttonVariants } from "../ui/button";
|
||||
import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { HiDotsHorizontal } from "react-icons/hi";
|
||||
import { IoClose } from "react-icons/io5";
|
||||
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import OverflowStrip from "../mobile/OverflowStrip";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
import { LuPencil, LuPlus } from "react-icons/lu";
|
||||
import {
|
||||
@@ -156,80 +146,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
||||
|
||||
const [addGroup, setAddGroup] = useState(false);
|
||||
|
||||
// mobile overflow reveal - the group strip sits left of the logo and is
|
||||
// clipped (not scrollable) when there are too many groups, so render only
|
||||
// the buttons that fully fit and surface a kebab next to the last visible
|
||||
// one that expands a panel revealing all of them
|
||||
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
// null => all buttons fit, render them all with no kebab; a number => only
|
||||
// that many fit alongside the kebab
|
||||
const [visibleCount, setVisibleCount] = useState<number | null>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const measureRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isDesktop) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wrapper = wrapperRef.current;
|
||||
const measure = measureRef.current;
|
||||
|
||||
if (!wrapper || !measure) {
|
||||
return;
|
||||
}
|
||||
|
||||
const gap = 8; // gap-2 between buttons in the strip
|
||||
const wrapperGap = 4; // gap-1 between the strip and the kebab
|
||||
|
||||
const compute = () => {
|
||||
const buttons = Array.from(measure.children) as HTMLElement[];
|
||||
|
||||
if (buttons.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// the trailing child of the measurement row is a kebab clone
|
||||
const kebab = buttons[buttons.length - 1];
|
||||
const groupButtons = buttons.slice(0, -1);
|
||||
const available = wrapper.clientWidth;
|
||||
const fullWidth =
|
||||
groupButtons.reduce((sum, el) => sum + el.offsetWidth, 0) +
|
||||
Math.max(groupButtons.length - 1, 0) * gap;
|
||||
|
||||
if (fullWidth <= available) {
|
||||
setVisibleCount(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const budget = available - kebab.offsetWidth - wrapperGap;
|
||||
let used = 0;
|
||||
let count = 0;
|
||||
|
||||
for (const el of groupButtons) {
|
||||
const next = (count === 0 ? 0 : gap) + el.offsetWidth;
|
||||
|
||||
if (used + next <= budget) {
|
||||
used += next;
|
||||
count += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setVisibleCount(Math.max(count, 1));
|
||||
};
|
||||
|
||||
compute();
|
||||
|
||||
const observer = new ResizeObserver(compute);
|
||||
observer.observe(wrapper);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [groups, isAdmin]);
|
||||
|
||||
const groupButtons = (afterSelect?: () => void) => {
|
||||
const groupButtons = () => {
|
||||
const buttons = [
|
||||
<Button
|
||||
key="default-group"
|
||||
@@ -245,7 +162,6 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
||||
if (group) {
|
||||
setGroup("default", true);
|
||||
}
|
||||
afterSelect?.();
|
||||
}}
|
||||
>
|
||||
<MdHome className="size-5" />
|
||||
@@ -263,12 +179,16 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setGroup(name, group != "default");
|
||||
afterSelect?.();
|
||||
}}
|
||||
>
|
||||
{config && config.icon && isValidIconName(config.icon) && (
|
||||
<IconRenderer icon={LuIcons[config.icon]} className="size-5" />
|
||||
)}
|
||||
<IconRenderer
|
||||
icon={
|
||||
isValidIconName(config.icon)
|
||||
? LuIcons[config.icon]
|
||||
: LuIcons.LuFolder
|
||||
}
|
||||
className="size-5"
|
||||
/>
|
||||
</Button>
|
||||
)),
|
||||
];
|
||||
@@ -282,7 +202,6 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setAddGroup(true);
|
||||
afterSelect?.();
|
||||
}}
|
||||
>
|
||||
<LuPencil className="size-5 text-primary-variant" />
|
||||
@@ -350,12 +269,14 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
||||
onMouseEnter={() => showTooltip(name)}
|
||||
onMouseLeave={() => showTooltip(undefined)}
|
||||
>
|
||||
{config && config.icon && isValidIconName(config.icon) && (
|
||||
<IconRenderer
|
||||
icon={LuIcons[config.icon]}
|
||||
className="size-4"
|
||||
/>
|
||||
)}
|
||||
<IconRenderer
|
||||
icon={
|
||||
isValidIconName(config.icon)
|
||||
? LuIcons[config.icon]
|
||||
: LuIcons.LuFolder
|
||||
}
|
||||
className="size-4"
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
@@ -390,74 +311,13 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={cn("flex min-w-0 items-center gap-1", className)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2 overflow-hidden whitespace-nowrap">
|
||||
{visibleCount == null
|
||||
? groupButtons()
|
||||
: groupButtons().slice(0, visibleCount)}
|
||||
</div>
|
||||
{visibleCount != null && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="shrink-0 px-2 text-secondary-foreground"
|
||||
aria-label={t("group.showAll")}
|
||||
onClick={() => setExpanded(true)}
|
||||
>
|
||||
<HiDotsHorizontal className="size-5" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* invisible row used only to measure natural button widths so we
|
||||
can render exactly the buttons that fully fit */}
|
||||
<div
|
||||
className="pointer-events-none absolute left-0 top-0 h-0 w-0 overflow-hidden"
|
||||
aria-hidden
|
||||
inert
|
||||
>
|
||||
<div ref={measureRef} className="flex w-max items-center gap-2">
|
||||
{groupButtons()}
|
||||
<Button variant="ghost" size="sm" className="px-2">
|
||||
<HiDotsHorizontal className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div
|
||||
className="fixed inset-0 z-20"
|
||||
onClick={() => setExpanded(false)}
|
||||
/>
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{expanded && (
|
||||
<motion.div
|
||||
key="group-overlay"
|
||||
className="absolute inset-x-0 top-0 z-30 bg-background py-1 shadow-lg"
|
||||
initial={{ clipPath: "inset(0 100% 0 0)" }}
|
||||
animate={{ clipPath: "inset(0 0% 0 0)" }}
|
||||
exit={{ clipPath: "inset(0 100% 0 0)" }}
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{groupButtons(() => setExpanded(false))}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto shrink-0 px-2 text-secondary-foreground"
|
||||
aria-label={t("group.showLess")}
|
||||
onClick={() => setExpanded(false)}
|
||||
>
|
||||
<IoClose className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<OverflowStrip
|
||||
className={className}
|
||||
items={groupButtons()}
|
||||
activeIndex={groups.findIndex(([name]) => name == group) + 1}
|
||||
showAllLabel={t("group.showAll")}
|
||||
showLessLabel={t("group.showLess")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { ReactNode, useLayoutEffect, useRef, useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { HiDotsHorizontal } from "react-icons/hi";
|
||||
import { IoClose } from "react-icons/io5";
|
||||
import { Button } from "../ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type OverflowStripProps = {
|
||||
className?: string;
|
||||
items: ReactNode[];
|
||||
activeIndex?: number;
|
||||
gapClassName?: string;
|
||||
showAllLabel: string;
|
||||
showLessLabel: string;
|
||||
};
|
||||
|
||||
// Renders only the items that fully fit and surfaces a kebab next to the last
|
||||
// visible one. The kebab expands a panel over the nearest positioned ancestor
|
||||
// that reveals every item.
|
||||
export default function OverflowStrip({
|
||||
className,
|
||||
items,
|
||||
activeIndex = 0,
|
||||
gapClassName = "gap-2",
|
||||
showAllLabel,
|
||||
showLessLabel,
|
||||
}: OverflowStripProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
// null => all items fit, render them all with no kebab; a number => only
|
||||
// that many fit alongside the kebab
|
||||
const [visibleCount, setVisibleCount] = useState<number | null>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const measureRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const wrapper = wrapperRef.current;
|
||||
const measure = measureRef.current;
|
||||
|
||||
if (!wrapper || !measure) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wrapperGap = 4; // gap-1 between the strip and the kebab
|
||||
|
||||
const compute = () => {
|
||||
const children = Array.from(measure.children) as HTMLElement[];
|
||||
|
||||
if (children.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// the trailing child of the measurement row is a kebab clone
|
||||
const kebab = children[children.length - 1];
|
||||
const start = children[0].offsetLeft;
|
||||
const ends = children
|
||||
.slice(0, -1)
|
||||
.map((el) => el.offsetLeft + el.offsetWidth - start);
|
||||
const available = wrapper.clientWidth;
|
||||
|
||||
if (ends[ends.length - 1] <= available) {
|
||||
setVisibleCount(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const budget = available - kebab.offsetWidth - wrapperGap;
|
||||
const count = ends.filter((end) => end <= budget).length;
|
||||
|
||||
setVisibleCount(Math.max(count, 1));
|
||||
};
|
||||
|
||||
compute();
|
||||
|
||||
const observer = new ResizeObserver(compute);
|
||||
observer.observe(wrapper);
|
||||
observer.observe(measure);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [items.length, gapClassName]);
|
||||
|
||||
// a selected item past the cut takes the last visible slot
|
||||
const visibleItems =
|
||||
visibleCount == null
|
||||
? items
|
||||
: activeIndex >= visibleCount
|
||||
? [...items.slice(0, visibleCount - 1), items[activeIndex]]
|
||||
: items.slice(0, visibleCount);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={cn("flex min-w-0 items-center gap-1", className)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 items-center overflow-hidden whitespace-nowrap",
|
||||
gapClassName,
|
||||
)}
|
||||
>
|
||||
{visibleItems}
|
||||
</div>
|
||||
{visibleCount != null && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="shrink-0 px-2 text-secondary-foreground"
|
||||
aria-label={showAllLabel}
|
||||
onClick={() => setExpanded(true)}
|
||||
>
|
||||
<HiDotsHorizontal className="size-5" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* invisible row used only to measure natural item widths so we can
|
||||
render exactly the items that fully fit */}
|
||||
<div
|
||||
className="pointer-events-none absolute left-0 top-0 h-0 w-0 overflow-hidden"
|
||||
aria-hidden
|
||||
inert
|
||||
>
|
||||
<div
|
||||
ref={measureRef}
|
||||
className={cn("flex w-max items-center", gapClassName)}
|
||||
>
|
||||
{items}
|
||||
<Button variant="ghost" size="sm" className="px-2">
|
||||
<HiDotsHorizontal className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div
|
||||
className="fixed inset-0 z-20"
|
||||
onClick={() => setExpanded(false)}
|
||||
/>
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{expanded && (
|
||||
<motion.div
|
||||
key="overflow-overlay"
|
||||
className="absolute inset-x-0 top-0 z-30 bg-background py-1 shadow-lg"
|
||||
initial={{ clipPath: "inset(0 100% 0 0)" }}
|
||||
animate={{ clipPath: "inset(0 0% 0 0)" }}
|
||||
exit={{ clipPath: "inset(0 100% 0 0)" }}
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
>
|
||||
{/* a tap on any item bubbles up and collapses the panel */}
|
||||
<div
|
||||
className={cn("flex flex-wrap items-center", gapClassName)}
|
||||
onClick={() => setExpanded(false)}
|
||||
>
|
||||
{items}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto shrink-0 px-2 text-secondary-foreground"
|
||||
aria-label={showLessLabel}
|
||||
>
|
||||
<IoClose className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+63
-30
@@ -1,8 +1,10 @@
|
||||
import useSWR from "swr";
|
||||
import { FrigateStats } from "@/types/stats";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||
import TimeAgo from "@/components/dynamic/TimeAgo";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { Toggle } from "@/components/ui/toggle";
|
||||
import OverflowStrip from "@/components/mobile/OverflowStrip";
|
||||
import { isDesktop, isMobile } from "react-device-detect";
|
||||
import GeneralMetrics from "@/views/system/GeneralMetrics";
|
||||
import StorageMetrics from "@/views/system/StorageMetrics";
|
||||
@@ -36,6 +38,14 @@ const allMetrics = [
|
||||
] as const;
|
||||
type SystemMetric = (typeof allMetrics)[number];
|
||||
|
||||
const metricIcons: Record<SystemMetric, ReactNode> = {
|
||||
health: <LuHeartPulse className="size-4" />,
|
||||
general: <LuActivity className="size-4" />,
|
||||
enrichments: <LuSearchCode className="size-4" />,
|
||||
storage: <LuHardDrive className="size-4" />,
|
||||
cameras: <FaVideo className="size-4" />,
|
||||
};
|
||||
|
||||
function System() {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||
@@ -98,43 +108,66 @@ function System() {
|
||||
{isMobile && (
|
||||
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
|
||||
)}
|
||||
<ScrollArea className={cn("whitespace-nowrap", isMobile && "w-[45%]")}>
|
||||
<div className="flex flex-row">
|
||||
<ToggleGroup
|
||||
className="*:rounded-md *:px-3 *:py-4"
|
||||
type="single"
|
||||
size="sm"
|
||||
value={pageToggle}
|
||||
onValueChange={(value: SystemMetric) => {
|
||||
if (value) {
|
||||
setPageToggle(value);
|
||||
}
|
||||
}} // don't allow the severity to be unselected
|
||||
>
|
||||
{Object.values(metrics).map((item) => (
|
||||
<ToggleGroupItem
|
||||
{isMobile ? (
|
||||
<div className="w-[calc(50%-1rem)]">
|
||||
<OverflowStrip
|
||||
items={metrics.map((item) => (
|
||||
<Toggle
|
||||
key={item}
|
||||
className={`flex items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
|
||||
value={item}
|
||||
className={cn(
|
||||
"shrink-0 rounded-md px-3 py-4",
|
||||
pageToggle != item && "*:text-muted-foreground",
|
||||
)}
|
||||
size="sm"
|
||||
pressed={pageToggle == item}
|
||||
onPressedChange={() => setPageToggle(item)}
|
||||
aria-label={t("selectItem", {
|
||||
ns: "common",
|
||||
item: t(item + ".title"),
|
||||
})}
|
||||
>
|
||||
{item == "health" && <LuHeartPulse className="size-4" />}
|
||||
{item == "general" && <LuActivity className="size-4" />}
|
||||
{item == "enrichments" && <LuSearchCode className="size-4" />}
|
||||
{item == "storage" && <LuHardDrive className="size-4" />}
|
||||
{item == "cameras" && <FaVideo className="size-4" />}
|
||||
{isDesktop && (
|
||||
<div className="smart-capitalize">{t(item + ".title")}</div>
|
||||
)}
|
||||
</ToggleGroupItem>
|
||||
{metricIcons[item]}
|
||||
</Toggle>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
<ScrollBar orientation="horizontal" className="h-0" />
|
||||
activeIndex={metrics.indexOf(pageToggle)}
|
||||
gapClassName="gap-0.5"
|
||||
showAllLabel={t("showAllTabs")}
|
||||
showLessLabel={t("showLessTabs")}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<ScrollArea className="whitespace-nowrap">
|
||||
<div className="flex flex-row">
|
||||
<ToggleGroup
|
||||
className="*:rounded-md *:px-3 *:py-4"
|
||||
type="single"
|
||||
size="sm"
|
||||
value={pageToggle}
|
||||
onValueChange={(value: SystemMetric) => {
|
||||
if (value) {
|
||||
setPageToggle(value);
|
||||
}
|
||||
}} // don't allow the severity to be unselected
|
||||
>
|
||||
{Object.values(metrics).map((item) => (
|
||||
<ToggleGroupItem
|
||||
key={item}
|
||||
className={`flex items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
|
||||
value={item}
|
||||
aria-label={t("selectItem", {
|
||||
ns: "common",
|
||||
item: t(item + ".title"),
|
||||
})}
|
||||
>
|
||||
{metricIcons[item]}
|
||||
<div className="smart-capitalize">{t(item + ".title")}</div>
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
<ScrollBar orientation="horizontal" className="h-0" />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
<div className="flex h-full items-center">
|
||||
{pageToggle == "health" && (
|
||||
|
||||
@@ -419,7 +419,7 @@ export default function LiveDashboardView({
|
||||
{isMobile && (
|
||||
<div className="relative flex h-11 items-center justify-between">
|
||||
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
|
||||
<div className="w-[45%]">
|
||||
<div className="w-[calc(50%-1rem)]">
|
||||
<CameraGroupSelector />
|
||||
</div>
|
||||
{(!cameraGroup || cameraGroup == "default" || isMobileOnly) && (
|
||||
|
||||
Reference in New Issue
Block a user