mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-12 17:52:48 +03:00
Compare commits
2
Commits
eef55045b6
...
45012d1c02
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45012d1c02 | ||
|
|
d014b62701 |
@@ -499,7 +499,7 @@ Media files (event snapshots, event thumbnails, review thumbnails, previews, exp
|
||||
|
||||
Normal operation may leave small numbers of orphaned files until Frigate's scheduled cleanup, but crashes, configuration changes, or upgrades may cause more orphaned files that Frigate does not clean up. This feature checks the file system for media files and removes any that are not referenced in the database.
|
||||
|
||||
The Maintenance pane in the Frigate UI or an API endpoint `POST /api/media/sync` can be used to trigger a media sync. When using the API, a job ID is returned and the operation continues on the server. Status can be checked with the `/api/media/sync/status/{job_id}` endpoint.
|
||||
The Maintenance pane in the Frigate UI or an API endpoint `POST /api/media/sync` can be used to trigger a media sync. When using the API, a job ID is returned and the operation continues on the server. Status can be checked with the `/api/media/sync/status/{job_id}` endpoint. Results include the disk space reclaimed, or with `dry_run: true`, the space that would be reclaimed.
|
||||
|
||||
Setting `verbose: true` writes a detailed report of every orphaned file and database entry to `/config/media_sync/<job_id>.txt`. For recordings, the report separates orphaned database entries (DB records whose files are missing from disk) from orphaned files (files on disk with no corresponding database record).
|
||||
|
||||
|
||||
@@ -171,3 +171,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.
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
@@ -250,6 +298,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."""
|
||||
@@ -326,6 +378,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:
|
||||
@@ -357,6 +411,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]
|
||||
@@ -504,6 +574,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
|
||||
@@ -584,6 +658,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:
|
||||
@@ -594,7 +676,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)
|
||||
|
||||
@@ -603,7 +685,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)
|
||||
@@ -613,13 +699,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 (
|
||||
@@ -631,12 +721,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,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -13,3 +13,37 @@ class EnrichmentModelTypeEnum(str, Enum):
|
||||
jina_v2 = "jina_v2"
|
||||
paddleocr = "paddleocr"
|
||||
yolov9_license_plate = "yolov9_license_plate"
|
||||
|
||||
|
||||
# which enrichment each model type belongs to; the single place this lives
|
||||
ENRICHMENT_FOR_MODEL_TYPE: dict[str, str] = {
|
||||
EnrichmentModelTypeEnum.arcface.value: "face_recognition",
|
||||
EnrichmentModelTypeEnum.facenet.value: "face_recognition",
|
||||
EnrichmentModelTypeEnum.jina_v1.value: "semantic_search",
|
||||
EnrichmentModelTypeEnum.jina_v2.value: "semantic_search",
|
||||
EnrichmentModelTypeEnum.paddleocr.value: "lpr",
|
||||
EnrichmentModelTypeEnum.yolov9_license_plate.value: "lpr",
|
||||
}
|
||||
|
||||
|
||||
def fold_runtime_devices(loaded: dict[str, tuple[str, str]]) -> dict[str, str]:
|
||||
"""One device per enrichment from the per model registry.
|
||||
|
||||
A non CPU device wins so a model pinned to the CPU on purpose (the Jina V1
|
||||
text model) does not hide the accelerator its sibling loaded on, while an
|
||||
enrichment whose models all fell back to the CPU still reports CPU.
|
||||
"""
|
||||
folded: dict[str, str] = {}
|
||||
|
||||
for model_type, device in loaded.values():
|
||||
enrichment = ENRICHMENT_FOR_MODEL_TYPE.get(model_type)
|
||||
|
||||
if enrichment is None:
|
||||
continue
|
||||
|
||||
current = folded.get(enrichment)
|
||||
|
||||
if current is None or ("CPU" in current and "CPU" not in device):
|
||||
folded[enrichment] = device
|
||||
|
||||
return folded
|
||||
|
||||
+83
-72
@@ -136,6 +136,86 @@ def get_detector_stats(
|
||||
return detector_stats
|
||||
|
||||
|
||||
def embeddings_stats(
|
||||
config: FrigateConfig, embeddings_metrics: DataProcessorMetrics | None
|
||||
) -> dict[str, Any]:
|
||||
"""Enrichment speed metrics plus the device each enrichment loaded on."""
|
||||
stats: dict[str, Any] = {}
|
||||
|
||||
if not embeddings_metrics:
|
||||
return stats
|
||||
|
||||
# Add metrics based on what's enabled
|
||||
if config.semantic_search.enabled:
|
||||
stats.update(
|
||||
{
|
||||
"image_embedding_speed": round(
|
||||
embeddings_metrics.image_embeddings_speed.value * 1000, 2
|
||||
),
|
||||
"image_embedding": round(
|
||||
embeddings_metrics.image_embeddings_eps.value, 2
|
||||
),
|
||||
"text_embedding_speed": round(
|
||||
embeddings_metrics.text_embeddings_speed.value * 1000, 2
|
||||
),
|
||||
"text_embedding": round(
|
||||
embeddings_metrics.text_embeddings_eps.value, 2
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
if config.face_recognition.enabled:
|
||||
stats["face_recognition_speed"] = round(
|
||||
embeddings_metrics.face_rec_speed.value * 1000, 2
|
||||
)
|
||||
stats["face_recognition"] = round(embeddings_metrics.face_rec_fps.value, 2)
|
||||
|
||||
if config.lpr.enabled:
|
||||
stats["plate_recognition_speed"] = round(
|
||||
embeddings_metrics.alpr_speed.value * 1000, 2
|
||||
)
|
||||
stats["plate_recognition"] = round(embeddings_metrics.alpr_pps.value, 2)
|
||||
|
||||
if embeddings_metrics.yolov9_lpr_pps.value > 0.0:
|
||||
stats["yolov9_plate_detection_speed"] = round(
|
||||
embeddings_metrics.yolov9_lpr_speed.value * 1000, 2
|
||||
)
|
||||
stats["yolov9_plate_detection"] = round(
|
||||
embeddings_metrics.yolov9_lpr_pps.value, 2
|
||||
)
|
||||
|
||||
if embeddings_metrics.review_desc_speed.value > 0.0:
|
||||
stats["review_description_speed"] = round(
|
||||
embeddings_metrics.review_desc_speed.value * 1000, 2
|
||||
)
|
||||
stats["review_description_events_per_second"] = round(
|
||||
embeddings_metrics.review_desc_dps.value, 2
|
||||
)
|
||||
|
||||
if embeddings_metrics.object_desc_speed.value > 0.0:
|
||||
stats["object_description_speed"] = round(
|
||||
embeddings_metrics.object_desc_speed.value * 1000, 2
|
||||
)
|
||||
stats["object_description_events_per_second"] = round(
|
||||
embeddings_metrics.object_desc_dps.value, 2
|
||||
)
|
||||
|
||||
for key in embeddings_metrics.classification_speeds.keys():
|
||||
stats[f"{key}_classification_speed"] = round(
|
||||
embeddings_metrics.classification_speeds[key].value * 1000, 2
|
||||
)
|
||||
stats[f"{key}_classification_events_per_second"] = round(
|
||||
embeddings_metrics.classification_cps[key].value, 2
|
||||
)
|
||||
|
||||
devices = dict(embeddings_metrics.runtime_devices)
|
||||
|
||||
if devices:
|
||||
stats["devices"] = devices
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def stats_snapshot(
|
||||
config: FrigateConfig,
|
||||
stats_tracking: StatsTrackingTypes,
|
||||
@@ -209,78 +289,9 @@ def stats_snapshot(
|
||||
stats["skipped_fps"] = round(total_skipped_fps, 2)
|
||||
stats["detection_fps"] = round(total_detection_fps, 2)
|
||||
|
||||
stats["embeddings"] = {}
|
||||
|
||||
# Get metrics if available
|
||||
embeddings_metrics = stats_tracking.get("embeddings_metrics")
|
||||
|
||||
if embeddings_metrics:
|
||||
# Add metrics based on what's enabled
|
||||
if config.semantic_search.enabled:
|
||||
stats["embeddings"].update(
|
||||
{
|
||||
"image_embedding_speed": round(
|
||||
embeddings_metrics.image_embeddings_speed.value * 1000, 2
|
||||
),
|
||||
"image_embedding": round(
|
||||
embeddings_metrics.image_embeddings_eps.value, 2
|
||||
),
|
||||
"text_embedding_speed": round(
|
||||
embeddings_metrics.text_embeddings_speed.value * 1000, 2
|
||||
),
|
||||
"text_embedding": round(
|
||||
embeddings_metrics.text_embeddings_eps.value, 2
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
if config.face_recognition.enabled:
|
||||
stats["embeddings"]["face_recognition_speed"] = round(
|
||||
embeddings_metrics.face_rec_speed.value * 1000, 2
|
||||
)
|
||||
stats["embeddings"]["face_recognition"] = round(
|
||||
embeddings_metrics.face_rec_fps.value, 2
|
||||
)
|
||||
|
||||
if config.lpr.enabled:
|
||||
stats["embeddings"]["plate_recognition_speed"] = round(
|
||||
embeddings_metrics.alpr_speed.value * 1000, 2
|
||||
)
|
||||
stats["embeddings"]["plate_recognition"] = round(
|
||||
embeddings_metrics.alpr_pps.value, 2
|
||||
)
|
||||
|
||||
if embeddings_metrics.yolov9_lpr_pps.value > 0.0:
|
||||
stats["embeddings"]["yolov9_plate_detection_speed"] = round(
|
||||
embeddings_metrics.yolov9_lpr_speed.value * 1000, 2
|
||||
)
|
||||
stats["embeddings"]["yolov9_plate_detection"] = round(
|
||||
embeddings_metrics.yolov9_lpr_pps.value, 2
|
||||
)
|
||||
|
||||
if embeddings_metrics.review_desc_speed.value > 0.0:
|
||||
stats["embeddings"]["review_description_speed"] = round(
|
||||
embeddings_metrics.review_desc_speed.value * 1000, 2
|
||||
)
|
||||
stats["embeddings"]["review_description_events_per_second"] = round(
|
||||
embeddings_metrics.review_desc_dps.value, 2
|
||||
)
|
||||
|
||||
if embeddings_metrics.object_desc_speed.value > 0.0:
|
||||
stats["embeddings"]["object_description_speed"] = round(
|
||||
embeddings_metrics.object_desc_speed.value * 1000, 2
|
||||
)
|
||||
stats["embeddings"]["object_description_events_per_second"] = round(
|
||||
embeddings_metrics.object_desc_dps.value, 2
|
||||
)
|
||||
|
||||
for key in embeddings_metrics.classification_speeds.keys():
|
||||
stats["embeddings"][f"{key}_classification_speed"] = round(
|
||||
embeddings_metrics.classification_speeds[key].value * 1000, 2
|
||||
)
|
||||
stats["embeddings"][f"{key}_classification_events_per_second"] = round(
|
||||
embeddings_metrics.classification_cps[key].value, 2
|
||||
)
|
||||
stats["embeddings"] = embeddings_stats(
|
||||
config, stats_tracking.get("embeddings_metrics")
|
||||
)
|
||||
|
||||
hardware_stats.update_stats(stats)
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Tests for credential scrubbing in the ffprobe API."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
|
||||
|
||||
class TestHttpFfprobe(BaseTestHttp):
|
||||
def setUp(self):
|
||||
super().setUp([])
|
||||
self.app = self.create_app()
|
||||
|
||||
def client(self) -> TestClient:
|
||||
return AuthTestClient(self.app)
|
||||
|
||||
def test_failed_probe_scrubs_credentials_from_stderr(self):
|
||||
failed = SimpleNamespace(
|
||||
returncode=1,
|
||||
stdout=b"",
|
||||
stderr=(
|
||||
b"[tcp @ 0x1] Connection to tcp://10.0.0.1:554 failed\n"
|
||||
b"rtsp://admin:hunter2@10.0.0.1:554/video: Connection refused\n"
|
||||
),
|
||||
)
|
||||
|
||||
with patch("frigate.api.camera.ffprobe_stream", return_value=failed):
|
||||
with self.client() as client:
|
||||
response = client.get("/ffprobe", params={"paths": "camera:front_door"})
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
lines = response.json()[0]["stderr"]
|
||||
self.assertEqual(len(lines), 2)
|
||||
self.assertNotIn("hunter2", " ".join(lines))
|
||||
self.assertEqual(lines[1], "rtsp://*:*@10.0.0.1:554/video: Connection refused")
|
||||
@@ -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)
|
||||
@@ -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"])
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
+65
-7
@@ -41,6 +41,14 @@ FFPROBE_PATH = (
|
||||
)
|
||||
|
||||
|
||||
def _file_size(path: str) -> int:
|
||||
"""Return the size of a file in bytes, or 0 if it cannot be read."""
|
||||
try:
|
||||
return os.path.getsize(path)
|
||||
except OSError:
|
||||
return 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class SyncResult:
|
||||
"""Result of a sync operation."""
|
||||
@@ -49,6 +57,7 @@ class SyncResult:
|
||||
files_checked: int = 0
|
||||
orphans_found: int = 0
|
||||
orphans_deleted: int = 0
|
||||
bytes_reclaimed: int = 0
|
||||
orphan_paths: list[str] = field(default_factory=list)
|
||||
orphan_db_paths: list[str] = field(default_factory=list)
|
||||
aborted: bool = False
|
||||
@@ -60,6 +69,7 @@ class SyncResult:
|
||||
"files_checked": self.files_checked,
|
||||
"orphans_found": self.orphans_found,
|
||||
"orphans_deleted": self.orphans_deleted,
|
||||
"bytes_reclaimed": self.bytes_reclaimed,
|
||||
"aborted": self.aborted,
|
||||
"error": self.error,
|
||||
}
|
||||
@@ -235,6 +245,7 @@ def sync_recordings(
|
||||
return result
|
||||
|
||||
if dry_run:
|
||||
result.bytes_reclaimed = sum(_file_size(f) for f in files_to_delete)
|
||||
logger.info(
|
||||
f"Recordings sync (dry run): Found {len(files_to_delete)} orphaned files"
|
||||
)
|
||||
@@ -243,11 +254,15 @@ def sync_recordings(
|
||||
# Delete orphans
|
||||
logger.info(f"Deleting {len(files_to_delete)} orphaned recordings files")
|
||||
for file in files_to_delete:
|
||||
size = _file_size(file)
|
||||
try:
|
||||
os.unlink(file)
|
||||
result.orphans_deleted += 1
|
||||
except OSError as e:
|
||||
logger.error(f"Failed to delete {file}: {e}")
|
||||
continue
|
||||
|
||||
result.orphans_deleted += 1
|
||||
result.bytes_reclaimed += size
|
||||
|
||||
logger.debug("End sync recordings.")
|
||||
|
||||
@@ -325,6 +340,7 @@ def sync_event_snapshots(dry_run: bool = False, force: bool = False) -> SyncResu
|
||||
return result
|
||||
|
||||
if dry_run:
|
||||
result.bytes_reclaimed = sum(_file_size(p) for p in orphans)
|
||||
logger.info(
|
||||
f"Event snapshots sync (dry run): Found {len(orphans)} orphaned files"
|
||||
)
|
||||
@@ -333,11 +349,15 @@ def sync_event_snapshots(dry_run: bool = False, force: bool = False) -> SyncResu
|
||||
# Delete orphans
|
||||
logger.info(f"Deleting {len(orphans)} orphaned event snapshot files")
|
||||
for file_path in orphans:
|
||||
size = _file_size(file_path)
|
||||
try:
|
||||
os.unlink(file_path)
|
||||
result.orphans_deleted += 1
|
||||
except OSError as e:
|
||||
logger.error(f"Failed to delete {file_path}: {e}")
|
||||
continue
|
||||
|
||||
result.orphans_deleted += 1
|
||||
result.bytes_reclaimed += size
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error syncing event snapshots: {e}")
|
||||
@@ -421,6 +441,7 @@ def sync_event_thumbnails(dry_run: bool = False, force: bool = False) -> SyncRes
|
||||
return result
|
||||
|
||||
if dry_run:
|
||||
result.bytes_reclaimed = sum(_file_size(p) for p in orphans)
|
||||
logger.info(
|
||||
f"Event thumbnails sync (dry run): Found {len(orphans)} orphaned files"
|
||||
)
|
||||
@@ -429,11 +450,15 @@ def sync_event_thumbnails(dry_run: bool = False, force: bool = False) -> SyncRes
|
||||
# Delete orphans
|
||||
logger.info(f"Deleting {len(orphans)} orphaned event thumbnail files")
|
||||
for file_path in orphans:
|
||||
size = _file_size(file_path)
|
||||
try:
|
||||
os.unlink(file_path)
|
||||
result.orphans_deleted += 1
|
||||
except OSError as e:
|
||||
logger.error(f"Failed to delete {file_path}: {e}")
|
||||
continue
|
||||
|
||||
result.orphans_deleted += 1
|
||||
result.bytes_reclaimed += size
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error syncing event thumbnails: {e}")
|
||||
@@ -501,6 +526,7 @@ def sync_review_thumbnails(dry_run: bool = False, force: bool = False) -> SyncRe
|
||||
return result
|
||||
|
||||
if dry_run:
|
||||
result.bytes_reclaimed = sum(_file_size(p) for p in orphans)
|
||||
logger.info(
|
||||
f"Review thumbnails sync (dry run): Found {len(orphans)} orphaned files"
|
||||
)
|
||||
@@ -509,11 +535,15 @@ def sync_review_thumbnails(dry_run: bool = False, force: bool = False) -> SyncRe
|
||||
# Delete orphans
|
||||
logger.info(f"Deleting {len(orphans)} orphaned review thumbnail files")
|
||||
for file_path in orphans:
|
||||
size = _file_size(file_path)
|
||||
try:
|
||||
os.unlink(file_path)
|
||||
result.orphans_deleted += 1
|
||||
except OSError as e:
|
||||
logger.error(f"Failed to delete {file_path}: {e}")
|
||||
continue
|
||||
|
||||
result.orphans_deleted += 1
|
||||
result.bytes_reclaimed += size
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error syncing review thumbnails: {e}")
|
||||
@@ -581,17 +611,22 @@ def sync_previews(dry_run: bool = False, force: bool = False) -> SyncResult:
|
||||
return result
|
||||
|
||||
if dry_run:
|
||||
result.bytes_reclaimed = sum(_file_size(p) for p in orphans)
|
||||
logger.info(f"Previews sync (dry run): Found {len(orphans)} orphaned files")
|
||||
return result
|
||||
|
||||
# Delete orphans
|
||||
logger.info(f"Deleting {len(orphans)} orphaned preview files")
|
||||
for file_path in orphans:
|
||||
size = _file_size(file_path)
|
||||
try:
|
||||
os.unlink(file_path)
|
||||
result.orphans_deleted += 1
|
||||
except OSError as e:
|
||||
logger.error(f"Failed to delete {file_path}: {e}")
|
||||
continue
|
||||
|
||||
result.orphans_deleted += 1
|
||||
result.bytes_reclaimed += size
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error syncing previews: {e}")
|
||||
@@ -673,17 +708,22 @@ def sync_exports(dry_run: bool = False, force: bool = False) -> SyncResult:
|
||||
return result
|
||||
|
||||
if dry_run:
|
||||
result.bytes_reclaimed = sum(_file_size(p) for p in orphans)
|
||||
logger.info(f"Exports sync (dry run): Found {len(orphans)} orphaned files")
|
||||
return result
|
||||
|
||||
# Delete orphans
|
||||
logger.info(f"Deleting {len(orphans)} orphaned export files")
|
||||
for file_path in orphans:
|
||||
size = _file_size(file_path)
|
||||
try:
|
||||
os.unlink(file_path)
|
||||
result.orphans_deleted += 1
|
||||
except OSError as e:
|
||||
logger.error(f"Failed to delete {file_path}: {e}")
|
||||
continue
|
||||
|
||||
result.orphans_deleted += 1
|
||||
result.bytes_reclaimed += size
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error syncing exports: {e}")
|
||||
@@ -733,6 +773,21 @@ class MediaSyncResults:
|
||||
total += result.orphans_found
|
||||
return total
|
||||
|
||||
@property
|
||||
def total_bytes_reclaimed(self) -> int:
|
||||
total = 0
|
||||
for result in [
|
||||
self.event_snapshots,
|
||||
self.event_thumbnails,
|
||||
self.review_thumbnails,
|
||||
self.previews,
|
||||
self.exports,
|
||||
self.recordings,
|
||||
]:
|
||||
if result:
|
||||
total += result.bytes_reclaimed
|
||||
return total
|
||||
|
||||
@property
|
||||
def total_orphans_deleted(self) -> int:
|
||||
total = 0
|
||||
@@ -764,6 +819,7 @@ class MediaSyncResults:
|
||||
"files_checked": result.files_checked,
|
||||
"orphans_found": result.orphans_found,
|
||||
"orphans_deleted": result.orphans_deleted,
|
||||
"bytes_reclaimed": result.bytes_reclaimed,
|
||||
"aborted": result.aborted,
|
||||
"error": result.error,
|
||||
}
|
||||
@@ -771,6 +827,7 @@ class MediaSyncResults:
|
||||
"files_checked": self.total_files_checked,
|
||||
"orphans_found": self.total_orphans_found,
|
||||
"orphans_deleted": self.total_orphans_deleted,
|
||||
"bytes_reclaimed": self.total_bytes_reclaimed,
|
||||
}
|
||||
return results
|
||||
|
||||
@@ -874,7 +931,8 @@ def sync_all_media(
|
||||
logger.info(
|
||||
f"Media sync complete: checked {results.total_files_checked} files, "
|
||||
f"found {results.total_orphans_found} orphans, "
|
||||
f"deleted {results.total_orphans_deleted}"
|
||||
f"deleted {results.total_orphans_deleted}, "
|
||||
f"reclaimed {results.total_bytes_reclaimed} bytes"
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
@@ -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.media.install();
|
||||
await this.api.install(overrides);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,13 @@ export const BASE_STATS = {
|
||||
},
|
||||
gpu_usages: {},
|
||||
npu_usages: {},
|
||||
embeddings: {
|
||||
image_embedding_speed: 0,
|
||||
face_embedding_speed: 0,
|
||||
plate_recognition_speed: 0,
|
||||
text_embedding_speed: 0,
|
||||
devices: {} as Record<string, string>,
|
||||
},
|
||||
processes: {},
|
||||
service: {
|
||||
last_updated: Date.now() / 1000,
|
||||
@@ -57,6 +64,13 @@ export const BASE_STATS = {
|
||||
used: 500000000,
|
||||
mount_type: "tmpfs",
|
||||
},
|
||||
"/dev/shm": {
|
||||
free: 98,
|
||||
total: 128,
|
||||
used: 30,
|
||||
mount_type: "tmpfs",
|
||||
min_shm: 64,
|
||||
},
|
||||
},
|
||||
uptime: 86400,
|
||||
latest_version: "0.15.0",
|
||||
|
||||
@@ -50,8 +50,29 @@ export interface ApiMockOverrides {
|
||||
users?: { username: string; role: string }[];
|
||||
notices?: unknown[];
|
||||
noticeStats?: unknown[];
|
||||
/** camera name to the ffprobe entries returned for `paths=camera:<name>` */
|
||||
ffprobe?: Record<string, unknown[]>;
|
||||
}
|
||||
|
||||
export const FFPROBE_OK = [
|
||||
{
|
||||
return_code: 0,
|
||||
stderr: "",
|
||||
stdout: {
|
||||
streams: [
|
||||
{
|
||||
codec_type: "video",
|
||||
codec_name: "h264",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
avg_frame_rate: "15/1",
|
||||
},
|
||||
{ codec_type: "audio", codec_name: "aac" },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export class ApiMocker {
|
||||
private page: Page;
|
||||
|
||||
@@ -203,6 +224,16 @@ export class ApiMocker {
|
||||
}),
|
||||
);
|
||||
|
||||
// ffprobe. The Health tab's stream checks probe `camera:<name>`; the
|
||||
// wizard probes raw URLs. Both get a healthy h264 + aac answer by default.
|
||||
await this.page.route("**/api/ffprobe**", (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const paths = url.searchParams.get("paths") ?? "";
|
||||
const camera = paths.startsWith("camera:") ? paths.slice(7) : undefined;
|
||||
const entries = (camera && overrides?.ffprobe?.[camera]) || FFPROBE_OK;
|
||||
return route.fulfill({ json: entries });
|
||||
});
|
||||
|
||||
// Notices. The stats route is registered after the list route so it wins
|
||||
// for /api/notices/stats; the list glob does not match a sub-path anyway.
|
||||
await this.page.route("**/api/notices", (route) =>
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1469,6 +1469,8 @@
|
||||
"filesChecked": "Files Checked",
|
||||
"orphansFound": "Orphans Found",
|
||||
"orphansDeleted": "Orphans Deleted",
|
||||
"spaceToReclaim": "Space To Reclaim",
|
||||
"spaceReclaimed": "Space Reclaimed",
|
||||
"aborted": "Aborted. Deletion would exceed safety threshold.",
|
||||
"error": "Error",
|
||||
"totals": "Totals"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -187,10 +187,20 @@ export default function Statusbar() {
|
||||
</div>
|
||||
<div className="no-scrollbar flex h-full max-w-[50%] items-center gap-2 overflow-x-auto">
|
||||
{Object.entries(messages).length === 0 ? (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<FaCheck className="size-3 text-green-500" />
|
||||
{t("stats.healthy")}
|
||||
</div>
|
||||
isAdmin ? (
|
||||
<Link
|
||||
to="/system#health"
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<FaCheck className="size-3 text-green-500" />
|
||||
{t("stats.healthy")}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<FaCheck className="size-3 text-green-500" />
|
||||
{t("stats.healthy")}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
Object.entries(messages).map(([key, messageArray]) => (
|
||||
<div key={key} className="flex h-full items-center gap-2">
|
||||
|
||||
@@ -6,6 +6,7 @@ const audio: SectionConfigOverrides = {
|
||||
messages: [
|
||||
{
|
||||
key: "no-audio-role",
|
||||
health: (ctx) => ctx.fullCameraConfig?.audio?.enabled === true,
|
||||
messageKey: "configMessages.audio.noAudioRole",
|
||||
severity: "warning",
|
||||
condition: (ctx) => {
|
||||
|
||||
@@ -6,6 +6,8 @@ const audioTranscription: SectionConfigOverrides = {
|
||||
messages: [
|
||||
{
|
||||
key: "audio-detection-disabled",
|
||||
health: (ctx) =>
|
||||
ctx.fullCameraConfig?.audio_transcription?.enabled === true,
|
||||
messageKey: "configMessages.audioTranscription.audioDetectionDisabled",
|
||||
severity: "warning",
|
||||
condition: (ctx) => {
|
||||
|
||||
@@ -15,6 +15,7 @@ const detect: SectionConfigOverrides = {
|
||||
fieldMessages: [
|
||||
{
|
||||
key: "detect-resolution-not-multiple-of-four",
|
||||
health: true,
|
||||
field: "width",
|
||||
position: "before",
|
||||
messageKey: "configMessages.detect.resolutionShouldBeMultipleOfFour",
|
||||
@@ -46,6 +47,7 @@ const detect: SectionConfigOverrides = {
|
||||
},
|
||||
{
|
||||
key: "detect-resolution-high",
|
||||
health: true,
|
||||
field: "width",
|
||||
position: "before",
|
||||
messageKey: "configMessages.detect.resolutionHigh",
|
||||
@@ -61,6 +63,7 @@ const detect: SectionConfigOverrides = {
|
||||
},
|
||||
{
|
||||
key: "detect-square-resolution",
|
||||
health: true,
|
||||
field: "width",
|
||||
position: "before",
|
||||
messageKey: "configMessages.detect.squareResolution",
|
||||
@@ -112,6 +115,7 @@ const detect: SectionConfigOverrides = {
|
||||
},
|
||||
{
|
||||
key: "detect-scene-without-model",
|
||||
health: true,
|
||||
field: "scene",
|
||||
position: "after",
|
||||
messageKey: "configMessages.detect.sceneWithoutModel",
|
||||
@@ -127,6 +131,7 @@ const detect: SectionConfigOverrides = {
|
||||
},
|
||||
{
|
||||
key: "fps-greater-than-five",
|
||||
health: true,
|
||||
field: "fps",
|
||||
messageKey: "configMessages.detect.fpsGreaterThanFive",
|
||||
severity: "info",
|
||||
|
||||
@@ -6,6 +6,8 @@ const faceRecognition: SectionConfigOverrides = {
|
||||
messages: [
|
||||
{
|
||||
key: "global-disabled",
|
||||
health: (ctx) =>
|
||||
ctx.fullCameraConfig?.face_recognition?.enabled === true,
|
||||
messageKey: "configMessages.faceRecognition.globalDisabled",
|
||||
severity: "warning",
|
||||
condition: (ctx) => {
|
||||
@@ -15,6 +17,9 @@ const faceRecognition: SectionConfigOverrides = {
|
||||
},
|
||||
{
|
||||
key: "person-not-tracked",
|
||||
health: (ctx) =>
|
||||
ctx.fullCameraConfig?.face_recognition?.enabled === true &&
|
||||
ctx.fullConfig.face_recognition?.enabled === true,
|
||||
messageKey: "configMessages.faceRecognition.personNotTracked",
|
||||
severity: "info",
|
||||
condition: (ctx) => {
|
||||
|
||||
@@ -52,6 +52,7 @@ const ffmpeg: SectionConfigOverrides = {
|
||||
},
|
||||
{
|
||||
key: "inputs-missing-go2rtc-stream",
|
||||
health: true,
|
||||
field: "inputs",
|
||||
position: "before",
|
||||
messageKey: "configMessages.ffmpeg.inputsMissingGo2rtcStream",
|
||||
|
||||
@@ -7,6 +7,7 @@ const lpr: SectionConfigOverrides = {
|
||||
messages: [
|
||||
{
|
||||
key: "global-disabled",
|
||||
health: (ctx) => ctx.fullCameraConfig?.lpr?.enabled === true,
|
||||
messageKey: "configMessages.lpr.globalDisabled",
|
||||
severity: "warning",
|
||||
condition: (ctx) => {
|
||||
@@ -16,6 +17,9 @@ const lpr: SectionConfigOverrides = {
|
||||
},
|
||||
{
|
||||
key: "vehicle-not-tracked",
|
||||
health: (ctx) =>
|
||||
ctx.fullCameraConfig?.lpr?.enabled === true &&
|
||||
ctx.fullConfig.lpr?.enabled === true,
|
||||
messageKey: "configMessages.lpr.vehicleNotTracked",
|
||||
severity: "info",
|
||||
condition: (ctx) => {
|
||||
|
||||
@@ -35,6 +35,7 @@ const models: SectionConfigOverrides = {
|
||||
},
|
||||
{
|
||||
key: "model-input-dimensions-not-detect-resolution",
|
||||
health: true,
|
||||
field: "height",
|
||||
position: "after",
|
||||
messageKey: "configMessages.model.inputDimensionsNotDetectResolution",
|
||||
|
||||
@@ -72,6 +72,9 @@ const objects: SectionConfigOverrides = {
|
||||
fieldMessages: [
|
||||
{
|
||||
key: "genai-no-descriptions-provider",
|
||||
health: (ctx) =>
|
||||
(ctx.formData as { genai?: { enabled?: boolean } })?.genai
|
||||
?.enabled === true,
|
||||
field: "genai.enabled",
|
||||
messageKey: "configMessages.objects.genaiNoDescriptionsProvider",
|
||||
severity: "warning",
|
||||
|
||||
@@ -28,6 +28,8 @@ const onvif: SectionConfigOverrides = {
|
||||
fieldMessages: [
|
||||
{
|
||||
key: "autotracking-no-zones",
|
||||
health: (ctx) =>
|
||||
ctx.fullCameraConfig?.onvif?.autotracking?.enabled === true,
|
||||
field: "autotracking.required_zones",
|
||||
messageKey: "configMessages.onvif.autotrackingNoZones",
|
||||
severity: "error",
|
||||
|
||||
@@ -6,6 +6,7 @@ const record: SectionConfigOverrides = {
|
||||
messages: [
|
||||
{
|
||||
key: "no-record-role",
|
||||
health: (ctx) => ctx.fullCameraConfig?.record?.enabled === true,
|
||||
messageKey: "configMessages.record.noRecordRole",
|
||||
severity: "warning",
|
||||
condition: (ctx) => {
|
||||
@@ -17,6 +18,7 @@ const record: SectionConfigOverrides = {
|
||||
},
|
||||
{
|
||||
key: "no-record-sub-role",
|
||||
health: (ctx) => ctx.fullCameraConfig?.record?.sub?.enabled === true,
|
||||
messageKey: "configMessages.record.noRecordSubRole",
|
||||
severity: "warning",
|
||||
condition: (ctx) => {
|
||||
|
||||
@@ -43,6 +43,9 @@ const review: SectionConfigOverrides = {
|
||||
},
|
||||
{
|
||||
key: "genai-no-descriptions-provider",
|
||||
health: (ctx) =>
|
||||
(ctx.formData as { genai?: { enabled?: boolean } })?.genai
|
||||
?.enabled === true,
|
||||
field: "genai.enabled",
|
||||
messageKey: "configMessages.objects.genaiNoDescriptionsProvider",
|
||||
severity: "warning",
|
||||
@@ -57,6 +60,7 @@ const review: SectionConfigOverrides = {
|
||||
},
|
||||
{
|
||||
key: "genai-image-source-recordings-record-disabled",
|
||||
health: true,
|
||||
field: "genai.image_source",
|
||||
messageKey:
|
||||
"configMessages.review.genaiImageSourceRecordingsRecordDisabled",
|
||||
|
||||
@@ -21,6 +21,7 @@ const semanticSearch: SectionConfigOverrides = {
|
||||
fieldMessages: [
|
||||
{
|
||||
key: "jinav2-small-model-size",
|
||||
health: (ctx) => ctx.fullConfig.semantic_search?.enabled === true,
|
||||
field: "model_size",
|
||||
messageKey: "configMessages.semanticSearch.jinav2SmallModelSize",
|
||||
severity: "warning",
|
||||
|
||||
@@ -28,6 +28,13 @@ export type ConditionalMessage = {
|
||||
values?: Record<string, unknown>;
|
||||
/** Optional documentation path (e.g. "/configuration/object_detectors#model"). */
|
||||
docLink?: string;
|
||||
/**
|
||||
* Whether the Health tab evaluates this message against the saved config.
|
||||
* Absent or false: form only. true: shown whenever condition() holds. A
|
||||
* function: shown when both condition(ctx) and health(ctx) hold, for
|
||||
* messages the form deliberately shows even when the feature is off.
|
||||
*/
|
||||
health?: boolean | ((ctx: MessageConditionContext) => boolean);
|
||||
};
|
||||
|
||||
/** Field-level conditional message, adds field targeting */
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/** settings page id that edits each config section at camera level */
|
||||
export const CAMERA_PAGE_BY_SECTION: Record<string, string> = {
|
||||
detect: "cameraDetect",
|
||||
ffmpeg: "cameraFfmpeg",
|
||||
record: "cameraRecording",
|
||||
snapshots: "cameraSnapshots",
|
||||
motion: "cameraMotion",
|
||||
objects: "cameraObjects",
|
||||
review: "cameraReview",
|
||||
audio: "cameraAudioEvents",
|
||||
audio_transcription: "cameraAudioTranscription",
|
||||
notifications: "cameraNotifications",
|
||||
live: "cameraLivePlayback",
|
||||
birdseye: "cameraBirdseye",
|
||||
face_recognition: "cameraFaceRecognition",
|
||||
lpr: "cameraLpr",
|
||||
timestamp_style: "cameraTimestampStyle",
|
||||
onvif: "cameraOnvif",
|
||||
};
|
||||
|
||||
/** settings page id that edits each config section at global level */
|
||||
export const GLOBAL_PAGE_BY_SECTION: Record<string, string> = {
|
||||
detect: "globalDetect",
|
||||
record: "globalRecording",
|
||||
snapshots: "globalSnapshots",
|
||||
ffmpeg: "globalFfmpeg",
|
||||
motion: "globalMotion",
|
||||
objects: "globalObjects",
|
||||
review: "globalReview",
|
||||
audio: "globalAudioEvents",
|
||||
live: "globalLivePlayback",
|
||||
timestamp_style: "globalTimestampStyle",
|
||||
database: "systemDatabase",
|
||||
tls: "systemTls",
|
||||
auth: "systemAuthentication",
|
||||
networking: "systemNetworking",
|
||||
proxy: "systemProxy",
|
||||
ui: "systemUi",
|
||||
logger: "systemLogging",
|
||||
environment_vars: "systemEnvironmentVariables",
|
||||
telemetry: "systemTelemetry",
|
||||
birdseye: "systemBirdseye",
|
||||
models: "systemDetectorsAndModel",
|
||||
mqtt: "systemMqtt",
|
||||
semantic_search: "integrationSemanticSearch",
|
||||
genai: "integrationGenerativeAi",
|
||||
face_recognition: "integrationFaceRecognition",
|
||||
lpr: "integrationLpr",
|
||||
classification: "integrationObjectClassification",
|
||||
audio_transcription: "integrationAudioTranscription",
|
||||
};
|
||||
|
||||
export function settingsLink(
|
||||
section: string,
|
||||
level: "global" | "camera",
|
||||
cameraName?: string,
|
||||
): string | undefined {
|
||||
if (level === "camera") {
|
||||
const page = CAMERA_PAGE_BY_SECTION[section];
|
||||
return page && cameraName
|
||||
? `/settings?page=${page}&camera=${encodeURIComponent(cameraName)}`
|
||||
: undefined;
|
||||
}
|
||||
|
||||
const page = GLOBAL_PAGE_BY_SECTION[section];
|
||||
return page ? `/settings?page=${page}` : undefined;
|
||||
}
|
||||
@@ -25,24 +25,7 @@ import {
|
||||
pathMatchesHiddenPattern,
|
||||
} from "@/utils/configUtil";
|
||||
import { useOverrideFieldLabel } from "./useOverrideFieldLabel";
|
||||
|
||||
const CAMERA_PAGE_BY_SECTION: Record<string, string> = {
|
||||
detect: "cameraDetect",
|
||||
ffmpeg: "cameraFfmpeg",
|
||||
record: "cameraRecording",
|
||||
snapshots: "cameraSnapshots",
|
||||
motion: "cameraMotion",
|
||||
objects: "cameraObjects",
|
||||
review: "cameraReview",
|
||||
audio: "cameraAudioEvents",
|
||||
audio_transcription: "cameraAudioTranscription",
|
||||
notifications: "cameraNotifications",
|
||||
live: "cameraLivePlayback",
|
||||
birdseye: "cameraBirdseye",
|
||||
face_recognition: "cameraFaceRecognition",
|
||||
lpr: "cameraLpr",
|
||||
timestamp_style: "cameraTimestampStyle",
|
||||
};
|
||||
import { CAMERA_PAGE_BY_SECTION } from "@/components/config-form/sectionPages";
|
||||
|
||||
const MAX_FIELDS_PER_CAMERA = 5;
|
||||
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaCircleCheck } from "react-icons/fa6";
|
||||
import { LuRefreshCw } from "react-icons/lu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
import useSWR from "swr";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import TimeAgo from "@/components/dynamic/TimeAgo";
|
||||
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||
import { ConnectionQualityIndicator } from "@/components/camera/ConnectionQualityIndicator";
|
||||
import HardwareStatusRow from "@/components/health/HardwareStatusRow";
|
||||
import { useHardwareHealth } from "@/hooks/use-hardware-health";
|
||||
import { useHealthChecks } from "@/hooks/use-health-checks";
|
||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
import type { HardwareRow } from "@/utils/health";
|
||||
import { streamHealth } from "@/utils/streamHealth";
|
||||
|
||||
function Card({
|
||||
title,
|
||||
className,
|
||||
action,
|
||||
subtitle,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
className?: string;
|
||||
/** an icon button right after the title */
|
||||
action?: React.ReactNode;
|
||||
/** a muted line under the title */
|
||||
subtitle?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg bg-background_alt p-2.5 pb-5 md:rounded-2xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="mb-5 flex flex-col">
|
||||
<div className="flex items-center gap-3">
|
||||
<span>{title}</span>
|
||||
{action}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<div className="text-xs text-muted-foreground">{subtitle}</div>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineAction({
|
||||
label,
|
||||
busy,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
busy?: boolean;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 shrink-0 text-muted-foreground hover:text-primary"
|
||||
aria-label={label}
|
||||
disabled={busy || disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
{busy ? (
|
||||
<ActivityIndicator className="size-3.5" size={14} />
|
||||
) : (
|
||||
<LuRefreshCw className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function Group({ title, rows }: { title: string; rows: HardwareRow[] }) {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
|
||||
return (
|
||||
<Card title={title}>
|
||||
{rows.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("health.hardware.nothingConfigured")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{rows.map((row) => (
|
||||
<HardwareStatusRow key={row.id} row={row} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** Stream checks live here: the check sits next to its result. */
|
||||
function StreamsGroup() {
|
||||
const { t } = useTranslation(["views/system", "views/settings"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
const { stream } = useHealthChecks();
|
||||
const summary = useMemo(
|
||||
() => (config ? streamHealth(config, stream.results, t) : undefined),
|
||||
[config, stream.results, t],
|
||||
);
|
||||
const row: HardwareRow | undefined = useMemo(() => {
|
||||
if (stream.running || !summary || !stream.results) {
|
||||
return undefined;
|
||||
}
|
||||
const flagged = summary.checked - summary.clean;
|
||||
return {
|
||||
id: "streams",
|
||||
state: flagged > 0 ? "warning" : "ok",
|
||||
label: t("health.hardware.streamsChecked", { count: summary.checked }),
|
||||
detail:
|
||||
flagged > 0
|
||||
? t("health.hardware.streamsFlagged", { count: flagged })
|
||||
: t("health.hardware.streamsClean"),
|
||||
};
|
||||
}, [stream.running, stream.results, summary, t]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={t("health.hardware.cameraStreams")}
|
||||
action={
|
||||
<InlineAction
|
||||
label={
|
||||
stream.results
|
||||
? t("health.hardware.runAgain")
|
||||
: t("health.hardware.runStreamChecks")
|
||||
}
|
||||
busy={stream.running}
|
||||
disabled={!config}
|
||||
onClick={() => stream.run()}
|
||||
/>
|
||||
}
|
||||
subtitle={
|
||||
stream.running
|
||||
? t("health.hardware.streamsChecking", {
|
||||
done: stream.total - stream.pending.length,
|
||||
total: stream.total,
|
||||
})
|
||||
: stream.results && (
|
||||
<>
|
||||
{t("health.hardware.checked")}{" "}
|
||||
<TimeAgo time={stream.results.checkedAt * 1000} dense />
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3 text-sm" data-testid="camera-streams">
|
||||
{row ? (
|
||||
<HardwareStatusRow row={row} />
|
||||
) : (
|
||||
<HardwareStatusRow
|
||||
row={{
|
||||
id: "streams",
|
||||
state: "unknown",
|
||||
label: t("health.hardware.streamsNotChecked"),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function HardwareHeading() {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
const { hardware } = useHealthChecks();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-md font-medium text-primary-variant">
|
||||
{t("health.hardware.title")}
|
||||
</div>
|
||||
<InlineAction
|
||||
label={t("health.hardware.recheck")}
|
||||
busy={hardware.rechecking}
|
||||
onClick={() => hardware.recheck()}
|
||||
/>
|
||||
</div>
|
||||
{hardware.rechecking ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("health.hardware.probing")}
|
||||
</div>
|
||||
) : (
|
||||
hardware.probedAt && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("health.hardware.probed")}{" "}
|
||||
<TimeAgo time={hardware.probedAt * 1000} dense />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HardwarePane() {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
const { rows, statsLoaded } = useHardwareHealth();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<HardwareHeading />
|
||||
{!rows ? (
|
||||
<Skeleton className="h-40 w-full rounded-lg md:rounded-2xl" />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="grid grid-cols-1 gap-2 md:grid-cols-3">
|
||||
<Group
|
||||
title={t("health.hardware.objectDetection")}
|
||||
rows={rows.detection}
|
||||
/>
|
||||
<Group
|
||||
title={t("health.hardware.hardwareAcceleration")}
|
||||
rows={rows.hwaccel}
|
||||
/>
|
||||
<Group
|
||||
title={t("health.hardware.enrichments.title")}
|
||||
rows={rows.enrichments}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
|
||||
<StreamsGroup />
|
||||
<Card title={t("health.hardware.cameraConnections")}>
|
||||
{!statsLoaded ? (
|
||||
<Skeleton className="h-10 w-full" />
|
||||
) : rows.cameras.length === 0 ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<FaCircleCheck className="size-4 text-success" />
|
||||
{t("health.hardware.allCamerasExcellent")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
{rows.cameras.map((cell) => (
|
||||
<div
|
||||
key={cell.camera}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
data-testid={`camera-connection-${cell.camera}`}
|
||||
>
|
||||
<ConnectionQualityIndicator
|
||||
quality={cell.quality}
|
||||
expectedFps={cell.expectedFps}
|
||||
reconnects={cell.reconnects}
|
||||
stalls={cell.stalls}
|
||||
/>
|
||||
<CameraNameLabel
|
||||
camera={cell.camera}
|
||||
className="smart-capitalize"
|
||||
/>
|
||||
<span className="text-muted-foreground">
|
||||
{t("health.hardware.fps", {
|
||||
camera: cell.cameraFps.toFixed(1),
|
||||
expected: cell.expectedFps,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { FaCircleCheck, FaTriangleExclamation } from "react-icons/fa6";
|
||||
import { LuCircleHelp, LuX } from "react-icons/lu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { HardwareRow } from "@/utils/health";
|
||||
|
||||
const MESSAGE_COLOR: Record<HardwareRow["state"], string> = {
|
||||
ok: "text-success",
|
||||
warning: "text-yellow-500",
|
||||
error: "text-danger",
|
||||
unknown: "text-muted-foreground",
|
||||
};
|
||||
|
||||
export default function HardwareStatusRow({ row }: { row: HardwareRow }) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-start gap-2 text-sm"
|
||||
data-testid={`hardware-row-${row.id}`}
|
||||
data-state={row.state}
|
||||
>
|
||||
<div className="mt-0.5 flex shrink-0">
|
||||
{row.state === "ok" && (
|
||||
<FaCircleCheck className="size-4 text-success" />
|
||||
)}
|
||||
{row.state === "warning" && (
|
||||
<FaTriangleExclamation className="size-4 text-yellow-500" />
|
||||
)}
|
||||
{row.state === "error" && <LuX className="size-4 text-danger" />}
|
||||
{row.state === "unknown" && (
|
||||
<LuCircleHelp className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div>
|
||||
<span>{row.label}</span>
|
||||
{row.detail && (
|
||||
<span className="ml-2 text-muted-foreground">{row.detail}</span>
|
||||
)}
|
||||
</div>
|
||||
{row.message && (
|
||||
<div className={cn("mt-0.5", MESSAGE_COLOR[row.state])}>
|
||||
{row.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,82 +7,164 @@ import {
|
||||
LuSlidersHorizontal,
|
||||
LuX,
|
||||
} from "react-icons/lu";
|
||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import type { HealthProblem } from "@/types/health";
|
||||
|
||||
type HealthProblemRowProps = {
|
||||
problem: HealthProblem;
|
||||
};
|
||||
|
||||
const ICON_BUTTON_CLASS =
|
||||
"size-6 shrink-0 text-muted-foreground hover:text-primary";
|
||||
|
||||
function RowAction({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HealthProblemRow({ problem }: HealthProblemRowProps) {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
const { t } = useTranslation(["views/system", "common"]);
|
||||
const { getLocaleDocUrl } = useDocDomain();
|
||||
const hasDetails = problem.scope || problem.meta;
|
||||
const hasActions =
|
||||
problem.link ||
|
||||
problem.docLink ||
|
||||
problem.externalLink ||
|
||||
problem.onDismiss;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-start gap-2 border-b border-border px-1 py-2 text-sm last:border-b-0"
|
||||
className="flex items-center gap-2 border-b border-border px-1 py-2 text-sm last:border-b-0"
|
||||
data-testid={`health-problem-${problem.id}`}
|
||||
data-severity={problem.severity}
|
||||
>
|
||||
<div className="mt-0.5 flex shrink-0">
|
||||
{problem.severity === "error" && <LuX className="size-4 text-danger" />}
|
||||
{problem.severity === "warning" && (
|
||||
<FaTriangleExclamation className="size-4 text-yellow-500" />
|
||||
)}
|
||||
{problem.severity === "info" && (
|
||||
<LuInfo className="size-4 text-selected" />
|
||||
<div className="flex shrink-0 self-start pt-0.5">
|
||||
{problem.pending ? (
|
||||
<ActivityIndicator className="" size={16} />
|
||||
) : (
|
||||
<>
|
||||
{problem.severity === "error" && (
|
||||
<LuX className="size-4 text-danger" />
|
||||
)}
|
||||
{problem.severity === "warning" && (
|
||||
<FaTriangleExclamation className="size-4 text-yellow-500" />
|
||||
)}
|
||||
{problem.severity === "info" && (
|
||||
<LuInfo className="size-4 text-selected" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{problem.scope && (
|
||||
<span className="rounded-md bg-secondary px-1.5 py-0.5 text-xs text-secondary-foreground smart-capitalize">
|
||||
{problem.scopeIsCamera ? (
|
||||
<CameraNameLabel camera={problem.scope} />
|
||||
) : (
|
||||
problem.scope
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div>{problem.text}</div>
|
||||
{problem.meta && (
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||
{problem.meta}
|
||||
{hasDetails && (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
{problem.scope && (
|
||||
<span className="rounded bg-secondary px-1.5 py-0.5 text-xs text-primary-variant">
|
||||
{problem.scopeIsCamera ? (
|
||||
<CameraNameLabel camera={problem.scope} />
|
||||
) : (
|
||||
problem.scope
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{problem.meta && <span>{problem.meta}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 text-muted-foreground">
|
||||
{problem.onDismiss && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2"
|
||||
onClick={problem.onDismiss}
|
||||
aria-label={t("health.notices.dismiss")}
|
||||
>
|
||||
{t("health.notices.dismiss")}
|
||||
</Button>
|
||||
)}
|
||||
{problem.link && (
|
||||
<Link
|
||||
to={problem.link}
|
||||
aria-label={t("health.notices.openSettings")}
|
||||
className="hover:text-primary"
|
||||
>
|
||||
<LuSlidersHorizontal className="size-4" />
|
||||
</Link>
|
||||
)}
|
||||
{problem.externalLink && (
|
||||
<a
|
||||
href={problem.externalLink}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={t("health.notices.openLink")}
|
||||
className="hover:text-primary"
|
||||
>
|
||||
<LuExternalLink className="size-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{hasActions && (
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
{problem.link && (
|
||||
<RowAction label={t("health.notices.openSettings")}>
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={ICON_BUTTON_CLASS}
|
||||
>
|
||||
<Link
|
||||
to={problem.link}
|
||||
aria-label={t("health.notices.openSettings")}
|
||||
>
|
||||
<LuSlidersHorizontal className="size-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</RowAction>
|
||||
)}
|
||||
{problem.docLink && (
|
||||
<RowAction label={t("readTheDocumentation", { ns: "common" })}>
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={ICON_BUTTON_CLASS}
|
||||
>
|
||||
<a
|
||||
href={getLocaleDocUrl(problem.docLink)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={t("readTheDocumentation", { ns: "common" })}
|
||||
>
|
||||
<LuExternalLink className="size-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
</RowAction>
|
||||
)}
|
||||
{problem.externalLink && (
|
||||
<RowAction label={t("health.notices.openLink")}>
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={ICON_BUTTON_CLASS}
|
||||
>
|
||||
<a
|
||||
href={problem.externalLink}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={t("health.notices.openLink")}
|
||||
>
|
||||
<LuExternalLink className="size-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
</RowAction>
|
||||
)}
|
||||
{problem.onDismiss && (
|
||||
<RowAction label={t("health.notices.dismiss")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={ICON_BUTTON_CLASS}
|
||||
aria-label={t("health.notices.dismiss")}
|
||||
onClick={problem.onDismiss}
|
||||
>
|
||||
<LuX className="size-3.5" />
|
||||
</Button>
|
||||
</RowAction>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,8 +7,14 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useNotices } from "@/hooks/use-notices";
|
||||
import { useDateLocale } from "@/hooks/use-date-locale";
|
||||
import { useTimezone } from "@/hooks/use-date-utils";
|
||||
import useStats, { useAutoFrigateStats } from "@/hooks/use-stats";
|
||||
import { useHealthChecks } from "@/hooks/use-health-checks";
|
||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||
import { releaseUrl } from "@/utils/versionUtil";
|
||||
import { evaluateConfigHealth } from "@/utils/configHealth";
|
||||
import { isStartupWindow } from "@/utils/health";
|
||||
import { sortHealthProblems } from "@/utils/healthSort";
|
||||
import { streamHealth } from "@/utils/streamHealth";
|
||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
import type { HealthProblem } from "@/types/health";
|
||||
import type { Notice, NoticeKind, NoticeStats } from "@/types/notice";
|
||||
@@ -75,6 +81,7 @@ function useNoticeProblems(
|
||||
|
||||
return {
|
||||
id: `notice:${notice.id}`,
|
||||
source: "registry" as const,
|
||||
severity: notice.severity,
|
||||
scope: notice.scope ?? undefined,
|
||||
scopeIsCamera: notice.category === "camera",
|
||||
@@ -93,19 +100,73 @@ function useNoticeProblems(
|
||||
}
|
||||
|
||||
export default function NoticesPane() {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
const { t } = useTranslation(["views/system", "views/settings"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
const stats = useAutoFrigateStats();
|
||||
const { notices, statsByKind, dismiss } = useNotices();
|
||||
const problems = useNoticeProblems(notices, statsByKind, dismiss);
|
||||
const registryProblems = useNoticeProblems(notices, statsByKind, dismiss);
|
||||
const { potentialProblems } = useStats(stats);
|
||||
const {
|
||||
stream: { results },
|
||||
} = useHealthChecks();
|
||||
|
||||
const liveProblems = useMemo<HealthProblem[]>(() => {
|
||||
if (!stats) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (isStartupWindow(stats)) {
|
||||
return [
|
||||
{
|
||||
id: "live:startup",
|
||||
source: "live",
|
||||
severity: "info",
|
||||
text: t("health.notices.startupWindow"),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return potentialProblems.map((problem, index) => ({
|
||||
id: `live:${index}:${problem.text}`,
|
||||
source: "live",
|
||||
severity: problem.severity,
|
||||
text: problem.text,
|
||||
link: problem.relevantLink?.replace(/^(?!\/)/, "/"),
|
||||
}));
|
||||
}, [stats, potentialProblems, t]);
|
||||
|
||||
const configProblems = useMemo<HealthProblem[]>(
|
||||
() => (config ? evaluateConfigHealth(config, t) : []),
|
||||
[config, t],
|
||||
);
|
||||
|
||||
const streamProblems = useMemo<HealthProblem[]>(
|
||||
() => (config ? streamHealth(config, results, t).problems : []),
|
||||
[config, results, t],
|
||||
);
|
||||
|
||||
const problems = useMemo(
|
||||
() =>
|
||||
sortHealthProblems([
|
||||
...registryProblems,
|
||||
...liveProblems,
|
||||
...configProblems,
|
||||
...streamProblems,
|
||||
]),
|
||||
[registryProblems, liveProblems, configProblems, streamProblems],
|
||||
);
|
||||
|
||||
const loading = notices === undefined || !config;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<div className="text-md font-medium text-primary-variant">
|
||||
{t("health.notices.title")}
|
||||
</div>
|
||||
<div className="text-md font-medium text-primary-variant">
|
||||
{t("health.notices.title")}
|
||||
</div>
|
||||
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
|
||||
{notices === undefined ? (
|
||||
{loading ? (
|
||||
<Skeleton className="h-24 w-full" />
|
||||
) : problems.length === 0 ? (
|
||||
<div className="flex items-center gap-2 px-1 py-2 text-sm">
|
||||
|
||||
@@ -20,6 +20,7 @@ import { FaCircleCheck, FaTriangleExclamation } from "react-icons/fa6";
|
||||
import { LuX } from "react-icons/lu";
|
||||
import { Card, CardContent } from "../../ui/card";
|
||||
import { maskUri } from "@/utils/cameraUtil";
|
||||
import { ffprobeToTestResult, getStreamIssues } from "@/utils/streamIssues";
|
||||
|
||||
type Step4ValidationProps = {
|
||||
wizardData: Partial<WizardFormData>;
|
||||
@@ -74,44 +75,7 @@ export default function Step4Validation({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
if (response.data?.[0]?.return_code === 0) {
|
||||
const probeData = response.data[0];
|
||||
const streamData = probeData.stdout.streams || [];
|
||||
|
||||
const videoStream = streamData.find(
|
||||
(s: { codec_type?: string; codec_name?: string }) =>
|
||||
s.codec_type === "video" ||
|
||||
s.codec_name?.includes("h264") ||
|
||||
s.codec_name?.includes("h265"),
|
||||
);
|
||||
|
||||
const audioStream = streamData.find(
|
||||
(s: { codec_type?: string; codec_name?: string }) =>
|
||||
s.codec_type === "audio" ||
|
||||
s.codec_name?.includes("aac") ||
|
||||
s.codec_name?.includes("mp3"),
|
||||
);
|
||||
|
||||
const resolution = videoStream
|
||||
? `${videoStream.width}x${videoStream.height}`
|
||||
: undefined;
|
||||
|
||||
const fps = videoStream?.avg_frame_rate
|
||||
? parseFloat(videoStream.avg_frame_rate.split("/")[0]) /
|
||||
parseFloat(videoStream.avg_frame_rate.split("/")[1])
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
resolution,
|
||||
videoCodec: videoStream?.codec_name,
|
||||
audioCodec: audioStream?.codec_name,
|
||||
fps: fps && !isNaN(fps) ? fps : undefined,
|
||||
};
|
||||
} else {
|
||||
const error = response.data?.[0]?.stderr || "Unknown error";
|
||||
return { success: false, error };
|
||||
}
|
||||
return ffprobeToTestResult(response.data?.[0]);
|
||||
} catch (error) {
|
||||
const axiosError = error as {
|
||||
response?: { data?: { message?: string; detail?: string } };
|
||||
@@ -528,149 +492,21 @@ function StreamIssues({
|
||||
}: StreamIssuesProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
|
||||
const issues = useMemo(() => {
|
||||
const result: Array<{
|
||||
type: "good" | "warning" | "error";
|
||||
message: string;
|
||||
}> = [];
|
||||
|
||||
if (wizardData.brandTemplate === "reolink") {
|
||||
const streamUrl = stream.url.toLowerCase();
|
||||
if (streamUrl.startsWith("rtsp://")) {
|
||||
result.push({
|
||||
type: "warning",
|
||||
message: t("cameraWizard.step4.issues.brands.reolink-rtsp"),
|
||||
});
|
||||
}
|
||||
|
||||
if (streamUrl.startsWith("http://") && !stream.useFfmpeg) {
|
||||
result.push({
|
||||
type: "warning",
|
||||
message: t("cameraWizard.step4.issues.brands.reolink-http"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Video codec check
|
||||
if (stream.testResult?.videoCodec) {
|
||||
const videoCodec = stream.testResult.videoCodec.toLowerCase();
|
||||
if (["h264", "h265", "hevc"].includes(videoCodec)) {
|
||||
result.push({
|
||||
type: "good",
|
||||
message: t("cameraWizard.step4.issues.videoCodecGood", {
|
||||
codec: stream.testResult.videoCodec,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Audio codec check
|
||||
if (stream.roles.includes("record")) {
|
||||
if (stream.testResult?.audioCodec) {
|
||||
const audioCodec = stream.testResult.audioCodec.toLowerCase();
|
||||
if (audioCodec === "aac") {
|
||||
result.push({
|
||||
type: "good",
|
||||
message: t("cameraWizard.step4.issues.audioCodecGood", {
|
||||
codec: stream.testResult.audioCodec,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
type: "error",
|
||||
message: t("cameraWizard.step4.issues.audioCodecRecordError"),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
result.push({
|
||||
type: "warning",
|
||||
message: t("cameraWizard.step4.issues.noAudioWarning"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Audio detection check
|
||||
if (stream.roles.includes("audio")) {
|
||||
if (!stream.testResult?.audioCodec) {
|
||||
result.push({
|
||||
type: "error",
|
||||
message: t("cameraWizard.step4.issues.audioCodecRequired"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Restreaming check
|
||||
if (stream.roles.includes("record")) {
|
||||
if (stream.restream) {
|
||||
result.push({
|
||||
type: "warning",
|
||||
message: t("cameraWizard.step4.issues.restreamingWarning"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (stream.roles.includes("detect") && stream.testResult) {
|
||||
const probedResolution = stream.testResult.resolution;
|
||||
let probedWidth = 0;
|
||||
let probedHeight = 0;
|
||||
if (probedResolution) {
|
||||
const [w, h] = probedResolution.split("x").map(Number);
|
||||
if (!isNaN(w) && !isNaN(h)) {
|
||||
probedWidth = w;
|
||||
probedHeight = h;
|
||||
}
|
||||
}
|
||||
|
||||
if (probedWidth <= 0 || probedHeight <= 0) {
|
||||
result.push({
|
||||
type: "error",
|
||||
message: t("cameraWizard.step4.issues.resolutionUnknown"),
|
||||
});
|
||||
} else {
|
||||
const minDimension = Math.min(probedWidth, probedHeight);
|
||||
const maxDimension = Math.max(probedWidth, probedHeight);
|
||||
if (minDimension > 1080) {
|
||||
result.push({
|
||||
type: "warning",
|
||||
message: t("cameraWizard.step4.issues.resolutionHigh", {
|
||||
resolution: probedResolution,
|
||||
}),
|
||||
});
|
||||
} else if (maxDimension < 640) {
|
||||
result.push({
|
||||
type: "error",
|
||||
message: t("cameraWizard.step4.issues.resolutionLow", {
|
||||
resolution: probedResolution,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Substream Check
|
||||
if (
|
||||
wizardData.brandTemplate == "dahua" &&
|
||||
stream.roles.includes("detect") &&
|
||||
stream.url.includes("subtype=1")
|
||||
) {
|
||||
result.push({
|
||||
type: "warning",
|
||||
message: t("cameraWizard.step4.issues.dahua.substreamWarning"),
|
||||
});
|
||||
}
|
||||
if (
|
||||
wizardData.brandTemplate == "hikvision" &&
|
||||
stream.roles.includes("detect") &&
|
||||
stream.url.includes("/102")
|
||||
) {
|
||||
result.push({
|
||||
type: "warning",
|
||||
message: t("cameraWizard.step4.issues.hikvision.substreamWarning"),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [stream, wizardData, t]);
|
||||
const issues = useMemo(
|
||||
() =>
|
||||
getStreamIssues(
|
||||
{
|
||||
url: stream.url,
|
||||
roles: stream.roles,
|
||||
brand: wizardData.brandTemplate,
|
||||
useFfmpeg: stream.useFfmpeg,
|
||||
restream: stream.restream,
|
||||
testResult: stream.testResult,
|
||||
},
|
||||
t,
|
||||
),
|
||||
[stream, wizardData, t],
|
||||
);
|
||||
|
||||
if (issues.length === 0) {
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useMemo } from "react";
|
||||
import useSWR from "swr";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
DetectionHardware,
|
||||
HwaccelRecommendation,
|
||||
} from "@/types/hardware";
|
||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { useAutoFrigateStats } from "@/hooks/use-stats";
|
||||
import {
|
||||
cameraConnectionCells,
|
||||
detectionRows,
|
||||
enrichmentRows,
|
||||
hwaccelRows,
|
||||
isStartupWindow,
|
||||
} from "@/utils/health";
|
||||
|
||||
export function useHardwareHealth() {
|
||||
const { t } = useTranslation(["views/system", "views/setup"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
const { data: hardware, error: probeError } = useSWR<DetectionHardware[]>(
|
||||
"hardware/probe",
|
||||
{ revalidateOnFocus: false },
|
||||
);
|
||||
const { data: hwaccel, error: hwaccelError } = useSWR<HwaccelRecommendation>(
|
||||
"hardware/hwaccel",
|
||||
{ revalidateOnFocus: false },
|
||||
);
|
||||
const stats = useAutoFrigateStats();
|
||||
const rows = useMemo(() => {
|
||||
if (!config) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const startup = isStartupWindow(stats);
|
||||
return {
|
||||
detection: detectionRows({
|
||||
models: config.models,
|
||||
hardware,
|
||||
probeFailed: !!probeError,
|
||||
stats,
|
||||
startup,
|
||||
t,
|
||||
}),
|
||||
hwaccel: hwaccelRows({
|
||||
config,
|
||||
hwaccel,
|
||||
hwaccelFailed: !!hwaccelError,
|
||||
stats,
|
||||
t,
|
||||
}),
|
||||
enrichments: enrichmentRows({
|
||||
config,
|
||||
hardware,
|
||||
probeFailed: !!probeError,
|
||||
stats,
|
||||
startup,
|
||||
t,
|
||||
}),
|
||||
cameras: cameraConnectionCells(config, stats),
|
||||
};
|
||||
}, [config, hardware, probeError, hwaccel, hwaccelError, stats, t]);
|
||||
|
||||
return { rows, statsLoaded: !!stats };
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
import axios from "axios";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
import type { TestResult } from "@/types/cameraWizard";
|
||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { ffprobeToTestResult, type FfprobeEntry } from "@/utils/streamIssues";
|
||||
import { activeCameras } from "@/utils/health";
|
||||
|
||||
export type CameraStreamCheck = {
|
||||
/** whole-camera failure (request error or timeout) */
|
||||
error?: string;
|
||||
/** one entry per config input, in config order */
|
||||
streams: TestResult[];
|
||||
};
|
||||
|
||||
export type StreamCheckResults = {
|
||||
checkedAt: number;
|
||||
byCamera: Record<string, CameraStreamCheck>;
|
||||
};
|
||||
|
||||
type HealthChecksState = {
|
||||
stream: {
|
||||
results?: StreamCheckResults;
|
||||
/** cameras still being probed in the current run */
|
||||
pending: string[];
|
||||
/** camera count of the current run */
|
||||
total: number;
|
||||
};
|
||||
hardware: {
|
||||
rechecking: boolean;
|
||||
/** last on-demand probe; absent means the startup probe is current */
|
||||
probedAt?: number;
|
||||
};
|
||||
};
|
||||
|
||||
// The tab bar button and both panes read this, so it lives outside React
|
||||
// and survives tab switches until reload. SWR is not used because a null
|
||||
// fetcher falls back to the global one and would GET /api/health/...
|
||||
let state: HealthChecksState = {
|
||||
stream: { pending: [], total: 0 },
|
||||
hardware: { rechecking: false },
|
||||
};
|
||||
const listeners = new Set<() => void>();
|
||||
let streamRunning = false;
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
function getState() {
|
||||
return state;
|
||||
}
|
||||
|
||||
function update(patch: (current: HealthChecksState) => HealthChecksState) {
|
||||
state = patch(state);
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
const CONCURRENCY = 2;
|
||||
// the backend probes each input with a 6 s timeout plus one retry
|
||||
const TIMEOUT_PER_INPUT_MS = 12_000;
|
||||
const TIMEOUT_BASE_MS = 5_000;
|
||||
|
||||
async function probeCamera(
|
||||
name: string,
|
||||
inputs: number,
|
||||
): Promise<CameraStreamCheck> {
|
||||
try {
|
||||
const response = await axios.get("ffprobe", {
|
||||
params: { paths: `camera:${name}`, detailed: true },
|
||||
timeout: TIMEOUT_BASE_MS + TIMEOUT_PER_INPUT_MS * Math.max(inputs, 1),
|
||||
});
|
||||
const entries: FfprobeEntry[] = Array.isArray(response.data)
|
||||
? response.data
|
||||
: [];
|
||||
return { streams: entries.map(ffprobeToTestResult) };
|
||||
} catch (error) {
|
||||
const axiosError = error as {
|
||||
response?: { data?: { message?: string } };
|
||||
message?: string;
|
||||
};
|
||||
return {
|
||||
error:
|
||||
axiosError.response?.data?.message ||
|
||||
axiosError.message ||
|
||||
"Connection failed",
|
||||
streams: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function runStreamChecks(config: FrigateConfig) {
|
||||
if (streamRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
streamRunning = true;
|
||||
const cameras = activeCameras(config);
|
||||
const names = cameras.map((camera) => camera.name);
|
||||
update((s) => ({
|
||||
...s,
|
||||
stream: { ...s.stream, pending: names, total: names.length },
|
||||
}));
|
||||
const byCamera: Record<string, CameraStreamCheck> = {};
|
||||
const queue = [...cameras];
|
||||
|
||||
const worker = async () => {
|
||||
while (queue.length > 0) {
|
||||
const camera = queue.shift();
|
||||
if (!camera) {
|
||||
return;
|
||||
}
|
||||
byCamera[camera.name] = await probeCamera(
|
||||
camera.name,
|
||||
camera.ffmpeg.inputs.length,
|
||||
);
|
||||
update((s) => ({
|
||||
...s,
|
||||
stream: {
|
||||
...s.stream,
|
||||
pending: s.stream.pending.filter((c) => c !== camera.name),
|
||||
},
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(CONCURRENCY, cameras.length) }, worker),
|
||||
);
|
||||
update((s) => ({
|
||||
...s,
|
||||
stream: {
|
||||
...s.stream,
|
||||
results: { checkedAt: Date.now() / 1000, byCamera },
|
||||
},
|
||||
}));
|
||||
} finally {
|
||||
streamRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function useHealthChecks() {
|
||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
const { mutate } = useSWRConfig();
|
||||
const current = useSyncExternalStore(subscribe, getState);
|
||||
|
||||
const run = useCallback(() => {
|
||||
if (config) {
|
||||
return runStreamChecks(config);
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
const recheck = useCallback(async () => {
|
||||
if (state.hardware.rechecking) {
|
||||
return;
|
||||
}
|
||||
|
||||
update((s) => ({ ...s, hardware: { ...s.hardware, rechecking: true } }));
|
||||
try {
|
||||
await axios.get("hardware/probe", { params: { refresh: true } });
|
||||
await Promise.all([mutate("hardware/probe"), mutate("hardware/hwaccel")]);
|
||||
update((s) => ({
|
||||
...s,
|
||||
hardware: { rechecking: false, probedAt: Date.now() / 1000 },
|
||||
}));
|
||||
} catch {
|
||||
update((s) => ({ ...s, hardware: { ...s.hardware, rechecking: false } }));
|
||||
}
|
||||
}, [mutate]);
|
||||
|
||||
const runAll = useCallback(
|
||||
() => Promise.all([recheck(), run()]),
|
||||
[recheck, run],
|
||||
);
|
||||
|
||||
return {
|
||||
stream: {
|
||||
...current.stream,
|
||||
running: current.stream.pending.length > 0,
|
||||
run,
|
||||
},
|
||||
hardware: { ...current.hardware, recheck },
|
||||
runAll,
|
||||
ready: !!config,
|
||||
};
|
||||
}
|
||||
+85
-55
@@ -4,7 +4,7 @@ import {
|
||||
CameraFfmpegThreshold,
|
||||
InferenceThreshold,
|
||||
} from "@/types/graph";
|
||||
import { FrigateStats, PotentialProblem } from "@/types/stats";
|
||||
import { FrigateStats, PotentialProblem, ProblemSeverity } from "@/types/stats";
|
||||
import { useMemo } from "react";
|
||||
import useSWR from "swr";
|
||||
import useDeepMemo from "./use-deep-memo";
|
||||
@@ -15,6 +15,22 @@ import { useIsAdmin } from "./use-is-admin";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// the status bar has always rendered these exact classes; keep them byte for
|
||||
// byte so its output does not change
|
||||
const SEVERITY_COLOR: Record<ProblemSeverity, string> = {
|
||||
error: "text-danger",
|
||||
warning: "text-orange-400",
|
||||
info: "text-selected",
|
||||
};
|
||||
|
||||
function problem(
|
||||
severity: ProblemSeverity,
|
||||
text: string,
|
||||
relevantLink?: string,
|
||||
): PotentialProblem {
|
||||
return { text, severity, color: SEVERITY_COLOR[severity], relevantLink };
|
||||
}
|
||||
|
||||
export default function useStats(stats: FrigateStats | undefined) {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
@@ -48,36 +64,42 @@ export default function useStats(stats: FrigateStats | undefined) {
|
||||
// check shm level
|
||||
const shm = memoizedStats.service.storage["/dev/shm"];
|
||||
if (shm?.total && shm?.min_shm && shm.total < shm.min_shm) {
|
||||
problems.push({
|
||||
text: t("stats.shmTooLow", {
|
||||
total: shm.total,
|
||||
min: shm.min_shm,
|
||||
}),
|
||||
color: "text-danger",
|
||||
relevantLink: "/system#storage",
|
||||
});
|
||||
problems.push(
|
||||
problem(
|
||||
"error",
|
||||
t("stats.shmTooLow", {
|
||||
total: shm.total,
|
||||
min: shm.min_shm,
|
||||
}),
|
||||
"/system#storage",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// check detectors for high inference speeds
|
||||
Object.entries(memoizedStats["detectors"]).forEach(([key, det]) => {
|
||||
if (det["inference_speed"] > InferenceThreshold.error) {
|
||||
problems.push({
|
||||
text: t("stats.detectIsVerySlow", {
|
||||
detect: capitalizeFirstLetter(key),
|
||||
speed: det["inference_speed"],
|
||||
}),
|
||||
color: "text-danger",
|
||||
relevantLink: "/system#general",
|
||||
});
|
||||
problems.push(
|
||||
problem(
|
||||
"error",
|
||||
t("stats.detectIsVerySlow", {
|
||||
detect: capitalizeFirstLetter(key),
|
||||
speed: det["inference_speed"],
|
||||
}),
|
||||
"/system#general",
|
||||
),
|
||||
);
|
||||
} else if (det["inference_speed"] > InferenceThreshold.warning) {
|
||||
problems.push({
|
||||
text: t("stats.detectIsSlow", {
|
||||
detect: capitalizeFirstLetter(key),
|
||||
speed: det["inference_speed"],
|
||||
}),
|
||||
color: "text-orange-400",
|
||||
relevantLink: "/system#general",
|
||||
});
|
||||
problems.push(
|
||||
problem(
|
||||
"warning",
|
||||
t("stats.detectIsSlow", {
|
||||
detect: capitalizeFirstLetter(key),
|
||||
speed: det["inference_speed"],
|
||||
}),
|
||||
"/system#general",
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -94,13 +116,15 @@ export default function useStats(stats: FrigateStats | undefined) {
|
||||
|
||||
const cameraName = config.cameras?.[name]?.friendly_name ?? name;
|
||||
if (config.cameras?.[name]?.enabled && cam["camera_fps"] == 0) {
|
||||
problems.push({
|
||||
text: t("stats.cameraIsOffline", {
|
||||
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
|
||||
}),
|
||||
color: "text-danger",
|
||||
relevantLink: "logs",
|
||||
});
|
||||
problems.push(
|
||||
problem(
|
||||
"error",
|
||||
t("stats.cameraIsOffline", {
|
||||
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
|
||||
}),
|
||||
"logs",
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -121,37 +145,43 @@ export default function useStats(stats: FrigateStats | undefined) {
|
||||
const cameraName = config?.cameras?.[name]?.friendly_name ?? name;
|
||||
|
||||
if (!isNaN(ffmpegAvg) && ffmpegAvg >= CameraFfmpegThreshold.error) {
|
||||
problems.push({
|
||||
text: t("stats.ffmpegHighCpuUsage", {
|
||||
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
|
||||
ffmpegAvg,
|
||||
}),
|
||||
color: "text-danger",
|
||||
relevantLink: "/system#cameras",
|
||||
});
|
||||
problems.push(
|
||||
problem(
|
||||
"error",
|
||||
t("stats.ffmpegHighCpuUsage", {
|
||||
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
|
||||
ffmpegAvg,
|
||||
}),
|
||||
"/system#cameras",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!isNaN(detectAvg) && detectAvg >= CameraDetectThreshold.error) {
|
||||
problems.push({
|
||||
text: t("stats.detectHighCpuUsage", {
|
||||
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
|
||||
detectAvg,
|
||||
}),
|
||||
color: "text-danger",
|
||||
relevantLink: "/system#cameras",
|
||||
});
|
||||
problems.push(
|
||||
problem(
|
||||
"error",
|
||||
t("stats.detectHighCpuUsage", {
|
||||
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
|
||||
detectAvg,
|
||||
}),
|
||||
"/system#cameras",
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Add message if debug replay is active
|
||||
if (replayActive) {
|
||||
problems.push({
|
||||
text: t("stats.debugReplayActive", {
|
||||
defaultValue: "Debug replay session is active",
|
||||
}),
|
||||
color: "text-selected",
|
||||
relevantLink: "/replay",
|
||||
});
|
||||
problems.push(
|
||||
problem(
|
||||
"info",
|
||||
t("stats.debugReplayActive", {
|
||||
defaultValue: "Debug replay session is active",
|
||||
}),
|
||||
"/replay",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return problems;
|
||||
|
||||
@@ -705,7 +705,9 @@ export default function Settings() {
|
||||
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
|
||||
}, [config]);
|
||||
|
||||
const [selectedCamera, setSelectedCamera] = useState<string>("");
|
||||
const [selectedCamera, setSelectedCamera] = useState<string>(
|
||||
() => searchParams.get("camera") ?? "",
|
||||
);
|
||||
|
||||
// Get all camera overrides for the selected camera
|
||||
const cameraOverrides = useAllCameraOverrides(config, selectedCamera);
|
||||
@@ -1164,6 +1166,12 @@ export default function Settings() {
|
||||
});
|
||||
|
||||
useSearchEffect("camera", (camera: string) => {
|
||||
// the config drives the camera list, so keep the param until it loads
|
||||
// rather than consuming it against an empty list
|
||||
if (cameras.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cameraNames = cameras.map((c) => c.name);
|
||||
if (cameraNames.includes(camera)) {
|
||||
setSelectedCamera(camera);
|
||||
|
||||
@@ -122,7 +122,7 @@ function System() {
|
||||
</ToggleGroup>
|
||||
|
||||
<div className="flex h-full items-center">
|
||||
{lastUpdated && (
|
||||
{lastUpdated && pageToggle != "health" && (
|
||||
<div className="h-full content-center text-sm text-muted-foreground">
|
||||
{t("lastRefreshed")}
|
||||
<TimeAgo time={lastUpdated * 1000} dense />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -28,13 +28,15 @@ export type BirdseyeMode =
|
||||
|
||||
export interface FaceRecognitionConfig {
|
||||
enabled: boolean;
|
||||
device?: string | null;
|
||||
model_size: SearchModelSize;
|
||||
unknown_score: number;
|
||||
detection_threshold: number;
|
||||
recognition_threshold: number;
|
||||
}
|
||||
|
||||
export type SearchModel = "jinav1" | "jinav2";
|
||||
// a GenAI provider name is also accepted by the backend
|
||||
export type SearchModel = "jinav1" | "jinav2" | (string & NonNullable<unknown>);
|
||||
export type SearchModelSize = "small" | "large";
|
||||
|
||||
export interface CameraConfig {
|
||||
@@ -85,11 +87,11 @@ export interface CameraConfig {
|
||||
};
|
||||
ffmpeg: {
|
||||
global_args: string[];
|
||||
hwaccel_args: string;
|
||||
hwaccel_args: string | string[];
|
||||
input_args: string;
|
||||
inputs: {
|
||||
global_args: string[];
|
||||
hwaccel_args: string[];
|
||||
hwaccel_args: string | string[];
|
||||
input_args: string;
|
||||
path: string;
|
||||
roles: string[];
|
||||
@@ -448,6 +450,7 @@ export interface FrigateConfig {
|
||||
|
||||
audio_transcription: {
|
||||
enabled: boolean;
|
||||
device: "GPU" | "CPU";
|
||||
};
|
||||
|
||||
auth: {
|
||||
@@ -501,7 +504,7 @@ export interface FrigateConfig {
|
||||
|
||||
ffmpeg: {
|
||||
global_args: string[];
|
||||
hwaccel_args: string;
|
||||
hwaccel_args: string | string[];
|
||||
input_args: string;
|
||||
output_args: {
|
||||
detect: string[];
|
||||
@@ -527,6 +530,7 @@ export interface FrigateConfig {
|
||||
|
||||
lpr: {
|
||||
enabled: boolean;
|
||||
device?: string | null;
|
||||
};
|
||||
|
||||
logger: {
|
||||
@@ -615,6 +619,7 @@ export interface FrigateConfig {
|
||||
|
||||
semantic_search: {
|
||||
enabled: boolean;
|
||||
device?: string | null;
|
||||
reindex: boolean;
|
||||
model: SearchModel;
|
||||
model_size: SearchModelSize;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -54,6 +54,7 @@ export type EmbeddingsStats = {
|
||||
face_embedding_speed: number;
|
||||
plate_recognition_speed: number;
|
||||
text_embedding_speed: number;
|
||||
devices?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type ExtraProcessStats = {
|
||||
@@ -119,8 +120,11 @@ export type CameraStorage = {
|
||||
};
|
||||
};
|
||||
|
||||
export type ProblemSeverity = "error" | "warning" | "info";
|
||||
|
||||
export type PotentialProblem = {
|
||||
text: string;
|
||||
severity: ProblemSeverity;
|
||||
color: string;
|
||||
relevantLink?: string;
|
||||
};
|
||||
|
||||
@@ -131,6 +131,7 @@ export type MediaSyncStats = {
|
||||
files_checked: number;
|
||||
orphans_found: number;
|
||||
orphans_deleted: number;
|
||||
bytes_reclaimed: number;
|
||||
aborted: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
@@ -139,6 +140,7 @@ export type MediaSyncTotals = {
|
||||
files_checked: number;
|
||||
orphans_found: number;
|
||||
orphans_deleted: number;
|
||||
bytes_reclaimed: number;
|
||||
};
|
||||
|
||||
export type MediaSyncResults = {
|
||||
@@ -154,4 +156,5 @@ export type Job<TResults = unknown> = {
|
||||
start_time?: number;
|
||||
end_time?: number;
|
||||
error_message?: string;
|
||||
dry_run?: boolean;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { sectionConfigs } from "@/components/config-form/sectionConfigs";
|
||||
import type {
|
||||
ConditionalMessage,
|
||||
MessageConditionContext,
|
||||
} from "@/components/config-form/section-configs/types";
|
||||
import { settingsLink } from "@/components/config-form/sectionPages";
|
||||
import type { ConfigSectionData } from "@/types/configForm";
|
||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
import type { HealthProblem } from "@/types/health";
|
||||
import { getSectionConfig } from "@/utils/configUtil";
|
||||
import { activeCameras } from "@/utils/health";
|
||||
|
||||
function healthMessages(
|
||||
section: string,
|
||||
level: "global" | "camera",
|
||||
): ConditionalMessage[] {
|
||||
const config = getSectionConfig(section, level);
|
||||
return [...(config.messages ?? []), ...(config.fieldMessages ?? [])].filter(
|
||||
(message) => message.health,
|
||||
);
|
||||
}
|
||||
|
||||
function isActive(
|
||||
message: ConditionalMessage,
|
||||
ctx: MessageConditionContext,
|
||||
): boolean {
|
||||
if (!message.condition(ctx)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return typeof message.health === "function" ? message.health(ctx) : true;
|
||||
}
|
||||
|
||||
function toProblem(
|
||||
message: ConditionalMessage,
|
||||
section: string,
|
||||
ctx: MessageConditionContext,
|
||||
scope: string | undefined,
|
||||
scopeIsCamera: boolean,
|
||||
idSuffix: string,
|
||||
t: TFunction,
|
||||
): HealthProblem {
|
||||
return {
|
||||
id: `config:${section}:${message.key}:${idSuffix}`,
|
||||
source: "config",
|
||||
severity: message.severity,
|
||||
scope,
|
||||
scopeIsCamera,
|
||||
text: t(message.messageKey, {
|
||||
ns: "views/settings",
|
||||
...(message.values ?? {}),
|
||||
}),
|
||||
docLink: message.docLink,
|
||||
link: settingsLink(section, ctx.level, ctx.cameraName),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate every config message flagged for the Health tab against the saved,
|
||||
* resolved config. The rules stay in the section configs, so the settings
|
||||
* form and the Health tab can never disagree.
|
||||
*/
|
||||
/**
|
||||
* Whether a camera section still carries the global section's values. Only
|
||||
* keys the global block sets count: the resolved global detect leaves width
|
||||
* and height null while every camera has numbers, so a full JSON comparison
|
||||
* would never match.
|
||||
*/
|
||||
function inheritsGlobal(
|
||||
cameraSection: Record<string, unknown>,
|
||||
globalSection: Record<string, unknown>,
|
||||
): boolean {
|
||||
return Object.entries(globalSection).every(([key, globalValue]) => {
|
||||
if (globalValue === null || globalValue === undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const cameraValue = cameraSection[key];
|
||||
|
||||
if (
|
||||
typeof globalValue === "object" &&
|
||||
!Array.isArray(globalValue) &&
|
||||
typeof cameraValue === "object" &&
|
||||
cameraValue !== null &&
|
||||
!Array.isArray(cameraValue)
|
||||
) {
|
||||
return inheritsGlobal(
|
||||
cameraValue as Record<string, unknown>,
|
||||
globalValue as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
|
||||
return JSON.stringify(cameraValue) === JSON.stringify(globalValue);
|
||||
});
|
||||
}
|
||||
|
||||
export function evaluateConfigHealth(
|
||||
config: FrigateConfig,
|
||||
t: TFunction,
|
||||
): HealthProblem[] {
|
||||
const problems: HealthProblem[] = [];
|
||||
const firedGlobally = new Set<string>();
|
||||
const cameras = activeCameras(config);
|
||||
const record = config as unknown as Record<string, unknown>;
|
||||
|
||||
Object.keys(sectionConfigs).forEach((section) => {
|
||||
const globalMessages = healthMessages(section, "global");
|
||||
|
||||
if (globalMessages.length > 0) {
|
||||
const sectionData = record[section];
|
||||
// models is a list; every other section is one object
|
||||
const items: {
|
||||
formData: ConfigSectionData;
|
||||
scope?: string;
|
||||
idSuffix: string;
|
||||
}[] =
|
||||
section === "models" && Array.isArray(sectionData)
|
||||
? sectionData.map((model, index) => ({
|
||||
formData: model as ConfigSectionData,
|
||||
scope: t(
|
||||
`detectionModels.scenes.${(model as { scene?: string }).scene || "all"}`,
|
||||
{ ns: "views/settings" },
|
||||
),
|
||||
idSuffix: `model${index}`,
|
||||
}))
|
||||
: [
|
||||
{
|
||||
formData: (sectionData ?? {}) as ConfigSectionData,
|
||||
idSuffix: "global",
|
||||
},
|
||||
];
|
||||
|
||||
items.forEach(({ formData, scope, idSuffix }) => {
|
||||
const ctx: MessageConditionContext = {
|
||||
fullConfig: config,
|
||||
level: "global",
|
||||
formData,
|
||||
};
|
||||
globalMessages
|
||||
.filter((message) => isActive(message, ctx))
|
||||
.forEach((message) => {
|
||||
const problem = toProblem(
|
||||
message,
|
||||
section,
|
||||
ctx,
|
||||
scope,
|
||||
false,
|
||||
idSuffix,
|
||||
t,
|
||||
);
|
||||
firedGlobally.add(`${section}:${message.key}`);
|
||||
problems.push(problem);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const cameraMessages = healthMessages(section, "camera");
|
||||
|
||||
if (cameraMessages.length > 0) {
|
||||
const globalSection = (record[section] ?? {}) as Record<string, unknown>;
|
||||
|
||||
cameras.forEach((camera) => {
|
||||
const cameraRecord = camera as unknown as Record<string, unknown>;
|
||||
const sectionData = cameraRecord[section] ?? {};
|
||||
// cameras inherit global values, so a problem the global row already
|
||||
// states would repeat once per camera; a camera that overrides the
|
||||
// section keeps its own row and link
|
||||
const inherited = inheritsGlobal(
|
||||
sectionData as Record<string, unknown>,
|
||||
globalSection,
|
||||
);
|
||||
const ctx: MessageConditionContext = {
|
||||
fullConfig: config,
|
||||
fullCameraConfig: camera,
|
||||
level: "camera",
|
||||
cameraName: camera.name,
|
||||
formData: sectionData as ConfigSectionData,
|
||||
};
|
||||
cameraMessages
|
||||
.filter((message) => isActive(message, ctx))
|
||||
.forEach((message) => {
|
||||
const problem = toProblem(
|
||||
message,
|
||||
section,
|
||||
ctx,
|
||||
camera.name,
|
||||
true,
|
||||
camera.name,
|
||||
t,
|
||||
);
|
||||
|
||||
if (
|
||||
!(inherited && firedGlobally.has(`${section}:${message.key}`))
|
||||
) {
|
||||
problems.push(problem);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return problems;
|
||||
}
|
||||
@@ -0,0 +1,779 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import type {
|
||||
DetectionHardware,
|
||||
HwaccelRecommendation,
|
||||
} from "@/types/hardware";
|
||||
import type {
|
||||
CameraConfig,
|
||||
DetectionModelConfig,
|
||||
FrigateConfig,
|
||||
} from "@/types/frigateConfig";
|
||||
import type { FrigateStats, GpuVendor } from "@/types/stats";
|
||||
import { InferenceThreshold } from "@/types/graph";
|
||||
import { summarizeDevices } from "@/utils/detectionHardware";
|
||||
import { isReplayCamera } from "@/utils/cameraUtil";
|
||||
import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
|
||||
|
||||
export type HealthState = "ok" | "warning" | "error" | "unknown";
|
||||
|
||||
export type HardwareRow = {
|
||||
id: string;
|
||||
state: HealthState;
|
||||
label: string;
|
||||
/** muted text on the label line, what is actually running */
|
||||
detail?: string;
|
||||
/** reason line under the label, colored by state */
|
||||
message?: string;
|
||||
};
|
||||
|
||||
/** seconds after startup during which stats-based rules report unknown */
|
||||
export const STARTUP_WINDOW_S = 120;
|
||||
|
||||
// ---------------------------------------------------------------- detection
|
||||
|
||||
/**
|
||||
* Detector runner names exactly as the backend's runner_names() builds them:
|
||||
* every model's devices in config order, first occurrence is the raw device
|
||||
* string, the Nth repeat is "raw#N".
|
||||
*/
|
||||
export function runnerNames(models: DetectionModelConfig[]): string[] {
|
||||
const counts = new Map<string, number>();
|
||||
const names: string[] = [];
|
||||
|
||||
models.forEach((model) => {
|
||||
model.devices.forEach((raw) => {
|
||||
const count = (counts.get(raw) ?? 0) + 1;
|
||||
counts.set(raw, count);
|
||||
names.push(count === 1 ? raw : `${raw}#${count}`);
|
||||
});
|
||||
});
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
/** detectors the probe reports; anything else cannot be checked for presence */
|
||||
export const PROBED_DETECTORS = new Set([
|
||||
"cpu",
|
||||
"edgetpu",
|
||||
"hailo8l",
|
||||
"memryx",
|
||||
"openvino",
|
||||
"onnx",
|
||||
"tensorrt",
|
||||
"rknn",
|
||||
"axengine",
|
||||
"synaptics",
|
||||
]);
|
||||
|
||||
/** detectors that fall back to the CPU when no accelerator is present */
|
||||
const CPU_FALLBACK_DETECTORS = new Set(["onnx", "openvino"]);
|
||||
|
||||
export type DevicePresence = "present" | "unverified" | "absent";
|
||||
|
||||
/**
|
||||
* Whether a configured device string was found by the hardware probe.
|
||||
* "unverified" means the detector's hardware is present but the probe does
|
||||
* not enumerate this particular device (openvino:AUTO, rknn:0), so it must
|
||||
* not be reported as missing.
|
||||
*/
|
||||
export function devicePresence(
|
||||
device: string,
|
||||
hardware: DetectionHardware[],
|
||||
): DevicePresence {
|
||||
const [detector, ...rest] = device.split(":");
|
||||
const devicePart = rest.join(":");
|
||||
|
||||
if (detector === "cpu" || devicePart.toUpperCase() === "CPU") {
|
||||
return "present";
|
||||
}
|
||||
|
||||
if (!PROBED_DETECTORS.has(detector)) {
|
||||
return "unverified";
|
||||
}
|
||||
|
||||
const entries = hardware.filter((entry) => entry.detector === detector);
|
||||
const generic = devicePart === "" || devicePart.toUpperCase() === "AUTO";
|
||||
|
||||
if (entries.length === 0) {
|
||||
// a bare onnx or openvino runs on the CPU when nothing is attached, so
|
||||
// an empty probe is not proof of missing hardware for those
|
||||
return generic && CPU_FALLBACK_DETECTORS.has(detector)
|
||||
? "unverified"
|
||||
: "absent";
|
||||
}
|
||||
|
||||
if (generic) {
|
||||
return "present";
|
||||
}
|
||||
|
||||
const unitMatch = entries.some((entry) =>
|
||||
entry.units.some(
|
||||
(unit) =>
|
||||
unit.device === device ||
|
||||
unit.device.startsWith(`${device}:`) ||
|
||||
unit.device.startsWith(`${device}.`),
|
||||
),
|
||||
);
|
||||
|
||||
return unitMatch ? "present" : "unverified";
|
||||
}
|
||||
|
||||
type DetectionArgs = {
|
||||
models: DetectionModelConfig[];
|
||||
hardware: DetectionHardware[] | undefined;
|
||||
probeFailed: boolean;
|
||||
stats: FrigateStats | undefined;
|
||||
startup: boolean;
|
||||
t: TFunction;
|
||||
};
|
||||
|
||||
export function detectionRows({
|
||||
models,
|
||||
hardware,
|
||||
probeFailed,
|
||||
stats,
|
||||
startup,
|
||||
t,
|
||||
}: DetectionArgs): HardwareRow[] {
|
||||
const names = runnerNames(models);
|
||||
let cursor = 0;
|
||||
|
||||
return models.map((model, index) => {
|
||||
const modelRunners = names.slice(cursor, cursor + model.devices.length);
|
||||
cursor += model.devices.length;
|
||||
|
||||
const label = t(`detectionModels.scenes.${model.scene || "all"}`, {
|
||||
ns: "views/settings",
|
||||
});
|
||||
const id = `detection:${index}`;
|
||||
const detail = probeFailed
|
||||
? t("health.hardware.probeUnavailable", {
|
||||
ns: "views/system",
|
||||
})
|
||||
: summarizeDevices(hardware ?? [], model.devices);
|
||||
|
||||
if (!probeFailed && hardware) {
|
||||
const presence = new Map(
|
||||
model.devices.map((device) => [
|
||||
device,
|
||||
devicePresence(device, hardware),
|
||||
]),
|
||||
);
|
||||
const missing = [...presence]
|
||||
.filter(([, state]) => state === "absent")
|
||||
.map(([device]) => device);
|
||||
|
||||
if (missing.length > 0) {
|
||||
return {
|
||||
id,
|
||||
state: "error",
|
||||
label,
|
||||
detail,
|
||||
message: t("health.hardware.deviceNotFound", {
|
||||
ns: "views/system",
|
||||
devices: missing.join(", "),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// unverified devices are skipped by the presence rule; the runtime
|
||||
// rules below still decide the row
|
||||
}
|
||||
|
||||
if (startup || !stats) {
|
||||
return {
|
||||
id,
|
||||
state: "unknown",
|
||||
label,
|
||||
detail,
|
||||
message: t("health.hardware.justStarted", {
|
||||
ns: "views/system",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const missingRunner = modelRunners.find((name) => !stats.detectors[name]);
|
||||
|
||||
if (missingRunner) {
|
||||
return {
|
||||
id,
|
||||
state: "error",
|
||||
label,
|
||||
detail,
|
||||
message: t("health.hardware.detectorNotRunning", {
|
||||
ns: "views/system",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const slowest = Math.max(
|
||||
...modelRunners.map((name) => stats.detectors[name].inference_speed),
|
||||
);
|
||||
|
||||
if (slowest > InferenceThreshold.error) {
|
||||
return {
|
||||
id,
|
||||
state: "error",
|
||||
label,
|
||||
detail,
|
||||
message: t("health.hardware.inferenceVerySlow", {
|
||||
ns: "views/system",
|
||||
speed: slowest,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (slowest > InferenceThreshold.warning) {
|
||||
return {
|
||||
id,
|
||||
state: "warning",
|
||||
label,
|
||||
detail,
|
||||
message: t("health.hardware.inferenceSlow", {
|
||||
ns: "views/system",
|
||||
speed: slowest,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
state: "ok",
|
||||
label,
|
||||
detail: [
|
||||
detail,
|
||||
t("health.hardware.inferenceMs", {
|
||||
ns: "views/system",
|
||||
speed: slowest,
|
||||
}),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ hwaccel
|
||||
|
||||
export type HwaccelFamilyKey =
|
||||
| "nvidia"
|
||||
| "vaapi"
|
||||
| "intel-qsv"
|
||||
| "rkmpp"
|
||||
| "jetson"
|
||||
| "rpi";
|
||||
|
||||
export type HwaccelClass =
|
||||
| { kind: "none" }
|
||||
| { kind: "custom" }
|
||||
| { kind: "preset"; family: HwaccelFamilyKey };
|
||||
|
||||
const PRESET_FAMILIES: [string, HwaccelFamilyKey][] = [
|
||||
["preset-nvidia", "nvidia"],
|
||||
["preset-vaapi", "vaapi"],
|
||||
["preset-intel-qsv", "intel-qsv"],
|
||||
["preset-rk", "rkmpp"],
|
||||
["preset-jetson", "jetson"],
|
||||
["preset-rpi", "rpi"],
|
||||
];
|
||||
|
||||
export function hwaccelFamily(value: string | string[]): HwaccelClass {
|
||||
if (Array.isArray(value)) {
|
||||
return value.length === 0 ? { kind: "none" } : { kind: "custom" };
|
||||
}
|
||||
|
||||
// the backend resolves global and camera auto at startup; a literal auto
|
||||
// left on an input means no hardware decoding for it at runtime
|
||||
if (value === "" || value === "auto") {
|
||||
return { kind: "none" };
|
||||
}
|
||||
|
||||
const match = PRESET_FAMILIES.find(([prefix]) => value.startsWith(prefix));
|
||||
return match ? { kind: "preset", family: match[1] } : { kind: "custom" };
|
||||
}
|
||||
|
||||
const FAMILY_VENDORS: Record<HwaccelFamilyKey, GpuVendor[]> = {
|
||||
nvidia: ["nvidia"],
|
||||
jetson: ["nvidia"],
|
||||
"intel-qsv": ["intel"],
|
||||
vaapi: ["intel", "amd"],
|
||||
rkmpp: ["rockchip"],
|
||||
rpi: ["rpi"],
|
||||
};
|
||||
|
||||
function decoderUsage(
|
||||
family: HwaccelFamilyKey,
|
||||
stats: FrigateStats | undefined,
|
||||
): string | undefined {
|
||||
if (!stats?.gpu_usages) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const entry = Object.values(stats.gpu_usages).find(
|
||||
(gpu) =>
|
||||
gpu.vendor && FAMILY_VENDORS[family].includes(gpu.vendor) && gpu.dec,
|
||||
);
|
||||
return entry?.dec;
|
||||
}
|
||||
|
||||
function valueKey(value: string | string[]): string {
|
||||
return Array.isArray(value) ? JSON.stringify(value) : value;
|
||||
}
|
||||
|
||||
type HwaccelArgs = {
|
||||
config: FrigateConfig;
|
||||
hwaccel: HwaccelRecommendation | undefined;
|
||||
hwaccelFailed: boolean;
|
||||
stats: FrigateStats | undefined;
|
||||
t: TFunction;
|
||||
};
|
||||
|
||||
export function hwaccelRows({
|
||||
config,
|
||||
hwaccel,
|
||||
hwaccelFailed,
|
||||
stats,
|
||||
t,
|
||||
}: HwaccelArgs): HardwareRow[] {
|
||||
const cameras = activeCameras(config);
|
||||
const camerasByValue = new Map<
|
||||
string,
|
||||
{ value: string | string[]; cameras: string[] }
|
||||
>();
|
||||
|
||||
cameras.forEach((camera) => {
|
||||
const values: (string | string[])[] = [camera.ffmpeg.hwaccel_args ?? ""];
|
||||
camera.ffmpeg.inputs.forEach((input) => {
|
||||
if (input.hwaccel_args && input.hwaccel_args.length > 0) {
|
||||
values.push(input.hwaccel_args);
|
||||
}
|
||||
});
|
||||
|
||||
values.forEach((value) => {
|
||||
const key = valueKey(value);
|
||||
const entry = camerasByValue.get(key) ?? { value, cameras: [] };
|
||||
if (!entry.cameras.includes(camera.name)) {
|
||||
entry.cameras.push(camera.name);
|
||||
}
|
||||
camerasByValue.set(key, entry);
|
||||
});
|
||||
});
|
||||
|
||||
const familyName = (family: HwaccelFamilyKey | "none") =>
|
||||
t(`setupWizard.hwaccel.families.${family}`, { ns: "views/setup" });
|
||||
|
||||
return [...camerasByValue.entries()].map(([key, entry]) => {
|
||||
const id = `hwaccel:${key}`;
|
||||
const cameraList =
|
||||
entry.cameras.length === cameras.length
|
||||
? t("health.hardware.allCameras", {
|
||||
ns: "views/system",
|
||||
})
|
||||
: entry.cameras
|
||||
.map((name) => resolveCameraName(config, name))
|
||||
.join(", ");
|
||||
const classified = hwaccelFamily(entry.value);
|
||||
|
||||
if (hwaccelFailed) {
|
||||
return {
|
||||
id,
|
||||
state: "unknown",
|
||||
label:
|
||||
classified.kind === "preset" ? familyName(classified.family) : key,
|
||||
detail: cameraList,
|
||||
message: t("health.hardware.probeUnavailable", {
|
||||
ns: "views/system",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (classified.kind === "custom") {
|
||||
return {
|
||||
id,
|
||||
state: "unknown",
|
||||
label: t("health.hardware.customArgs", {
|
||||
ns: "views/system",
|
||||
}),
|
||||
detail: cameraList,
|
||||
message: t("health.hardware.customArgsNotVerified", {
|
||||
ns: "views/system",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const available = hwaccel?.available ?? [];
|
||||
|
||||
if (classified.kind === "none") {
|
||||
if (available.length > 0 && hwaccel?.recommended) {
|
||||
return {
|
||||
id,
|
||||
state: "warning",
|
||||
label: familyName("none"),
|
||||
detail: cameraList,
|
||||
message: t("health.hardware.hwaccelNotConfigured", {
|
||||
ns: "views/system",
|
||||
family: familyName(hwaccel.recommended as HwaccelFamilyKey),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return { id, state: "ok", label: familyName("none"), detail: cameraList };
|
||||
}
|
||||
|
||||
const label = familyName(classified.family);
|
||||
const present = available.some(
|
||||
(family) => family.key === classified.family,
|
||||
);
|
||||
|
||||
// a warning, not an error: the resolved config comes from go2rtc's
|
||||
// answer while `available` comes from the device probe, and the two
|
||||
// disagree on whole platform families
|
||||
if (!present) {
|
||||
return {
|
||||
id,
|
||||
state: "warning",
|
||||
label,
|
||||
detail: cameraList,
|
||||
message: t("health.hardware.hwaccelHardwareMissing", {
|
||||
ns: "views/system",
|
||||
family: label,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const dec = decoderUsage(classified.family, stats);
|
||||
const detail = dec
|
||||
? `${cameraList} · ${t("health.hardware.decoderUsage", {
|
||||
ns: "views/system",
|
||||
usage: dec,
|
||||
})}`
|
||||
: cameraList;
|
||||
|
||||
return { id, state: "ok", label, detail };
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- enrichments
|
||||
|
||||
const ANY_ACCELERATOR = [
|
||||
"onnx:nvidia",
|
||||
"onnx:amd",
|
||||
"openvino:GPU",
|
||||
"openvino:NPU",
|
||||
"rknn",
|
||||
"tensorrt",
|
||||
];
|
||||
|
||||
/**
|
||||
* Probe keys that satisfy a requested device string. AUTO and the implicit
|
||||
* defaults accept any accelerator; an explicit override must match its own
|
||||
* hardware. Undefined means the string is not one we can check.
|
||||
*/
|
||||
export function acceleratorKeysFor(
|
||||
requested: string,
|
||||
nvidiaOnly: boolean,
|
||||
): string[] | undefined {
|
||||
if (nvidiaOnly) {
|
||||
return ["onnx:nvidia"];
|
||||
}
|
||||
|
||||
const upper = requested.toUpperCase();
|
||||
|
||||
if (upper === "AUTO") {
|
||||
return ANY_ACCELERATOR;
|
||||
}
|
||||
|
||||
// ONNX Runtime puts a plain GPU request on whichever GPU it has; only an
|
||||
// indexed GPU.n names OpenVINO specifically
|
||||
if (upper === "GPU") {
|
||||
return ["openvino:GPU", "onnx:nvidia", "onnx:amd"];
|
||||
}
|
||||
|
||||
if (/^GPU\.\d+$/.test(upper)) {
|
||||
return ["openvino:GPU"];
|
||||
}
|
||||
|
||||
if (upper === "NPU") {
|
||||
return ["openvino:NPU"];
|
||||
}
|
||||
|
||||
if (upper.startsWith("CUDA") || upper.startsWith("TENSORRT")) {
|
||||
return ["onnx:nvidia"];
|
||||
}
|
||||
|
||||
if (upper.startsWith("ROCM") || upper.startsWith("MIGRAPHX")) {
|
||||
return ["onnx:amd"];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function acceleratorPresent(
|
||||
hardware: DetectionHardware[] | undefined,
|
||||
keys: string[],
|
||||
): boolean {
|
||||
return (hardware ?? []).some((entry) => keys.includes(entry.key));
|
||||
}
|
||||
|
||||
type EnrichmentSpec = {
|
||||
id: "semantic_search" | "face_recognition" | "lpr" | "audio_transcription";
|
||||
enabled: boolean;
|
||||
/** what the config asks for, after the backend's own defaults */
|
||||
requested: string;
|
||||
explicit: boolean;
|
||||
remote: boolean;
|
||||
nvidiaOnly: boolean;
|
||||
/** runtime device is not reported for this enrichment in v1 */
|
||||
presenceOnly: boolean;
|
||||
};
|
||||
|
||||
function enrichmentSpecs(config: FrigateConfig): EnrichmentSpec[] {
|
||||
const ss = config.semantic_search;
|
||||
const anyCameraTranscribes = Object.values(config.cameras).some(
|
||||
(camera) => camera.audio_transcription?.enabled,
|
||||
);
|
||||
|
||||
return [
|
||||
{
|
||||
id: "semantic_search",
|
||||
enabled: ss.enabled,
|
||||
requested: ss.device ?? (ss.model_size === "large" ? "GPU" : "CPU"),
|
||||
explicit: ss.device != null,
|
||||
remote: ss.model !== "jinav1" && ss.model !== "jinav2",
|
||||
nvidiaOnly: false,
|
||||
presenceOnly: false,
|
||||
},
|
||||
{
|
||||
id: "face_recognition",
|
||||
enabled: config.face_recognition.enabled,
|
||||
requested: config.face_recognition.device ?? "GPU",
|
||||
explicit: config.face_recognition.device != null,
|
||||
remote: false,
|
||||
nvidiaOnly: false,
|
||||
presenceOnly: false,
|
||||
},
|
||||
{
|
||||
id: "lpr",
|
||||
enabled: config.lpr.enabled,
|
||||
requested: config.lpr.device ?? "AUTO",
|
||||
explicit: config.lpr.device != null,
|
||||
remote: false,
|
||||
nvidiaOnly: false,
|
||||
presenceOnly: false,
|
||||
},
|
||||
{
|
||||
id: "audio_transcription",
|
||||
enabled: config.audio_transcription.enabled || anyCameraTranscribes,
|
||||
requested: config.audio_transcription.device ?? "CPU",
|
||||
explicit: true,
|
||||
remote: false,
|
||||
nvidiaOnly: true,
|
||||
presenceOnly: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
type EnrichmentArgs = {
|
||||
config: FrigateConfig;
|
||||
hardware: DetectionHardware[] | undefined;
|
||||
probeFailed: boolean;
|
||||
stats: FrigateStats | undefined;
|
||||
startup: boolean;
|
||||
t: TFunction;
|
||||
};
|
||||
|
||||
export function enrichmentRows({
|
||||
config,
|
||||
hardware,
|
||||
probeFailed,
|
||||
stats,
|
||||
startup,
|
||||
t,
|
||||
}: EnrichmentArgs): HardwareRow[] {
|
||||
return enrichmentSpecs(config)
|
||||
.filter((spec) => spec.enabled)
|
||||
.map((spec) => {
|
||||
const id = `enrichment:${spec.id}`;
|
||||
const label = t(`health.hardware.enrichments.${spec.id}`, {
|
||||
ns: "views/system",
|
||||
});
|
||||
|
||||
if (spec.remote) {
|
||||
return {
|
||||
id,
|
||||
state: "ok",
|
||||
label,
|
||||
detail: t("health.hardware.remoteProvider", {
|
||||
ns: "views/system",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (spec.requested.toUpperCase() === "CPU") {
|
||||
return { id, state: "ok", label, detail: "CPU" };
|
||||
}
|
||||
|
||||
if (probeFailed) {
|
||||
return {
|
||||
id,
|
||||
state: "unknown",
|
||||
label,
|
||||
message: t("health.hardware.probeUnavailable", {
|
||||
ns: "views/system",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// implicit defaults (GPU for face recognition and large semantic
|
||||
// search) accept any accelerator; only an explicit override is matched
|
||||
// against its own hardware
|
||||
const keys = spec.explicit
|
||||
? acceleratorKeysFor(spec.requested, spec.nvidiaOnly)
|
||||
: spec.nvidiaOnly
|
||||
? ["onnx:nvidia"]
|
||||
: ANY_ACCELERATOR;
|
||||
|
||||
if (!keys) {
|
||||
return {
|
||||
id,
|
||||
state: "unknown",
|
||||
label,
|
||||
message: t("health.hardware.unrecognizedDevice", {
|
||||
ns: "views/system",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const present = acceleratorPresent(hardware, keys);
|
||||
const runtime = startup
|
||||
? undefined
|
||||
: stats?.embeddings?.devices?.[spec.id];
|
||||
const runtimeIsCpu = !!runtime && runtime.toUpperCase().includes("CPU");
|
||||
|
||||
// a model that reports an accelerator is proof enough, whatever the
|
||||
// probe keys say
|
||||
if (runtime && !runtimeIsCpu) {
|
||||
return { id, state: "ok", label, detail: runtime };
|
||||
}
|
||||
|
||||
if (
|
||||
spec.explicit &&
|
||||
spec.requested.toUpperCase() !== "AUTO" &&
|
||||
hardware &&
|
||||
!present
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
state: "error",
|
||||
label,
|
||||
message: t("health.hardware.acceleratorMissing", {
|
||||
ns: "views/system",
|
||||
device: spec.requested,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (spec.presenceOnly) {
|
||||
return { id, state: "ok", label, detail: spec.requested };
|
||||
}
|
||||
|
||||
if (!runtime) {
|
||||
return {
|
||||
id,
|
||||
state: "unknown",
|
||||
label,
|
||||
message: t("health.hardware.modelNotRunYet", {
|
||||
ns: "views/system",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
runtimeIsCpu &&
|
||||
present &&
|
||||
spec.explicit &&
|
||||
spec.requested.toUpperCase() !== "AUTO"
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
state: "error",
|
||||
label,
|
||||
detail: "CPU",
|
||||
message: t("health.hardware.fellBackToCpu", {
|
||||
ns: "views/system",
|
||||
device: spec.requested,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (runtimeIsCpu && present) {
|
||||
return {
|
||||
id,
|
||||
state: "warning",
|
||||
label,
|
||||
detail: "CPU",
|
||||
message: t("health.hardware.cpuDespiteAccelerator", {
|
||||
ns: "views/system",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return { id, state: "ok", label, detail: runtimeIsCpu ? "CPU" : runtime };
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- camera connections
|
||||
|
||||
export type CameraConnectionCell = {
|
||||
camera: string;
|
||||
quality: "excellent" | "fair" | "poor" | "unusable";
|
||||
cameraFps: number;
|
||||
expectedFps: number;
|
||||
reconnects: number;
|
||||
stalls: number;
|
||||
};
|
||||
|
||||
/** enabled, non-replay cameras whose latest connection is not excellent */
|
||||
export function cameraConnectionCells(
|
||||
config: FrigateConfig,
|
||||
stats: FrigateStats | undefined,
|
||||
): CameraConnectionCell[] {
|
||||
if (!stats) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return activeCameras(config)
|
||||
.map((camera): CameraConnectionCell | undefined => {
|
||||
const cam = stats.cameras[camera.name];
|
||||
|
||||
if (
|
||||
!cam ||
|
||||
!cam.connection_quality ||
|
||||
cam.connection_quality === "excellent"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
camera: camera.name,
|
||||
quality: cam.connection_quality,
|
||||
cameraFps: cam.camera_fps,
|
||||
expectedFps: cam.expected_fps ?? 0,
|
||||
reconnects: cam.reconnects_last_hour ?? 0,
|
||||
stalls: cam.stalls_last_hour ?? 0,
|
||||
};
|
||||
})
|
||||
.filter((cell): cell is CameraConnectionCell => cell !== undefined);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- helpers
|
||||
|
||||
export function activeCameras(config: FrigateConfig): CameraConfig[] {
|
||||
return Object.values(config.cameras)
|
||||
.filter((camera) => camera.enabled && !isReplayCamera(camera.name))
|
||||
.sort((a, b) => a.ui.order - b.ui.order);
|
||||
}
|
||||
|
||||
export function isStartupWindow(stats: FrigateStats | undefined): boolean {
|
||||
return !!stats && stats.service.uptime < STARTUP_WINDOW_S;
|
||||
}
|
||||
@@ -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 ?? ""),
|
||||
);
|
||||
}
|
||||
@@ -5,3 +5,11 @@ export const getUnitSize = (MB: number) => {
|
||||
|
||||
return `${(MB / 1048576).toFixed(2)} TiB`;
|
||||
};
|
||||
|
||||
export const getUnitSizeFromBytes = (bytes: number) => {
|
||||
if (bytes === null || isNaN(bytes) || bytes < 0) return "Invalid number";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1048576) return `${(bytes / 1024).toFixed(2)} KiB`;
|
||||
|
||||
return getUnitSize(bytes / 1048576);
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { parseRestreamStreamName } from "@/components/config-form/theme/fields/streamSource";
|
||||
import type { CameraBrand, StreamRole, TestResult } from "@/types/cameraWizard";
|
||||
|
||||
export type StreamIssue = {
|
||||
type: "good" | "warning" | "error";
|
||||
message: string;
|
||||
/** stable key for the rule that fired, for filtering and tests */
|
||||
rule: string;
|
||||
};
|
||||
|
||||
export type StreamIssueInput = {
|
||||
url: string;
|
||||
roles: StreamRole[];
|
||||
brand?: CameraBrand;
|
||||
useFfmpeg?: boolean;
|
||||
restream?: boolean;
|
||||
testResult?: TestResult;
|
||||
};
|
||||
|
||||
type ProbeStream = {
|
||||
codec_type?: string;
|
||||
codec_name?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
avg_frame_rate?: string;
|
||||
};
|
||||
|
||||
export type FfprobeEntry = {
|
||||
return_code?: number;
|
||||
stdout?: { streams?: ProbeStream[] } | string;
|
||||
/** the backend sends a list of non-empty lines on failure */
|
||||
stderr?: string | string[];
|
||||
};
|
||||
|
||||
function errorText(stderr: string | string[] | undefined): string {
|
||||
const text = Array.isArray(stderr) ? stderr.join("\n") : stderr;
|
||||
return text?.trim() || "Unknown error";
|
||||
}
|
||||
|
||||
/** The human-readable end of an ffprobe error; the first lines are plumbing. */
|
||||
export function lastErrorLine(error: string | undefined): string {
|
||||
const lines = (error ?? "")
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
return lines[lines.length - 1] ?? "";
|
||||
}
|
||||
|
||||
/** Parse one entry of the ffprobe API response the way the wizard does. */
|
||||
export function ffprobeToTestResult(
|
||||
entry: FfprobeEntry | undefined,
|
||||
): TestResult {
|
||||
if (!entry || entry.return_code !== 0 || typeof entry.stdout !== "object") {
|
||||
return { success: false, error: errorText(entry?.stderr) };
|
||||
}
|
||||
|
||||
const streams = entry.stdout?.streams ?? [];
|
||||
const videoStream = streams.find(
|
||||
(s) =>
|
||||
s.codec_type === "video" ||
|
||||
s.codec_name?.includes("h264") ||
|
||||
s.codec_name?.includes("h265"),
|
||||
);
|
||||
const audioStream = streams.find(
|
||||
(s) =>
|
||||
s.codec_type === "audio" ||
|
||||
s.codec_name?.includes("aac") ||
|
||||
s.codec_name?.includes("mp3"),
|
||||
);
|
||||
|
||||
const resolution = videoStream
|
||||
? `${videoStream.width}x${videoStream.height}`
|
||||
: undefined;
|
||||
const fps = videoStream?.avg_frame_rate
|
||||
? parseFloat(videoStream.avg_frame_rate.split("/")[0]) /
|
||||
parseFloat(videoStream.avg_frame_rate.split("/")[1])
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
resolution,
|
||||
videoCodec: videoStream?.codec_name,
|
||||
audioCodec: audioStream?.codec_name,
|
||||
fps: fps && !isNaN(fps) ? fps : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** The wizard's Stream Validation rules, unchanged, over plain input. */
|
||||
export function getStreamIssues(
|
||||
input: StreamIssueInput,
|
||||
t: TFunction,
|
||||
): StreamIssue[] {
|
||||
const result: StreamIssue[] = [];
|
||||
const { roles, testResult } = input;
|
||||
|
||||
if (input.brand === "reolink") {
|
||||
const streamUrl = input.url.toLowerCase();
|
||||
if (streamUrl.startsWith("rtsp://")) {
|
||||
result.push({
|
||||
type: "warning",
|
||||
rule: "reolink-rtsp",
|
||||
message: t("cameraWizard.step4.issues.brands.reolink-rtsp", {
|
||||
ns: "views/settings",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (streamUrl.startsWith("http://") && !input.useFfmpeg) {
|
||||
result.push({
|
||||
type: "warning",
|
||||
rule: "reolink-http",
|
||||
message: t("cameraWizard.step4.issues.brands.reolink-http", {
|
||||
ns: "views/settings",
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (testResult?.videoCodec) {
|
||||
const videoCodec = testResult.videoCodec.toLowerCase();
|
||||
if (["h264", "h265", "hevc"].includes(videoCodec)) {
|
||||
result.push({
|
||||
type: "good",
|
||||
rule: "video-codec",
|
||||
message: t("cameraWizard.step4.issues.videoCodecGood", {
|
||||
ns: "views/settings",
|
||||
codec: testResult.videoCodec,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (roles.includes("record")) {
|
||||
if (testResult?.audioCodec) {
|
||||
const audioCodec = testResult.audioCodec.toLowerCase();
|
||||
if (audioCodec === "aac") {
|
||||
result.push({
|
||||
type: "good",
|
||||
rule: "audio-codec",
|
||||
message: t("cameraWizard.step4.issues.audioCodecGood", {
|
||||
ns: "views/settings",
|
||||
codec: testResult.audioCodec,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
type: "error",
|
||||
rule: "audio-codec-record",
|
||||
message: t("cameraWizard.step4.issues.audioCodecRecordError", {
|
||||
ns: "views/settings",
|
||||
}),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
result.push({
|
||||
type: "warning",
|
||||
rule: "no-audio",
|
||||
message: t("cameraWizard.step4.issues.noAudioWarning", {
|
||||
ns: "views/settings",
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (roles.includes("audio") && !testResult?.audioCodec) {
|
||||
result.push({
|
||||
type: "error",
|
||||
rule: "audio-required",
|
||||
message: t("cameraWizard.step4.issues.audioCodecRequired", {
|
||||
ns: "views/settings",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (roles.includes("record") && input.restream) {
|
||||
result.push({
|
||||
type: "warning",
|
||||
rule: "restream",
|
||||
message: t("cameraWizard.step4.issues.restreamingWarning", {
|
||||
ns: "views/settings",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (roles.includes("detect") && testResult) {
|
||||
const probedResolution = testResult.resolution;
|
||||
let probedWidth = 0;
|
||||
let probedHeight = 0;
|
||||
if (probedResolution) {
|
||||
const [w, h] = probedResolution.split("x").map(Number);
|
||||
if (!isNaN(w) && !isNaN(h)) {
|
||||
probedWidth = w;
|
||||
probedHeight = h;
|
||||
}
|
||||
}
|
||||
|
||||
if (probedWidth <= 0 || probedHeight <= 0) {
|
||||
result.push({
|
||||
type: "error",
|
||||
rule: "resolution-unknown",
|
||||
message: t("cameraWizard.step4.issues.resolutionUnknown", {
|
||||
ns: "views/settings",
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
const minDimension = Math.min(probedWidth, probedHeight);
|
||||
const maxDimension = Math.max(probedWidth, probedHeight);
|
||||
if (minDimension > 1080) {
|
||||
result.push({
|
||||
type: "warning",
|
||||
rule: "resolution-high",
|
||||
message: t("cameraWizard.step4.issues.resolutionHigh", {
|
||||
ns: "views/settings",
|
||||
resolution: probedResolution,
|
||||
}),
|
||||
});
|
||||
} else if (maxDimension < 640) {
|
||||
result.push({
|
||||
type: "error",
|
||||
rule: "resolution-low",
|
||||
message: t("cameraWizard.step4.issues.resolutionLow", {
|
||||
ns: "views/settings",
|
||||
resolution: probedResolution,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
input.brand === "dahua" &&
|
||||
roles.includes("detect") &&
|
||||
input.url.includes("subtype=1")
|
||||
) {
|
||||
result.push({
|
||||
type: "warning",
|
||||
rule: "dahua-substream",
|
||||
message: t("cameraWizard.step4.issues.dahua.substreamWarning", {
|
||||
ns: "views/settings",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
input.brand === "hikvision" &&
|
||||
roles.includes("detect") &&
|
||||
input.url.includes("/102")
|
||||
) {
|
||||
result.push({
|
||||
type: "warning",
|
||||
rule: "hikvision-substream",
|
||||
message: t("cameraWizard.step4.issues.hikvision.substreamWarning", {
|
||||
ns: "views/settings",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* For an input that points at a go2rtc restream, find the camera URL behind
|
||||
* it. Returns undefined when the path is not a restream or the stream is not
|
||||
* in the go2rtc config. The /config response redacts credentials in these
|
||||
* sources, so the URL is only good for pattern matching.
|
||||
*/
|
||||
export function resolveRestreamSource(
|
||||
path: string,
|
||||
streams: Record<string, string | string[]> | undefined,
|
||||
): { url: string; useFfmpeg: boolean } | undefined {
|
||||
const name = parseRestreamStreamName(path);
|
||||
|
||||
if (!name || !streams) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const configured = streams[name];
|
||||
const sources = Array.isArray(configured)
|
||||
? configured
|
||||
: configured
|
||||
? [configured]
|
||||
: [];
|
||||
const source = sources.find((s) => !s.startsWith(`ffmpeg:${name}`));
|
||||
|
||||
if (!source) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (source.startsWith("ffmpeg:")) {
|
||||
return {
|
||||
url: source.slice("ffmpeg:".length).split("#")[0],
|
||||
useFfmpeg: true,
|
||||
};
|
||||
}
|
||||
|
||||
return { url: source, useFfmpeg: false };
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||
import { MediaSyncResults, MediaSyncStats } from "@/types/ws";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import { Link } from "react-router-dom";
|
||||
import { getUnitSizeFromBytes } from "@/utils/storageUtil";
|
||||
|
||||
export default function MediaSyncSettingsView() {
|
||||
const { t } = useTranslation("views/settings");
|
||||
@@ -376,6 +377,22 @@ export default function MediaSyncSettingsView() {
|
||||
{mediaStats.orphans_deleted}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{currentJob?.dry_run
|
||||
? t(
|
||||
"maintenance.sync.resultsFields.spaceToReclaim",
|
||||
)
|
||||
: t(
|
||||
"maintenance.sync.resultsFields.spaceReclaimed",
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
{getUnitSizeFromBytes(
|
||||
mediaStats.bytes_reclaimed,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{mediaStats.aborted && (
|
||||
<div className="flex items-center gap-2 text-destructive">
|
||||
<LuX className="size-4" />
|
||||
@@ -449,6 +466,22 @@ export default function MediaSyncSettingsView() {
|
||||
{mediaSyncResults.totals.orphans_deleted}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{currentJob?.dry_run
|
||||
? t(
|
||||
"maintenance.sync.resultsFields.spaceToReclaim",
|
||||
)
|
||||
: t(
|
||||
"maintenance.sync.resultsFields.spaceReclaimed",
|
||||
)}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{getUnitSizeFromBytes(
|
||||
mediaSyncResults.totals.bytes_reclaimed,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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_");
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import HardwarePane from "@/components/health/HardwarePane";
|
||||
import NoticesPane from "@/components/health/NoticesPane";
|
||||
|
||||
export default function HealthMetrics() {
|
||||
return (
|
||||
<div className="scrollbar-container mt-4 flex size-full flex-col gap-4 overflow-y-auto">
|
||||
<NoticesPane />
|
||||
<HardwarePane />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user