Compare commits

..
4 Commits
Author SHA1 Message Date
Josh HawkinsandGitHub 9b2839f4fb Fix go2rtc missing from process stats after a restart (#24546)
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / AMD64 Build (push) Waiting to run
CI / AMD64 Smoke Test (push) Blocked by required conditions
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* re-resolve go2rtc pid on every stats snapshot

* add snapshot test for go2rtc pid after restart
2026-10-02 15:35:04 -06:00
Josh HawkinsandGitHub c06bf97b5e retry and report Frigate+ connection failures at startup (#24545)
A Frigate+ model that wasn't cached yet needed api.frigate.video at startup, and when it couldn't be reached (a network that comes up late, a DNS blip) the requests ConnectionError wasn't a validation error, so Frigate crashed with a traceback before it could start. PlusApi requests now go through a session that retries connection failures for about 30 seconds, and a connection failure that outlasts that is raised as a ValueError so it shows up as a clear config validation error instead.
2026-10-02 15:34:26 -06:00
Josh HawkinsandGitHub f3723698cd Refactor camera status caching (#24544)
* refactor camera status caching

* fix tests
2026-10-02 15:33:58 -06:00
Josh HawkinsandGitHub 38b87feece support env var substitution for notification email (#24517)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
`notifications.email` is now an `EnvString`, so it can come from `secrets.yaml`, a Docker secret, or a container env var. `/api/config` returns the resolved value, so the email is now redacted for non-admin users, including each camera's inherited copy and the profile `base_config` copy.
2026-10-02 06:58:29 -06:00
21 changed files with 513 additions and 423 deletions
@@ -571,6 +571,8 @@ 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
+14 -3
View File
@@ -311,9 +311,12 @@ def config(request: Request):
mode="json", warnings="none", exclude_none=True mode="json", warnings="none", exclude_none=True
) )
# remove environment_vars for non-admin users is_admin = request.headers.get("remote-role") == "admin"
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")
@@ -370,7 +373,15 @@ def config(request: Request):
camera_name camera_name
) )
if base_sections: if base_sections:
camera_dict["base_config"] = 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"
)
# 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(
+2 -1
View File
@@ -1,6 +1,7 @@
from pydantic import Field from pydantic import Field
from ..base import FrigateBaseModel from ..base import FrigateBaseModel
from ..env import EnvString
__all__ = ["NotificationConfig"] __all__ = ["NotificationConfig"]
@@ -11,7 +12,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: str | None = Field( email: EnvString | 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.",
+16 -3
View File
@@ -186,15 +186,28 @@ 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):
download_url = plus_api.get_model_download_url(model_id) try:
r = requests.get(download_url) 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
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(plus_api.get_model_info(model_id), f) json.dump(model_info, f)
model_info = load_plus_model_info(model_id) model_info = load_plus_model_info(model_id)
+15 -4
View File
@@ -9,7 +9,9 @@ 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
@@ -101,6 +103,13 @@ 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
@@ -111,7 +120,9 @@ 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 = requests.get(f"{self.host}/v1/auth/token", auth=(parts[0], parts[1])) r = self._session.get(
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()
@@ -121,19 +132,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 requests.get( return self._session.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 requests.post( return self._session.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 requests.put( return self._session.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,
+17 -1
View File
@@ -236,6 +236,15 @@ 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,
@@ -356,6 +365,14 @@ 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] = {
@@ -364,7 +381,6 @@ 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", ""))
+25
View File
@@ -4,6 +4,7 @@ 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
@@ -111,6 +112,30 @@ 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)
+5 -6
View File
@@ -59,7 +59,6 @@ def build_watchdog(
MagicMock(), MagicMock(),
) )
watchdog.requestor = MagicMock()
return watchdog return watchdog
@@ -108,8 +107,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._send_record_status(STREAM_TYPE_MAIN, "online", 100.0) watchdog.record_status[STREAM_TYPE_MAIN].send("online", 100.0)
watchdog._send_record_status(STREAM_TYPE_SUB, "offline", 100.0) watchdog.record_status[STREAM_TYPE_SUB].send("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"
@@ -121,9 +120,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._send_record_status(STREAM_TYPE_MAIN, "online", 100.0) watchdog.record_status[STREAM_TYPE_MAIN].send("online", 100.0)
watchdog._send_record_status(STREAM_TYPE_SUB, "online", 100.0) watchdog.record_status[STREAM_TYPE_SUB].send("online", 100.0)
watchdog._send_record_status(STREAM_TYPE_MAIN, "online", 100.0) watchdog.record_status[STREAM_TYPE_MAIN].send("online", 100.0)
assert watchdog.requestor.send_data.call_count == 2 assert watchdog.requestor.send_data.call_count == 2
+26
View File
@@ -6,6 +6,7 @@ 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
@@ -1595,6 +1596,31 @@ 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"},
+70
View File
@@ -0,0 +1,70 @@
"""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"})
+48 -43
View File
@@ -6,6 +6,7 @@ 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
@@ -108,6 +109,27 @@ 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,
@@ -186,33 +208,16 @@ 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._last_detect_status: str | None = None self.detect_status = self._role_status("detect")
self._last_record_status: dict[str, str] = {} self.record_status = {
self._last_detect_status_update_time: float = 0.0 stream_type: self._role_status(role)
self._last_record_status_update_time: dict[str, float] = defaultdict(float) for stream_type, role in STREAM_TYPE_TO_ROLE.items()
}
def _send_detect_status(self, status: str, now: float) -> None: def _role_status(self, role: str) -> RoleStatus:
"""Send detect status only if changed or retry_interval has elapsed.""" return RoleStatus(
if ( self.requestor, f"{self.config.name}/status/{role}", self.sleeptime
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."""
@@ -222,7 +227,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._send_record_status(stream_type, "offline", now) self.record_status[stream_type].send("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"
@@ -388,11 +393,11 @@ class CameraWatchdog(threading.Thread):
# update camera status # update camera status
now = datetime.now().timestamp() now = datetime.now().timestamp()
self._send_detect_status("disabled", now) self.detect_status.send("disabled", now)
self._send_record_status(STREAM_TYPE_MAIN, "disabled", now) self.record_status[STREAM_TYPE_MAIN].send("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._send_record_status(STREAM_TYPE_SUB, "disabled", now) self.record_status[STREAM_TYPE_SUB].send("disabled", now)
self.was_enabled = enabled self.was_enabled = enabled
continue continue
@@ -465,7 +470,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._send_detect_status("offline", now) self.detect_status.send("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}."
@@ -477,7 +482,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._send_detect_status("offline", now) self.detect_status.send("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(
@@ -487,7 +492,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._send_detect_status("offline", now) self.detect_status.send("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..."
@@ -497,7 +502,7 @@ class CameraWatchdog(threading.Thread):
last_restart_time = now last_restart_time = now
else: else:
# process is running normally # process is running normally
self._send_detect_status("online", now) self.detect_status.send("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:
@@ -540,7 +545,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._send_record_status(stream_type, "online", now) self.record_status[stream_type].send("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]
@@ -556,22 +561,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 ( if self.detect_process_records_sub and self.config.record.stream_enabled(
self.detect_process_records_sub STREAM_TYPE_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 stale_reason is None: if self.detect_status.last_status == "offline":
self._send_record_status(STREAM_TYPE_SUB, "online", now) # 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)
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._send_record_status(STREAM_TYPE_SUB, "offline", now) self.record_status[STREAM_TYPE_SUB].send("offline", now)
self.reset_capture_thread() self.reset_capture_thread()
last_restart_time = now last_restart_time = now
-11
View File
@@ -1,11 +0,0 @@
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();
}
+2 -3
View File
@@ -7,13 +7,12 @@
*/ */
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(systemTab(frigateApp, "general")).toBeVisible({ await expect(frigateApp.page.getByLabel("Select general")).toBeVisible({
timeout: 15_000, timeout: 15_000,
}); });
}); });
@@ -29,7 +28,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(systemTab(frigateApp, "frigate")).toBeVisible({ await expect(frigateApp.page.getByLabel("Select frigate")).toBeVisible({
timeout: 5_000, timeout: 5_000,
}); });
}); });
-19
View File
@@ -287,22 +287,3 @@ 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 });
});
});
+2 -3
View File
@@ -6,7 +6,6 @@
*/ */
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);
@@ -56,7 +55,7 @@ test.describe("System — Health tab @medium", () => {
}); });
await frigateApp.goto("/system"); await frigateApp.goto("/system");
await expect(systemTab(frigateApp, "health")).toHaveAttribute( await expect(frigateApp.page.getByLabel("Select health")).toHaveAttribute(
"data-state", "data-state",
"on", "on",
{ timeout: 15_000 }, { timeout: 15_000 },
@@ -802,7 +801,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(systemTab(frigateApp, "health")).toHaveAttribute( await expect(frigateApp.page.getByLabel("Select health")).toHaveAttribute(
"data-state", "data-state",
"on", "on",
{ timeout: 15_000 }, { timeout: 15_000 },
+71 -66
View File
@@ -6,66 +6,42 @@
* RestartDialog cancel flow. * RestartDialog cancel flow.
*/ */
import { test, expect, FrigateApp } from "../fixtures/frigate-test"; import { test, expect } 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(systemTab(frigateApp, "general")).toHaveAttribute( await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
"data-state", "data-state",
"on", "on",
{ timeout: 15_000 }, { timeout: 15_000 },
); );
if (!frigateApp.isMobile) { await expect(frigateApp.page.getByLabel("Select storage")).toBeVisible();
await expect(systemTab(frigateApp, "storage")).toBeVisible(); await expect(frigateApp.page.getByLabel("Select cameras")).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(systemTab(frigateApp, "general")).toHaveAttribute( await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
"data-state", "data-state",
"on", "on",
{ timeout: 15_000 }, { timeout: 15_000 },
); );
await selectTab(frigateApp, "storage"); await frigateApp.page.getByLabel("Select storage").click();
await expectTabActive(frigateApp, "storage"); await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute(
await expect(systemTab(frigateApp, "general")).toHaveAttribute( "data-state",
"on",
{ timeout: 5_000 },
);
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
"data-state", "data-state",
"off", "off",
); );
@@ -73,20 +49,24 @@ 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(systemTab(frigateApp, "general")).toHaveAttribute( await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
"data-state", "data-state",
"on", "on",
{ timeout: 15_000 }, { timeout: 15_000 },
); );
await selectTab(frigateApp, "cameras"); await frigateApp.page.getByLabel("Select cameras").click();
await expectTabActive(frigateApp, "cameras"); await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute(
"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(systemTab(frigateApp, "general")).toHaveAttribute( await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
"data-state", "data-state",
"on", "on",
{ timeout: 15_000 }, { timeout: 15_000 },
@@ -107,13 +87,17 @@ test.describe("System — tabs @medium", () => {
frigateApp, frigateApp,
}) => { }) => {
await frigateApp.goto("/system#general"); await frigateApp.goto("/system#general");
await expect(systemTab(frigateApp, "general")).toHaveAttribute( await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
"data-state", "data-state",
"on", "on",
{ timeout: 15_000 }, { timeout: 15_000 },
); );
await selectTab(frigateApp, "storage"); await frigateApp.page.getByLabel("Select storage").click();
await expectTabActive(frigateApp, "storage"); await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute(
"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
@@ -128,23 +112,25 @@ 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(systemTab(frigateApp, "general")).toHaveAttribute( await expect(
"data-state", frigateApp.page.getByLabel("Select general"),
"off", ).toHaveAttribute("data-state", "off", { timeout: 5_000 });
{ 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(systemTab(frigateApp, "general")).toHaveAttribute( await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
"data-state", "data-state",
"on", "on",
{ timeout: 15_000 }, { timeout: 15_000 },
); );
await selectTab(frigateApp, "cameras"); await frigateApp.page.getByLabel("Select cameras").click();
await expectTabActive(frigateApp, "cameras"); await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute(
"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"]) {
@@ -165,13 +151,17 @@ 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(systemTab(frigateApp, "general")).toHaveAttribute( await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
"data-state", "data-state",
"on", "on",
{ timeout: 15_000 }, { timeout: 15_000 },
); );
await selectTab(frigateApp, "enrichments"); const enrichTab = frigateApp.page.getByLabel(/select enrichments/i).first();
await expectTabActive(frigateApp, "enrichments"); await expect(enrichTab).toBeVisible({ timeout: 5_000 });
await enrichTab.click();
await expect(enrichTab).toHaveAttribute("data-state", "on", {
timeout: 5_000,
});
}); });
}); });
@@ -233,27 +223,31 @@ 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(systemTab(frigateApp, "general")).toBeVisible({ await expect(frigateApp.page.getByLabel("Select 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(systemTab(frigateApp, "general")).toHaveAttribute( await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
"data-state", "data-state",
"on", "on",
{ timeout: 15_000 }, { timeout: 15_000 },
); );
await selectTab(frigateApp, "storage"); await frigateApp.page.getByLabel("Select storage").click();
await expectTabActive(frigateApp, "storage"); await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute(
"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(systemTab(frigateApp, "general")).toHaveAttribute( await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
"data-state", "data-state",
"on", "on",
{ timeout: 15_000 }, { timeout: 15_000 },
@@ -261,9 +255,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 kebab = frigateApp.page.getByLabel("Show all tabs"); const tabs = frigateApp.page
await expect(kebab).toBeVisible(); .locator("[data-radix-scroll-area-viewport]")
const tabs = kebab.locator(".."); .filter({ has: frigateApp.page.getByLabel("Select general") });
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();
@@ -273,9 +267,20 @@ 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 tabs that do not fit stay reachable through the kebab // the clipped tabs stay reachable by scrolling
await selectTab(frigateApp, "cameras"); const overflow = await tabs.evaluate((el) => ({
await expectTabActive(frigateApp, "cameras"); scroll: el.scrollWidth,
await expect(frigateApp.page.getByLabel("Show less")).toHaveCount(0); 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 },
);
}); });
}); });
-2
View File
@@ -14,8 +14,6 @@
}, },
"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": {
+166 -26
View File
@@ -8,8 +8,18 @@ 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 { useCallback, useContext, useEffect, useMemo, useState } from "react"; import {
import OverflowStrip from "../mobile/OverflowStrip"; 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 { 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 {
@@ -146,7 +156,80 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
const [addGroup, setAddGroup] = useState(false); const [addGroup, setAddGroup] = useState(false);
const groupButtons = () => { // 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 buttons = [ const buttons = [
<Button <Button
key="default-group" key="default-group"
@@ -162,6 +245,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
if (group) { if (group) {
setGroup("default", true); setGroup("default", true);
} }
afterSelect?.();
}} }}
> >
<MdHome className="size-5" /> <MdHome className="size-5" />
@@ -179,16 +263,12 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
size="sm" size="sm"
onClick={() => { onClick={() => {
setGroup(name, group != "default"); setGroup(name, group != "default");
afterSelect?.();
}} }}
> >
<IconRenderer {config && config.icon && isValidIconName(config.icon) && (
icon={ <IconRenderer icon={LuIcons[config.icon]} className="size-5" />
isValidIconName(config.icon) )}
? LuIcons[config.icon]
: LuIcons.LuFolder
}
className="size-5"
/>
</Button> </Button>
)), )),
]; ];
@@ -202,6 +282,7 @@ 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" />
@@ -269,14 +350,12 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
onMouseEnter={() => showTooltip(name)} onMouseEnter={() => showTooltip(name)}
onMouseLeave={() => showTooltip(undefined)} onMouseLeave={() => showTooltip(undefined)}
> >
<IconRenderer {config && config.icon && isValidIconName(config.icon) && (
icon={ <IconRenderer
isValidIconName(config.icon) icon={LuIcons[config.icon]}
? LuIcons[config.icon] className="size-4"
: LuIcons.LuFolder />
} )}
className="size-4"
/>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipPortal> <TooltipPortal>
@@ -311,13 +390,74 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
)} )}
</div> </div>
) : ( ) : (
<OverflowStrip <div
className={className} ref={wrapperRef}
items={groupButtons()} className={cn("flex min-w-0 items-center gap-1", className)}
activeIndex={groups.findIndex(([name]) => name == group) + 1} >
showAllLabel={t("group.showAll")} <div className="flex min-w-0 items-center gap-2 overflow-hidden whitespace-nowrap">
showLessLabel={t("group.showLess")} {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>
)} )}
</> </>
); );
-167
View File
@@ -1,167 +0,0 @@
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>
);
}
+31 -64
View File
@@ -1,10 +1,8 @@
import useSWR from "swr"; import useSWR from "swr";
import { FrigateStats } from "@/types/stats"; import { FrigateStats } from "@/types/stats";
import { ReactNode, useEffect, useMemo, useRef, useState } from "react"; import { 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";
@@ -38,14 +36,6 @@ 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", {
@@ -108,66 +98,43 @@ 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" />
)} )}
{isMobile ? ( <ScrollArea className={cn("whitespace-nowrap", isMobile && "w-[45%]")}>
<div className="w-[calc(50%-1rem)]"> <div className="flex flex-row">
<OverflowStrip <ToggleGroup
items={metrics.map((item) => ( className="*:rounded-md *:px-3 *:py-4"
<Toggle 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} key={item}
className={cn( className={`flex items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
"shrink-0 rounded-md px-3 py-4", value={item}
pageToggle != item && "*:text-muted-foreground",
)}
size="sm"
pressed={pageToggle == item}
onPressedChange={() => setPageToggle(item)}
aria-label={t("selectItem", { aria-label={t("selectItem", {
ns: "common", ns: "common",
item: t(item + ".title"), item: t(item + ".title"),
})} })}
> >
{metricIcons[item]} {item == "health" && <LuHeartPulse className="size-4" />}
</Toggle> {item == "general" && <LuActivity className="size-4" />}
))} {item == "enrichments" && <LuSearchCode className="size-4" />}
activeIndex={metrics.indexOf(pageToggle)} {item == "storage" && <LuHardDrive className="size-4" />}
gapClassName="gap-0.5" {item == "cameras" && <FaVideo className="size-4" />}
showAllLabel={t("showAllTabs")} {isDesktop && (
showLessLabel={t("showLessTabs")}
/>
</div>
) : (
<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> <div className="smart-capitalize">{t(item + ".title")}</div>
</ToggleGroupItem> )}
))} </ToggleGroupItem>
</ToggleGroup> ))}
<ScrollBar orientation="horizontal" className="h-0" /> </ToggleGroup>
</div> <ScrollBar orientation="horizontal" className="h-0" />
</ScrollArea> </div>
)} </ScrollArea>
<div className="flex h-full items-center"> <div className="flex h-full items-center">
{pageToggle == "health" && ( {pageToggle == "health" && (
+1 -1
View File
@@ -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-[calc(50%-1rem)]"> <div className="w-[45%]">
<CameraGroupSelector /> <CameraGroupSelector />
</div> </div>
{(!cameraGroup || cameraGroup == "default" || isMobileOnly) && ( {(!cameraGroup || cameraGroup == "default" || isMobileOnly) && (