diff --git a/docs/docs/troubleshooting/faqs.md b/docs/docs/troubleshooting/faqs.md index f70cda27fc..4503352134 100644 --- a/docs/docs/troubleshooting/faqs.md +++ b/docs/docs/troubleshooting/faqs.md @@ -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. diff --git a/frigate/api/camera.py b/frigate/api/camera.py index 0e9f8c2984..f99a0e4815 100644 --- a/frigate/api/camera.py +++ b/frigate/api/camera.py @@ -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 = { diff --git a/frigate/data_processing/types.py b/frigate/data_processing/types.py index 5cd1f50087..86a5ff304f 100644 --- a/frigate/data_processing/types.py +++ b/frigate/data_processing/types.py @@ -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: diff --git a/frigate/detectors/detection_runners.py b/frigate/detectors/detection_runners.py index a4060eb8e5..5b5a4cba98 100644 --- a/frigate/detectors/detection_runners.py +++ b/frigate/detectors/detection_runners.py @@ -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, ) diff --git a/frigate/embeddings/maintainer.py b/frigate/embeddings/maintainer.py index 17b09383d9..15997537a8 100644 --- a/frigate/embeddings/maintainer.py +++ b/frigate/embeddings/maintainer.py @@ -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() diff --git a/frigate/embeddings/onnx/face_embedding.py b/frigate/embeddings/onnx/face_embedding.py index 31d2bd2066..0f6823e9e9 100644 --- a/frigate/embeddings/onnx/face_embedding.py +++ b/frigate/embeddings/onnx/face_embedding.py @@ -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() diff --git a/frigate/embeddings/types.py b/frigate/embeddings/types.py index 32cbe5dd08..f5d4242e5e 100644 --- a/frigate/embeddings/types.py +++ b/frigate/embeddings/types.py @@ -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 diff --git a/frigate/stats/util.py b/frigate/stats/util.py index 1252db1f4e..9072e60efe 100644 --- a/frigate/stats/util.py +++ b/frigate/stats/util.py @@ -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) diff --git a/frigate/test/http_api/test_http_ffprobe.py b/frigate/test/http_api/test_http_ffprobe.py new file mode 100644 index 0000000000..5038c07c85 --- /dev/null +++ b/frigate/test/http_api/test_http_ffprobe.py @@ -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") diff --git a/frigate/test/test_facenet_device.py b/frigate/test/test_facenet_device.py new file mode 100644 index 0000000000..e86b4dc044 --- /dev/null +++ b/frigate/test/test_facenet_device.py @@ -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) diff --git a/frigate/test/test_model_downloader.py b/frigate/test/test_model_downloader.py index e4857bcbd1..eb1eea6868 100644 --- a/frigate/test/test_model_downloader.py +++ b/frigate/test/test_model_downloader.py @@ -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"]) diff --git a/frigate/test/test_runner_devices.py b/frigate/test/test_runner_devices.py new file mode 100644 index 0000000000..8df7be5ef5 --- /dev/null +++ b/frigate/test/test_runner_devices.py @@ -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) diff --git a/frigate/test/test_runtime_device_fold.py b/frigate/test/test_runtime_device_fold.py new file mode 100644 index 0000000000..517b31b70c --- /dev/null +++ b/frigate/test/test_runtime_device_fold.py @@ -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) diff --git a/frigate/util/downloader.py b/frigate/util/downloader.py index 9249286663..b674218308 100644 --- a/frigate/util/downloader.py +++ b/frigate/util/downloader.py @@ -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() diff --git a/web/e2e/fixtures/frigate-test.ts b/web/e2e/fixtures/frigate-test.ts index 892df68417..5254e3b1c5 100644 --- a/web/e2e/fixtures/frigate-test.ts +++ b/web/e2e/fixtures/frigate-test.ts @@ -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 diff --git a/web/e2e/fixtures/mock-data/stats.ts b/web/e2e/fixtures/mock-data/stats.ts index c1b5d1a78c..69759f6eaa 100644 --- a/web/e2e/fixtures/mock-data/stats.ts +++ b/web/e2e/fixtures/mock-data/stats.ts @@ -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, + }, 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", diff --git a/web/e2e/helpers/api-mocker.ts b/web/e2e/helpers/api-mocker.ts index 3209d6fe6d..56f37c3e15 100644 --- a/web/e2e/helpers/api-mocker.ts +++ b/web/e2e/helpers/api-mocker.ts @@ -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:` */ + ffprobe?: Record; } +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:`; 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) => diff --git a/web/e2e/helpers/ws-mocker.ts b/web/e2e/helpers/ws-mocker.ts index 03db9f7410..aaeb5a61f3 100644 --- a/web/e2e/helpers/ws-mocker.ts +++ b/web/e2e/helpers/ws-mocker.ts @@ -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, - }), + ), ); } diff --git a/web/e2e/specs/system-health.spec.ts b/web/e2e/specs/system-health.spec.ts index 06d1dfa0c6..4072ebc9ee 100644 --- a/web/e2e/specs/system-health.spec.ts +++ b/web/e2e/specs/system-health.spec.ts @@ -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/); + }); +}); diff --git a/web/public/locales/en/views/system.json b/web/public/locales/en/views/system.json index 2b0d0e629f..da8d204581 100644 --- a/web/public/locales/en/views/system.json +++ b/web/public/locales/en/views/system.json @@ -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": { diff --git a/web/src/components/Statusbar.tsx b/web/src/components/Statusbar.tsx index 4e7c4053c3..e8bbc2fa7b 100644 --- a/web/src/components/Statusbar.tsx +++ b/web/src/components/Statusbar.tsx @@ -187,10 +187,20 @@ export default function Statusbar() {
{Object.entries(messages).length === 0 ? ( -
- - {t("stats.healthy")} -
+ isAdmin ? ( + + + {t("stats.healthy")} + + ) : ( +
+ + {t("stats.healthy")} +
+ ) ) : ( Object.entries(messages).map(([key, messageArray]) => (
diff --git a/web/src/components/config-form/section-configs/audio.ts b/web/src/components/config-form/section-configs/audio.ts index 06f1670c67..a7fa212714 100644 --- a/web/src/components/config-form/section-configs/audio.ts +++ b/web/src/components/config-form/section-configs/audio.ts @@ -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) => { diff --git a/web/src/components/config-form/section-configs/audio_transcription.ts b/web/src/components/config-form/section-configs/audio_transcription.ts index 0983708a25..f42791626e 100644 --- a/web/src/components/config-form/section-configs/audio_transcription.ts +++ b/web/src/components/config-form/section-configs/audio_transcription.ts @@ -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) => { diff --git a/web/src/components/config-form/section-configs/detect.ts b/web/src/components/config-form/section-configs/detect.ts index 13d037cbfa..60725fd350 100644 --- a/web/src/components/config-form/section-configs/detect.ts +++ b/web/src/components/config-form/section-configs/detect.ts @@ -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", diff --git a/web/src/components/config-form/section-configs/face_recognition.ts b/web/src/components/config-form/section-configs/face_recognition.ts index 031152fff7..516da3a130 100644 --- a/web/src/components/config-form/section-configs/face_recognition.ts +++ b/web/src/components/config-form/section-configs/face_recognition.ts @@ -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) => { diff --git a/web/src/components/config-form/section-configs/ffmpeg.ts b/web/src/components/config-form/section-configs/ffmpeg.ts index 5fd7b8c1cc..d71bef9e0c 100644 --- a/web/src/components/config-form/section-configs/ffmpeg.ts +++ b/web/src/components/config-form/section-configs/ffmpeg.ts @@ -52,6 +52,7 @@ const ffmpeg: SectionConfigOverrides = { }, { key: "inputs-missing-go2rtc-stream", + health: true, field: "inputs", position: "before", messageKey: "configMessages.ffmpeg.inputsMissingGo2rtcStream", diff --git a/web/src/components/config-form/section-configs/lpr.ts b/web/src/components/config-form/section-configs/lpr.ts index fa1f7f2cb5..e5e1a9796b 100644 --- a/web/src/components/config-form/section-configs/lpr.ts +++ b/web/src/components/config-form/section-configs/lpr.ts @@ -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) => { diff --git a/web/src/components/config-form/section-configs/models.ts b/web/src/components/config-form/section-configs/models.ts index 3e09664b4c..65b77e576c 100644 --- a/web/src/components/config-form/section-configs/models.ts +++ b/web/src/components/config-form/section-configs/models.ts @@ -35,6 +35,7 @@ const models: SectionConfigOverrides = { }, { key: "model-input-dimensions-not-detect-resolution", + health: true, field: "height", position: "after", messageKey: "configMessages.model.inputDimensionsNotDetectResolution", diff --git a/web/src/components/config-form/section-configs/objects.ts b/web/src/components/config-form/section-configs/objects.ts index 371a1f5140..070981ad5d 100644 --- a/web/src/components/config-form/section-configs/objects.ts +++ b/web/src/components/config-form/section-configs/objects.ts @@ -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", diff --git a/web/src/components/config-form/section-configs/onvif.ts b/web/src/components/config-form/section-configs/onvif.ts index a031c992c0..7c846c5110 100644 --- a/web/src/components/config-form/section-configs/onvif.ts +++ b/web/src/components/config-form/section-configs/onvif.ts @@ -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", diff --git a/web/src/components/config-form/section-configs/record.ts b/web/src/components/config-form/section-configs/record.ts index a8a9a05347..fcfca7b3bb 100644 --- a/web/src/components/config-form/section-configs/record.ts +++ b/web/src/components/config-form/section-configs/record.ts @@ -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) => { diff --git a/web/src/components/config-form/section-configs/review.ts b/web/src/components/config-form/section-configs/review.ts index 294a438e47..91d2136d94 100644 --- a/web/src/components/config-form/section-configs/review.ts +++ b/web/src/components/config-form/section-configs/review.ts @@ -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", diff --git a/web/src/components/config-form/section-configs/semantic_search.ts b/web/src/components/config-form/section-configs/semantic_search.ts index dde2b75531..81d4f7f511 100644 --- a/web/src/components/config-form/section-configs/semantic_search.ts +++ b/web/src/components/config-form/section-configs/semantic_search.ts @@ -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", diff --git a/web/src/components/config-form/section-configs/types.ts b/web/src/components/config-form/section-configs/types.ts index 510d3bfa4f..1e75b0c8d6 100644 --- a/web/src/components/config-form/section-configs/types.ts +++ b/web/src/components/config-form/section-configs/types.ts @@ -28,6 +28,13 @@ export type ConditionalMessage = { values?: Record; /** 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 */ diff --git a/web/src/components/config-form/sectionPages.ts b/web/src/components/config-form/sectionPages.ts new file mode 100644 index 0000000000..6705c17172 --- /dev/null +++ b/web/src/components/config-form/sectionPages.ts @@ -0,0 +1,67 @@ +/** settings page id that edits each config section at camera level */ +export const CAMERA_PAGE_BY_SECTION: Record = { + 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 = { + 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; +} diff --git a/web/src/components/config-form/sections/CameraOverridesBadge.tsx b/web/src/components/config-form/sections/CameraOverridesBadge.tsx index d686d7f9e1..7159cb9e98 100644 --- a/web/src/components/config-form/sections/CameraOverridesBadge.tsx +++ b/web/src/components/config-form/sections/CameraOverridesBadge.tsx @@ -25,24 +25,7 @@ import { pathMatchesHiddenPattern, } from "@/utils/configUtil"; import { useOverrideFieldLabel } from "./useOverrideFieldLabel"; - -const CAMERA_PAGE_BY_SECTION: Record = { - 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; diff --git a/web/src/components/health/HardwarePane.tsx b/web/src/components/health/HardwarePane.tsx new file mode 100644 index 0000000000..b5e3d973db --- /dev/null +++ b/web/src/components/health/HardwarePane.tsx @@ -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 ( +
+
+
+ {title} + {action} +
+ {subtitle && ( +
{subtitle}
+ )} +
+ {children} +
+ ); +} + +function InlineAction({ + label, + busy, + disabled, + onClick, +}: { + label: string; + busy?: boolean; + disabled?: boolean; + onClick: () => void; +}) { + return ( + + + + + + {label} + + + ); +} + +function Group({ title, rows }: { title: string; rows: HardwareRow[] }) { + const { t } = useTranslation(["views/system"]); + + return ( + + {rows.length === 0 ? ( +
+ {t("health.hardware.nothingConfigured")} +
+ ) : ( +
+ {rows.map((row) => ( + + ))} +
+ )} +
+ ); +} + +/** Stream checks live here: the check sits next to its result. */ +function StreamsGroup() { + const { t } = useTranslation(["views/system", "views/settings"]); + const { data: config } = useSWR("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 ( + stream.run()} + /> + } + subtitle={ + stream.running + ? t("health.hardware.streamsChecking", { + done: stream.total - stream.pending.length, + total: stream.total, + }) + : stream.results && ( + <> + {t("health.hardware.checked")}{" "} + + + ) + } + > +
+ {row ? ( + + ) : ( + + )} +
+
+ ); +} + +function HardwareHeading() { + const { t } = useTranslation(["views/system"]); + const { hardware } = useHealthChecks(); + + return ( +
+
+
+ {t("health.hardware.title")} +
+ hardware.recheck()} + /> +
+ {hardware.rechecking ? ( +
+ {t("health.hardware.probing")} +
+ ) : ( + hardware.probedAt && ( +
+ {t("health.hardware.probed")}{" "} + +
+ ) + )} +
+ ); +} + +export default function HardwarePane() { + const { t } = useTranslation(["views/system"]); + const { rows, statsLoaded } = useHardwareHealth(); + + return ( +
+ + {!rows ? ( + + ) : ( + <> +
+
+ + + +
+
+ + + {!statsLoaded ? ( + + ) : rows.cameras.length === 0 ? ( +
+ + {t("health.hardware.allCamerasExcellent")} +
+ ) : ( +
+ {rows.cameras.map((cell) => ( +
+ + + + {t("health.hardware.fps", { + camera: cell.cameraFps.toFixed(1), + expected: cell.expectedFps, + })} + +
+ ))} +
+ )} +
+
+
+ + )} +
+ ); +} diff --git a/web/src/components/health/HardwareStatusRow.tsx b/web/src/components/health/HardwareStatusRow.tsx new file mode 100644 index 0000000000..26da1d6ee3 --- /dev/null +++ b/web/src/components/health/HardwareStatusRow.tsx @@ -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 = { + ok: "text-success", + warning: "text-yellow-500", + error: "text-danger", + unknown: "text-muted-foreground", +}; + +export default function HardwareStatusRow({ row }: { row: HardwareRow }) { + return ( +
+
+ {row.state === "ok" && ( + + )} + {row.state === "warning" && ( + + )} + {row.state === "error" && } + {row.state === "unknown" && ( + + )} +
+
+
+ {row.label} + {row.detail && ( + {row.detail} + )} +
+ {row.message && ( +
+ {row.message} +
+ )} +
+
+ ); +} diff --git a/web/src/components/health/HealthProblemRow.tsx b/web/src/components/health/HealthProblemRow.tsx index 11163fe1fe..e153f9107d 100644 --- a/web/src/components/health/HealthProblemRow.tsx +++ b/web/src/components/health/HealthProblemRow.tsx @@ -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 ( + + {children} + + {label} + + + ); +} + 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 (
-
- {problem.severity === "error" && } - {problem.severity === "warning" && ( - - )} - {problem.severity === "info" && ( - +
+ {problem.pending ? ( + + ) : ( + <> + {problem.severity === "error" && ( + + )} + {problem.severity === "warning" && ( + + )} + {problem.severity === "info" && ( + + )} + )}
- {problem.scope && ( - - {problem.scopeIsCamera ? ( - - ) : ( - problem.scope - )} - - )}
{problem.text}
- {problem.meta && ( -
- {problem.meta} + {hasDetails && ( +
+ {problem.scope && ( + + {problem.scopeIsCamera ? ( + + ) : ( + problem.scope + )} + + )} + {problem.meta && {problem.meta}}
)}
-
- {problem.onDismiss && ( - - )} - {problem.link && ( - - - - )} - {problem.externalLink && ( - - - - )} -
+ {hasActions && ( +
+ {problem.link && ( + + + + )} + {problem.docLink && ( + + + + )} + {problem.externalLink && ( + + + + )} + {problem.onDismiss && ( + + + + )} +
+ )}
); } diff --git a/web/src/components/health/NoticesPane.tsx b/web/src/components/health/NoticesPane.tsx index 3a655c46d5..e315aa5b0e 100644 --- a/web/src/components/health/NoticesPane.tsx +++ b/web/src/components/health/NoticesPane.tsx @@ -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("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(() => { + 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( + () => (config ? evaluateConfigHealth(config, t) : []), + [config, t], + ); + + const streamProblems = useMemo( + () => (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 (
-
-
- {t("health.notices.title")} -
+
+ {t("health.notices.title")}
- {notices === undefined ? ( + {loading ? ( ) : problems.length === 0 ? (
diff --git a/web/src/components/settings/wizard/Step4Validation.tsx b/web/src/components/settings/wizard/Step4Validation.tsx index d1bd9bbfbe..0a35e22f6c 100644 --- a/web/src/components/settings/wizard/Step4Validation.tsx +++ b/web/src/components/settings/wizard/Step4Validation.tsx @@ -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; @@ -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; diff --git a/web/src/hooks/use-hardware-health.ts b/web/src/hooks/use-hardware-health.ts new file mode 100644 index 0000000000..cc67cffedd --- /dev/null +++ b/web/src/hooks/use-hardware-health.ts @@ -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("config", { + revalidateOnFocus: false, + }); + const { data: hardware, error: probeError } = useSWR( + "hardware/probe", + { revalidateOnFocus: false }, + ); + const { data: hwaccel, error: hwaccelError } = useSWR( + "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 }; +} diff --git a/web/src/hooks/use-health-checks.ts b/web/src/hooks/use-health-checks.ts new file mode 100644 index 0000000000..4029d48f03 --- /dev/null +++ b/web/src/hooks/use-health-checks.ts @@ -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; +}; + +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 { + 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 = {}; + 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("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, + }; +} diff --git a/web/src/hooks/use-stats.ts b/web/src/hooks/use-stats.ts index b2fe6ca629..8679bbf506 100644 --- a/web/src/hooks/use-stats.ts +++ b/web/src/hooks/use-stats.ts @@ -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 = { + 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("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; diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index d33e64087a..e82b45b84a 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -705,7 +705,9 @@ export default function Settings() { .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order); }, [config]); - const [selectedCamera, setSelectedCamera] = useState(""); + const [selectedCamera, setSelectedCamera] = useState( + () => 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); diff --git a/web/src/pages/System.tsx b/web/src/pages/System.tsx index 1acb1a5006..dae979984e 100644 --- a/web/src/pages/System.tsx +++ b/web/src/pages/System.tsx @@ -122,7 +122,7 @@ function System() {
- {lastUpdated && ( + {lastUpdated && pageToggle != "health" && (
{t("lastRefreshed")} diff --git a/web/src/types/cameraWizard.ts b/web/src/types/cameraWizard.ts index 18aeb42b41..06cf6d4a6a 100644 --- a/web/src/types/cameraWizard.ts +++ b/web/src/types/cameraWizard.ts @@ -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; +} diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index fcb6a9aace..ee09fd3f96 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -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); 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; diff --git a/web/src/types/health.ts b/web/src/types/health.ts index d2c69d5e35..3125a5cee9 100644 --- a/web/src/types/health.ts +++ b/web/src/types/health.ts @@ -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; }; diff --git a/web/src/types/stats.ts b/web/src/types/stats.ts index 8354ed4ab6..beb0239205 100644 --- a/web/src/types/stats.ts +++ b/web/src/types/stats.ts @@ -54,6 +54,7 @@ export type EmbeddingsStats = { face_embedding_speed: number; plate_recognition_speed: number; text_embedding_speed: number; + devices?: Record; }; 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; }; diff --git a/web/src/utils/configHealth.ts b/web/src/utils/configHealth.ts new file mode 100644 index 0000000000..7415826d04 --- /dev/null +++ b/web/src/utils/configHealth.ts @@ -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, + globalSection: Record, +): 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, + globalValue as Record, + ); + } + + return JSON.stringify(cameraValue) === JSON.stringify(globalValue); + }); +} + +export function evaluateConfigHealth( + config: FrigateConfig, + t: TFunction, +): HealthProblem[] { + const problems: HealthProblem[] = []; + const firedGlobally = new Set(); + const cameras = activeCameras(config); + const record = config as unknown as Record; + + 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; + + cameras.forEach((camera) => { + const cameraRecord = camera as unknown as Record; + 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, + 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; +} diff --git a/web/src/utils/health.ts b/web/src/utils/health.ts new file mode 100644 index 0000000000..8d468f5e02 --- /dev/null +++ b/web/src/utils/health.ts @@ -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(); + 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 = { + 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; +} diff --git a/web/src/utils/healthSort.ts b/web/src/utils/healthSort.ts new file mode 100644 index 0000000000..672971f38d --- /dev/null +++ b/web/src/utils/healthSort.ts @@ -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 ?? ""), + ); +} diff --git a/web/src/utils/streamHealth.ts b/web/src/utils/streamHealth.ts new file mode 100644 index 0000000000..6fe72d0a2b --- /dev/null +++ b/web/src/utils/streamHealth.ts @@ -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 }; +} diff --git a/web/src/utils/streamIssues.ts b/web/src/utils/streamIssues.ts new file mode 100644 index 0000000000..a5db8716c0 --- /dev/null +++ b/web/src/utils/streamIssues.ts @@ -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 | 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 }; +} diff --git a/web/src/views/system/EnrichmentMetrics.tsx b/web/src/views/system/EnrichmentMetrics.tsx index fbd30d705c..00475afba0 100644 --- a/web/src/views/system/EnrichmentMetrics.tsx +++ b/web/src/views/system/EnrichmentMetrics.tsx @@ -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_"); diff --git a/web/src/views/system/HealthMetrics.tsx b/web/src/views/system/HealthMetrics.tsx index 70e6c8b67c..ca709bf92b 100644 --- a/web/src/views/system/HealthMetrics.tsx +++ b/web/src/views/system/HealthMetrics.tsx @@ -1,9 +1,11 @@ +import HardwarePane from "@/components/health/HardwarePane"; import NoticesPane from "@/components/health/NoticesPane"; export default function HealthMetrics() { return (
+
); }