Improve System Health pane (#24188)

* build out system health pane

* tweaks

* fixes

* fix notice link so it opens the correct camera

* tweak language
This commit is contained in:
Josh Hawkins
2026-09-12 07:30:04 -06:00
committed by Nicolas Mowen
parent 6d33b31bc6
commit 70ce193e09
57 changed files with 3965 additions and 464 deletions
+2
View File
@@ -185,3 +185,5 @@ Filters and masks only hide the incorrect result - they don't teach Frigate what
### Where do I see problems Frigate has detected?
Open System > Health. The Notices list shows problems the backend has noticed on its own, such as a camera whose ffmpeg keeps crashing, a detector that had to be restarted, a model download that failed, or recordings being deleted before their retention period. Entries that describe a one-time event can be dismissed; entries that describe an ongoing condition clear themselves once it is fixed.
The Hardware section below the notices shows whether the detection hardware, hardware acceleration, and enrichment devices in your config were found and are being used, so a GPU that silently fell back to the CPU shows up as a warning. Run stream checks to probe every camera's streams for the same problems the camera wizard reports.
+3 -1
View File
@@ -302,7 +302,9 @@ def ffprobe(request: Request, paths: str = "", detailed: bool = False):
stderr_decoded = str(ffprobe.stderr)
stderr_lines = [
line.strip() for line in stderr_decoded.split("\n") if line.strip()
clean_camera_user_pass(line.strip())
for line in stderr_decoded.split("\n")
if line.strip()
]
result = {
+2
View File
@@ -28,6 +28,7 @@ class DataProcessorMetrics:
object_desc_dps: ValueProxy[float]
classification_speeds: DictProxy[str, ValueProxy[float]]
classification_cps: DictProxy[str, ValueProxy[float]]
runtime_devices: DictProxy[str, str]
def __init__(self, manager: SyncManager, custom_classification_models: list[str]):
self.image_embeddings_speed = manager.Value("d", 0.0)
@@ -46,6 +47,7 @@ class DataProcessorMetrics:
self.object_desc_dps = manager.Value("d", 0.0)
self.classification_speeds = manager.dict()
self.classification_cps = manager.dict()
self.runtime_devices = manager.dict()
if custom_classification_models:
for key in custom_classification_models:
+109 -15
View File
@@ -18,6 +18,36 @@ logger = logging.getLogger(__name__)
# Process-wide lock serializing all OpenVINO compile/inference calls
_OPENVINO_LOCK = threading.Lock()
# model file path -> (model type, device the runner actually loaded on); the
# embeddings maintainer folds this per enrichment for the stats endpoint.
# Models load on several threads (reindex, lazy first use), so writes and
# snapshots go through the lock.
loaded_devices: dict[str, tuple[str, str]] = {}
_loaded_devices_lock = threading.Lock()
def record_loaded_device(model_path: str, model_type: str, device: str) -> None:
"""Record the device a model loaded on, for the enrichment stats."""
with _loaded_devices_lock:
loaded_devices[model_path] = (model_type, device)
logger.info("Loaded %s model on %s", model_type, device)
def snapshot_loaded_devices() -> dict[str, tuple[str, str]]:
"""A copy that is safe to iterate while other threads load models."""
with _loaded_devices_lock:
return dict(loaded_devices)
_PROVIDER_LABELS = {
"CUDAExecutionProvider": "CUDA",
"TensorrtExecutionProvider": "TensorRT",
"MIGraphXExecutionProvider": "MIGraphX",
"OpenVINOExecutionProvider": "OpenVINO",
"CPUExecutionProvider": "CPU",
}
def is_arm64_platform() -> bool:
"""Check if we're running on an ARM platform."""
@@ -117,6 +147,12 @@ class BaseModelRunner(ABC):
"""Run inference with the model."""
pass
@property
@abstractmethod
def device_name(self) -> str:
"""Short label of the device the model actually loaded on."""
pass
class ONNXModelRunner(BaseModelRunner):
"""Run ONNX models using ONNX Runtime."""
@@ -174,6 +210,18 @@ class ONNXModelRunner(BaseModelRunner):
return self.ort.run(None, input)
@property
def device_name(self) -> str:
providers = self.ort.get_providers()
if not providers:
return "CPU"
provider = providers[0]
return _PROVIDER_LABELS.get(
provider, provider.removesuffix("ExecutionProvider")
)
class CudaGraphRunner(BaseModelRunner):
"""Encapsulates CUDA Graph capture and replay using ONNX Runtime IOBinding.
@@ -261,6 +309,10 @@ class CudaGraphRunner(BaseModelRunner):
self._session.run_with_iobinding(self._io_binding, ro)
return self._io_binding.copy_outputs_to_cpu()
@property
def device_name(self) -> str:
return "CUDA"
class OpenVINOModelRunner(BaseModelRunner):
"""OpenVINO model runner that handles inference efficiently."""
@@ -337,6 +389,8 @@ class OpenVINOModelRunner(BaseModelRunner):
if device == "NPU" and OpenVINOModelRunner.is_detection_model(model_type):
compile_config["NPU_TURBO"] = "YES"
self.compiled_device = device
# Compile model under the shared lock
with _OPENVINO_LOCK:
try:
@@ -368,6 +422,22 @@ class OpenVINOModelRunner(BaseModelRunner):
# model is complex and has dynamic shape
pass
@property
def device_name(self) -> str:
device = self.compiled_device
if device == "AUTO":
try:
resolved = self.compiled_model.get_property("EXECUTION_DEVICES")
if resolved:
device = ",".join(str(d) for d in resolved)
except Exception:
# older OpenVINO builds do not expose the property
pass
return f"OpenVINO {device}"
def get_input_names(self) -> list[str]:
"""Get input names for the model."""
return [input.get_any_name() for input in self.compiled_model.inputs]
@@ -515,6 +585,10 @@ class RKNNModelRunner(BaseModelRunner):
logger.error(f"Error loading RKNN model: {e}")
raise
@property
def device_name(self) -> str:
return "RKNN"
def get_input_names(self) -> list[str]:
"""Get input names for the model."""
# For detection models, we typically use "input" as the default input name
@@ -595,6 +669,14 @@ class RKNNModelRunner(BaseModelRunner):
pass
def _record_runner(
model_path: str, model_type: str, runner: BaseModelRunner
) -> BaseModelRunner:
"""Record the device a freshly loaded runner ended up on."""
record_loaded_device(model_path, model_type, runner.device_name)
return runner
def get_optimized_runner(
model_path: str, device: str | None, model_type: str, **kwargs
) -> BaseModelRunner:
@@ -605,7 +687,7 @@ def get_optimized_runner(
rknn_path = auto_convert_model(model_path)
if rknn_path:
return RKNNModelRunner(rknn_path)
return _record_runner(model_path, model_type, RKNNModelRunner(rknn_path))
providers, options = get_ort_providers(device == "CPU", device, **kwargs)
@@ -614,7 +696,11 @@ def get_optimized_runner(
# In other images we will get CUDA / ROCm which are preferred over OpenVINO
# There is currently no way to prioritize OpenVINO over CUDA / ROCm in these images
if device != "CPU" and is_openvino_gpu_npu_available():
return OpenVINOModelRunner(model_path, device, model_type, **kwargs)
return _record_runner(
model_path,
model_type,
OpenVINOModelRunner(model_path, device, model_type, **kwargs),
)
if (
CudaGraphRunner.is_model_supported(model_type)
@@ -624,13 +710,17 @@ def get_optimized_runner(
**options[0],
"enable_cuda_graph": True,
}
return CudaGraphRunner(
ort.InferenceSession(
model_path,
providers=providers,
provider_options=options,
return _record_runner(
model_path,
model_type,
CudaGraphRunner(
ort.InferenceSession(
model_path,
providers=providers,
provider_options=options,
),
options[0]["device_id"],
),
options[0]["device_id"],
)
if (
@@ -642,12 +732,16 @@ def get_optimized_runner(
providers.pop(0)
options.pop(0)
return ONNXModelRunner(
ort.InferenceSession(
model_path,
sess_options=get_ort_session_options(model_type),
providers=providers,
provider_options=options,
return _record_runner(
model_path,
model_type,
ONNXModelRunner(
ort.InferenceSession(
model_path,
sess_options=get_ort_session_options(model_type),
providers=providers,
provider_options=options,
),
model_type=model_type,
),
model_type=model_type,
)
+17
View File
@@ -60,6 +60,8 @@ from frigate.data_processing.real_time.license_plate import (
)
from frigate.data_processing.types import DataProcessorMetrics, PostProcessDataEnum
from frigate.db.sqlitevecq import SqliteVecQueueDatabase
from frigate.detectors.detection_runners import snapshot_loaded_devices
from frigate.embeddings.types import fold_runtime_devices
from frigate.events.types import (
EventStateEnum,
EventTypeEnum,
@@ -101,6 +103,7 @@ class EmbeddingMaintainer(threading.Thread):
super().__init__(name="embeddings_maintainer")
self.config = config
self.metrics = metrics
self._published_devices: dict[str, str] = {}
self.embeddings = None
self.config_updater = CameraConfigUpdateSubscriber(
self.config,
@@ -337,6 +340,7 @@ class EmbeddingMaintainer(threading.Thread):
self._expire_dedicated_lpr()
self._process_finalized()
self._process_event_metadata()
self._publish_runtime_devices()
# Shutdown deferred processors
for processor in self.realtime_processors:
@@ -738,6 +742,19 @@ class EmbeddingMaintainer(threading.Thread):
if isinstance(processor, ReviewDescriptionProcessor):
processor.process_data(review_updates, PostProcessDataEnum.review)
def _publish_runtime_devices(self) -> None:
"""Push each enrichment's loaded device to the shared metrics dict.
Runs every loop iteration, so only changed entries cross the manager
boundary.
"""
for enrichment, device in fold_runtime_devices(
snapshot_loaded_devices()
).items():
if self._published_devices.get(enrichment) != device:
self.metrics.runtime_devices[enrichment] = device
self._published_devices[enrichment] = device
def _process_event_metadata(self):
# Check for regenerate description requests
(topic, payload) = self.event_metadata_subscriber.check_for_update()
+13 -5
View File
@@ -6,7 +6,10 @@ import os
import numpy as np
from frigate.const import MODEL_CACHE_DIR
from frigate.detectors.detection_runners import get_optimized_runner
from frigate.detectors.detection_runners import (
get_optimized_runner,
record_loaded_device,
)
from frigate.embeddings.types import EnrichmentModelTypeEnum
from frigate.log import suppress_stderr_during
from frigate.util.downloader import ModelDownloader
@@ -62,13 +65,18 @@ class FaceNetEmbedding(BaseEmbedding):
if self.downloader:
self.downloader.wait_for_download()
model_path = os.path.join(MODEL_CACHE_DIR, "facedet/facenet.tflite")
# Suppress TFLite delegate creation messages that bypass Python logging
with suppress_stderr_during("tflite_interpreter_init"):
self.runner = Interpreter(
model_path=os.path.join(MODEL_CACHE_DIR, "facedet/facenet.tflite"),
num_threads=2,
)
self.runner = Interpreter(model_path=model_path, num_threads=2)
self.runner.allocate_tensors()
# tflite never goes through get_optimized_runner, so the small face
# model would otherwise never report a device
record_loaded_device(
model_path, EnrichmentModelTypeEnum.facenet.value, "CPU"
)
self.tensor_input_details = self.runner.get_input_details()
self.tensor_output_details = self.runner.get_output_details()
+34
View File
@@ -13,3 +13,37 @@ class EnrichmentModelTypeEnum(str, Enum):
jina_v2 = "jina_v2"
paddleocr = "paddleocr"
yolov9_license_plate = "yolov9_license_plate"
# which enrichment each model type belongs to; the single place this lives
ENRICHMENT_FOR_MODEL_TYPE: dict[str, str] = {
EnrichmentModelTypeEnum.arcface.value: "face_recognition",
EnrichmentModelTypeEnum.facenet.value: "face_recognition",
EnrichmentModelTypeEnum.jina_v1.value: "semantic_search",
EnrichmentModelTypeEnum.jina_v2.value: "semantic_search",
EnrichmentModelTypeEnum.paddleocr.value: "lpr",
EnrichmentModelTypeEnum.yolov9_license_plate.value: "lpr",
}
def fold_runtime_devices(loaded: dict[str, tuple[str, str]]) -> dict[str, str]:
"""One device per enrichment from the per model registry.
A non CPU device wins so a model pinned to the CPU on purpose (the Jina V1
text model) does not hide the accelerator its sibling loaded on, while an
enrichment whose models all fell back to the CPU still reports CPU.
"""
folded: dict[str, str] = {}
for model_type, device in loaded.values():
enrichment = ENRICHMENT_FOR_MODEL_TYPE.get(model_type)
if enrichment is None:
continue
current = folded.get(enrichment)
if current is None or ("CPU" in current and "CPU" not in device):
folded[enrichment] = device
return folded
+83 -72
View File
@@ -136,6 +136,86 @@ def get_detector_stats(
return detector_stats
def embeddings_stats(
config: FrigateConfig, embeddings_metrics: DataProcessorMetrics | None
) -> dict[str, Any]:
"""Enrichment speed metrics plus the device each enrichment loaded on."""
stats: dict[str, Any] = {}
if not embeddings_metrics:
return stats
# Add metrics based on what's enabled
if config.semantic_search.enabled:
stats.update(
{
"image_embedding_speed": round(
embeddings_metrics.image_embeddings_speed.value * 1000, 2
),
"image_embedding": round(
embeddings_metrics.image_embeddings_eps.value, 2
),
"text_embedding_speed": round(
embeddings_metrics.text_embeddings_speed.value * 1000, 2
),
"text_embedding": round(
embeddings_metrics.text_embeddings_eps.value, 2
),
}
)
if config.face_recognition.enabled:
stats["face_recognition_speed"] = round(
embeddings_metrics.face_rec_speed.value * 1000, 2
)
stats["face_recognition"] = round(embeddings_metrics.face_rec_fps.value, 2)
if config.lpr.enabled:
stats["plate_recognition_speed"] = round(
embeddings_metrics.alpr_speed.value * 1000, 2
)
stats["plate_recognition"] = round(embeddings_metrics.alpr_pps.value, 2)
if embeddings_metrics.yolov9_lpr_pps.value > 0.0:
stats["yolov9_plate_detection_speed"] = round(
embeddings_metrics.yolov9_lpr_speed.value * 1000, 2
)
stats["yolov9_plate_detection"] = round(
embeddings_metrics.yolov9_lpr_pps.value, 2
)
if embeddings_metrics.review_desc_speed.value > 0.0:
stats["review_description_speed"] = round(
embeddings_metrics.review_desc_speed.value * 1000, 2
)
stats["review_description_events_per_second"] = round(
embeddings_metrics.review_desc_dps.value, 2
)
if embeddings_metrics.object_desc_speed.value > 0.0:
stats["object_description_speed"] = round(
embeddings_metrics.object_desc_speed.value * 1000, 2
)
stats["object_description_events_per_second"] = round(
embeddings_metrics.object_desc_dps.value, 2
)
for key in embeddings_metrics.classification_speeds.keys():
stats[f"{key}_classification_speed"] = round(
embeddings_metrics.classification_speeds[key].value * 1000, 2
)
stats[f"{key}_classification_events_per_second"] = round(
embeddings_metrics.classification_cps[key].value, 2
)
devices = dict(embeddings_metrics.runtime_devices)
if devices:
stats["devices"] = devices
return stats
def stats_snapshot(
config: FrigateConfig,
stats_tracking: StatsTrackingTypes,
@@ -209,78 +289,9 @@ def stats_snapshot(
stats["skipped_fps"] = round(total_skipped_fps, 2)
stats["detection_fps"] = round(total_detection_fps, 2)
stats["embeddings"] = {}
# Get metrics if available
embeddings_metrics = stats_tracking.get("embeddings_metrics")
if embeddings_metrics:
# Add metrics based on what's enabled
if config.semantic_search.enabled:
stats["embeddings"].update(
{
"image_embedding_speed": round(
embeddings_metrics.image_embeddings_speed.value * 1000, 2
),
"image_embedding": round(
embeddings_metrics.image_embeddings_eps.value, 2
),
"text_embedding_speed": round(
embeddings_metrics.text_embeddings_speed.value * 1000, 2
),
"text_embedding": round(
embeddings_metrics.text_embeddings_eps.value, 2
),
}
)
if config.face_recognition.enabled:
stats["embeddings"]["face_recognition_speed"] = round(
embeddings_metrics.face_rec_speed.value * 1000, 2
)
stats["embeddings"]["face_recognition"] = round(
embeddings_metrics.face_rec_fps.value, 2
)
if config.lpr.enabled:
stats["embeddings"]["plate_recognition_speed"] = round(
embeddings_metrics.alpr_speed.value * 1000, 2
)
stats["embeddings"]["plate_recognition"] = round(
embeddings_metrics.alpr_pps.value, 2
)
if embeddings_metrics.yolov9_lpr_pps.value > 0.0:
stats["embeddings"]["yolov9_plate_detection_speed"] = round(
embeddings_metrics.yolov9_lpr_speed.value * 1000, 2
)
stats["embeddings"]["yolov9_plate_detection"] = round(
embeddings_metrics.yolov9_lpr_pps.value, 2
)
if embeddings_metrics.review_desc_speed.value > 0.0:
stats["embeddings"]["review_description_speed"] = round(
embeddings_metrics.review_desc_speed.value * 1000, 2
)
stats["embeddings"]["review_description_events_per_second"] = round(
embeddings_metrics.review_desc_dps.value, 2
)
if embeddings_metrics.object_desc_speed.value > 0.0:
stats["embeddings"]["object_description_speed"] = round(
embeddings_metrics.object_desc_speed.value * 1000, 2
)
stats["embeddings"]["object_description_events_per_second"] = round(
embeddings_metrics.object_desc_dps.value, 2
)
for key in embeddings_metrics.classification_speeds.keys():
stats["embeddings"][f"{key}_classification_speed"] = round(
embeddings_metrics.classification_speeds[key].value * 1000, 2
)
stats["embeddings"][f"{key}_classification_events_per_second"] = round(
embeddings_metrics.classification_cps[key].value, 2
)
stats["embeddings"] = embeddings_stats(
config, stats_tracking.get("embeddings_metrics")
)
hardware_stats.update_stats(stats)
@@ -0,0 +1,37 @@
"""Tests for credential scrubbing in the ffprobe API."""
from types import SimpleNamespace
from unittest.mock import patch
from fastapi.testclient import TestClient
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
class TestHttpFfprobe(BaseTestHttp):
def setUp(self):
super().setUp([])
self.app = self.create_app()
def client(self) -> TestClient:
return AuthTestClient(self.app)
def test_failed_probe_scrubs_credentials_from_stderr(self):
failed = SimpleNamespace(
returncode=1,
stdout=b"",
stderr=(
b"[tcp @ 0x1] Connection to tcp://10.0.0.1:554 failed\n"
b"rtsp://admin:hunter2@10.0.0.1:554/video: Connection refused\n"
),
)
with patch("frigate.api.camera.ffprobe_stream", return_value=failed):
with self.client() as client:
response = client.get("/ffprobe", params={"paths": "camera:front_door"})
self.assertEqual(response.status_code, 200)
lines = response.json()[0]["stderr"]
self.assertEqual(len(lines), 2)
self.assertNotIn("hunter2", " ".join(lines))
self.assertEqual(lines[1], "rtsp://*:*@10.0.0.1:554/video: Connection refused")
+28
View File
@@ -0,0 +1,28 @@
"""Tests that the tflite face model reports a runtime device."""
import unittest
from unittest.mock import MagicMock, patch
from frigate.detectors.detection_runners import loaded_devices, snapshot_loaded_devices
try:
from frigate.embeddings.onnx import face_embedding
except ImportError: # tflite runtime is not installed everywhere
face_embedding = None
@unittest.skipIf(face_embedding is None, "tflite runtime not available")
class TestFaceNetDevice(unittest.TestCase):
def setUp(self):
loaded_devices.clear()
def test_facenet_records_cpu(self):
with (
patch.object(face_embedding, "Interpreter", return_value=MagicMock()),
patch.object(face_embedding.os.path, "exists", return_value=True),
):
embedding = face_embedding.FaceNetEmbedding()
recorded = list(snapshot_loaded_devices().values())
self.assertEqual(recorded, [("facenet", "CPU")])
self.assertIsNotNone(embedding.runner)
+55
View File
@@ -100,3 +100,58 @@ class TestModelDownloadNotice(unittest.TestCase):
[n["scope"] for n in self._notice_calls(sibling.requestor)],
["paddleocr-onnx/det.onnx"],
)
class TestModelDownloadState(unittest.TestCase):
"""A failed download marks the model state as error, not stuck downloading."""
def setUp(self):
self.download_path = tempfile.mkdtemp()
downloader.last_download_error.clear()
def _state_calls(self, requestor: MagicMock) -> list[dict]:
return [
call.args[1]
for call in requestor.send_data.call_args_list
if call.args[0] == "update_model_state"
]
def _downloader(self, download_func) -> ModelDownloader:
with patch("frigate.util.downloader.InterProcessRequestor"):
return ModelDownloader(
"facedet", self.download_path, ["facedet.onnx"], download_func
)
def test_raising_download_marks_error(self):
def failing(path: str) -> None:
raise RuntimeError("boom")
model_downloader = self._downloader(failing)
with self.assertRaises(RuntimeError):
model_downloader._download_models()
states = self._state_calls(model_downloader.requestor)
self.assertEqual(states[-1]["state"], "error")
self.assertEqual(states[-1]["model"], "facedet-facedet.onnx")
def test_missing_file_marks_error(self):
def swallowing(path: str) -> None:
pass
model_downloader = self._downloader(swallowing)
model_downloader._download_models()
states = self._state_calls(model_downloader.requestor)
self.assertEqual([s["state"] for s in states], ["error"])
def test_success_marks_downloaded(self):
def succeeding(path: str) -> None:
with open(path, "w") as f:
f.write("model")
model_downloader = self._downloader(succeeding)
model_downloader._download_models()
states = self._state_calls(model_downloader.requestor)
self.assertEqual([s["state"] for s in states], ["downloaded"])
+131
View File
@@ -0,0 +1,131 @@
"""Tests for the device each model runner reports after loading."""
import threading
import unittest
from unittest.mock import MagicMock, patch
from frigate.detectors import detection_runners
from frigate.detectors.detection_runners import (
CudaGraphRunner,
ONNXModelRunner,
OpenVINOModelRunner,
RKNNModelRunner,
get_optimized_runner,
loaded_devices,
record_loaded_device,
snapshot_loaded_devices,
)
class TestRunnerDeviceName(unittest.TestCase):
def _onnx(self, providers: list[str]) -> ONNXModelRunner:
session = MagicMock()
session.get_providers.return_value = providers
return ONNXModelRunner(session, "arcface")
def test_onnx_provider_names(self):
self.assertEqual(
self._onnx(["CUDAExecutionProvider", "CPUExecutionProvider"]).device_name,
"CUDA",
)
self.assertEqual(
self._onnx(["TensorrtExecutionProvider"]).device_name, "TensorRT"
)
self.assertEqual(
self._onnx(["MIGraphXExecutionProvider"]).device_name, "MIGraphX"
)
self.assertEqual(
self._onnx(["OpenVINOExecutionProvider"]).device_name, "OpenVINO"
)
self.assertEqual(self._onnx(["CPUExecutionProvider"]).device_name, "CPU")
self.assertEqual(self._onnx(["ROCMExecutionProvider"]).device_name, "ROCM")
self.assertEqual(self._onnx([]).device_name, "CPU")
def test_cuda_graph_runner(self):
runner = CudaGraphRunner(MagicMock(), 0)
self.assertEqual(runner.device_name, "CUDA")
def test_openvino_reports_compiled_device(self):
runner = OpenVINOModelRunner.__new__(OpenVINOModelRunner)
runner.compiled_device = "GPU"
runner.compiled_model = MagicMock()
self.assertEqual(runner.device_name, "OpenVINO GPU")
def test_openvino_auto_resolves_execution_device(self):
runner = OpenVINOModelRunner.__new__(OpenVINOModelRunner)
runner.compiled_device = "AUTO"
runner.compiled_model = MagicMock()
runner.compiled_model.get_property.return_value = ["GPU.0"]
self.assertEqual(runner.device_name, "OpenVINO GPU.0")
runner.compiled_model.get_property.side_effect = RuntimeError("no prop")
self.assertEqual(runner.device_name, "OpenVINO AUTO")
def test_rknn(self):
runner = RKNNModelRunner.__new__(RKNNModelRunner)
self.assertEqual(runner.device_name, "RKNN")
class TestLoadRegistry(unittest.TestCase):
def setUp(self):
loaded_devices.clear()
def test_get_optimized_runner_records_device(self):
session = MagicMock()
session.get_providers.return_value = ["CPUExecutionProvider"]
with (
patch.object(detection_runners, "is_rknn_compatible", return_value=False),
patch.object(
detection_runners,
"get_ort_providers",
return_value=(["CPUExecutionProvider"], [{}]),
),
patch.object(
detection_runners, "is_openvino_gpu_npu_available", return_value=False
),
patch.object(
detection_runners.ort, "InferenceSession", return_value=session
),
patch.object(
detection_runners, "get_ort_session_options", return_value=None
),
):
runner = get_optimized_runner("/models/arcface.onnx", "GPU", "arcface")
self.assertIsInstance(runner, ONNXModelRunner)
self.assertEqual(loaded_devices["/models/arcface.onnx"], ("arcface", "CPU"))
class TestLoadedDeviceSnapshot(unittest.TestCase):
def setUp(self):
loaded_devices.clear()
def test_record_and_snapshot_copy(self):
record_loaded_device("/m/a.onnx", "arcface", "CUDA")
snapshot = snapshot_loaded_devices()
self.assertEqual(snapshot, {"/m/a.onnx": ("arcface", "CUDA")})
# a copy, so a load on another thread cannot disturb a fold in progress
record_loaded_device("/m/b.onnx", "jina_v1", "CPU")
self.assertNotIn("/m/b.onnx", snapshot)
self.assertIn("/m/b.onnx", loaded_devices)
def test_snapshot_survives_concurrent_inserts(self):
stop = threading.Event()
def writer() -> None:
i = 0
while not stop.is_set():
record_loaded_device(f"/m/{i}.onnx", "paddleocr", "CPU")
i += 1
thread = threading.Thread(target=writer, daemon=True)
thread.start()
try:
for _ in range(200):
for _entry in snapshot_loaded_devices().values():
pass
finally:
stop.set()
thread.join(timeout=5)
+108
View File
@@ -0,0 +1,108 @@
"""Tests for folding loaded model devices per enrichment and emitting them."""
import unittest
from types import SimpleNamespace
from frigate.config import FrigateConfig
from frigate.embeddings.types import ENRICHMENT_FOR_MODEL_TYPE, fold_runtime_devices
from frigate.stats.util import embeddings_stats
class TestFoldRuntimeDevices(unittest.TestCase):
def test_every_enrichment_model_type_is_mapped(self):
self.assertEqual(
set(ENRICHMENT_FOR_MODEL_TYPE),
{
"arcface",
"facenet",
"jina_v1",
"jina_v2",
"paddleocr",
"yolov9_license_plate",
},
)
def test_non_cpu_wins_within_an_enrichment(self):
# jina v1 pins its text model to the CPU by design
loaded = {
"/m/jina_v1_text.onnx": ("jina_v1", "CPU"),
"/m/jina_v1_vision.onnx": ("jina_v1", "OpenVINO GPU"),
}
self.assertEqual(
fold_runtime_devices(loaded), {"semantic_search": "OpenVINO GPU"}
)
def test_all_cpu_stays_cpu(self):
loaded = {
"/m/jina_v1_text.onnx": ("jina_v1", "CPU"),
"/m/jina_v1_vision.onnx": ("jina_v1", "CPU"),
}
self.assertEqual(fold_runtime_devices(loaded), {"semantic_search": "CPU"})
def test_detector_models_are_ignored(self):
loaded = {"/m/yolo.onnx": ("yolov9", "CUDA")}
self.assertEqual(fold_runtime_devices(loaded), {})
def test_lpr_and_face(self):
loaded = {
"/m/det.onnx": ("paddleocr", "CUDA"),
"/m/rec.onnx": ("paddleocr", "CUDA"),
"/m/arcface.onnx": ("arcface", "CPU"),
}
self.assertEqual(
fold_runtime_devices(loaded), {"lpr": "CUDA", "face_recognition": "CPU"}
)
def _metrics(devices: dict[str, str]) -> SimpleNamespace:
value = SimpleNamespace(value=0.0)
return SimpleNamespace(
image_embeddings_speed=value,
image_embeddings_eps=value,
text_embeddings_speed=value,
text_embeddings_eps=value,
face_rec_speed=value,
face_rec_fps=value,
alpr_speed=value,
alpr_pps=value,
yolov9_lpr_speed=value,
yolov9_lpr_pps=value,
review_desc_speed=value,
review_desc_dps=value,
object_desc_speed=value,
object_desc_dps=value,
classification_speeds={},
classification_cps={},
runtime_devices=devices,
)
class TestEmbeddingsStats(unittest.TestCase):
def setUp(self):
self.config = FrigateConfig(
**{
"mqtt": {"host": "mqtt"},
"face_recognition": {"enabled": True},
"cameras": {
"front_door": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
}
]
}
}
},
}
)
def test_devices_emitted_when_present(self):
stats = embeddings_stats(self.config, _metrics({"face_recognition": "CUDA"}))
self.assertEqual(stats["devices"], {"face_recognition": "CUDA"})
self.assertIn("face_recognition_speed", stats)
def test_devices_omitted_when_empty(self):
stats = embeddings_stats(self.config, _metrics({}))
self.assertNotIn("devices", stats)
+9 -7
View File
@@ -85,6 +85,12 @@ class ModelDownloader:
},
)
def _send_state(self, file_name: str, state: ModelStatusTypesEnum) -> None:
self.requestor.send_data(
UPDATE_MODEL_STATE,
{"model": f"{self.model_name}-{file_name}", "state": state},
)
def _download_models(self):
for file_name in self.file_names:
path = os.path.join(self.download_path, file_name)
@@ -98,6 +104,7 @@ class ModelDownloader:
self.download_func(path)
except Exception as e:
self._report_failure(file_name, _first_line(e))
self._send_state(file_name, ModelStatusTypesEnum.error)
raise
if not os.path.exists(path):
@@ -105,16 +112,11 @@ class ModelDownloader:
file_name,
last_download_error.pop(path, "download failed"),
)
self._send_state(file_name, ModelStatusTypesEnum.error)
continue
self._resolve_failure(file_name)
self.requestor.send_data(
UPDATE_MODEL_STATE,
{
"model": f"{self.model_name}-{file_name}",
"state": ModelStatusTypesEnum.downloaded,
},
)
self._send_state(file_name, ModelStatusTypesEnum.downloaded)
if self.complete_func:
self.complete_func()
+2 -1
View File
@@ -22,6 +22,7 @@ import {
type ApiMockOverrides,
} from "../helpers/api-mocker";
import { WsMocker } from "../helpers/ws-mocker";
import { statsFactory } from "./mock-data/stats";
import { installErrorCollector, type ErrorCollector } from "./error-collector";
import { GLOBAL_ALLOWLIST } from "./error-allowlist";
@@ -53,7 +54,7 @@ export class FrigateApp {
return route.fallback();
});
await this.ws.install(this.page);
await this.ws.install(this.page, statsFactory(overrides?.stats));
await this.api.install(overrides);
// media goes last so its per-event routes win over the broader
// `**/api/events**` list route, which otherwise answers thumbnail and
+14
View File
@@ -41,6 +41,13 @@ export const BASE_STATS = {
},
gpu_usages: {},
npu_usages: {},
embeddings: {
image_embedding_speed: 0,
face_embedding_speed: 0,
plate_recognition_speed: 0,
text_embedding_speed: 0,
devices: {} as Record<string, string>,
},
processes: {},
service: {
last_updated: Date.now() / 1000,
@@ -57,6 +64,13 @@ export const BASE_STATS = {
used: 500000000,
mount_type: "tmpfs",
},
"/dev/shm": {
free: 98,
total: 128,
used: 30,
mount_type: "tmpfs",
min_shm: 64,
},
},
uptime: 86400,
latest_version: "0.15.0",
+31
View File
@@ -50,8 +50,29 @@ export interface ApiMockOverrides {
users?: { username: string; role: string }[];
notices?: unknown[];
noticeStats?: unknown[];
/** camera name to the ffprobe entries returned for `paths=camera:<name>` */
ffprobe?: Record<string, unknown[]>;
}
export const FFPROBE_OK = [
{
return_code: 0,
stderr: "",
stdout: {
streams: [
{
codec_type: "video",
codec_name: "h264",
width: 1920,
height: 1080,
avg_frame_rate: "15/1",
},
{ codec_type: "audio", codec_name: "aac" },
],
},
},
];
export class ApiMocker {
private page: Page;
@@ -203,6 +224,16 @@ export class ApiMocker {
}),
);
// ffprobe. The Health tab's stream checks probe `camera:<name>`; the
// wizard probes raw URLs. Both get a healthy h264 + aac answer by default.
await this.page.route("**/api/ffprobe**", (route) => {
const url = new URL(route.request().url());
const paths = url.searchParams.get("paths") ?? "";
const camera = paths.startsWith("camera:") ? paths.slice(7) : undefined;
const entries = (camera && overrides?.ffprobe?.[camera]) || FFPROBE_OK;
return route.fulfill({ json: entries });
});
// Notices. The stats route is registered after the list route so it wins
// for /api/notices/stats; the list glob does not match a sub-path anyway.
await this.page.route("**/api/notices", (route) =>
+35 -29
View File
@@ -12,12 +12,16 @@ import { cameraActivityPayload } from "../fixtures/mock-data/camera-activity";
export class WsMocker {
private mockWs: WebSocketRoute | null = null;
private cameras: string[];
// the live stats payload wins over the REST one in useAutoFrigateStats, so
// both come from the same factory or a test's `stats` override is ignored
private stats: unknown;
constructor(cameras: string[] = ["front_door", "backyard", "garage"]) {
this.cameras = cameras;
}
async install(page: Page) {
async install(page: Page, stats?: unknown) {
this.stats = stats;
await page.routeWebSocket("**/ws", (ws) => {
this.mockWs = ws;
@@ -42,35 +46,37 @@ export class WsMocker {
// Send initial stats
this.send(
"stats",
JSON.stringify({
cameras: Object.fromEntries(
this.cameras.map((c) => [
c,
{
camera_fps: 5,
detection_fps: 5,
process_fps: 5,
skipped_fps: 0,
detection_enabled: 1,
connection_quality: "excellent",
},
]),
),
service: {
last_updated: Date.now() / 1000,
uptime: 86400,
version: "0.15.0-test",
latest_version: "0.15.0",
storage: {},
JSON.stringify(
this.stats ?? {
cameras: Object.fromEntries(
this.cameras.map((c) => [
c,
{
camera_fps: 5,
detection_fps: 5,
process_fps: 5,
skipped_fps: 0,
detection_enabled: 1,
connection_quality: "excellent",
},
]),
),
service: {
last_updated: Date.now() / 1000,
uptime: 86400,
version: "0.15.0-test",
latest_version: "0.15.0",
storage: {},
},
detectors: {},
cpu_usages: {},
gpu_usages: {},
camera_fps: 15,
process_fps: 15,
skipped_fps: 0,
detection_fps: 15,
},
detectors: {},
cpu_usages: {},
gpu_usages: {},
camera_fps: 15,
process_fps: 15,
skipped_fps: 0,
detection_fps: 15,
}),
),
);
}
+665 -7
View File
@@ -8,6 +8,9 @@ import { test, expect } from "../fixtures/frigate-test";
const NOW = Math.floor(Date.now() / 1000);
// the fixture detector runs at 75.5 ms, above the live warning threshold
const QUIET_STATS = { detectors: { cpu: { inference_speed: 10 } } };
const STATE_NOTICE = {
id: "ffmpeg_crash_loop:front_door",
kind: "ffmpeg_crash_loop",
@@ -41,6 +44,7 @@ test.describe("System — Health tab @medium", () => {
frigateApp,
}) => {
await frigateApp.installDefaults({
stats: QUIET_STATS,
notices: [STATE_NOTICE, EVENT_NOTICE],
noticeStats: [
{
@@ -65,8 +69,6 @@ test.describe("System — Health tab @medium", () => {
await expect(rows.nth(0)).toHaveAttribute("data-severity", "error");
await expect(rows.nth(0)).toContainText("ffmpeg has crashed 6 times");
await expect(rows.nth(0)).toContainText("14 times since");
// the default fixture's cpu detector is slow (75.5 ms); PR 2 merges live
// problems into this list, PR 1 does not, so no extra row here
await expect(
rows.nth(0).getByRole("button", { name: "Dismiss" }),
).toHaveCount(0);
@@ -78,7 +80,10 @@ test.describe("System — Health tab @medium", () => {
});
test("dismiss posts and removes the row", async ({ frigateApp }) => {
await frigateApp.installDefaults({ notices: [EVENT_NOTICE] });
await frigateApp.installDefaults({
stats: QUIET_STATS,
notices: [EVENT_NOTICE],
});
// the list shrinks after the dismiss so the refetch shows the row gone
let dismissed = false;
@@ -105,14 +110,18 @@ test.describe("System — Health tab @medium", () => {
await expect(
frigateApp.page.locator("[data-testid^='health-problem-']"),
).toHaveCount(0, { timeout: 5_000 });
await expect(frigateApp.page.getByText("No notices")).toBeVisible();
await expect(
frigateApp.page.getByText("Your Frigate installation is healthy"),
).toBeVisible();
});
test("empty state with no notices", async ({ frigateApp }) => {
await frigateApp.installDefaults();
await frigateApp.installDefaults({ stats: QUIET_STATS });
await frigateApp.goto("/system#health");
await expect(frigateApp.page.getByText("No notices")).toBeVisible({
await expect(
frigateApp.page.getByText("Your Frigate installation is healthy"),
).toBeVisible({
timeout: 15_000,
});
});
@@ -121,6 +130,7 @@ test.describe("System — Health tab @medium", () => {
frigateApp,
}) => {
await frigateApp.installDefaults({
stats: QUIET_STATS,
notices: [
{
id: "update_available",
@@ -157,7 +167,10 @@ test.describe("System — Health tab mobile @medium @mobile", () => {
test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only");
test("notices render at mobile viewport", async ({ frigateApp }) => {
await frigateApp.installDefaults({ notices: [STATE_NOTICE] });
await frigateApp.installDefaults({
stats: QUIET_STATS,
notices: [STATE_NOTICE],
});
await frigateApp.goto("/system#health");
await expect(
@@ -167,3 +180,648 @@ test.describe("System — Health tab mobile @medium @mobile", () => {
).toBeVisible({ timeout: 15_000 });
});
});
test.describe("System — Health hardware pane @medium", () => {
test("detection row is ok with matching probe and fast inference", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({
config: { models: [{ scene: "all", devices: ["openvino:GPU"] }] },
stats: {
...QUIET_STATS,
detectors: { "openvino:GPU": { inference_speed: 12.3 } },
},
});
await frigateApp.goto("/system#health");
const row = frigateApp.page.getByTestId("hardware-row-detection:0");
await expect(row).toHaveAttribute("data-state", "ok", { timeout: 15_000 });
await expect(row).toContainText("12.3 ms");
});
test("detection row errors when the device is not probed", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({
config: { models: [{ scene: "all", devices: ["hailo8l"] }] },
stats: QUIET_STATS,
});
await frigateApp.goto("/system#health");
const row = frigateApp.page.getByTestId("hardware-row-detection:0");
await expect(row).toHaveAttribute("data-state", "error", {
timeout: 15_000,
});
await expect(row).toContainText("hailo8l was not found on this system");
});
test("a generic device the probe cannot enumerate is judged by its runtime", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({
config: { models: [{ scene: "all", devices: ["openvino:AUTO"] }] },
stats: {
...QUIET_STATS,
detectors: { "openvino:AUTO": { inference_speed: 12 } },
},
});
await frigateApp.goto("/system#health");
const row = frigateApp.page.getByTestId("hardware-row-detection:0");
await expect(row).toHaveAttribute("data-state", "ok", {
timeout: 15_000,
});
await expect(row).toContainText("12 ms");
});
test("a bare onnx detector with no probed accelerator is not an error", async ({
frigateApp,
}) => {
// the default image runs onnx on the CPU and the probe reports nothing
await frigateApp.installDefaults({
config: { models: [{ scene: "all", devices: ["onnx"] }] },
stats: { ...QUIET_STATS, detectors: { onnx: { inference_speed: 40 } } },
});
await frigateApp.goto("/system#health");
const row = frigateApp.page.getByTestId("hardware-row-detection:0");
await expect(row).toHaveAttribute("data-state", "ok", {
timeout: 15_000,
});
});
test("detection row warns on slow inference", async ({ frigateApp }) => {
await frigateApp.installDefaults({
config: { models: [{ scene: "all", devices: ["openvino:GPU"] }] },
stats: {
...QUIET_STATS,
detectors: { "openvino:GPU": { inference_speed: 60 } },
},
});
await frigateApp.goto("/system#health");
const row = frigateApp.page.getByTestId("hardware-row-detection:0");
await expect(row).toHaveAttribute("data-state", "warning", {
timeout: 15_000,
});
await expect(row).toContainText("Inference is slow (60 ms)");
});
test("hwaccel row states", async ({ frigateApp }) => {
await frigateApp.installDefaults({
config: {
cameras: {
front_door: { ffmpeg: { hwaccel_args: "preset-vaapi" } },
backyard: { ffmpeg: { hwaccel_args: "preset-nvidia" } },
garage: { ffmpeg: { hwaccel_args: "" } },
},
},
hwaccel: {
recommended: "vaapi",
available: [{ key: "vaapi", presets: { any: "preset-vaapi" } }],
},
stats: QUIET_STATS,
});
await frigateApp.goto("/system#health");
await expect(
frigateApp.page.getByTestId("hardware-row-hwaccel:preset-vaapi"),
).toHaveAttribute("data-state", "ok", { timeout: 15_000 });
await expect(
frigateApp.page.getByTestId("hardware-row-hwaccel:preset-nvidia"),
).toHaveAttribute("data-state", "warning");
await expect(
frigateApp.page.getByTestId("hardware-row-hwaccel:preset-nvidia"),
).toContainText("the hardware probe did not report it");
await expect(
frigateApp.page.getByTestId("hardware-row-hwaccel:"),
).toHaveAttribute("data-state", "warning");
});
test("face recognition row reflects the runtime device", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({
config: { face_recognition: { enabled: true } },
stats: {
...QUIET_STATS,
embeddings: { devices: { face_recognition: "CPU" } },
},
});
await frigateApp.goto("/system#health");
const row = frigateApp.page.getByTestId(
"hardware-row-enrichment:face_recognition",
);
await expect(row).toHaveAttribute("data-state", "warning", {
timeout: 15_000,
});
await expect(row).toContainText(
"Running on the CPU although an accelerator is available",
);
});
test("explicit GPU that loaded on CUDA is ok despite the probe", async ({
frigateApp,
}) => {
// the fixture probes an Intel GPU only; ONNX Runtime still puts a GPU
// request on CUDA when that image has it
await frigateApp.installDefaults({
config: { face_recognition: { enabled: true, device: "GPU" } },
stats: {
...QUIET_STATS,
embeddings: { devices: { face_recognition: "CUDA" } },
},
});
await frigateApp.goto("/system#health");
const row = frigateApp.page.getByTestId(
"hardware-row-enrichment:face_recognition",
);
await expect(row).toHaveAttribute("data-state", "ok", { timeout: 15_000 });
await expect(row).toContainText("CUDA");
});
test("explicit GPU falling back to CPU is an error", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({
config: { face_recognition: { enabled: true, device: "GPU" } },
stats: {
...QUIET_STATS,
embeddings: { devices: { face_recognition: "CPU" } },
},
});
await frigateApp.goto("/system#health");
const row = frigateApp.page.getByTestId(
"hardware-row-enrichment:face_recognition",
);
await expect(row).toHaveAttribute("data-state", "error", {
timeout: 15_000,
});
});
test("enrichment without a runtime device is unknown", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({
config: { face_recognition: { enabled: true } },
stats: QUIET_STATS,
});
await frigateApp.goto("/system#health");
const row = frigateApp.page.getByTestId(
"hardware-row-enrichment:face_recognition",
);
await expect(row).toHaveAttribute("data-state", "unknown", {
timeout: 15_000,
});
});
test("all-excellent camera connections show a green check", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({ stats: QUIET_STATS });
await frigateApp.goto("/system#health");
const line = frigateApp.page.getByText(
"All cameras have an excellent connection.",
);
await expect(line).toBeVisible({ timeout: 15_000 });
await expect(line.locator("svg")).toHaveClass(/text-success/);
});
test("camera connections lists only non-excellent cameras", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({
stats: {
...QUIET_STATS,
cameras: {
backyard: { connection_quality: "poor", camera_fps: 2.1 },
},
},
});
await frigateApp.goto("/system#health");
await expect(
frigateApp.page.getByTestId("camera-connection-backyard"),
).toBeVisible({ timeout: 15_000 });
await expect(
frigateApp.page.getByTestId("camera-connection-front_door"),
).toHaveCount(0);
});
});
test.describe("System — Health notices sources @medium", () => {
test("live offline camera and config checks render with links", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({
config: {
cameras: {
garage: { detect: { width: 2560, height: 1440, fps: 10 } },
},
},
stats: { ...QUIET_STATS, cameras: { front_door: { camera_fps: 0 } } },
});
await frigateApp.goto("/system#health");
const rows = frigateApp.page.locator("[data-testid^='health-problem-']");
await expect(rows.first()).toBeVisible({ timeout: 15_000 });
await expect(rows.filter({ hasText: "Front Door is offline" })).toHaveCount(
1,
);
await expect(
frigateApp.page.getByText(
"This detect resolution is higher than recommended",
),
).toBeVisible();
await expect(
rows
.filter({ hasText: "Front Door is offline" })
.getByRole("link", { name: "Open settings" }),
).toHaveAttribute("href", "/logs");
const fpsRow = frigateApp.page.getByTestId(
"health-problem-config:detect:fps-greater-than-five:garage",
);
await expect(fpsRow).toHaveAttribute("data-severity", "info");
await expect(
fpsRow.getByRole("link", { name: "Open settings" }),
).toHaveAttribute("href", "/settings?page=cameraDetect&camera=garage");
});
// the fixture's cameras all have a record role with recording off, so
// dropping the role isolates the gate on record.enabled
const NO_RECORD_ROLE = {
ffmpeg: { inputs: [{ path: "rtsp://x", roles: ["detect"] }] },
};
test("record role warning is hidden while recording is off", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({
config: {
cameras: {
front_door: { ...NO_RECORD_ROLE, record: { enabled: false } },
},
},
stats: QUIET_STATS,
});
await frigateApp.goto("/system#health");
await expect(frigateApp.page.getByLabel("Select health")).toHaveAttribute(
"data-state",
"on",
{ timeout: 15_000 },
);
await expect(
frigateApp.page.getByText("No streams have the record role defined"),
).toHaveCount(0);
});
test("record role warning shows once recording is on", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({
config: {
cameras: {
front_door: { ...NO_RECORD_ROLE, record: { enabled: true } },
},
},
stats: QUIET_STATS,
});
await frigateApp.goto("/system#health");
await expect(
frigateApp.page.getByText("No streams have the record role defined"),
).toBeVisible({ timeout: 15_000 });
});
test("a global config problem is not repeated per camera", async ({
frigateApp,
}) => {
// every fixture camera inherits the global size, with resolved defaults
// the global block leaves null, so only text equality can dedupe them
const size = { width: 2560, height: 1440 };
await frigateApp.installDefaults({
config: {
detect: size,
cameras: {
front_door: { detect: size },
backyard: { detect: size },
garage: { detect: size },
},
},
stats: QUIET_STATS,
});
await frigateApp.goto("/system#health");
const rows = frigateApp.page.locator(
"[data-testid^='health-problem-config:detect:detect-resolution-high']",
);
await expect(rows.first()).toBeVisible({ timeout: 15_000 });
await expect(rows).toHaveCount(1);
await expect(rows.first()).toHaveAttribute(
"data-testid",
"health-problem-config:detect:detect-resolution-high:global",
);
});
test("a camera that overrides the global value keeps its own row", async ({
frigateApp,
}) => {
// two cameras inherit the global size and are folded into the global
// row; the one that overrides it keeps a row with its own link
const size = { width: 2560, height: 1440 };
await frigateApp.installDefaults({
config: {
detect: size,
cameras: {
front_door: { detect: size },
backyard: { detect: size },
garage: { detect: { width: 3840, height: 2160 } },
},
},
stats: QUIET_STATS,
});
await frigateApp.goto("/system#health");
const rows = frigateApp.page.locator(
"[data-testid^='health-problem-config:detect:detect-resolution-high']",
);
await expect(rows.first()).toBeVisible({ timeout: 15_000 });
await expect(rows).toHaveCount(2);
await expect(
frigateApp.page.getByTestId(
"health-problem-config:detect:detect-resolution-high:garage",
),
).toBeVisible();
});
test("registry rows sort ahead of live rows and keep Dismiss", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({
notices: [
{
id: "detector_stuck:cpu",
kind: "detector_stuck",
mode: "event",
severity: "warning",
category: "detector",
scope: "cpu",
params: { detector: "cpu" },
first_seen: NOW - 60,
last_seen: NOW - 60,
count: 1,
dismissed_at: null,
},
],
// the default fixture's slow cpu detector is the live warning here
});
await frigateApp.goto("/system#health");
const rows = frigateApp.page.locator("[data-severity='warning']");
await expect(rows.first()).toBeVisible({ timeout: 15_000 });
await expect(rows.nth(0)).toContainText("Detector cpu was restarted");
await expect(
rows.nth(0).getByRole("button", { name: "Dismiss" }),
).toBeVisible();
await expect(rows.nth(1)).toContainText("Cpu is slow");
});
test("empty state when stats, config, and registry are clean", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({ stats: QUIET_STATS });
await frigateApp.goto("/system#health");
await expect(
frigateApp.page.getByText("Your Frigate installation is healthy"),
).toBeVisible({
timeout: 15_000,
});
await expect(
frigateApp.page.locator("[data-testid^='health-problem-']"),
).toHaveCount(0);
});
test("stream checks probe every camera and flag non-AAC audio", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({
// the default record preset transcodes to AAC, so the codec only
// matters for a camera that copies audio through
config: {
cameras: {
backyard: {
ffmpeg: {
output_args: { record: "preset-record-generic-audio-copy" },
},
},
},
},
ffprobe: {
backyard: [
{
return_code: 0,
stderr: "",
stdout: {
streams: [
{
codec_type: "video",
codec_name: "h264",
width: 1920,
height: 1080,
},
{ codec_type: "audio", codec_name: "pcm_mulaw" },
],
},
},
],
garage: [
{
return_code: 1,
// the backend sends every line; the tab shows the last one
stderr: [
"[tcp @ 0x1] Connection to tcp://10.0.0.3:554 failed",
"Connection refused",
],
stdout: "",
},
],
},
stats: QUIET_STATS,
});
await frigateApp.goto("/system#health");
const requests: string[] = [];
frigateApp.page.on("request", (req) => {
if (req.url().includes("/api/ffprobe")) {
requests.push(req.url());
}
});
await frigateApp.page
.getByRole("button", { name: "Run stream checks" })
.click();
await expect(
frigateApp.page.getByText("Stream 1: The AAC audio codec is required"),
).toBeVisible({ timeout: 15_000 });
await expect(
frigateApp.page.getByText(
"Stream 1 could not be probed: Connection refused",
),
).toBeVisible();
const streams = frigateApp.page.getByTestId("camera-streams");
await expect(streams).toContainText("3 cameras checked");
await expect(streams).toContainText("2 with problems");
await expect(frigateApp.page.getByText(/^Checked/)).toBeVisible();
await expect(
frigateApp.page.getByRole("button", { name: "Run again" }),
).toBeVisible();
// axios leaves ":" unescaped in query strings
expect(requests.filter((u) => u.includes("paths=camera:")).length).toBe(3);
});
test("non-AAC audio is fine when recordings transcode to AAC", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({
ffprobe: {
backyard: [
{
return_code: 0,
stderr: "",
stdout: {
streams: [
{
codec_type: "video",
codec_name: "h264",
width: 1920,
height: 1080,
},
{ codec_type: "audio", codec_name: "pcm_mulaw" },
],
},
},
],
},
stats: QUIET_STATS,
});
await frigateApp.goto("/system#health");
await frigateApp.page
.getByRole("button", { name: "Run stream checks" })
.click();
await expect(frigateApp.page.getByText(/^Checked/)).toBeVisible({
timeout: 15_000,
});
await expect(
frigateApp.page.getByText("The AAC audio codec is required"),
).toHaveCount(0);
});
test("wizard-only Reolink advice stays off the tab", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({
config: {
cameras: {
front_door: {
ffmpeg: {
inputs: [
{
path: "rtsp://10.0.0.1:554/h264Preview_01_main",
roles: ["detect", "record"],
},
],
},
},
},
},
stats: QUIET_STATS,
});
await frigateApp.goto("/system#health");
await frigateApp.page
.getByRole("button", { name: "Run stream checks" })
.click();
await expect(frigateApp.page.getByText(/^Checked/)).toBeVisible({
timeout: 15_000,
});
await expect(
frigateApp.page.getByText("Reolink RTSP is not recommended"),
).toHaveCount(0);
});
test("re-check re-probes the hardware and dates the probe", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({ stats: QUIET_STATS });
await frigateApp.goto("/system#health");
const probes: string[] = [];
frigateApp.page.on("request", (req) => {
if (req.url().includes("refresh=true")) {
probes.push(req.url());
}
});
const recheck = frigateApp.page.getByRole("button", {
name: "Re-check hardware",
});
await expect(recheck).toBeVisible({ timeout: 15_000 });
await expect(frigateApp.page.getByText(/^Probed/)).toHaveCount(0);
await recheck.click();
await expect(frigateApp.page.getByText(/^Probed/)).toBeVisible();
await expect(recheck).toBeEnabled();
expect(probes.length).toBe(1);
});
test("a camera notice link opens that camera's settings page", async ({
frigateApp,
}) => {
// garage is not the camera Settings would pick on its own, so a wrong
// selection here is visible rather than accidentally right
await frigateApp.installDefaults({
config: {
lpr: { enabled: false },
cameras: { garage: { lpr: { enabled: true } } },
},
stats: QUIET_STATS,
});
await frigateApp.goto("/system#health");
const row = frigateApp.page.getByTestId(
"health-problem-config:lpr:global-disabled:garage",
);
await expect(row).toBeVisible({ timeout: 15_000 });
await expect(
row.getByRole("link", { name: "Open settings" }),
).toHaveAttribute("href", "/settings?page=cameraLpr&camera=garage");
await row.getByRole("link", { name: "Open settings" }).click();
await expect(frigateApp.page.getByLabel("Select a camera")).toContainText(
"Garage",
{ timeout: 15_000 },
);
});
test("status bar healthy text links to the Health tab", async ({
frigateApp,
}) => {
test.skip(frigateApp.isMobile, "Status bar is desktop-only");
await frigateApp.installDefaults({ stats: QUIET_STATS });
await frigateApp.goto("/");
await frigateApp.page
.getByRole("link", { name: "System is healthy" })
.click();
await expect(frigateApp.page).toHaveURL(/\/system#health/);
});
});
+56 -2
View File
@@ -18,7 +18,7 @@
"title": "Health",
"notices": {
"title": "Notices",
"empty": "No notices",
"empty": "Your Frigate installation is healthy",
"dismiss": "Dismiss",
"openSettings": "Open settings",
"openLink": "Open link",
@@ -31,7 +31,61 @@
"model_download_failed": "Downloading {{file}} failed: {{error}}",
"retention_unmet": "Recordings were deleted before their retention period to free space ({{cleared_mb}} of {{needed_mb}} MB needed)",
"update_available": "Frigate {{version}} is available"
}
},
"streamPrefix": "Stream {{index}}: {{message}}",
"streamPrefixRestream": "Stream {{index}} (via go2rtc): {{message}}",
"streamProbeFailed": "Stream {{index}} could not be probed: {{error}}",
"cameraProbeFailed": "Streams could not be probed: {{error}}",
"startupWindow": "Frigate started less than two minutes ago, live checks begin after startup"
},
"hardware": {
"title": "Hardware",
"objectDetection": "Object detection",
"hardwareAcceleration": "Hardware acceleration",
"enrichments": {
"title": "Enrichments",
"semantic_search": "Semantic search",
"face_recognition": "Face recognition",
"lpr": "License plate recognition",
"audio_transcription": "Audio transcription"
},
"cameraConnections": "Camera connections",
"allCamerasExcellent": "All cameras have an excellent connection.",
"fps": "{{camera}} / {{expected}} fps",
"nothingConfigured": "Nothing configured",
"allCameras": "All cameras",
"probeUnavailable": "Hardware detection is unavailable",
"justStarted": "Frigate just started",
"deviceNotFound": "{{devices}} was not found on this system",
"detectorNotRunning": "Detector process is not running",
"inferenceVerySlow": "Inference is very slow ({{speed}} ms)",
"inferenceSlow": "Inference is slow ({{speed}} ms)",
"inferenceMs": "{{speed}} ms",
"customArgs": "Custom ffmpeg arguments",
"customArgsNotVerified": "Custom ffmpeg arguments, not verified",
"hwaccelNotConfigured": "No hardware decoding is in use, but {{family}} is available. Select it in the ffmpeg settings",
"hwaccelHardwareMissing": "{{family}} is configured, but the hardware probe did not report it",
"unrecognizedDevice": "Unrecognized device override, not verified",
"decoderUsage": "decoder {{usage}}",
"remoteProvider": "Remote provider",
"acceleratorMissing": "Configured for {{device}} but no compatible accelerator was found",
"modelNotRunYet": "Model has not run yet, the device is verified after first use",
"fellBackToCpu": "Configured for {{device}} but running on the CPU",
"cpuDespiteAccelerator": "Running on the CPU although an accelerator is available",
"probed": "Probed",
"probing": "Probing...",
"recheck": "Re-check hardware",
"cameraStreams": "Camera streams",
"streamsNotChecked": "Not checked yet",
"streamsChecking": "Checking {{done}} of {{total}}",
"streamsClean": "No stream problems",
"checked": "Checked",
"runAgain": "Run again",
"runStreamChecks": "Run stream checks",
"streamsChecked_one": "{{count}} camera checked",
"streamsChecked_other": "{{count}} cameras checked",
"streamsFlagged_one": "{{count}} with problems, listed under Notices",
"streamsFlagged_other": "{{count}} with problems, listed under Notices"
}
},
"logs": {
+14 -4
View File
@@ -187,10 +187,20 @@ export default function Statusbar() {
</div>
<div className="no-scrollbar flex h-full max-w-[50%] items-center gap-2 overflow-x-auto">
{Object.entries(messages).length === 0 ? (
<div className="flex items-center gap-2 text-sm">
<FaCheck className="size-3 text-green-500" />
{t("stats.healthy")}
</div>
isAdmin ? (
<Link
to="/system#health"
className="flex items-center gap-2 text-sm"
>
<FaCheck className="size-3 text-green-500" />
{t("stats.healthy")}
</Link>
) : (
<div className="flex items-center gap-2 text-sm">
<FaCheck className="size-3 text-green-500" />
{t("stats.healthy")}
</div>
)
) : (
Object.entries(messages).map(([key, messageArray]) => (
<div key={key} className="flex h-full items-center gap-2">
@@ -6,6 +6,7 @@ const audio: SectionConfigOverrides = {
messages: [
{
key: "no-audio-role",
health: (ctx) => ctx.fullCameraConfig?.audio?.enabled === true,
messageKey: "configMessages.audio.noAudioRole",
severity: "warning",
condition: (ctx) => {
@@ -6,6 +6,8 @@ const audioTranscription: SectionConfigOverrides = {
messages: [
{
key: "audio-detection-disabled",
health: (ctx) =>
ctx.fullCameraConfig?.audio_transcription?.enabled === true,
messageKey: "configMessages.audioTranscription.audioDetectionDisabled",
severity: "warning",
condition: (ctx) => {
@@ -15,6 +15,7 @@ const detect: SectionConfigOverrides = {
fieldMessages: [
{
key: "detect-resolution-not-multiple-of-four",
health: true,
field: "width",
position: "before",
messageKey: "configMessages.detect.resolutionShouldBeMultipleOfFour",
@@ -46,6 +47,7 @@ const detect: SectionConfigOverrides = {
},
{
key: "detect-resolution-high",
health: true,
field: "width",
position: "before",
messageKey: "configMessages.detect.resolutionHigh",
@@ -61,6 +63,7 @@ const detect: SectionConfigOverrides = {
},
{
key: "detect-square-resolution",
health: true,
field: "width",
position: "before",
messageKey: "configMessages.detect.squareResolution",
@@ -112,6 +115,7 @@ const detect: SectionConfigOverrides = {
},
{
key: "detect-scene-without-model",
health: true,
field: "scene",
position: "after",
messageKey: "configMessages.detect.sceneWithoutModel",
@@ -127,6 +131,7 @@ const detect: SectionConfigOverrides = {
},
{
key: "fps-greater-than-five",
health: true,
field: "fps",
messageKey: "configMessages.detect.fpsGreaterThanFive",
severity: "info",
@@ -6,6 +6,8 @@ const faceRecognition: SectionConfigOverrides = {
messages: [
{
key: "global-disabled",
health: (ctx) =>
ctx.fullCameraConfig?.face_recognition?.enabled === true,
messageKey: "configMessages.faceRecognition.globalDisabled",
severity: "warning",
condition: (ctx) => {
@@ -15,6 +17,9 @@ const faceRecognition: SectionConfigOverrides = {
},
{
key: "person-not-tracked",
health: (ctx) =>
ctx.fullCameraConfig?.face_recognition?.enabled === true &&
ctx.fullConfig.face_recognition?.enabled === true,
messageKey: "configMessages.faceRecognition.personNotTracked",
severity: "info",
condition: (ctx) => {
@@ -52,6 +52,7 @@ const ffmpeg: SectionConfigOverrides = {
},
{
key: "inputs-missing-go2rtc-stream",
health: true,
field: "inputs",
position: "before",
messageKey: "configMessages.ffmpeg.inputsMissingGo2rtcStream",
@@ -7,6 +7,7 @@ const lpr: SectionConfigOverrides = {
messages: [
{
key: "global-disabled",
health: (ctx) => ctx.fullCameraConfig?.lpr?.enabled === true,
messageKey: "configMessages.lpr.globalDisabled",
severity: "warning",
condition: (ctx) => {
@@ -16,6 +17,9 @@ const lpr: SectionConfigOverrides = {
},
{
key: "vehicle-not-tracked",
health: (ctx) =>
ctx.fullCameraConfig?.lpr?.enabled === true &&
ctx.fullConfig.lpr?.enabled === true,
messageKey: "configMessages.lpr.vehicleNotTracked",
severity: "info",
condition: (ctx) => {
@@ -35,6 +35,7 @@ const models: SectionConfigOverrides = {
},
{
key: "model-input-dimensions-not-detect-resolution",
health: true,
field: "height",
position: "after",
messageKey: "configMessages.model.inputDimensionsNotDetectResolution",
@@ -72,6 +72,9 @@ const objects: SectionConfigOverrides = {
fieldMessages: [
{
key: "genai-no-descriptions-provider",
health: (ctx) =>
(ctx.formData as { genai?: { enabled?: boolean } })?.genai
?.enabled === true,
field: "genai.enabled",
messageKey: "configMessages.objects.genaiNoDescriptionsProvider",
severity: "warning",
@@ -28,6 +28,8 @@ const onvif: SectionConfigOverrides = {
fieldMessages: [
{
key: "autotracking-no-zones",
health: (ctx) =>
ctx.fullCameraConfig?.onvif?.autotracking?.enabled === true,
field: "autotracking.required_zones",
messageKey: "configMessages.onvif.autotrackingNoZones",
severity: "error",
@@ -6,6 +6,7 @@ const record: SectionConfigOverrides = {
messages: [
{
key: "no-record-role",
health: (ctx) => ctx.fullCameraConfig?.record?.enabled === true,
messageKey: "configMessages.record.noRecordRole",
severity: "warning",
condition: (ctx) => {
@@ -17,6 +18,7 @@ const record: SectionConfigOverrides = {
},
{
key: "no-record-sub-role",
health: (ctx) => ctx.fullCameraConfig?.record?.sub?.enabled === true,
messageKey: "configMessages.record.noRecordSubRole",
severity: "warning",
condition: (ctx) => {
@@ -43,6 +43,9 @@ const review: SectionConfigOverrides = {
},
{
key: "genai-no-descriptions-provider",
health: (ctx) =>
(ctx.formData as { genai?: { enabled?: boolean } })?.genai
?.enabled === true,
field: "genai.enabled",
messageKey: "configMessages.objects.genaiNoDescriptionsProvider",
severity: "warning",
@@ -57,6 +60,7 @@ const review: SectionConfigOverrides = {
},
{
key: "genai-image-source-recordings-record-disabled",
health: true,
field: "genai.image_source",
messageKey:
"configMessages.review.genaiImageSourceRecordingsRecordDisabled",
@@ -21,6 +21,7 @@ const semanticSearch: SectionConfigOverrides = {
fieldMessages: [
{
key: "jinav2-small-model-size",
health: (ctx) => ctx.fullConfig.semantic_search?.enabled === true,
field: "model_size",
messageKey: "configMessages.semanticSearch.jinav2SmallModelSize",
severity: "warning",
@@ -28,6 +28,13 @@ export type ConditionalMessage = {
values?: Record<string, unknown>;
/** Optional documentation path (e.g. "/configuration/object_detectors#model"). */
docLink?: string;
/**
* Whether the Health tab evaluates this message against the saved config.
* Absent or false: form only. true: shown whenever condition() holds. A
* function: shown when both condition(ctx) and health(ctx) hold, for
* messages the form deliberately shows even when the feature is off.
*/
health?: boolean | ((ctx: MessageConditionContext) => boolean);
};
/** Field-level conditional message, adds field targeting */
@@ -0,0 +1,67 @@
/** settings page id that edits each config section at camera level */
export const CAMERA_PAGE_BY_SECTION: Record<string, string> = {
detect: "cameraDetect",
ffmpeg: "cameraFfmpeg",
record: "cameraRecording",
snapshots: "cameraSnapshots",
motion: "cameraMotion",
objects: "cameraObjects",
review: "cameraReview",
audio: "cameraAudioEvents",
audio_transcription: "cameraAudioTranscription",
notifications: "cameraNotifications",
live: "cameraLivePlayback",
birdseye: "cameraBirdseye",
face_recognition: "cameraFaceRecognition",
lpr: "cameraLpr",
timestamp_style: "cameraTimestampStyle",
onvif: "cameraOnvif",
};
/** settings page id that edits each config section at global level */
export const GLOBAL_PAGE_BY_SECTION: Record<string, string> = {
detect: "globalDetect",
record: "globalRecording",
snapshots: "globalSnapshots",
ffmpeg: "globalFfmpeg",
motion: "globalMotion",
objects: "globalObjects",
review: "globalReview",
audio: "globalAudioEvents",
live: "globalLivePlayback",
timestamp_style: "globalTimestampStyle",
database: "systemDatabase",
tls: "systemTls",
auth: "systemAuthentication",
networking: "systemNetworking",
proxy: "systemProxy",
ui: "systemUi",
logger: "systemLogging",
environment_vars: "systemEnvironmentVariables",
telemetry: "systemTelemetry",
birdseye: "systemBirdseye",
models: "systemDetectorsAndModel",
mqtt: "systemMqtt",
semantic_search: "integrationSemanticSearch",
genai: "integrationGenerativeAi",
face_recognition: "integrationFaceRecognition",
lpr: "integrationLpr",
classification: "integrationObjectClassification",
audio_transcription: "integrationAudioTranscription",
};
export function settingsLink(
section: string,
level: "global" | "camera",
cameraName?: string,
): string | undefined {
if (level === "camera") {
const page = CAMERA_PAGE_BY_SECTION[section];
return page && cameraName
? `/settings?page=${page}&camera=${encodeURIComponent(cameraName)}`
: undefined;
}
const page = GLOBAL_PAGE_BY_SECTION[section];
return page ? `/settings?page=${page}` : undefined;
}
@@ -25,24 +25,7 @@ import {
pathMatchesHiddenPattern,
} from "@/utils/configUtil";
import { useOverrideFieldLabel } from "./useOverrideFieldLabel";
const CAMERA_PAGE_BY_SECTION: Record<string, string> = {
detect: "cameraDetect",
ffmpeg: "cameraFfmpeg",
record: "cameraRecording",
snapshots: "cameraSnapshots",
motion: "cameraMotion",
objects: "cameraObjects",
review: "cameraReview",
audio: "cameraAudioEvents",
audio_transcription: "cameraAudioTranscription",
notifications: "cameraNotifications",
live: "cameraLivePlayback",
birdseye: "cameraBirdseye",
face_recognition: "cameraFaceRecognition",
lpr: "cameraLpr",
timestamp_style: "cameraTimestampStyle",
};
import { CAMERA_PAGE_BY_SECTION } from "@/components/config-form/sectionPages";
const MAX_FIELDS_PER_CAMERA = 5;
+294
View File
@@ -0,0 +1,294 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { FaCircleCheck } from "react-icons/fa6";
import { LuRefreshCw } from "react-icons/lu";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import useSWR from "swr";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import TimeAgo from "@/components/dynamic/TimeAgo";
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
import { ConnectionQualityIndicator } from "@/components/camera/ConnectionQualityIndicator";
import HardwareStatusRow from "@/components/health/HardwareStatusRow";
import { useHardwareHealth } from "@/hooks/use-hardware-health";
import { useHealthChecks } from "@/hooks/use-health-checks";
import type { FrigateConfig } from "@/types/frigateConfig";
import type { HardwareRow } from "@/utils/health";
import { streamHealth } from "@/utils/streamHealth";
function Card({
title,
className,
action,
subtitle,
children,
}: {
title: string;
className?: string;
/** an icon button right after the title */
action?: React.ReactNode;
/** a muted line under the title */
subtitle?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<div
className={cn(
"rounded-lg bg-background_alt p-2.5 pb-5 md:rounded-2xl",
className,
)}
>
<div className="mb-5 flex flex-col">
<div className="flex items-center gap-3">
<span>{title}</span>
{action}
</div>
{subtitle && (
<div className="text-xs text-muted-foreground">{subtitle}</div>
)}
</div>
{children}
</div>
);
}
function InlineAction({
label,
busy,
disabled,
onClick,
}: {
label: string;
busy?: boolean;
disabled?: boolean;
onClick: () => void;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-6 shrink-0 text-muted-foreground hover:text-primary"
aria-label={label}
disabled={busy || disabled}
onClick={onClick}
>
{busy ? (
<ActivityIndicator className="size-3.5" size={14} />
) : (
<LuRefreshCw className="size-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>{label}</TooltipContent>
</TooltipPortal>
</Tooltip>
);
}
function Group({ title, rows }: { title: string; rows: HardwareRow[] }) {
const { t } = useTranslation(["views/system"]);
return (
<Card title={title}>
{rows.length === 0 ? (
<div className="text-sm text-muted-foreground">
{t("health.hardware.nothingConfigured")}
</div>
) : (
<div className="flex flex-col gap-3">
{rows.map((row) => (
<HardwareStatusRow key={row.id} row={row} />
))}
</div>
)}
</Card>
);
}
/** Stream checks live here: the check sits next to its result. */
function StreamsGroup() {
const { t } = useTranslation(["views/system", "views/settings"]);
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const { stream } = useHealthChecks();
const summary = useMemo(
() => (config ? streamHealth(config, stream.results, t) : undefined),
[config, stream.results, t],
);
const row: HardwareRow | undefined = useMemo(() => {
if (stream.running || !summary || !stream.results) {
return undefined;
}
const flagged = summary.checked - summary.clean;
return {
id: "streams",
state: flagged > 0 ? "warning" : "ok",
label: t("health.hardware.streamsChecked", { count: summary.checked }),
detail:
flagged > 0
? t("health.hardware.streamsFlagged", { count: flagged })
: t("health.hardware.streamsClean"),
};
}, [stream.running, stream.results, summary, t]);
return (
<Card
title={t("health.hardware.cameraStreams")}
action={
<InlineAction
label={
stream.results
? t("health.hardware.runAgain")
: t("health.hardware.runStreamChecks")
}
busy={stream.running}
disabled={!config}
onClick={() => stream.run()}
/>
}
subtitle={
stream.running
? t("health.hardware.streamsChecking", {
done: stream.total - stream.pending.length,
total: stream.total,
})
: stream.results && (
<>
{t("health.hardware.checked")}{" "}
<TimeAgo time={stream.results.checkedAt * 1000} dense />
</>
)
}
>
<div className="flex flex-col gap-3 text-sm" data-testid="camera-streams">
{row ? (
<HardwareStatusRow row={row} />
) : (
<HardwareStatusRow
row={{
id: "streams",
state: "unknown",
label: t("health.hardware.streamsNotChecked"),
}}
/>
)}
</div>
</Card>
);
}
function HardwareHeading() {
const { t } = useTranslation(["views/system"]);
const { hardware } = useHealthChecks();
return (
<div className="flex flex-col">
<div className="flex items-center gap-3">
<div className="text-md font-medium text-primary-variant">
{t("health.hardware.title")}
</div>
<InlineAction
label={t("health.hardware.recheck")}
busy={hardware.rechecking}
onClick={() => hardware.recheck()}
/>
</div>
{hardware.rechecking ? (
<div className="text-xs text-muted-foreground">
{t("health.hardware.probing")}
</div>
) : (
hardware.probedAt && (
<div className="text-xs text-muted-foreground">
{t("health.hardware.probed")}{" "}
<TimeAgo time={hardware.probedAt * 1000} dense />
</div>
)
)}
</div>
);
}
export default function HardwarePane() {
const { t } = useTranslation(["views/system"]);
const { rows, statsLoaded } = useHardwareHealth();
return (
<div className="flex flex-col gap-4">
<HardwareHeading />
{!rows ? (
<Skeleton className="h-40 w-full rounded-lg md:rounded-2xl" />
) : (
<>
<div className="flex flex-col gap-2">
<div className="grid grid-cols-1 gap-2 md:grid-cols-3">
<Group
title={t("health.hardware.objectDetection")}
rows={rows.detection}
/>
<Group
title={t("health.hardware.hardwareAcceleration")}
rows={rows.hwaccel}
/>
<Group
title={t("health.hardware.enrichments.title")}
rows={rows.enrichments}
/>
</div>
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
<StreamsGroup />
<Card title={t("health.hardware.cameraConnections")}>
{!statsLoaded ? (
<Skeleton className="h-10 w-full" />
) : rows.cameras.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<FaCircleCheck className="size-4 text-success" />
{t("health.hardware.allCamerasExcellent")}
</div>
) : (
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
{rows.cameras.map((cell) => (
<div
key={cell.camera}
className="flex items-center gap-2 text-sm"
data-testid={`camera-connection-${cell.camera}`}
>
<ConnectionQualityIndicator
quality={cell.quality}
expectedFps={cell.expectedFps}
reconnects={cell.reconnects}
stalls={cell.stalls}
/>
<CameraNameLabel
camera={cell.camera}
className="smart-capitalize"
/>
<span className="text-muted-foreground">
{t("health.hardware.fps", {
camera: cell.cameraFps.toFixed(1),
expected: cell.expectedFps,
})}
</span>
</div>
))}
</div>
)}
</Card>
</div>
</div>
</>
)}
</div>
);
}
@@ -0,0 +1,47 @@
import { FaCircleCheck, FaTriangleExclamation } from "react-icons/fa6";
import { LuCircleHelp, LuX } from "react-icons/lu";
import { cn } from "@/lib/utils";
import type { HardwareRow } from "@/utils/health";
const MESSAGE_COLOR: Record<HardwareRow["state"], string> = {
ok: "text-success",
warning: "text-yellow-500",
error: "text-danger",
unknown: "text-muted-foreground",
};
export default function HardwareStatusRow({ row }: { row: HardwareRow }) {
return (
<div
className="flex items-start gap-2 text-sm"
data-testid={`hardware-row-${row.id}`}
data-state={row.state}
>
<div className="mt-0.5 flex shrink-0">
{row.state === "ok" && (
<FaCircleCheck className="size-4 text-success" />
)}
{row.state === "warning" && (
<FaTriangleExclamation className="size-4 text-yellow-500" />
)}
{row.state === "error" && <LuX className="size-4 text-danger" />}
{row.state === "unknown" && (
<LuCircleHelp className="size-4 text-muted-foreground" />
)}
</div>
<div className="min-w-0">
<div>
<span>{row.label}</span>
{row.detail && (
<span className="ml-2 text-muted-foreground">{row.detail}</span>
)}
</div>
{row.message && (
<div className={cn("mt-0.5", MESSAGE_COLOR[row.state])}>
{row.message}
</div>
)}
</div>
</div>
);
}
+136 -54
View File
@@ -7,82 +7,164 @@ import {
LuSlidersHorizontal,
LuX,
} from "react-icons/lu";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { useDocDomain } from "@/hooks/use-doc-domain";
import type { HealthProblem } from "@/types/health";
type HealthProblemRowProps = {
problem: HealthProblem;
};
const ICON_BUTTON_CLASS =
"size-6 shrink-0 text-muted-foreground hover:text-primary";
function RowAction({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipPortal>
<TooltipContent>{label}</TooltipContent>
</TooltipPortal>
</Tooltip>
);
}
export default function HealthProblemRow({ problem }: HealthProblemRowProps) {
const { t } = useTranslation(["views/system"]);
const { t } = useTranslation(["views/system", "common"]);
const { getLocaleDocUrl } = useDocDomain();
const hasDetails = problem.scope || problem.meta;
const hasActions =
problem.link ||
problem.docLink ||
problem.externalLink ||
problem.onDismiss;
return (
<div
className="flex items-start gap-2 border-b border-border px-1 py-2 text-sm last:border-b-0"
className="flex items-center gap-2 border-b border-border px-1 py-2 text-sm last:border-b-0"
data-testid={`health-problem-${problem.id}`}
data-severity={problem.severity}
>
<div className="mt-0.5 flex shrink-0">
{problem.severity === "error" && <LuX className="size-4 text-danger" />}
{problem.severity === "warning" && (
<FaTriangleExclamation className="size-4 text-yellow-500" />
)}
{problem.severity === "info" && (
<LuInfo className="size-4 text-selected" />
<div className="flex shrink-0 self-start pt-0.5">
{problem.pending ? (
<ActivityIndicator className="" size={16} />
) : (
<>
{problem.severity === "error" && (
<LuX className="size-4 text-danger" />
)}
{problem.severity === "warning" && (
<FaTriangleExclamation className="size-4 text-yellow-500" />
)}
{problem.severity === "info" && (
<LuInfo className="size-4 text-selected" />
)}
</>
)}
</div>
{problem.scope && (
<span className="rounded-md bg-secondary px-1.5 py-0.5 text-xs text-secondary-foreground smart-capitalize">
{problem.scopeIsCamera ? (
<CameraNameLabel camera={problem.scope} />
) : (
problem.scope
)}
</span>
)}
<div className="min-w-0 flex-1">
<div>{problem.text}</div>
{problem.meta && (
<div className="mt-0.5 text-xs text-muted-foreground">
{problem.meta}
{hasDetails && (
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
{problem.scope && (
<span className="rounded bg-secondary px-1.5 py-0.5 text-xs text-primary-variant">
{problem.scopeIsCamera ? (
<CameraNameLabel camera={problem.scope} />
) : (
problem.scope
)}
</span>
)}
{problem.meta && <span>{problem.meta}</span>}
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-2 text-muted-foreground">
{problem.onDismiss && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2"
onClick={problem.onDismiss}
aria-label={t("health.notices.dismiss")}
>
{t("health.notices.dismiss")}
</Button>
)}
{problem.link && (
<Link
to={problem.link}
aria-label={t("health.notices.openSettings")}
className="hover:text-primary"
>
<LuSlidersHorizontal className="size-4" />
</Link>
)}
{problem.externalLink && (
<a
href={problem.externalLink}
target="_blank"
rel="noreferrer"
aria-label={t("health.notices.openLink")}
className="hover:text-primary"
>
<LuExternalLink className="size-4" />
</a>
)}
</div>
{hasActions && (
<div className="ml-auto flex shrink-0 items-center gap-1">
{problem.link && (
<RowAction label={t("health.notices.openSettings")}>
<Button
asChild
variant="ghost"
size="icon"
className={ICON_BUTTON_CLASS}
>
<Link
to={problem.link}
aria-label={t("health.notices.openSettings")}
>
<LuSlidersHorizontal className="size-3.5" />
</Link>
</Button>
</RowAction>
)}
{problem.docLink && (
<RowAction label={t("readTheDocumentation", { ns: "common" })}>
<Button
asChild
variant="ghost"
size="icon"
className={ICON_BUTTON_CLASS}
>
<a
href={getLocaleDocUrl(problem.docLink)}
target="_blank"
rel="noreferrer"
aria-label={t("readTheDocumentation", { ns: "common" })}
>
<LuExternalLink className="size-3.5" />
</a>
</Button>
</RowAction>
)}
{problem.externalLink && (
<RowAction label={t("health.notices.openLink")}>
<Button
asChild
variant="ghost"
size="icon"
className={ICON_BUTTON_CLASS}
>
<a
href={problem.externalLink}
target="_blank"
rel="noreferrer"
aria-label={t("health.notices.openLink")}
>
<LuExternalLink className="size-3.5" />
</a>
</Button>
</RowAction>
)}
{problem.onDismiss && (
<RowAction label={t("health.notices.dismiss")}>
<Button
variant="ghost"
size="icon"
className={ICON_BUTTON_CLASS}
aria-label={t("health.notices.dismiss")}
onClick={problem.onDismiss}
>
<LuX className="size-3.5" />
</Button>
</RowAction>
)}
</div>
)}
</div>
);
}
+68 -7
View File
@@ -7,8 +7,14 @@ import { Skeleton } from "@/components/ui/skeleton";
import { useNotices } from "@/hooks/use-notices";
import { useDateLocale } from "@/hooks/use-date-locale";
import { useTimezone } from "@/hooks/use-date-utils";
import useStats, { useAutoFrigateStats } from "@/hooks/use-stats";
import { useHealthChecks } from "@/hooks/use-health-checks";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { releaseUrl } from "@/utils/versionUtil";
import { evaluateConfigHealth } from "@/utils/configHealth";
import { isStartupWindow } from "@/utils/health";
import { sortHealthProblems } from "@/utils/healthSort";
import { streamHealth } from "@/utils/streamHealth";
import type { FrigateConfig } from "@/types/frigateConfig";
import type { HealthProblem } from "@/types/health";
import type { Notice, NoticeKind, NoticeStats } from "@/types/notice";
@@ -75,6 +81,7 @@ function useNoticeProblems(
return {
id: `notice:${notice.id}`,
source: "registry" as const,
severity: notice.severity,
scope: notice.scope ?? undefined,
scopeIsCamera: notice.category === "camera",
@@ -93,19 +100,73 @@ function useNoticeProblems(
}
export default function NoticesPane() {
const { t } = useTranslation(["views/system"]);
const { t } = useTranslation(["views/system", "views/settings"]);
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const stats = useAutoFrigateStats();
const { notices, statsByKind, dismiss } = useNotices();
const problems = useNoticeProblems(notices, statsByKind, dismiss);
const registryProblems = useNoticeProblems(notices, statsByKind, dismiss);
const { potentialProblems } = useStats(stats);
const {
stream: { results },
} = useHealthChecks();
const liveProblems = useMemo<HealthProblem[]>(() => {
if (!stats) {
return [];
}
if (isStartupWindow(stats)) {
return [
{
id: "live:startup",
source: "live",
severity: "info",
text: t("health.notices.startupWindow"),
},
];
}
return potentialProblems.map((problem, index) => ({
id: `live:${index}:${problem.text}`,
source: "live",
severity: problem.severity,
text: problem.text,
link: problem.relevantLink?.replace(/^(?!\/)/, "/"),
}));
}, [stats, potentialProblems, t]);
const configProblems = useMemo<HealthProblem[]>(
() => (config ? evaluateConfigHealth(config, t) : []),
[config, t],
);
const streamProblems = useMemo<HealthProblem[]>(
() => (config ? streamHealth(config, results, t).problems : []),
[config, results, t],
);
const problems = useMemo(
() =>
sortHealthProblems([
...registryProblems,
...liveProblems,
...configProblems,
...streamProblems,
]),
[registryProblems, liveProblems, configProblems, streamProblems],
);
const loading = notices === undefined || !config;
return (
<div className="flex flex-col gap-4">
<div>
<div className="text-md font-medium text-primary-variant">
{t("health.notices.title")}
</div>
<div className="text-md font-medium text-primary-variant">
{t("health.notices.title")}
</div>
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
{notices === undefined ? (
{loading ? (
<Skeleton className="h-24 w-full" />
) : problems.length === 0 ? (
<div className="flex items-center gap-2 px-1 py-2 text-sm">
@@ -20,6 +20,7 @@ import { FaCircleCheck, FaTriangleExclamation } from "react-icons/fa6";
import { LuX } from "react-icons/lu";
import { Card, CardContent } from "../../ui/card";
import { maskUri } from "@/utils/cameraUtil";
import { ffprobeToTestResult, getStreamIssues } from "@/utils/streamIssues";
type Step4ValidationProps = {
wizardData: Partial<WizardFormData>;
@@ -74,44 +75,7 @@ export default function Step4Validation({
timeout: 10000,
});
if (response.data?.[0]?.return_code === 0) {
const probeData = response.data[0];
const streamData = probeData.stdout.streams || [];
const videoStream = streamData.find(
(s: { codec_type?: string; codec_name?: string }) =>
s.codec_type === "video" ||
s.codec_name?.includes("h264") ||
s.codec_name?.includes("h265"),
);
const audioStream = streamData.find(
(s: { codec_type?: string; codec_name?: string }) =>
s.codec_type === "audio" ||
s.codec_name?.includes("aac") ||
s.codec_name?.includes("mp3"),
);
const resolution = videoStream
? `${videoStream.width}x${videoStream.height}`
: undefined;
const fps = videoStream?.avg_frame_rate
? parseFloat(videoStream.avg_frame_rate.split("/")[0]) /
parseFloat(videoStream.avg_frame_rate.split("/")[1])
: undefined;
return {
success: true,
resolution,
videoCodec: videoStream?.codec_name,
audioCodec: audioStream?.codec_name,
fps: fps && !isNaN(fps) ? fps : undefined,
};
} else {
const error = response.data?.[0]?.stderr || "Unknown error";
return { success: false, error };
}
return ffprobeToTestResult(response.data?.[0]);
} catch (error) {
const axiosError = error as {
response?: { data?: { message?: string; detail?: string } };
@@ -528,149 +492,21 @@ function StreamIssues({
}: StreamIssuesProps) {
const { t } = useTranslation(["views/settings"]);
const issues = useMemo(() => {
const result: Array<{
type: "good" | "warning" | "error";
message: string;
}> = [];
if (wizardData.brandTemplate === "reolink") {
const streamUrl = stream.url.toLowerCase();
if (streamUrl.startsWith("rtsp://")) {
result.push({
type: "warning",
message: t("cameraWizard.step4.issues.brands.reolink-rtsp"),
});
}
if (streamUrl.startsWith("http://") && !stream.useFfmpeg) {
result.push({
type: "warning",
message: t("cameraWizard.step4.issues.brands.reolink-http"),
});
}
}
// Video codec check
if (stream.testResult?.videoCodec) {
const videoCodec = stream.testResult.videoCodec.toLowerCase();
if (["h264", "h265", "hevc"].includes(videoCodec)) {
result.push({
type: "good",
message: t("cameraWizard.step4.issues.videoCodecGood", {
codec: stream.testResult.videoCodec,
}),
});
}
}
// Audio codec check
if (stream.roles.includes("record")) {
if (stream.testResult?.audioCodec) {
const audioCodec = stream.testResult.audioCodec.toLowerCase();
if (audioCodec === "aac") {
result.push({
type: "good",
message: t("cameraWizard.step4.issues.audioCodecGood", {
codec: stream.testResult.audioCodec,
}),
});
} else {
result.push({
type: "error",
message: t("cameraWizard.step4.issues.audioCodecRecordError"),
});
}
} else {
result.push({
type: "warning",
message: t("cameraWizard.step4.issues.noAudioWarning"),
});
}
}
// Audio detection check
if (stream.roles.includes("audio")) {
if (!stream.testResult?.audioCodec) {
result.push({
type: "error",
message: t("cameraWizard.step4.issues.audioCodecRequired"),
});
}
}
// Restreaming check
if (stream.roles.includes("record")) {
if (stream.restream) {
result.push({
type: "warning",
message: t("cameraWizard.step4.issues.restreamingWarning"),
});
}
}
if (stream.roles.includes("detect") && stream.testResult) {
const probedResolution = stream.testResult.resolution;
let probedWidth = 0;
let probedHeight = 0;
if (probedResolution) {
const [w, h] = probedResolution.split("x").map(Number);
if (!isNaN(w) && !isNaN(h)) {
probedWidth = w;
probedHeight = h;
}
}
if (probedWidth <= 0 || probedHeight <= 0) {
result.push({
type: "error",
message: t("cameraWizard.step4.issues.resolutionUnknown"),
});
} else {
const minDimension = Math.min(probedWidth, probedHeight);
const maxDimension = Math.max(probedWidth, probedHeight);
if (minDimension > 1080) {
result.push({
type: "warning",
message: t("cameraWizard.step4.issues.resolutionHigh", {
resolution: probedResolution,
}),
});
} else if (maxDimension < 640) {
result.push({
type: "error",
message: t("cameraWizard.step4.issues.resolutionLow", {
resolution: probedResolution,
}),
});
}
}
}
// Substream Check
if (
wizardData.brandTemplate == "dahua" &&
stream.roles.includes("detect") &&
stream.url.includes("subtype=1")
) {
result.push({
type: "warning",
message: t("cameraWizard.step4.issues.dahua.substreamWarning"),
});
}
if (
wizardData.brandTemplate == "hikvision" &&
stream.roles.includes("detect") &&
stream.url.includes("/102")
) {
result.push({
type: "warning",
message: t("cameraWizard.step4.issues.hikvision.substreamWarning"),
});
}
return result;
}, [stream, wizardData, t]);
const issues = useMemo(
() =>
getStreamIssues(
{
url: stream.url,
roles: stream.roles,
brand: wizardData.brandTemplate,
useFfmpeg: stream.useFfmpeg,
restream: stream.restream,
testResult: stream.testResult,
},
t,
),
[stream, wizardData, t],
);
if (issues.length === 0) {
return null;
+67
View File
@@ -0,0 +1,67 @@
import { useMemo } from "react";
import useSWR from "swr";
import { useTranslation } from "react-i18next";
import type {
DetectionHardware,
HwaccelRecommendation,
} from "@/types/hardware";
import type { FrigateConfig } from "@/types/frigateConfig";
import { useAutoFrigateStats } from "@/hooks/use-stats";
import {
cameraConnectionCells,
detectionRows,
enrichmentRows,
hwaccelRows,
isStartupWindow,
} from "@/utils/health";
export function useHardwareHealth() {
const { t } = useTranslation(["views/system", "views/setup"]);
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const { data: hardware, error: probeError } = useSWR<DetectionHardware[]>(
"hardware/probe",
{ revalidateOnFocus: false },
);
const { data: hwaccel, error: hwaccelError } = useSWR<HwaccelRecommendation>(
"hardware/hwaccel",
{ revalidateOnFocus: false },
);
const stats = useAutoFrigateStats();
const rows = useMemo(() => {
if (!config) {
return undefined;
}
const startup = isStartupWindow(stats);
return {
detection: detectionRows({
models: config.models,
hardware,
probeFailed: !!probeError,
stats,
startup,
t,
}),
hwaccel: hwaccelRows({
config,
hwaccel,
hwaccelFailed: !!hwaccelError,
stats,
t,
}),
enrichments: enrichmentRows({
config,
hardware,
probeFailed: !!probeError,
stats,
startup,
t,
}),
cameras: cameraConnectionCells(config, stats),
};
}, [config, hardware, probeError, hwaccel, hwaccelError, stats, t]);
return { rows, statsLoaded: !!stats };
}
+192
View File
@@ -0,0 +1,192 @@
import { useCallback, useSyncExternalStore } from "react";
import axios from "axios";
import useSWR, { useSWRConfig } from "swr";
import type { TestResult } from "@/types/cameraWizard";
import type { FrigateConfig } from "@/types/frigateConfig";
import { ffprobeToTestResult, type FfprobeEntry } from "@/utils/streamIssues";
import { activeCameras } from "@/utils/health";
export type CameraStreamCheck = {
/** whole-camera failure (request error or timeout) */
error?: string;
/** one entry per config input, in config order */
streams: TestResult[];
};
export type StreamCheckResults = {
checkedAt: number;
byCamera: Record<string, CameraStreamCheck>;
};
type HealthChecksState = {
stream: {
results?: StreamCheckResults;
/** cameras still being probed in the current run */
pending: string[];
/** camera count of the current run */
total: number;
};
hardware: {
rechecking: boolean;
/** last on-demand probe; absent means the startup probe is current */
probedAt?: number;
};
};
// The tab bar button and both panes read this, so it lives outside React
// and survives tab switches until reload. SWR is not used because a null
// fetcher falls back to the global one and would GET /api/health/...
let state: HealthChecksState = {
stream: { pending: [], total: 0 },
hardware: { rechecking: false },
};
const listeners = new Set<() => void>();
let streamRunning = false;
function subscribe(listener: () => void) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
function getState() {
return state;
}
function update(patch: (current: HealthChecksState) => HealthChecksState) {
state = patch(state);
listeners.forEach((listener) => listener());
}
const CONCURRENCY = 2;
// the backend probes each input with a 6 s timeout plus one retry
const TIMEOUT_PER_INPUT_MS = 12_000;
const TIMEOUT_BASE_MS = 5_000;
async function probeCamera(
name: string,
inputs: number,
): Promise<CameraStreamCheck> {
try {
const response = await axios.get("ffprobe", {
params: { paths: `camera:${name}`, detailed: true },
timeout: TIMEOUT_BASE_MS + TIMEOUT_PER_INPUT_MS * Math.max(inputs, 1),
});
const entries: FfprobeEntry[] = Array.isArray(response.data)
? response.data
: [];
return { streams: entries.map(ffprobeToTestResult) };
} catch (error) {
const axiosError = error as {
response?: { data?: { message?: string } };
message?: string;
};
return {
error:
axiosError.response?.data?.message ||
axiosError.message ||
"Connection failed",
streams: [],
};
}
}
async function runStreamChecks(config: FrigateConfig) {
if (streamRunning) {
return;
}
streamRunning = true;
const cameras = activeCameras(config);
const names = cameras.map((camera) => camera.name);
update((s) => ({
...s,
stream: { ...s.stream, pending: names, total: names.length },
}));
const byCamera: Record<string, CameraStreamCheck> = {};
const queue = [...cameras];
const worker = async () => {
while (queue.length > 0) {
const camera = queue.shift();
if (!camera) {
return;
}
byCamera[camera.name] = await probeCamera(
camera.name,
camera.ffmpeg.inputs.length,
);
update((s) => ({
...s,
stream: {
...s.stream,
pending: s.stream.pending.filter((c) => c !== camera.name),
},
}));
}
};
try {
await Promise.all(
Array.from({ length: Math.min(CONCURRENCY, cameras.length) }, worker),
);
update((s) => ({
...s,
stream: {
...s.stream,
results: { checkedAt: Date.now() / 1000, byCamera },
},
}));
} finally {
streamRunning = false;
}
}
export function useHealthChecks() {
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const { mutate } = useSWRConfig();
const current = useSyncExternalStore(subscribe, getState);
const run = useCallback(() => {
if (config) {
return runStreamChecks(config);
}
}, [config]);
const recheck = useCallback(async () => {
if (state.hardware.rechecking) {
return;
}
update((s) => ({ ...s, hardware: { ...s.hardware, rechecking: true } }));
try {
await axios.get("hardware/probe", { params: { refresh: true } });
await Promise.all([mutate("hardware/probe"), mutate("hardware/hwaccel")]);
update((s) => ({
...s,
hardware: { rechecking: false, probedAt: Date.now() / 1000 },
}));
} catch {
update((s) => ({ ...s, hardware: { ...s.hardware, rechecking: false } }));
}
}, [mutate]);
const runAll = useCallback(
() => Promise.all([recheck(), run()]),
[recheck, run],
);
return {
stream: {
...current.stream,
running: current.stream.pending.length > 0,
run,
},
hardware: { ...current.hardware, recheck },
runAll,
ready: !!config,
};
}
+85 -55
View File
@@ -4,7 +4,7 @@ import {
CameraFfmpegThreshold,
InferenceThreshold,
} from "@/types/graph";
import { FrigateStats, PotentialProblem } from "@/types/stats";
import { FrigateStats, PotentialProblem, ProblemSeverity } from "@/types/stats";
import { useMemo } from "react";
import useSWR from "swr";
import useDeepMemo from "./use-deep-memo";
@@ -15,6 +15,22 @@ import { useIsAdmin } from "./use-is-admin";
import { useTranslation } from "react-i18next";
// the status bar has always rendered these exact classes; keep them byte for
// byte so its output does not change
const SEVERITY_COLOR: Record<ProblemSeverity, string> = {
error: "text-danger",
warning: "text-orange-400",
info: "text-selected",
};
function problem(
severity: ProblemSeverity,
text: string,
relevantLink?: string,
): PotentialProblem {
return { text, severity, color: SEVERITY_COLOR[severity], relevantLink };
}
export default function useStats(stats: FrigateStats | undefined) {
const { t } = useTranslation(["views/system"]);
const { data: config } = useSWR<FrigateConfig>("config");
@@ -48,36 +64,42 @@ export default function useStats(stats: FrigateStats | undefined) {
// check shm level
const shm = memoizedStats.service.storage["/dev/shm"];
if (shm?.total && shm?.min_shm && shm.total < shm.min_shm) {
problems.push({
text: t("stats.shmTooLow", {
total: shm.total,
min: shm.min_shm,
}),
color: "text-danger",
relevantLink: "/system#storage",
});
problems.push(
problem(
"error",
t("stats.shmTooLow", {
total: shm.total,
min: shm.min_shm,
}),
"/system#storage",
),
);
}
// check detectors for high inference speeds
Object.entries(memoizedStats["detectors"]).forEach(([key, det]) => {
if (det["inference_speed"] > InferenceThreshold.error) {
problems.push({
text: t("stats.detectIsVerySlow", {
detect: capitalizeFirstLetter(key),
speed: det["inference_speed"],
}),
color: "text-danger",
relevantLink: "/system#general",
});
problems.push(
problem(
"error",
t("stats.detectIsVerySlow", {
detect: capitalizeFirstLetter(key),
speed: det["inference_speed"],
}),
"/system#general",
),
);
} else if (det["inference_speed"] > InferenceThreshold.warning) {
problems.push({
text: t("stats.detectIsSlow", {
detect: capitalizeFirstLetter(key),
speed: det["inference_speed"],
}),
color: "text-orange-400",
relevantLink: "/system#general",
});
problems.push(
problem(
"warning",
t("stats.detectIsSlow", {
detect: capitalizeFirstLetter(key),
speed: det["inference_speed"],
}),
"/system#general",
),
);
}
});
@@ -94,13 +116,15 @@ export default function useStats(stats: FrigateStats | undefined) {
const cameraName = config.cameras?.[name]?.friendly_name ?? name;
if (config.cameras?.[name]?.enabled && cam["camera_fps"] == 0) {
problems.push({
text: t("stats.cameraIsOffline", {
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
}),
color: "text-danger",
relevantLink: "logs",
});
problems.push(
problem(
"error",
t("stats.cameraIsOffline", {
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
}),
"logs",
),
);
}
});
@@ -121,37 +145,43 @@ export default function useStats(stats: FrigateStats | undefined) {
const cameraName = config?.cameras?.[name]?.friendly_name ?? name;
if (!isNaN(ffmpegAvg) && ffmpegAvg >= CameraFfmpegThreshold.error) {
problems.push({
text: t("stats.ffmpegHighCpuUsage", {
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
ffmpegAvg,
}),
color: "text-danger",
relevantLink: "/system#cameras",
});
problems.push(
problem(
"error",
t("stats.ffmpegHighCpuUsage", {
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
ffmpegAvg,
}),
"/system#cameras",
),
);
}
if (!isNaN(detectAvg) && detectAvg >= CameraDetectThreshold.error) {
problems.push({
text: t("stats.detectHighCpuUsage", {
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
detectAvg,
}),
color: "text-danger",
relevantLink: "/system#cameras",
});
problems.push(
problem(
"error",
t("stats.detectHighCpuUsage", {
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
detectAvg,
}),
"/system#cameras",
),
);
}
});
// Add message if debug replay is active
if (replayActive) {
problems.push({
text: t("stats.debugReplayActive", {
defaultValue: "Debug replay session is active",
}),
color: "text-selected",
relevantLink: "/replay",
});
problems.push(
problem(
"info",
t("stats.debugReplayActive", {
defaultValue: "Debug replay session is active",
}),
"/replay",
),
);
}
return problems;
+9 -1
View File
@@ -705,7 +705,9 @@ export default function Settings() {
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config]);
const [selectedCamera, setSelectedCamera] = useState<string>("");
const [selectedCamera, setSelectedCamera] = useState<string>(
() => searchParams.get("camera") ?? "",
);
// Get all camera overrides for the selected camera
const cameraOverrides = useAllCameraOverrides(config, selectedCamera);
@@ -1164,6 +1166,12 @@ export default function Settings() {
});
useSearchEffect("camera", (camera: string) => {
// the config drives the camera list, so keep the param until it loads
// rather than consuming it against an empty list
if (cameras.length === 0) {
return false;
}
const cameraNames = cameras.map((c) => c.name);
if (cameraNames.includes(camera)) {
setSelectedCamera(camera);
+1 -1
View File
@@ -122,7 +122,7 @@ function System() {
</ToggleGroup>
<div className="flex h-full items-center">
{lastUpdated && (
{lastUpdated && pageToggle != "health" && (
<div className="h-full content-center text-sm text-muted-foreground">
{t("lastRefreshed")}
<TimeAgo time={lastUpdated * 1000} dense />
+22
View File
@@ -222,3 +222,25 @@ export type OnvifProbeResponse = {
message?: string;
detail?: string;
};
/**
* Best-effort brand from a camera URL, so the wizard's brand-specific stream
* warnings can run for cameras that were not created by the wizard.
*/
export function inferCameraBrand(url: string): CameraBrand | undefined {
const lower = url.toLowerCase();
if (lower.includes("app=bcs") || lower.includes("/preview_")) {
return "reolink";
}
if (lower.includes("/cam/realmonitor")) {
return "dahua";
}
if (lower.includes("/streaming/channels/")) {
return "hikvision";
}
return undefined;
}
+9 -4
View File
@@ -28,13 +28,15 @@ export type BirdseyeMode =
export interface FaceRecognitionConfig {
enabled: boolean;
device?: string | null;
model_size: SearchModelSize;
unknown_score: number;
detection_threshold: number;
recognition_threshold: number;
}
export type SearchModel = "jinav1" | "jinav2";
// a GenAI provider name is also accepted by the backend
export type SearchModel = "jinav1" | "jinav2" | (string & NonNullable<unknown>);
export type SearchModelSize = "small" | "large";
export interface CameraConfig {
@@ -85,11 +87,11 @@ export interface CameraConfig {
};
ffmpeg: {
global_args: string[];
hwaccel_args: string;
hwaccel_args: string | string[];
input_args: string;
inputs: {
global_args: string[];
hwaccel_args: string[];
hwaccel_args: string | string[];
input_args: string;
path: string;
roles: string[];
@@ -448,6 +450,7 @@ export interface FrigateConfig {
audio_transcription: {
enabled: boolean;
device: "GPU" | "CPU";
};
auth: {
@@ -501,7 +504,7 @@ export interface FrigateConfig {
ffmpeg: {
global_args: string[];
hwaccel_args: string;
hwaccel_args: string | string[];
input_args: string;
output_args: {
detect: string[];
@@ -527,6 +530,7 @@ export interface FrigateConfig {
lpr: {
enabled: boolean;
device?: string | null;
};
logger: {
@@ -615,6 +619,7 @@ export interface FrigateConfig {
semantic_search: {
enabled: boolean;
device?: string | null;
reindex: boolean;
model: SearchModel;
model_size: SearchModelSize;
+4
View File
@@ -9,6 +9,8 @@ export type HealthSeverity = "error" | "warning" | "info";
*/
export type HealthProblem = {
id: string;
/** which source produced the row; part of the sort order */
source: "registry" | "live" | "config" | "stream";
severity: HealthSeverity;
/** camera name or other scope shown as a chip before the text */
scope?: string;
@@ -23,5 +25,7 @@ export type HealthProblem = {
docLink?: string;
/** absolute URL rendered as an external link (the update notice's release page) */
externalLink?: string;
/** render with a spinner instead of the severity icon (stream check running) */
pending?: boolean;
onDismiss?: () => void;
};
+4
View File
@@ -54,6 +54,7 @@ export type EmbeddingsStats = {
face_embedding_speed: number;
plate_recognition_speed: number;
text_embedding_speed: number;
devices?: Record<string, string>;
};
export type ExtraProcessStats = {
@@ -119,8 +120,11 @@ export type CameraStorage = {
};
};
export type ProblemSeverity = "error" | "warning" | "info";
export type PotentialProblem = {
text: string;
severity: ProblemSeverity;
color: string;
relevantLink?: string;
};
+204
View File
@@ -0,0 +1,204 @@
import type { TFunction } from "i18next";
import { sectionConfigs } from "@/components/config-form/sectionConfigs";
import type {
ConditionalMessage,
MessageConditionContext,
} from "@/components/config-form/section-configs/types";
import { settingsLink } from "@/components/config-form/sectionPages";
import type { ConfigSectionData } from "@/types/configForm";
import type { FrigateConfig } from "@/types/frigateConfig";
import type { HealthProblem } from "@/types/health";
import { getSectionConfig } from "@/utils/configUtil";
import { activeCameras } from "@/utils/health";
function healthMessages(
section: string,
level: "global" | "camera",
): ConditionalMessage[] {
const config = getSectionConfig(section, level);
return [...(config.messages ?? []), ...(config.fieldMessages ?? [])].filter(
(message) => message.health,
);
}
function isActive(
message: ConditionalMessage,
ctx: MessageConditionContext,
): boolean {
if (!message.condition(ctx)) {
return false;
}
return typeof message.health === "function" ? message.health(ctx) : true;
}
function toProblem(
message: ConditionalMessage,
section: string,
ctx: MessageConditionContext,
scope: string | undefined,
scopeIsCamera: boolean,
idSuffix: string,
t: TFunction,
): HealthProblem {
return {
id: `config:${section}:${message.key}:${idSuffix}`,
source: "config",
severity: message.severity,
scope,
scopeIsCamera,
text: t(message.messageKey, {
ns: "views/settings",
...(message.values ?? {}),
}),
docLink: message.docLink,
link: settingsLink(section, ctx.level, ctx.cameraName),
};
}
/**
* Evaluate every config message flagged for the Health tab against the saved,
* resolved config. The rules stay in the section configs, so the settings
* form and the Health tab can never disagree.
*/
/**
* Whether a camera section still carries the global section's values. Only
* keys the global block sets count: the resolved global detect leaves width
* and height null while every camera has numbers, so a full JSON comparison
* would never match.
*/
function inheritsGlobal(
cameraSection: Record<string, unknown>,
globalSection: Record<string, unknown>,
): boolean {
return Object.entries(globalSection).every(([key, globalValue]) => {
if (globalValue === null || globalValue === undefined) {
return true;
}
const cameraValue = cameraSection[key];
if (
typeof globalValue === "object" &&
!Array.isArray(globalValue) &&
typeof cameraValue === "object" &&
cameraValue !== null &&
!Array.isArray(cameraValue)
) {
return inheritsGlobal(
cameraValue as Record<string, unknown>,
globalValue as Record<string, unknown>,
);
}
return JSON.stringify(cameraValue) === JSON.stringify(globalValue);
});
}
export function evaluateConfigHealth(
config: FrigateConfig,
t: TFunction,
): HealthProblem[] {
const problems: HealthProblem[] = [];
const firedGlobally = new Set<string>();
const cameras = activeCameras(config);
const record = config as unknown as Record<string, unknown>;
Object.keys(sectionConfigs).forEach((section) => {
const globalMessages = healthMessages(section, "global");
if (globalMessages.length > 0) {
const sectionData = record[section];
// models is a list; every other section is one object
const items: {
formData: ConfigSectionData;
scope?: string;
idSuffix: string;
}[] =
section === "models" && Array.isArray(sectionData)
? sectionData.map((model, index) => ({
formData: model as ConfigSectionData,
scope: t(
`detectionModels.scenes.${(model as { scene?: string }).scene || "all"}`,
{ ns: "views/settings" },
),
idSuffix: `model${index}`,
}))
: [
{
formData: (sectionData ?? {}) as ConfigSectionData,
idSuffix: "global",
},
];
items.forEach(({ formData, scope, idSuffix }) => {
const ctx: MessageConditionContext = {
fullConfig: config,
level: "global",
formData,
};
globalMessages
.filter((message) => isActive(message, ctx))
.forEach((message) => {
const problem = toProblem(
message,
section,
ctx,
scope,
false,
idSuffix,
t,
);
firedGlobally.add(`${section}:${message.key}`);
problems.push(problem);
});
});
}
const cameraMessages = healthMessages(section, "camera");
if (cameraMessages.length > 0) {
const globalSection = (record[section] ?? {}) as Record<string, unknown>;
cameras.forEach((camera) => {
const cameraRecord = camera as unknown as Record<string, unknown>;
const sectionData = cameraRecord[section] ?? {};
// cameras inherit global values, so a problem the global row already
// states would repeat once per camera; a camera that overrides the
// section keeps its own row and link
const inherited = inheritsGlobal(
sectionData as Record<string, unknown>,
globalSection,
);
const ctx: MessageConditionContext = {
fullConfig: config,
fullCameraConfig: camera,
level: "camera",
cameraName: camera.name,
formData: sectionData as ConfigSectionData,
};
cameraMessages
.filter((message) => isActive(message, ctx))
.forEach((message) => {
const problem = toProblem(
message,
section,
ctx,
camera.name,
true,
camera.name,
t,
);
if (
!(inherited && firedGlobally.has(`${section}:${message.key}`))
) {
problems.push(problem);
}
});
});
}
});
return problems;
}
+779
View File
@@ -0,0 +1,779 @@
import type { TFunction } from "i18next";
import type {
DetectionHardware,
HwaccelRecommendation,
} from "@/types/hardware";
import type {
CameraConfig,
DetectionModelConfig,
FrigateConfig,
} from "@/types/frigateConfig";
import type { FrigateStats, GpuVendor } from "@/types/stats";
import { InferenceThreshold } from "@/types/graph";
import { summarizeDevices } from "@/utils/detectionHardware";
import { isReplayCamera } from "@/utils/cameraUtil";
import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
export type HealthState = "ok" | "warning" | "error" | "unknown";
export type HardwareRow = {
id: string;
state: HealthState;
label: string;
/** muted text on the label line, what is actually running */
detail?: string;
/** reason line under the label, colored by state */
message?: string;
};
/** seconds after startup during which stats-based rules report unknown */
export const STARTUP_WINDOW_S = 120;
// ---------------------------------------------------------------- detection
/**
* Detector runner names exactly as the backend's runner_names() builds them:
* every model's devices in config order, first occurrence is the raw device
* string, the Nth repeat is "raw#N".
*/
export function runnerNames(models: DetectionModelConfig[]): string[] {
const counts = new Map<string, number>();
const names: string[] = [];
models.forEach((model) => {
model.devices.forEach((raw) => {
const count = (counts.get(raw) ?? 0) + 1;
counts.set(raw, count);
names.push(count === 1 ? raw : `${raw}#${count}`);
});
});
return names;
}
/** detectors the probe reports; anything else cannot be checked for presence */
export const PROBED_DETECTORS = new Set([
"cpu",
"edgetpu",
"hailo8l",
"memryx",
"openvino",
"onnx",
"tensorrt",
"rknn",
"axengine",
"synaptics",
]);
/** detectors that fall back to the CPU when no accelerator is present */
const CPU_FALLBACK_DETECTORS = new Set(["onnx", "openvino"]);
export type DevicePresence = "present" | "unverified" | "absent";
/**
* Whether a configured device string was found by the hardware probe.
* "unverified" means the detector's hardware is present but the probe does
* not enumerate this particular device (openvino:AUTO, rknn:0), so it must
* not be reported as missing.
*/
export function devicePresence(
device: string,
hardware: DetectionHardware[],
): DevicePresence {
const [detector, ...rest] = device.split(":");
const devicePart = rest.join(":");
if (detector === "cpu" || devicePart.toUpperCase() === "CPU") {
return "present";
}
if (!PROBED_DETECTORS.has(detector)) {
return "unverified";
}
const entries = hardware.filter((entry) => entry.detector === detector);
const generic = devicePart === "" || devicePart.toUpperCase() === "AUTO";
if (entries.length === 0) {
// a bare onnx or openvino runs on the CPU when nothing is attached, so
// an empty probe is not proof of missing hardware for those
return generic && CPU_FALLBACK_DETECTORS.has(detector)
? "unverified"
: "absent";
}
if (generic) {
return "present";
}
const unitMatch = entries.some((entry) =>
entry.units.some(
(unit) =>
unit.device === device ||
unit.device.startsWith(`${device}:`) ||
unit.device.startsWith(`${device}.`),
),
);
return unitMatch ? "present" : "unverified";
}
type DetectionArgs = {
models: DetectionModelConfig[];
hardware: DetectionHardware[] | undefined;
probeFailed: boolean;
stats: FrigateStats | undefined;
startup: boolean;
t: TFunction;
};
export function detectionRows({
models,
hardware,
probeFailed,
stats,
startup,
t,
}: DetectionArgs): HardwareRow[] {
const names = runnerNames(models);
let cursor = 0;
return models.map((model, index) => {
const modelRunners = names.slice(cursor, cursor + model.devices.length);
cursor += model.devices.length;
const label = t(`detectionModels.scenes.${model.scene || "all"}`, {
ns: "views/settings",
});
const id = `detection:${index}`;
const detail = probeFailed
? t("health.hardware.probeUnavailable", {
ns: "views/system",
})
: summarizeDevices(hardware ?? [], model.devices);
if (!probeFailed && hardware) {
const presence = new Map(
model.devices.map((device) => [
device,
devicePresence(device, hardware),
]),
);
const missing = [...presence]
.filter(([, state]) => state === "absent")
.map(([device]) => device);
if (missing.length > 0) {
return {
id,
state: "error",
label,
detail,
message: t("health.hardware.deviceNotFound", {
ns: "views/system",
devices: missing.join(", "),
}),
};
}
// unverified devices are skipped by the presence rule; the runtime
// rules below still decide the row
}
if (startup || !stats) {
return {
id,
state: "unknown",
label,
detail,
message: t("health.hardware.justStarted", {
ns: "views/system",
}),
};
}
const missingRunner = modelRunners.find((name) => !stats.detectors[name]);
if (missingRunner) {
return {
id,
state: "error",
label,
detail,
message: t("health.hardware.detectorNotRunning", {
ns: "views/system",
}),
};
}
const slowest = Math.max(
...modelRunners.map((name) => stats.detectors[name].inference_speed),
);
if (slowest > InferenceThreshold.error) {
return {
id,
state: "error",
label,
detail,
message: t("health.hardware.inferenceVerySlow", {
ns: "views/system",
speed: slowest,
}),
};
}
if (slowest > InferenceThreshold.warning) {
return {
id,
state: "warning",
label,
detail,
message: t("health.hardware.inferenceSlow", {
ns: "views/system",
speed: slowest,
}),
};
}
return {
id,
state: "ok",
label,
detail: [
detail,
t("health.hardware.inferenceMs", {
ns: "views/system",
speed: slowest,
}),
]
.filter(Boolean)
.join(" · "),
};
});
}
// ------------------------------------------------------------------ hwaccel
export type HwaccelFamilyKey =
| "nvidia"
| "vaapi"
| "intel-qsv"
| "rkmpp"
| "jetson"
| "rpi";
export type HwaccelClass =
| { kind: "none" }
| { kind: "custom" }
| { kind: "preset"; family: HwaccelFamilyKey };
const PRESET_FAMILIES: [string, HwaccelFamilyKey][] = [
["preset-nvidia", "nvidia"],
["preset-vaapi", "vaapi"],
["preset-intel-qsv", "intel-qsv"],
["preset-rk", "rkmpp"],
["preset-jetson", "jetson"],
["preset-rpi", "rpi"],
];
export function hwaccelFamily(value: string | string[]): HwaccelClass {
if (Array.isArray(value)) {
return value.length === 0 ? { kind: "none" } : { kind: "custom" };
}
// the backend resolves global and camera auto at startup; a literal auto
// left on an input means no hardware decoding for it at runtime
if (value === "" || value === "auto") {
return { kind: "none" };
}
const match = PRESET_FAMILIES.find(([prefix]) => value.startsWith(prefix));
return match ? { kind: "preset", family: match[1] } : { kind: "custom" };
}
const FAMILY_VENDORS: Record<HwaccelFamilyKey, GpuVendor[]> = {
nvidia: ["nvidia"],
jetson: ["nvidia"],
"intel-qsv": ["intel"],
vaapi: ["intel", "amd"],
rkmpp: ["rockchip"],
rpi: ["rpi"],
};
function decoderUsage(
family: HwaccelFamilyKey,
stats: FrigateStats | undefined,
): string | undefined {
if (!stats?.gpu_usages) {
return undefined;
}
const entry = Object.values(stats.gpu_usages).find(
(gpu) =>
gpu.vendor && FAMILY_VENDORS[family].includes(gpu.vendor) && gpu.dec,
);
return entry?.dec;
}
function valueKey(value: string | string[]): string {
return Array.isArray(value) ? JSON.stringify(value) : value;
}
type HwaccelArgs = {
config: FrigateConfig;
hwaccel: HwaccelRecommendation | undefined;
hwaccelFailed: boolean;
stats: FrigateStats | undefined;
t: TFunction;
};
export function hwaccelRows({
config,
hwaccel,
hwaccelFailed,
stats,
t,
}: HwaccelArgs): HardwareRow[] {
const cameras = activeCameras(config);
const camerasByValue = new Map<
string,
{ value: string | string[]; cameras: string[] }
>();
cameras.forEach((camera) => {
const values: (string | string[])[] = [camera.ffmpeg.hwaccel_args ?? ""];
camera.ffmpeg.inputs.forEach((input) => {
if (input.hwaccel_args && input.hwaccel_args.length > 0) {
values.push(input.hwaccel_args);
}
});
values.forEach((value) => {
const key = valueKey(value);
const entry = camerasByValue.get(key) ?? { value, cameras: [] };
if (!entry.cameras.includes(camera.name)) {
entry.cameras.push(camera.name);
}
camerasByValue.set(key, entry);
});
});
const familyName = (family: HwaccelFamilyKey | "none") =>
t(`setupWizard.hwaccel.families.${family}`, { ns: "views/setup" });
return [...camerasByValue.entries()].map(([key, entry]) => {
const id = `hwaccel:${key}`;
const cameraList =
entry.cameras.length === cameras.length
? t("health.hardware.allCameras", {
ns: "views/system",
})
: entry.cameras
.map((name) => resolveCameraName(config, name))
.join(", ");
const classified = hwaccelFamily(entry.value);
if (hwaccelFailed) {
return {
id,
state: "unknown",
label:
classified.kind === "preset" ? familyName(classified.family) : key,
detail: cameraList,
message: t("health.hardware.probeUnavailable", {
ns: "views/system",
}),
};
}
if (classified.kind === "custom") {
return {
id,
state: "unknown",
label: t("health.hardware.customArgs", {
ns: "views/system",
}),
detail: cameraList,
message: t("health.hardware.customArgsNotVerified", {
ns: "views/system",
}),
};
}
const available = hwaccel?.available ?? [];
if (classified.kind === "none") {
if (available.length > 0 && hwaccel?.recommended) {
return {
id,
state: "warning",
label: familyName("none"),
detail: cameraList,
message: t("health.hardware.hwaccelNotConfigured", {
ns: "views/system",
family: familyName(hwaccel.recommended as HwaccelFamilyKey),
}),
};
}
return { id, state: "ok", label: familyName("none"), detail: cameraList };
}
const label = familyName(classified.family);
const present = available.some(
(family) => family.key === classified.family,
);
// a warning, not an error: the resolved config comes from go2rtc's
// answer while `available` comes from the device probe, and the two
// disagree on whole platform families
if (!present) {
return {
id,
state: "warning",
label,
detail: cameraList,
message: t("health.hardware.hwaccelHardwareMissing", {
ns: "views/system",
family: label,
}),
};
}
const dec = decoderUsage(classified.family, stats);
const detail = dec
? `${cameraList} · ${t("health.hardware.decoderUsage", {
ns: "views/system",
usage: dec,
})}`
: cameraList;
return { id, state: "ok", label, detail };
});
}
// -------------------------------------------------------------- enrichments
const ANY_ACCELERATOR = [
"onnx:nvidia",
"onnx:amd",
"openvino:GPU",
"openvino:NPU",
"rknn",
"tensorrt",
];
/**
* Probe keys that satisfy a requested device string. AUTO and the implicit
* defaults accept any accelerator; an explicit override must match its own
* hardware. Undefined means the string is not one we can check.
*/
export function acceleratorKeysFor(
requested: string,
nvidiaOnly: boolean,
): string[] | undefined {
if (nvidiaOnly) {
return ["onnx:nvidia"];
}
const upper = requested.toUpperCase();
if (upper === "AUTO") {
return ANY_ACCELERATOR;
}
// ONNX Runtime puts a plain GPU request on whichever GPU it has; only an
// indexed GPU.n names OpenVINO specifically
if (upper === "GPU") {
return ["openvino:GPU", "onnx:nvidia", "onnx:amd"];
}
if (/^GPU\.\d+$/.test(upper)) {
return ["openvino:GPU"];
}
if (upper === "NPU") {
return ["openvino:NPU"];
}
if (upper.startsWith("CUDA") || upper.startsWith("TENSORRT")) {
return ["onnx:nvidia"];
}
if (upper.startsWith("ROCM") || upper.startsWith("MIGRAPHX")) {
return ["onnx:amd"];
}
return undefined;
}
function acceleratorPresent(
hardware: DetectionHardware[] | undefined,
keys: string[],
): boolean {
return (hardware ?? []).some((entry) => keys.includes(entry.key));
}
type EnrichmentSpec = {
id: "semantic_search" | "face_recognition" | "lpr" | "audio_transcription";
enabled: boolean;
/** what the config asks for, after the backend's own defaults */
requested: string;
explicit: boolean;
remote: boolean;
nvidiaOnly: boolean;
/** runtime device is not reported for this enrichment in v1 */
presenceOnly: boolean;
};
function enrichmentSpecs(config: FrigateConfig): EnrichmentSpec[] {
const ss = config.semantic_search;
const anyCameraTranscribes = Object.values(config.cameras).some(
(camera) => camera.audio_transcription?.enabled,
);
return [
{
id: "semantic_search",
enabled: ss.enabled,
requested: ss.device ?? (ss.model_size === "large" ? "GPU" : "CPU"),
explicit: ss.device != null,
remote: ss.model !== "jinav1" && ss.model !== "jinav2",
nvidiaOnly: false,
presenceOnly: false,
},
{
id: "face_recognition",
enabled: config.face_recognition.enabled,
requested: config.face_recognition.device ?? "GPU",
explicit: config.face_recognition.device != null,
remote: false,
nvidiaOnly: false,
presenceOnly: false,
},
{
id: "lpr",
enabled: config.lpr.enabled,
requested: config.lpr.device ?? "AUTO",
explicit: config.lpr.device != null,
remote: false,
nvidiaOnly: false,
presenceOnly: false,
},
{
id: "audio_transcription",
enabled: config.audio_transcription.enabled || anyCameraTranscribes,
requested: config.audio_transcription.device ?? "CPU",
explicit: true,
remote: false,
nvidiaOnly: true,
presenceOnly: true,
},
];
}
type EnrichmentArgs = {
config: FrigateConfig;
hardware: DetectionHardware[] | undefined;
probeFailed: boolean;
stats: FrigateStats | undefined;
startup: boolean;
t: TFunction;
};
export function enrichmentRows({
config,
hardware,
probeFailed,
stats,
startup,
t,
}: EnrichmentArgs): HardwareRow[] {
return enrichmentSpecs(config)
.filter((spec) => spec.enabled)
.map((spec) => {
const id = `enrichment:${spec.id}`;
const label = t(`health.hardware.enrichments.${spec.id}`, {
ns: "views/system",
});
if (spec.remote) {
return {
id,
state: "ok",
label,
detail: t("health.hardware.remoteProvider", {
ns: "views/system",
}),
};
}
if (spec.requested.toUpperCase() === "CPU") {
return { id, state: "ok", label, detail: "CPU" };
}
if (probeFailed) {
return {
id,
state: "unknown",
label,
message: t("health.hardware.probeUnavailable", {
ns: "views/system",
}),
};
}
// implicit defaults (GPU for face recognition and large semantic
// search) accept any accelerator; only an explicit override is matched
// against its own hardware
const keys = spec.explicit
? acceleratorKeysFor(spec.requested, spec.nvidiaOnly)
: spec.nvidiaOnly
? ["onnx:nvidia"]
: ANY_ACCELERATOR;
if (!keys) {
return {
id,
state: "unknown",
label,
message: t("health.hardware.unrecognizedDevice", {
ns: "views/system",
}),
};
}
const present = acceleratorPresent(hardware, keys);
const runtime = startup
? undefined
: stats?.embeddings?.devices?.[spec.id];
const runtimeIsCpu = !!runtime && runtime.toUpperCase().includes("CPU");
// a model that reports an accelerator is proof enough, whatever the
// probe keys say
if (runtime && !runtimeIsCpu) {
return { id, state: "ok", label, detail: runtime };
}
if (
spec.explicit &&
spec.requested.toUpperCase() !== "AUTO" &&
hardware &&
!present
) {
return {
id,
state: "error",
label,
message: t("health.hardware.acceleratorMissing", {
ns: "views/system",
device: spec.requested,
}),
};
}
if (spec.presenceOnly) {
return { id, state: "ok", label, detail: spec.requested };
}
if (!runtime) {
return {
id,
state: "unknown",
label,
message: t("health.hardware.modelNotRunYet", {
ns: "views/system",
}),
};
}
if (
runtimeIsCpu &&
present &&
spec.explicit &&
spec.requested.toUpperCase() !== "AUTO"
) {
return {
id,
state: "error",
label,
detail: "CPU",
message: t("health.hardware.fellBackToCpu", {
ns: "views/system",
device: spec.requested,
}),
};
}
if (runtimeIsCpu && present) {
return {
id,
state: "warning",
label,
detail: "CPU",
message: t("health.hardware.cpuDespiteAccelerator", {
ns: "views/system",
}),
};
}
return { id, state: "ok", label, detail: runtimeIsCpu ? "CPU" : runtime };
});
}
// ------------------------------------------------------- camera connections
export type CameraConnectionCell = {
camera: string;
quality: "excellent" | "fair" | "poor" | "unusable";
cameraFps: number;
expectedFps: number;
reconnects: number;
stalls: number;
};
/** enabled, non-replay cameras whose latest connection is not excellent */
export function cameraConnectionCells(
config: FrigateConfig,
stats: FrigateStats | undefined,
): CameraConnectionCell[] {
if (!stats) {
return [];
}
return activeCameras(config)
.map((camera): CameraConnectionCell | undefined => {
const cam = stats.cameras[camera.name];
if (
!cam ||
!cam.connection_quality ||
cam.connection_quality === "excellent"
) {
return undefined;
}
return {
camera: camera.name,
quality: cam.connection_quality,
cameraFps: cam.camera_fps,
expectedFps: cam.expected_fps ?? 0,
reconnects: cam.reconnects_last_hour ?? 0,
stalls: cam.stalls_last_hour ?? 0,
};
})
.filter((cell): cell is CameraConnectionCell => cell !== undefined);
}
// --------------------------------------------------------------- helpers
export function activeCameras(config: FrigateConfig): CameraConfig[] {
return Object.values(config.cameras)
.filter((camera) => camera.enabled && !isReplayCamera(camera.name))
.sort((a, b) => a.ui.order - b.ui.order);
}
export function isStartupWindow(stats: FrigateStats | undefined): boolean {
return !!stats && stats.service.uptime < STARTUP_WINDOW_S;
}
+14
View File
@@ -0,0 +1,14 @@
import type { HealthProblem } from "@/types/health";
const SEVERITY_ORDER = { error: 0, warning: 1, info: 2 } as const;
const SOURCE_ORDER = { registry: 0, live: 1, config: 2, stream: 3 } as const;
/** errors first, then warnings, then info; within a severity by source, then scope */
export function sortHealthProblems(problems: HealthProblem[]): HealthProblem[] {
return [...problems].sort(
(a, b) =>
SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] ||
SOURCE_ORDER[a.source] - SOURCE_ORDER[b.source] ||
(a.scope ?? "").localeCompare(b.scope ?? ""),
);
}
+154
View File
@@ -0,0 +1,154 @@
import type { TFunction } from "i18next";
import type { StreamCheckResults } from "@/hooks/use-health-checks";
import type { StreamRole } from "@/types/cameraWizard";
import { inferCameraBrand } from "@/types/cameraWizard";
import type { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import type { HealthProblem } from "@/types/health";
import {
getStreamIssues,
lastErrorLine,
resolveRestreamSource,
type StreamIssue,
} from "@/utils/streamIssues";
// rules the add camera wizard shows that do not belong on the Health tab
const WIZARD_ONLY_RULES = new Set(["restream", "reolink-rtsp", "reolink-http"]);
/**
* Whether the record output keeps the camera's audio codec. The default
* preset transcodes to AAC, so a non-AAC source only matters when the args
* copy audio through.
*/
function recordCopiesAudio(camera: CameraConfig): boolean {
const args = camera.ffmpeg.output_args?.record;
const text = Array.isArray(args) ? args.join(" ") : (args ?? "");
return (
text === "preset-record-generic-audio-copy" ||
/(^|\s)-(c:a|acodec)\s+copy(\s|$)/.test(text)
);
}
export type StreamHealth = {
problems: HealthProblem[];
/** enabled cameras the results cover */
checked: number;
/** cameras with no stream problem */
clean: number;
};
/** Turn one stream check run into notice rows plus the counts a summary needs. */
export function streamHealth(
config: FrigateConfig,
results: StreamCheckResults | undefined,
t: TFunction,
): StreamHealth {
const problems: HealthProblem[] = [];
let checked = 0;
let clean = 0;
if (!results) {
return { problems, checked, clean };
}
Object.entries(results.byCamera).forEach(([name, check]) => {
const camera = config.cameras[name];
if (!camera || !camera.enabled) {
return;
}
checked += 1;
const link = `/settings?page=cameraFfmpeg&camera=${encodeURIComponent(name)}`;
const copiesAudio = recordCopiesAudio(camera);
let flagged = false;
if (check.error) {
problems.push({
id: `stream:${name}:error`,
source: "stream",
severity: "error",
scope: name,
scopeIsCamera: true,
text: t("health.notices.cameraProbeFailed", {
ns: "views/system",
error: check.error,
}),
link,
});
return;
}
camera.ffmpeg.inputs.forEach((input, index) => {
const result = check.streams[index];
const streamNumber = index + 1;
if (!result || !result.success) {
flagged = true;
problems.push({
id: `stream:${name}:${index}:probe`,
source: "stream",
severity: "error",
scope: name,
scopeIsCamera: true,
text: t("health.notices.streamProbeFailed", {
ns: "views/system",
index: streamNumber,
error: lastErrorLine(result?.error),
}),
link,
});
return;
}
const restream = resolveRestreamSource(
input.path,
config.go2rtc?.streams,
);
const url = restream?.url ?? input.path;
// a restreamed input's probe describes go2rtc's output, and a missing
// AAC track is fixed on the go2rtc stream, not on the camera
const streamLink = restream ? "/settings?page=systemGo2rtcStreams" : link;
const prefixKey = restream
? "health.notices.streamPrefixRestream"
: "health.notices.streamPrefix";
getStreamIssues(
{
url,
roles: input.roles as StreamRole[],
brand: inferCameraBrand(url),
useFfmpeg: restream?.useFfmpeg,
restream: !!restream,
testResult: result,
},
t,
)
.filter(
(issue): issue is StreamIssue & { type: "warning" | "error" } =>
issue.type !== "good" &&
!WIZARD_ONLY_RULES.has(issue.rule) &&
(issue.rule !== "audio-codec-record" || copiesAudio),
)
.forEach((issue) => {
flagged = true;
problems.push({
id: `stream:${name}:${index}:${issue.rule}`,
source: "stream",
severity: issue.type,
scope: name,
scopeIsCamera: true,
text: t(prefixKey, {
ns: "views/system",
index: streamNumber,
message: issue.message,
}),
link: streamLink,
});
});
});
if (!flagged) {
clean += 1;
}
});
return { problems, checked, clean };
}
+298
View File
@@ -0,0 +1,298 @@
import type { TFunction } from "i18next";
import { parseRestreamStreamName } from "@/components/config-form/theme/fields/streamSource";
import type { CameraBrand, StreamRole, TestResult } from "@/types/cameraWizard";
export type StreamIssue = {
type: "good" | "warning" | "error";
message: string;
/** stable key for the rule that fired, for filtering and tests */
rule: string;
};
export type StreamIssueInput = {
url: string;
roles: StreamRole[];
brand?: CameraBrand;
useFfmpeg?: boolean;
restream?: boolean;
testResult?: TestResult;
};
type ProbeStream = {
codec_type?: string;
codec_name?: string;
width?: number;
height?: number;
avg_frame_rate?: string;
};
export type FfprobeEntry = {
return_code?: number;
stdout?: { streams?: ProbeStream[] } | string;
/** the backend sends a list of non-empty lines on failure */
stderr?: string | string[];
};
function errorText(stderr: string | string[] | undefined): string {
const text = Array.isArray(stderr) ? stderr.join("\n") : stderr;
return text?.trim() || "Unknown error";
}
/** The human-readable end of an ffprobe error; the first lines are plumbing. */
export function lastErrorLine(error: string | undefined): string {
const lines = (error ?? "")
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
return lines[lines.length - 1] ?? "";
}
/** Parse one entry of the ffprobe API response the way the wizard does. */
export function ffprobeToTestResult(
entry: FfprobeEntry | undefined,
): TestResult {
if (!entry || entry.return_code !== 0 || typeof entry.stdout !== "object") {
return { success: false, error: errorText(entry?.stderr) };
}
const streams = entry.stdout?.streams ?? [];
const videoStream = streams.find(
(s) =>
s.codec_type === "video" ||
s.codec_name?.includes("h264") ||
s.codec_name?.includes("h265"),
);
const audioStream = streams.find(
(s) =>
s.codec_type === "audio" ||
s.codec_name?.includes("aac") ||
s.codec_name?.includes("mp3"),
);
const resolution = videoStream
? `${videoStream.width}x${videoStream.height}`
: undefined;
const fps = videoStream?.avg_frame_rate
? parseFloat(videoStream.avg_frame_rate.split("/")[0]) /
parseFloat(videoStream.avg_frame_rate.split("/")[1])
: undefined;
return {
success: true,
resolution,
videoCodec: videoStream?.codec_name,
audioCodec: audioStream?.codec_name,
fps: fps && !isNaN(fps) ? fps : undefined,
};
}
/** The wizard's Stream Validation rules, unchanged, over plain input. */
export function getStreamIssues(
input: StreamIssueInput,
t: TFunction,
): StreamIssue[] {
const result: StreamIssue[] = [];
const { roles, testResult } = input;
if (input.brand === "reolink") {
const streamUrl = input.url.toLowerCase();
if (streamUrl.startsWith("rtsp://")) {
result.push({
type: "warning",
rule: "reolink-rtsp",
message: t("cameraWizard.step4.issues.brands.reolink-rtsp", {
ns: "views/settings",
}),
});
}
if (streamUrl.startsWith("http://") && !input.useFfmpeg) {
result.push({
type: "warning",
rule: "reolink-http",
message: t("cameraWizard.step4.issues.brands.reolink-http", {
ns: "views/settings",
}),
});
}
}
if (testResult?.videoCodec) {
const videoCodec = testResult.videoCodec.toLowerCase();
if (["h264", "h265", "hevc"].includes(videoCodec)) {
result.push({
type: "good",
rule: "video-codec",
message: t("cameraWizard.step4.issues.videoCodecGood", {
ns: "views/settings",
codec: testResult.videoCodec,
}),
});
}
}
if (roles.includes("record")) {
if (testResult?.audioCodec) {
const audioCodec = testResult.audioCodec.toLowerCase();
if (audioCodec === "aac") {
result.push({
type: "good",
rule: "audio-codec",
message: t("cameraWizard.step4.issues.audioCodecGood", {
ns: "views/settings",
codec: testResult.audioCodec,
}),
});
} else {
result.push({
type: "error",
rule: "audio-codec-record",
message: t("cameraWizard.step4.issues.audioCodecRecordError", {
ns: "views/settings",
}),
});
}
} else {
result.push({
type: "warning",
rule: "no-audio",
message: t("cameraWizard.step4.issues.noAudioWarning", {
ns: "views/settings",
}),
});
}
}
if (roles.includes("audio") && !testResult?.audioCodec) {
result.push({
type: "error",
rule: "audio-required",
message: t("cameraWizard.step4.issues.audioCodecRequired", {
ns: "views/settings",
}),
});
}
if (roles.includes("record") && input.restream) {
result.push({
type: "warning",
rule: "restream",
message: t("cameraWizard.step4.issues.restreamingWarning", {
ns: "views/settings",
}),
});
}
if (roles.includes("detect") && testResult) {
const probedResolution = testResult.resolution;
let probedWidth = 0;
let probedHeight = 0;
if (probedResolution) {
const [w, h] = probedResolution.split("x").map(Number);
if (!isNaN(w) && !isNaN(h)) {
probedWidth = w;
probedHeight = h;
}
}
if (probedWidth <= 0 || probedHeight <= 0) {
result.push({
type: "error",
rule: "resolution-unknown",
message: t("cameraWizard.step4.issues.resolutionUnknown", {
ns: "views/settings",
}),
});
} else {
const minDimension = Math.min(probedWidth, probedHeight);
const maxDimension = Math.max(probedWidth, probedHeight);
if (minDimension > 1080) {
result.push({
type: "warning",
rule: "resolution-high",
message: t("cameraWizard.step4.issues.resolutionHigh", {
ns: "views/settings",
resolution: probedResolution,
}),
});
} else if (maxDimension < 640) {
result.push({
type: "error",
rule: "resolution-low",
message: t("cameraWizard.step4.issues.resolutionLow", {
ns: "views/settings",
resolution: probedResolution,
}),
});
}
}
}
if (
input.brand === "dahua" &&
roles.includes("detect") &&
input.url.includes("subtype=1")
) {
result.push({
type: "warning",
rule: "dahua-substream",
message: t("cameraWizard.step4.issues.dahua.substreamWarning", {
ns: "views/settings",
}),
});
}
if (
input.brand === "hikvision" &&
roles.includes("detect") &&
input.url.includes("/102")
) {
result.push({
type: "warning",
rule: "hikvision-substream",
message: t("cameraWizard.step4.issues.hikvision.substreamWarning", {
ns: "views/settings",
}),
});
}
return result;
}
/**
* For an input that points at a go2rtc restream, find the camera URL behind
* it. Returns undefined when the path is not a restream or the stream is not
* in the go2rtc config. The /config response redacts credentials in these
* sources, so the URL is only good for pattern matching.
*/
export function resolveRestreamSource(
path: string,
streams: Record<string, string | string[]> | undefined,
): { url: string; useFfmpeg: boolean } | undefined {
const name = parseRestreamStreamName(path);
if (!name || !streams) {
return undefined;
}
const configured = streams[name];
const sources = Array.isArray(configured)
? configured
: configured
? [configured]
: [];
const source = sources.find((s) => !s.startsWith(`ffmpeg:${name}`));
if (!source) {
return undefined;
}
if (source.startsWith("ffmpeg:")) {
return {
url: source.slice("ffmpeg:".length).split("#")[0],
useFfmpeg: true,
};
}
return { url: source, useFfmpeg: false };
}
@@ -114,6 +114,11 @@ export default function EnrichmentMetrics({
}
Object.entries(stats.embeddings).forEach(([rawKey, stat]) => {
// embeddings.devices is a label map, not a metric series
if (typeof stat !== "number") {
return;
}
const key = rawKey.replaceAll("_", " ");
if (!(key in series)) {
const classificationIndex = rawKey.indexOf("_classification_");
+2
View File
@@ -1,9 +1,11 @@
import HardwarePane from "@/components/health/HardwarePane";
import NoticesPane from "@/components/health/NoticesPane";
export default function HealthMetrics() {
return (
<div className="scrollbar-container mt-4 flex size-full flex-col gap-4 overflow-y-auto">
<NoticesPane />
<HardwarePane />
</div>
);
}