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
|
enabled: False
|
||||||
# Optional: Email for push service to reach out to
|
# Optional: Email for push service to reach out to
|
||||||
# NOTE: This is required to use notifications
|
# 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"
|
email: "admin@example.com"
|
||||||
# Optional: Cooldown time for notifications in seconds (default: shown below)
|
# Optional: Cooldown time for notifications in seconds (default: shown below)
|
||||||
cooldown: 0
|
cooldown: 0
|
||||||
|
|||||||
+3
-14
@@ -311,12 +311,9 @@ def config(request: Request):
|
|||||||
mode="json", warnings="none", exclude_none=True
|
mode="json", warnings="none", exclude_none=True
|
||||||
)
|
)
|
||||||
|
|
||||||
is_admin = request.headers.get("remote-role") == "admin"
|
# remove environment_vars for non-admin users
|
||||||
|
if request.headers.get("remote-role") != "admin":
|
||||||
# hide environment_vars and the notification email from non-admin users
|
|
||||||
if not is_admin:
|
|
||||||
config.pop("environment_vars", None)
|
config.pop("environment_vars", None)
|
||||||
redact_credential(config["notifications"], "email")
|
|
||||||
|
|
||||||
# redact mqtt credentials
|
# redact mqtt credentials
|
||||||
redact_credential(config["mqtt"], "password")
|
redact_credential(config["mqtt"], "password")
|
||||||
@@ -373,15 +370,7 @@ def config(request: Request):
|
|||||||
camera_name
|
camera_name
|
||||||
)
|
)
|
||||||
if base_sections:
|
if base_sections:
|
||||||
# copy so redaction below can't alter the profile manager's cache
|
camera_dict["base_config"] = base_sections
|
||||||
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"
|
|
||||||
)
|
|
||||||
|
|
||||||
# remove go2rtc stream passwords
|
# remove go2rtc stream passwords
|
||||||
go2rtc: dict[str, Any] = config_obj.go2rtc.model_dump(
|
go2rtc: dict[str, Any] = config_obj.go2rtc.model_dump(
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
from ..base import FrigateBaseModel
|
from ..base import FrigateBaseModel
|
||||||
from ..env import EnvString
|
|
||||||
|
|
||||||
__all__ = ["NotificationConfig"]
|
__all__ = ["NotificationConfig"]
|
||||||
|
|
||||||
@@ -12,7 +11,7 @@ class NotificationConfig(FrigateBaseModel):
|
|||||||
title="Enable notifications",
|
title="Enable notifications",
|
||||||
description="Enable or disable notifications for all cameras; can be overridden per-camera.",
|
description="Enable or disable notifications for all cameras; can be overridden per-camera.",
|
||||||
)
|
)
|
||||||
email: EnvString | None = Field(
|
email: str | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
title="Notification email",
|
title="Notification email",
|
||||||
description="Email address used for push notifications or required by certain notification providers.",
|
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
|
# download the model if it doesn't exist
|
||||||
if not os.path.isfile(self.path):
|
if not os.path.isfile(self.path):
|
||||||
try:
|
|
||||||
download_url = plus_api.get_model_download_url(model_id)
|
download_url = plus_api.get_model_download_url(model_id)
|
||||||
r = requests.get(download_url)
|
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
|
|
||||||
|
|
||||||
with open(self.path, "wb") as f:
|
with open(self.path, "wb") as f:
|
||||||
f.write(r.content)
|
f.write(r.content)
|
||||||
|
|
||||||
# download the model info if it doesn't exist
|
# download the model info if it doesn't exist
|
||||||
if not os.path.isfile(model_info_path):
|
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:
|
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)
|
model_info = load_plus_model_info(model_id)
|
||||||
|
|
||||||
|
|||||||
+4
-15
@@ -9,9 +9,7 @@ from typing import Any
|
|||||||
import cv2
|
import cv2
|
||||||
import requests
|
import requests
|
||||||
from numpy import ndarray
|
from numpy import ndarray
|
||||||
from requests.adapters import HTTPAdapter
|
|
||||||
from requests.models import Response
|
from requests.models import Response
|
||||||
from urllib3.util.retry import Retry
|
|
||||||
|
|
||||||
from frigate.const import MODEL_CACHE_DIR, PLUS_API_HOST, PLUS_ENV_VAR
|
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._is_active: bool = self.key is not None
|
||||||
self._token_data: dict = {}
|
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:
|
def _refresh_token_if_needed(self) -> None:
|
||||||
if (
|
if (
|
||||||
self._token_data.get("expires") is None
|
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"
|
"Plus API key not set. See https://docs.frigate.video/integrations/plus#set-your-api-key"
|
||||||
)
|
)
|
||||||
parts = self.key.split(":")
|
parts = self.key.split(":")
|
||||||
r = self._session.get(
|
r = requests.get(f"{self.host}/v1/auth/token", auth=(parts[0], parts[1]))
|
||||||
f"{self.host}/v1/auth/token", auth=(parts[0], parts[1])
|
|
||||||
)
|
|
||||||
if not r.ok:
|
if not r.ok:
|
||||||
raise Exception(f"Unable to refresh API token: {r.text}")
|
raise Exception(f"Unable to refresh API token: {r.text}")
|
||||||
self._token_data = r.json()
|
self._token_data = r.json()
|
||||||
@@ -132,19 +121,19 @@ class PlusApi:
|
|||||||
return {"authorization": f"Bearer {self._token_data.get('accessToken')}"}
|
return {"authorization": f"Bearer {self._token_data.get('accessToken')}"}
|
||||||
|
|
||||||
def _get(self, path: str) -> Response:
|
def _get(self, path: str) -> Response:
|
||||||
return self._session.get(
|
return requests.get(
|
||||||
f"{self.host}/v1/{path}", headers=self._get_authorization_header()
|
f"{self.host}/v1/{path}", headers=self._get_authorization_header()
|
||||||
)
|
)
|
||||||
|
|
||||||
def _post(self, path: str, data: dict) -> Response:
|
def _post(self, path: str, data: dict) -> Response:
|
||||||
return self._session.post(
|
return requests.post(
|
||||||
f"{self.host}/v1/{path}",
|
f"{self.host}/v1/{path}",
|
||||||
headers=self._get_authorization_header(),
|
headers=self._get_authorization_header(),
|
||||||
json=data,
|
json=data,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _put(self, path: str, data: dict) -> Response:
|
def _put(self, path: str, data: dict) -> Response:
|
||||||
return self._session.put(
|
return requests.put(
|
||||||
f"{self.host}/v1/{path}",
|
f"{self.host}/v1/{path}",
|
||||||
headers=self._get_authorization_header(),
|
headers=self._get_authorization_header(),
|
||||||
json=data,
|
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)
|
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(
|
def stats_snapshot(
|
||||||
config: FrigateConfig,
|
config: FrigateConfig,
|
||||||
stats_tracking: StatsTrackingTypes,
|
stats_tracking: StatsTrackingTypes,
|
||||||
@@ -365,14 +356,6 @@ def stats_snapshot(
|
|||||||
|
|
||||||
stats["service"]["storage"]["/dev/shm"] = calculate_shm_requirements(config)
|
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"] = {}
|
stats["processes"] = {}
|
||||||
for name, pid in stats_tracking["processes"].items():
|
for name, pid in stats_tracking["processes"].items():
|
||||||
stats["processes"][name] = {
|
stats["processes"][name] = {
|
||||||
@@ -381,6 +364,7 @@ def stats_snapshot(
|
|||||||
|
|
||||||
# Embed cpu/mem stats into detectors, cameras, and processes
|
# Embed cpu/mem stats into detectors, cameras, and processes
|
||||||
# so history consumers don't need the full cpu_usages dict
|
# so history consumers don't need the full cpu_usages dict
|
||||||
|
cpu_usages = stats.get("cpu_usages", {})
|
||||||
|
|
||||||
for det_stats in stats["detectors"].values():
|
for det_stats in stats["detectors"].values():
|
||||||
pid_str = str(det_stats.get("pid", ""))
|
pid_str = str(det_stats.get("pid", ""))
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from unittest.mock import Mock, patch
|
|||||||
|
|
||||||
import frigate.genai
|
import frigate.genai
|
||||||
from frigate.config import GenAIProviderEnum
|
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.const import MODEL_CACHE_DIR, REDACTED_CREDENTIAL_SENTINEL
|
||||||
from frigate.genai import GenAIClient
|
from frigate.genai import GenAIClient
|
||||||
from frigate.models import Event, Recordings, ReviewSegment
|
from frigate.models import Event, Recordings, ReviewSegment
|
||||||
@@ -112,30 +111,6 @@ class TestHttpApp(BaseTestHttp):
|
|||||||
mqtt = response.json()["mqtt"]
|
mqtt = response.json()["mqtt"]
|
||||||
assert mqtt["password"] == REDACTED_CREDENTIAL_SENTINEL
|
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):
|
def test_config_response_keeps_plus_model_reference(self):
|
||||||
model_id = "test_plus_reference"
|
model_id = "test_plus_reference"
|
||||||
model_path = os.path.join(MODEL_CACHE_DIR, model_id)
|
model_path = os.path.join(MODEL_CACHE_DIR, model_id)
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ def build_watchdog(
|
|||||||
MagicMock(),
|
MagicMock(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watchdog.requestor = MagicMock()
|
||||||
return watchdog
|
return watchdog
|
||||||
|
|
||||||
|
|
||||||
@@ -107,8 +108,8 @@ class TestCameraWatchdogStreamHealth(unittest.TestCase):
|
|||||||
def test_status_goes_to_the_matching_role_topic(self):
|
def test_status_goes_to_the_matching_role_topic(self):
|
||||||
watchdog = self._build_watchdog()
|
watchdog = self._build_watchdog()
|
||||||
|
|
||||||
watchdog.record_status[STREAM_TYPE_MAIN].send("online", 100.0)
|
watchdog._send_record_status(STREAM_TYPE_MAIN, "online", 100.0)
|
||||||
watchdog.record_status[STREAM_TYPE_SUB].send("offline", 100.0)
|
watchdog._send_record_status(STREAM_TYPE_SUB, "offline", 100.0)
|
||||||
|
|
||||||
watchdog.requestor.send_data.assert_any_call(
|
watchdog.requestor.send_data.assert_any_call(
|
||||||
"front_door/status/record", "online"
|
"front_door/status/record", "online"
|
||||||
@@ -120,9 +121,9 @@ class TestCameraWatchdogStreamHealth(unittest.TestCase):
|
|||||||
def test_status_is_cached_per_stream(self):
|
def test_status_is_cached_per_stream(self):
|
||||||
watchdog = self._build_watchdog()
|
watchdog = self._build_watchdog()
|
||||||
|
|
||||||
watchdog.record_status[STREAM_TYPE_MAIN].send("online", 100.0)
|
watchdog._send_record_status(STREAM_TYPE_MAIN, "online", 100.0)
|
||||||
watchdog.record_status[STREAM_TYPE_SUB].send("online", 100.0)
|
watchdog._send_record_status(STREAM_TYPE_SUB, "online", 100.0)
|
||||||
watchdog.record_status[STREAM_TYPE_MAIN].send("online", 100.0)
|
watchdog._send_record_status(STREAM_TYPE_MAIN, "online", 100.0)
|
||||||
|
|
||||||
assert watchdog.requestor.send_data.call_count == 2
|
assert watchdog.requestor.send_data.call_count == 2
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ from copy import deepcopy
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import requests
|
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
from ruamel.yaml.constructor import DuplicateKeyError
|
from ruamel.yaml.constructor import DuplicateKeyError
|
||||||
|
|
||||||
@@ -1596,31 +1595,6 @@ class TestConfig(unittest.TestCase):
|
|||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert frigate_config.primary_model.merged_labelmap[0] == "amazon"
|
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):
|
def test_fails_on_invalid_role(self):
|
||||||
config = {
|
config = {
|
||||||
"mqtt": {"host": "mqtt"},
|
"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"})
|
|
||||||
+42
-47
@@ -6,7 +6,6 @@ import subprocess as sp
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from collections import defaultdict, deque
|
from collections import defaultdict, deque
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from multiprocessing import Queue, Value
|
from multiprocessing import Queue, Value
|
||||||
from multiprocessing.synchronize import Event as MpEvent
|
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
|
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):
|
class CameraWatchdog(threading.Thread):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -208,16 +186,33 @@ class CameraWatchdog(threading.Thread):
|
|||||||
self._stall_active: bool = False
|
self._stall_active: bool = False
|
||||||
|
|
||||||
# Status caching to reduce message volume
|
# Status caching to reduce message volume
|
||||||
self.detect_status = self._role_status("detect")
|
self._last_detect_status: str | None = None
|
||||||
self.record_status = {
|
self._last_record_status: dict[str, str] = {}
|
||||||
stream_type: self._role_status(role)
|
self._last_detect_status_update_time: float = 0.0
|
||||||
for stream_type, role in STREAM_TYPE_TO_ROLE.items()
|
self._last_record_status_update_time: dict[str, float] = defaultdict(float)
|
||||||
}
|
|
||||||
|
|
||||||
def _role_status(self, role: str) -> RoleStatus:
|
def _send_detect_status(self, status: str, now: float) -> None:
|
||||||
return RoleStatus(
|
"""Send detect status only if changed or retry_interval has elapsed."""
|
||||||
self.requestor, f"{self.config.name}/status/{role}", self.sleeptime
|
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:
|
def _send_roles_offline(self, roles: list[CameraRoleEnum], now: float) -> None:
|
||||||
"""Send offline status for each role of a restarted ffmpeg process."""
|
"""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)
|
stream_type = ROLE_TO_STREAM_TYPE.get(role.value)
|
||||||
|
|
||||||
if stream_type is not None:
|
if stream_type is not None:
|
||||||
self.record_status[stream_type].send("offline", now)
|
self._send_record_status(stream_type, "offline", now)
|
||||||
else:
|
else:
|
||||||
self.requestor.send_data(
|
self.requestor.send_data(
|
||||||
f"{self.config.name}/status/{role.value}", "offline"
|
f"{self.config.name}/status/{role.value}", "offline"
|
||||||
@@ -393,11 +388,11 @@ class CameraWatchdog(threading.Thread):
|
|||||||
|
|
||||||
# update camera status
|
# update camera status
|
||||||
now = datetime.now().timestamp()
|
now = datetime.now().timestamp()
|
||||||
self.detect_status.send("disabled", now)
|
self._send_detect_status("disabled", now)
|
||||||
self.record_status[STREAM_TYPE_MAIN].send("disabled", now)
|
self._send_record_status(STREAM_TYPE_MAIN, "disabled", now)
|
||||||
# cameras without a sub stream never get a record_sub topic
|
# cameras without a sub stream never get a record_sub topic
|
||||||
if self.config.record.sub.enabled:
|
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
|
self.was_enabled = enabled
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -470,7 +465,7 @@ class CameraWatchdog(threading.Thread):
|
|||||||
can_restart = time_since_last_restart >= self.sleeptime
|
can_restart = time_since_last_restart >= self.sleeptime
|
||||||
|
|
||||||
if not self.capture_thread.is_alive():
|
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.camera_fps.value = 0
|
||||||
self.logger.error(
|
self.logger.error(
|
||||||
f"Ffmpeg process crashed unexpectedly for {self.config.name}."
|
f"Ffmpeg process crashed unexpectedly for {self.config.name}."
|
||||||
@@ -482,7 +477,7 @@ class CameraWatchdog(threading.Thread):
|
|||||||
self.fps_overflow_count += 1
|
self.fps_overflow_count += 1
|
||||||
|
|
||||||
if self.fps_overflow_count == 3:
|
if self.fps_overflow_count == 3:
|
||||||
self.detect_status.send("offline", now)
|
self._send_detect_status("offline", now)
|
||||||
self.fps_overflow_count = 0
|
self.fps_overflow_count = 0
|
||||||
self.camera_fps.value = 0
|
self.camera_fps.value = 0
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
@@ -492,7 +487,7 @@ class CameraWatchdog(threading.Thread):
|
|||||||
self.reset_capture_thread(drain_output=False)
|
self.reset_capture_thread(drain_output=False)
|
||||||
last_restart_time = now
|
last_restart_time = now
|
||||||
elif now - self.capture_thread.current_frame.value > 20:
|
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.camera_fps.value = 0
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
f"No frames received from {self.config.name} in 20 seconds. Exiting ffmpeg..."
|
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
|
last_restart_time = now
|
||||||
else:
|
else:
|
||||||
# process is running normally
|
# process is running normally
|
||||||
self.detect_status.send("online", now)
|
self._send_detect_status("online", now)
|
||||||
self.fps_overflow_count = 0
|
self.fps_overflow_count = 0
|
||||||
|
|
||||||
for p in self.ffmpeg_other_processes:
|
for p in self.ffmpeg_other_processes:
|
||||||
@@ -545,7 +540,7 @@ class CameraWatchdog(threading.Thread):
|
|||||||
elif stale_stream is None:
|
elif stale_stream is None:
|
||||||
if poll is None:
|
if poll is None:
|
||||||
for stream_type in recorded_streams:
|
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(
|
p["latest_segment_time"] = max(
|
||||||
self.latest_cache_segment_time[stream_type]
|
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"]
|
p["cmd"], self.logger, p["logpipe"], ffmpeg_process=p["process"]
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.detect_process_records_sub and self.config.record.stream_enabled(
|
if (
|
||||||
STREAM_TYPE_SUB
|
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)
|
now_utc = datetime.now().astimezone(UTC)
|
||||||
stale_reason = self._stream_staleness(STREAM_TYPE_SUB, now_utc)
|
stale_reason = self._stream_staleness(STREAM_TYPE_SUB, now_utc)
|
||||||
|
|
||||||
if self.detect_status.last_status == "offline":
|
if stale_reason is None:
|
||||||
# the sub stream is down whenever the detect process is
|
self._send_record_status(STREAM_TYPE_SUB, "online", now)
|
||||||
self.record_status[STREAM_TYPE_SUB].send("offline", now)
|
|
||||||
elif stale_reason is None:
|
|
||||||
self.record_status[STREAM_TYPE_SUB].send("online", now)
|
|
||||||
elif can_restart:
|
elif can_restart:
|
||||||
self.logger.error(
|
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..."
|
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()
|
self.reset_capture_thread()
|
||||||
last_restart_time = now
|
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 { test, expect } from "../fixtures/frigate-test";
|
||||||
|
import { systemTab } from "../helpers/system-tabs";
|
||||||
import { viewerProfile } from "../fixtures/mock-data/profile";
|
import { viewerProfile } from "../fixtures/mock-data/profile";
|
||||||
|
|
||||||
test.describe("Auth — admin access @high", () => {
|
test.describe("Auth — admin access @high", () => {
|
||||||
test("admin /system renders general tab", async ({ frigateApp }) => {
|
test("admin /system renders general tab", async ({ frigateApp }) => {
|
||||||
await frigateApp.goto("/system");
|
await frigateApp.goto("/system");
|
||||||
await expect(frigateApp.page.getByLabel("Select general")).toBeVisible({
|
await expect(systemTab(frigateApp, "general")).toBeVisible({
|
||||||
timeout: 15_000,
|
timeout: 15_000,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -28,7 +29,7 @@ test.describe("Auth — admin access @high", () => {
|
|||||||
|
|
||||||
test("admin /logs renders frigate tab", async ({ frigateApp }) => {
|
test("admin /logs renders frigate tab", async ({ frigateApp }) => {
|
||||||
await frigateApp.goto("/logs");
|
await frigateApp.goto("/logs");
|
||||||
await expect(frigateApp.page.getByLabel("Select frigate")).toBeVisible({
|
await expect(systemTab(frigateApp, "frigate")).toBeVisible({
|
||||||
timeout: 5_000,
|
timeout: 5_000,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -287,3 +287,22 @@ test.describe("Live mobile layout @critical @mobile", () => {
|
|||||||
await expect(frigateApp.page.locator("body")).toBeVisible();
|
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 { test, expect } from "../fixtures/frigate-test";
|
||||||
|
import { systemTab } from "../helpers/system-tabs";
|
||||||
import { viewerProfile } from "../fixtures/mock-data/profile";
|
import { viewerProfile } from "../fixtures/mock-data/profile";
|
||||||
|
|
||||||
const NOW = Math.floor(Date.now() / 1000);
|
const NOW = Math.floor(Date.now() / 1000);
|
||||||
@@ -55,7 +56,7 @@ test.describe("System — Health tab @medium", () => {
|
|||||||
});
|
});
|
||||||
await frigateApp.goto("/system");
|
await frigateApp.goto("/system");
|
||||||
|
|
||||||
await expect(frigateApp.page.getByLabel("Select health")).toHaveAttribute(
|
await expect(systemTab(frigateApp, "health")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
"on",
|
"on",
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
@@ -801,7 +802,7 @@ test.describe("System — Health notices sources @medium", () => {
|
|||||||
stats: QUIET_STATS,
|
stats: QUIET_STATS,
|
||||||
});
|
});
|
||||||
await frigateApp.goto("/system#health");
|
await frigateApp.goto("/system#health");
|
||||||
await expect(frigateApp.page.getByLabel("Select health")).toHaveAttribute(
|
await expect(systemTab(frigateApp, "health")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
"on",
|
"on",
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
|
|||||||
@@ -6,42 +6,66 @@
|
|||||||
* RestartDialog cancel flow.
|
* RestartDialog cancel flow.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from "../fixtures/frigate-test";
|
import { test, expect, FrigateApp } from "../fixtures/frigate-test";
|
||||||
import {
|
import {
|
||||||
expectBodyInteractive,
|
expectBodyInteractive,
|
||||||
waitForBodyInteractive,
|
waitForBodyInteractive,
|
||||||
} from "../helpers/overlay-interaction";
|
} 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.describe("System — tabs @medium", () => {
|
||||||
test("general tab is active by default via #general hash", async ({
|
test("general tab is active by default via #general hash", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.goto("/system#general");
|
await frigateApp.goto("/system#general");
|
||||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
"on",
|
"on",
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
);
|
);
|
||||||
await expect(frigateApp.page.getByLabel("Select storage")).toBeVisible();
|
if (!frigateApp.isMobile) {
|
||||||
await expect(frigateApp.page.getByLabel("Select cameras")).toBeVisible();
|
await expect(systemTab(frigateApp, "storage")).toBeVisible();
|
||||||
|
await expect(systemTab(frigateApp, "cameras")).toBeVisible();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Storage tab activates and deactivates General", async ({
|
test("Storage tab activates and deactivates General", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.goto("/system#general");
|
await frigateApp.goto("/system#general");
|
||||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
"on",
|
"on",
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
);
|
);
|
||||||
await frigateApp.page.getByLabel("Select storage").click();
|
await selectTab(frigateApp, "storage");
|
||||||
await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute(
|
await expectTabActive(frigateApp, "storage");
|
||||||
"data-state",
|
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||||
"on",
|
|
||||||
{ timeout: 5_000 },
|
|
||||||
);
|
|
||||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
|
||||||
"data-state",
|
"data-state",
|
||||||
"off",
|
"off",
|
||||||
);
|
);
|
||||||
@@ -49,24 +73,20 @@ test.describe("System — tabs @medium", () => {
|
|||||||
|
|
||||||
test("Cameras tab activates", async ({ frigateApp }) => {
|
test("Cameras tab activates", async ({ frigateApp }) => {
|
||||||
await frigateApp.goto("/system#general");
|
await frigateApp.goto("/system#general");
|
||||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
"on",
|
"on",
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
);
|
);
|
||||||
await frigateApp.page.getByLabel("Select cameras").click();
|
await selectTab(frigateApp, "cameras");
|
||||||
await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute(
|
await expectTabActive(frigateApp, "cameras");
|
||||||
"data-state",
|
|
||||||
"on",
|
|
||||||
{ timeout: 5_000 },
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("general tab shows version and last-refreshed", async ({
|
test("general tab shows version and last-refreshed", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.goto("/system#general");
|
await frigateApp.goto("/system#general");
|
||||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
"on",
|
"on",
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
@@ -87,17 +107,13 @@ test.describe("System — tabs @medium", () => {
|
|||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.goto("/system#general");
|
await frigateApp.goto("/system#general");
|
||||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
"on",
|
"on",
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
);
|
);
|
||||||
await frigateApp.page.getByLabel("Select storage").click();
|
await selectTab(frigateApp, "storage");
|
||||||
await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute(
|
await expectTabActive(frigateApp, "storage");
|
||||||
"data-state",
|
|
||||||
"on",
|
|
||||||
{ timeout: 5_000 },
|
|
||||||
);
|
|
||||||
// On desktop, tab buttons render text labels so the word "storage"
|
// On desktop, tab buttons render text labels so the word "storage"
|
||||||
// always appears in #pageRoot after switching. On mobile, tabs are
|
// always appears in #pageRoot after switching. On mobile, tabs are
|
||||||
// icon-only, so we verify the general-tab content disappears instead
|
// icon-only, so we verify the general-tab content disappears instead
|
||||||
@@ -112,25 +128,23 @@ test.describe("System — tabs @medium", () => {
|
|||||||
} else {
|
} else {
|
||||||
// Mobile: tab activation (data-state "on") already asserted above.
|
// Mobile: tab activation (data-state "on") already asserted above.
|
||||||
// Additionally confirm general tab is no longer the active tab.
|
// Additionally confirm general tab is no longer the active tab.
|
||||||
await expect(
|
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||||
frigateApp.page.getByLabel("Select general"),
|
"data-state",
|
||||||
).toHaveAttribute("data-state", "off", { timeout: 5_000 });
|
"off",
|
||||||
|
{ timeout: 5_000 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("cameras tab renders each configured camera", async ({ frigateApp }) => {
|
test("cameras tab renders each configured camera", async ({ frigateApp }) => {
|
||||||
await frigateApp.goto("/system#general");
|
await frigateApp.goto("/system#general");
|
||||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
"on",
|
"on",
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
);
|
);
|
||||||
await frigateApp.page.getByLabel("Select cameras").click();
|
await selectTab(frigateApp, "cameras");
|
||||||
await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute(
|
await expectTabActive(frigateApp, "cameras");
|
||||||
"data-state",
|
|
||||||
"on",
|
|
||||||
{ timeout: 5_000 },
|
|
||||||
);
|
|
||||||
// Cameras tab lists every camera from config/stats. The default
|
// Cameras tab lists every camera from config/stats. The default
|
||||||
// mock has front_door, backyard, garage.
|
// mock has front_door, backyard, garage.
|
||||||
for (const cam of ["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 } },
|
config: { semantic_search: { enabled: true } },
|
||||||
});
|
});
|
||||||
await frigateApp.goto("/system#general");
|
await frigateApp.goto("/system#general");
|
||||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
"on",
|
"on",
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
);
|
);
|
||||||
const enrichTab = frigateApp.page.getByLabel(/select enrichments/i).first();
|
await selectTab(frigateApp, "enrichments");
|
||||||
await expect(enrichTab).toBeVisible({ timeout: 5_000 });
|
await expectTabActive(frigateApp, "enrichments");
|
||||||
await enrichTab.click();
|
|
||||||
await expect(enrichTab).toHaveAttribute("data-state", "on", {
|
|
||||||
timeout: 5_000,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -223,31 +233,27 @@ test.describe("System — mobile @medium @mobile", () => {
|
|||||||
|
|
||||||
test("tabs render at mobile viewport", async ({ frigateApp }) => {
|
test("tabs render at mobile viewport", async ({ frigateApp }) => {
|
||||||
await frigateApp.goto("/system#general");
|
await frigateApp.goto("/system#general");
|
||||||
await expect(frigateApp.page.getByLabel("Select general")).toBeVisible({
|
await expect(systemTab(frigateApp, "general")).toBeVisible({
|
||||||
timeout: 15_000,
|
timeout: 15_000,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("switching tabs works at mobile viewport", async ({ frigateApp }) => {
|
test("switching tabs works at mobile viewport", async ({ frigateApp }) => {
|
||||||
await frigateApp.goto("/system#general");
|
await frigateApp.goto("/system#general");
|
||||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
"on",
|
"on",
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
);
|
);
|
||||||
await frigateApp.page.getByLabel("Select storage").click();
|
await selectTab(frigateApp, "storage");
|
||||||
await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute(
|
await expectTabActive(frigateApp, "storage");
|
||||||
"data-state",
|
|
||||||
"on",
|
|
||||||
{ timeout: 5_000 },
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("header controls leave the logo uncovered on a narrow phone", async ({
|
test("header controls leave the logo uncovered on a narrow phone", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.goto("/system#general");
|
await frigateApp.goto("/system#general");
|
||||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
"on",
|
"on",
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
@@ -255,9 +261,9 @@ test.describe("System — mobile @medium @mobile", () => {
|
|||||||
await frigateApp.page.setViewportSize({ width: 320, height: 740 });
|
await frigateApp.page.setViewportSize({ width: 320, height: 740 });
|
||||||
|
|
||||||
const logo = frigateApp.page.locator("svg.fill-current").first();
|
const logo = frigateApp.page.locator("svg.fill-current").first();
|
||||||
const tabs = frigateApp.page
|
const kebab = frigateApp.page.getByLabel("Show all tabs");
|
||||||
.locator("[data-radix-scroll-area-viewport]")
|
await expect(kebab).toBeVisible();
|
||||||
.filter({ has: frigateApp.page.getByLabel("Select general") });
|
const tabs = kebab.locator("..");
|
||||||
const refreshed = frigateApp.page.getByText(/Just now|ago/);
|
const refreshed = frigateApp.page.getByText(/Just now|ago/);
|
||||||
|
|
||||||
const logoBox = await logo.boundingBox();
|
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(tabsBox!.x + tabsBox!.width).toBeLessThanOrEqual(logoBox!.x + 1);
|
||||||
expect(refreshedBox!.x).toBeGreaterThanOrEqual(logoBox!.x + logoBox!.width);
|
expect(refreshedBox!.x).toBeGreaterThanOrEqual(logoBox!.x + logoBox!.width);
|
||||||
|
|
||||||
// the clipped tabs stay reachable by scrolling
|
// the tabs that do not fit stay reachable through the kebab
|
||||||
const overflow = await tabs.evaluate((el) => ({
|
await selectTab(frigateApp, "cameras");
|
||||||
scroll: el.scrollWidth,
|
await expectTabActive(frigateApp, "cameras");
|
||||||
client: el.clientWidth,
|
await expect(frigateApp.page.getByLabel("Show less")).toHaveCount(0);
|
||||||
}));
|
|
||||||
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 },
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
},
|
},
|
||||||
"title": "System",
|
"title": "System",
|
||||||
"metrics": "System metrics",
|
"metrics": "System metrics",
|
||||||
|
"showAllTabs": "Show all tabs",
|
||||||
|
"showLessTabs": "Show less",
|
||||||
"health": {
|
"health": {
|
||||||
"title": "Health",
|
"title": "Health",
|
||||||
"notices": {
|
"notices": {
|
||||||
|
|||||||
@@ -8,18 +8,8 @@ import { isDesktop, isMobile } from "react-device-detect";
|
|||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { MdHome } from "react-icons/md";
|
import { MdHome } from "react-icons/md";
|
||||||
import { Button, buttonVariants } from "../ui/button";
|
import { Button, buttonVariants } from "../ui/button";
|
||||||
import {
|
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||||
useCallback,
|
import OverflowStrip from "../mobile/OverflowStrip";
|
||||||
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 { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||||
import { LuPencil, LuPlus } from "react-icons/lu";
|
import { LuPencil, LuPlus } from "react-icons/lu";
|
||||||
import {
|
import {
|
||||||
@@ -156,80 +146,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
|||||||
|
|
||||||
const [addGroup, setAddGroup] = useState(false);
|
const [addGroup, setAddGroup] = useState(false);
|
||||||
|
|
||||||
// mobile overflow reveal - the group strip sits left of the logo and is
|
const groupButtons = () => {
|
||||||
// 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 buttons = [
|
const buttons = [
|
||||||
<Button
|
<Button
|
||||||
key="default-group"
|
key="default-group"
|
||||||
@@ -245,7 +162,6 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
|||||||
if (group) {
|
if (group) {
|
||||||
setGroup("default", true);
|
setGroup("default", true);
|
||||||
}
|
}
|
||||||
afterSelect?.();
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MdHome className="size-5" />
|
<MdHome className="size-5" />
|
||||||
@@ -263,12 +179,16 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setGroup(name, group != "default");
|
setGroup(name, group != "default");
|
||||||
afterSelect?.();
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{config && config.icon && isValidIconName(config.icon) && (
|
<IconRenderer
|
||||||
<IconRenderer icon={LuIcons[config.icon]} className="size-5" />
|
icon={
|
||||||
)}
|
isValidIconName(config.icon)
|
||||||
|
? LuIcons[config.icon]
|
||||||
|
: LuIcons.LuFolder
|
||||||
|
}
|
||||||
|
className="size-5"
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
)),
|
)),
|
||||||
];
|
];
|
||||||
@@ -282,7 +202,6 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAddGroup(true);
|
setAddGroup(true);
|
||||||
afterSelect?.();
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LuPencil className="size-5 text-primary-variant" />
|
<LuPencil className="size-5 text-primary-variant" />
|
||||||
@@ -350,12 +269,14 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
|||||||
onMouseEnter={() => showTooltip(name)}
|
onMouseEnter={() => showTooltip(name)}
|
||||||
onMouseLeave={() => showTooltip(undefined)}
|
onMouseLeave={() => showTooltip(undefined)}
|
||||||
>
|
>
|
||||||
{config && config.icon && isValidIconName(config.icon) && (
|
|
||||||
<IconRenderer
|
<IconRenderer
|
||||||
icon={LuIcons[config.icon]}
|
icon={
|
||||||
|
isValidIconName(config.icon)
|
||||||
|
? LuIcons[config.icon]
|
||||||
|
: LuIcons.LuFolder
|
||||||
|
}
|
||||||
className="size-4"
|
className="size-4"
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipPortal>
|
<TooltipPortal>
|
||||||
@@ -390,75 +311,14 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<OverflowStrip
|
||||||
ref={wrapperRef}
|
className={className}
|
||||||
className={cn("flex min-w-0 items-center gap-1", className)}
|
items={groupButtons()}
|
||||||
>
|
activeIndex={groups.findIndex(([name]) => name == group) + 1}
|
||||||
<div className="flex min-w-0 items-center gap-2 overflow-hidden whitespace-nowrap">
|
showAllLabel={t("group.showAll")}
|
||||||
{visibleCount == null
|
showLessLabel={t("group.showLess")}
|
||||||
? 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>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { FrigateStats } from "@/types/stats";
|
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 TimeAgo from "@/components/dynamic/TimeAgo";
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
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 { isDesktop, isMobile } from "react-device-detect";
|
||||||
import GeneralMetrics from "@/views/system/GeneralMetrics";
|
import GeneralMetrics from "@/views/system/GeneralMetrics";
|
||||||
import StorageMetrics from "@/views/system/StorageMetrics";
|
import StorageMetrics from "@/views/system/StorageMetrics";
|
||||||
@@ -36,6 +38,14 @@ const allMetrics = [
|
|||||||
] as const;
|
] as const;
|
||||||
type SystemMetric = (typeof allMetrics)[number];
|
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() {
|
function System() {
|
||||||
const { t } = useTranslation(["views/system"]);
|
const { t } = useTranslation(["views/system"]);
|
||||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||||
@@ -98,7 +108,35 @@ function System() {
|
|||||||
{isMobile && (
|
{isMobile && (
|
||||||
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
|
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
|
||||||
)}
|
)}
|
||||||
<ScrollArea className={cn("whitespace-nowrap", isMobile && "w-[45%]")}>
|
{isMobile ? (
|
||||||
|
<div className="w-[calc(50%-1rem)]">
|
||||||
|
<OverflowStrip
|
||||||
|
items={metrics.map((item) => (
|
||||||
|
<Toggle
|
||||||
|
key={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"),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{metricIcons[item]}
|
||||||
|
</Toggle>
|
||||||
|
))}
|
||||||
|
activeIndex={metrics.indexOf(pageToggle)}
|
||||||
|
gapClassName="gap-0.5"
|
||||||
|
showAllLabel={t("showAllTabs")}
|
||||||
|
showLessLabel={t("showLessTabs")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="whitespace-nowrap">
|
||||||
<div className="flex flex-row">
|
<div className="flex flex-row">
|
||||||
<ToggleGroup
|
<ToggleGroup
|
||||||
className="*:rounded-md *:px-3 *:py-4"
|
className="*:rounded-md *:px-3 *:py-4"
|
||||||
@@ -121,20 +159,15 @@ function System() {
|
|||||||
item: t(item + ".title"),
|
item: t(item + ".title"),
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{item == "health" && <LuHeartPulse className="size-4" />}
|
{metricIcons[item]}
|
||||||
{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>
|
<div className="smart-capitalize">{t(item + ".title")}</div>
|
||||||
)}
|
|
||||||
</ToggleGroupItem>
|
</ToggleGroupItem>
|
||||||
))}
|
))}
|
||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
<ScrollBar orientation="horizontal" className="h-0" />
|
<ScrollBar orientation="horizontal" className="h-0" />
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex h-full items-center">
|
<div className="flex h-full items-center">
|
||||||
{pageToggle == "health" && (
|
{pageToggle == "health" && (
|
||||||
|
|||||||
@@ -419,7 +419,7 @@ export default function LiveDashboardView({
|
|||||||
{isMobile && (
|
{isMobile && (
|
||||||
<div className="relative flex h-11 items-center justify-between">
|
<div className="relative flex h-11 items-center justify-between">
|
||||||
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
|
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
|
||||||
<div className="w-[45%]">
|
<div className="w-[calc(50%-1rem)]">
|
||||||
<CameraGroupSelector />
|
<CameraGroupSelector />
|
||||||
</div>
|
</div>
|
||||||
{(!cameraGroup || cameraGroup == "default" || isMobileOnly) && (
|
{(!cameraGroup || cameraGroup == "default" || isMobileOnly) && (
|
||||||
|
|||||||
Reference in New Issue
Block a user