mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-01 20:36:51 +03:00
Improve System Health pane (#24188)
* build out system health pane * tweaks * fixes * fix notice link so it opens the correct camera * tweak language
This commit is contained in:
committed by
Nicolas Mowen
parent
6d33b31bc6
commit
70ce193e09
@@ -185,3 +185,5 @@ Filters and masks only hide the incorrect result - they don't teach Frigate what
|
|||||||
### Where do I see problems Frigate has detected?
|
### 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.
|
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_decoded = str(ffprobe.stderr)
|
||||||
|
|
||||||
stderr_lines = [
|
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 = {
|
result = {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class DataProcessorMetrics:
|
|||||||
object_desc_dps: ValueProxy[float]
|
object_desc_dps: ValueProxy[float]
|
||||||
classification_speeds: DictProxy[str, ValueProxy[float]]
|
classification_speeds: DictProxy[str, ValueProxy[float]]
|
||||||
classification_cps: 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]):
|
def __init__(self, manager: SyncManager, custom_classification_models: list[str]):
|
||||||
self.image_embeddings_speed = manager.Value("d", 0.0)
|
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.object_desc_dps = manager.Value("d", 0.0)
|
||||||
self.classification_speeds = manager.dict()
|
self.classification_speeds = manager.dict()
|
||||||
self.classification_cps = manager.dict()
|
self.classification_cps = manager.dict()
|
||||||
|
self.runtime_devices = manager.dict()
|
||||||
|
|
||||||
if custom_classification_models:
|
if custom_classification_models:
|
||||||
for key in 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
|
# Process-wide lock serializing all OpenVINO compile/inference calls
|
||||||
_OPENVINO_LOCK = threading.Lock()
|
_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:
|
def is_arm64_platform() -> bool:
|
||||||
"""Check if we're running on an ARM platform."""
|
"""Check if we're running on an ARM platform."""
|
||||||
@@ -117,6 +147,12 @@ class BaseModelRunner(ABC):
|
|||||||
"""Run inference with the model."""
|
"""Run inference with the model."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def device_name(self) -> str:
|
||||||
|
"""Short label of the device the model actually loaded on."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ONNXModelRunner(BaseModelRunner):
|
class ONNXModelRunner(BaseModelRunner):
|
||||||
"""Run ONNX models using ONNX Runtime."""
|
"""Run ONNX models using ONNX Runtime."""
|
||||||
@@ -174,6 +210,18 @@ class ONNXModelRunner(BaseModelRunner):
|
|||||||
|
|
||||||
return self.ort.run(None, input)
|
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):
|
class CudaGraphRunner(BaseModelRunner):
|
||||||
"""Encapsulates CUDA Graph capture and replay using ONNX Runtime IOBinding.
|
"""Encapsulates CUDA Graph capture and replay using ONNX Runtime IOBinding.
|
||||||
@@ -261,6 +309,10 @@ class CudaGraphRunner(BaseModelRunner):
|
|||||||
self._session.run_with_iobinding(self._io_binding, ro)
|
self._session.run_with_iobinding(self._io_binding, ro)
|
||||||
return self._io_binding.copy_outputs_to_cpu()
|
return self._io_binding.copy_outputs_to_cpu()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_name(self) -> str:
|
||||||
|
return "CUDA"
|
||||||
|
|
||||||
|
|
||||||
class OpenVINOModelRunner(BaseModelRunner):
|
class OpenVINOModelRunner(BaseModelRunner):
|
||||||
"""OpenVINO model runner that handles inference efficiently."""
|
"""OpenVINO model runner that handles inference efficiently."""
|
||||||
@@ -337,6 +389,8 @@ class OpenVINOModelRunner(BaseModelRunner):
|
|||||||
if device == "NPU" and OpenVINOModelRunner.is_detection_model(model_type):
|
if device == "NPU" and OpenVINOModelRunner.is_detection_model(model_type):
|
||||||
compile_config["NPU_TURBO"] = "YES"
|
compile_config["NPU_TURBO"] = "YES"
|
||||||
|
|
||||||
|
self.compiled_device = device
|
||||||
|
|
||||||
# Compile model under the shared lock
|
# Compile model under the shared lock
|
||||||
with _OPENVINO_LOCK:
|
with _OPENVINO_LOCK:
|
||||||
try:
|
try:
|
||||||
@@ -368,6 +422,22 @@ class OpenVINOModelRunner(BaseModelRunner):
|
|||||||
# model is complex and has dynamic shape
|
# model is complex and has dynamic shape
|
||||||
pass
|
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]:
|
def get_input_names(self) -> list[str]:
|
||||||
"""Get input names for the model."""
|
"""Get input names for the model."""
|
||||||
return [input.get_any_name() for input in self.compiled_model.inputs]
|
return [input.get_any_name() for input in self.compiled_model.inputs]
|
||||||
@@ -515,6 +585,10 @@ class RKNNModelRunner(BaseModelRunner):
|
|||||||
logger.error(f"Error loading RKNN model: {e}")
|
logger.error(f"Error loading RKNN model: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_name(self) -> str:
|
||||||
|
return "RKNN"
|
||||||
|
|
||||||
def get_input_names(self) -> list[str]:
|
def get_input_names(self) -> list[str]:
|
||||||
"""Get input names for the model."""
|
"""Get input names for the model."""
|
||||||
# For detection models, we typically use "input" as the default input name
|
# For detection models, we typically use "input" as the default input name
|
||||||
@@ -595,6 +669,14 @@ class RKNNModelRunner(BaseModelRunner):
|
|||||||
pass
|
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(
|
def get_optimized_runner(
|
||||||
model_path: str, device: str | None, model_type: str, **kwargs
|
model_path: str, device: str | None, model_type: str, **kwargs
|
||||||
) -> BaseModelRunner:
|
) -> BaseModelRunner:
|
||||||
@@ -605,7 +687,7 @@ def get_optimized_runner(
|
|||||||
rknn_path = auto_convert_model(model_path)
|
rknn_path = auto_convert_model(model_path)
|
||||||
|
|
||||||
if rknn_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)
|
providers, options = get_ort_providers(device == "CPU", device, **kwargs)
|
||||||
|
|
||||||
@@ -614,7 +696,11 @@ def get_optimized_runner(
|
|||||||
# In other images we will get CUDA / ROCm which are preferred over OpenVINO
|
# 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
|
# There is currently no way to prioritize OpenVINO over CUDA / ROCm in these images
|
||||||
if device != "CPU" and is_openvino_gpu_npu_available():
|
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 (
|
if (
|
||||||
CudaGraphRunner.is_model_supported(model_type)
|
CudaGraphRunner.is_model_supported(model_type)
|
||||||
@@ -624,13 +710,17 @@ def get_optimized_runner(
|
|||||||
**options[0],
|
**options[0],
|
||||||
"enable_cuda_graph": True,
|
"enable_cuda_graph": True,
|
||||||
}
|
}
|
||||||
return CudaGraphRunner(
|
return _record_runner(
|
||||||
ort.InferenceSession(
|
model_path,
|
||||||
model_path,
|
model_type,
|
||||||
providers=providers,
|
CudaGraphRunner(
|
||||||
provider_options=options,
|
ort.InferenceSession(
|
||||||
|
model_path,
|
||||||
|
providers=providers,
|
||||||
|
provider_options=options,
|
||||||
|
),
|
||||||
|
options[0]["device_id"],
|
||||||
),
|
),
|
||||||
options[0]["device_id"],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -642,12 +732,16 @@ def get_optimized_runner(
|
|||||||
providers.pop(0)
|
providers.pop(0)
|
||||||
options.pop(0)
|
options.pop(0)
|
||||||
|
|
||||||
return ONNXModelRunner(
|
return _record_runner(
|
||||||
ort.InferenceSession(
|
model_path,
|
||||||
model_path,
|
model_type,
|
||||||
sess_options=get_ort_session_options(model_type),
|
ONNXModelRunner(
|
||||||
providers=providers,
|
ort.InferenceSession(
|
||||||
provider_options=options,
|
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.data_processing.types import DataProcessorMetrics, PostProcessDataEnum
|
||||||
from frigate.db.sqlitevecq import SqliteVecQueueDatabase
|
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 (
|
from frigate.events.types import (
|
||||||
EventStateEnum,
|
EventStateEnum,
|
||||||
EventTypeEnum,
|
EventTypeEnum,
|
||||||
@@ -101,6 +103,7 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
super().__init__(name="embeddings_maintainer")
|
super().__init__(name="embeddings_maintainer")
|
||||||
self.config = config
|
self.config = config
|
||||||
self.metrics = metrics
|
self.metrics = metrics
|
||||||
|
self._published_devices: dict[str, str] = {}
|
||||||
self.embeddings = None
|
self.embeddings = None
|
||||||
self.config_updater = CameraConfigUpdateSubscriber(
|
self.config_updater = CameraConfigUpdateSubscriber(
|
||||||
self.config,
|
self.config,
|
||||||
@@ -337,6 +340,7 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
self._expire_dedicated_lpr()
|
self._expire_dedicated_lpr()
|
||||||
self._process_finalized()
|
self._process_finalized()
|
||||||
self._process_event_metadata()
|
self._process_event_metadata()
|
||||||
|
self._publish_runtime_devices()
|
||||||
|
|
||||||
# Shutdown deferred processors
|
# Shutdown deferred processors
|
||||||
for processor in self.realtime_processors:
|
for processor in self.realtime_processors:
|
||||||
@@ -738,6 +742,19 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
if isinstance(processor, ReviewDescriptionProcessor):
|
if isinstance(processor, ReviewDescriptionProcessor):
|
||||||
processor.process_data(review_updates, PostProcessDataEnum.review)
|
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):
|
def _process_event_metadata(self):
|
||||||
# Check for regenerate description requests
|
# Check for regenerate description requests
|
||||||
(topic, payload) = self.event_metadata_subscriber.check_for_update()
|
(topic, payload) = self.event_metadata_subscriber.check_for_update()
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import os
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from frigate.const import MODEL_CACHE_DIR
|
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.embeddings.types import EnrichmentModelTypeEnum
|
||||||
from frigate.log import suppress_stderr_during
|
from frigate.log import suppress_stderr_during
|
||||||
from frigate.util.downloader import ModelDownloader
|
from frigate.util.downloader import ModelDownloader
|
||||||
@@ -62,13 +65,18 @@ class FaceNetEmbedding(BaseEmbedding):
|
|||||||
if self.downloader:
|
if self.downloader:
|
||||||
self.downloader.wait_for_download()
|
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
|
# Suppress TFLite delegate creation messages that bypass Python logging
|
||||||
with suppress_stderr_during("tflite_interpreter_init"):
|
with suppress_stderr_during("tflite_interpreter_init"):
|
||||||
self.runner = Interpreter(
|
self.runner = Interpreter(model_path=model_path, num_threads=2)
|
||||||
model_path=os.path.join(MODEL_CACHE_DIR, "facedet/facenet.tflite"),
|
|
||||||
num_threads=2,
|
|
||||||
)
|
|
||||||
self.runner.allocate_tensors()
|
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_input_details = self.runner.get_input_details()
|
||||||
self.tensor_output_details = self.runner.get_output_details()
|
self.tensor_output_details = self.runner.get_output_details()
|
||||||
|
|
||||||
|
|||||||
@@ -13,3 +13,37 @@ class EnrichmentModelTypeEnum(str, Enum):
|
|||||||
jina_v2 = "jina_v2"
|
jina_v2 = "jina_v2"
|
||||||
paddleocr = "paddleocr"
|
paddleocr = "paddleocr"
|
||||||
yolov9_license_plate = "yolov9_license_plate"
|
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
|
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(
|
def stats_snapshot(
|
||||||
config: FrigateConfig,
|
config: FrigateConfig,
|
||||||
stats_tracking: StatsTrackingTypes,
|
stats_tracking: StatsTrackingTypes,
|
||||||
@@ -209,78 +289,9 @@ def stats_snapshot(
|
|||||||
stats["skipped_fps"] = round(total_skipped_fps, 2)
|
stats["skipped_fps"] = round(total_skipped_fps, 2)
|
||||||
stats["detection_fps"] = round(total_detection_fps, 2)
|
stats["detection_fps"] = round(total_detection_fps, 2)
|
||||||
|
|
||||||
stats["embeddings"] = {}
|
stats["embeddings"] = embeddings_stats(
|
||||||
|
config, stats_tracking.get("embeddings_metrics")
|
||||||
# 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
|
|
||||||
)
|
|
||||||
|
|
||||||
hardware_stats.update_stats(stats)
|
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)],
|
[n["scope"] for n in self._notice_calls(sibling.requestor)],
|
||||||
["paddleocr-onnx/det.onnx"],
|
["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):
|
def _download_models(self):
|
||||||
for file_name in self.file_names:
|
for file_name in self.file_names:
|
||||||
path = os.path.join(self.download_path, file_name)
|
path = os.path.join(self.download_path, file_name)
|
||||||
@@ -98,6 +104,7 @@ class ModelDownloader:
|
|||||||
self.download_func(path)
|
self.download_func(path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._report_failure(file_name, _first_line(e))
|
self._report_failure(file_name, _first_line(e))
|
||||||
|
self._send_state(file_name, ModelStatusTypesEnum.error)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
@@ -105,16 +112,11 @@ class ModelDownloader:
|
|||||||
file_name,
|
file_name,
|
||||||
last_download_error.pop(path, "download failed"),
|
last_download_error.pop(path, "download failed"),
|
||||||
)
|
)
|
||||||
|
self._send_state(file_name, ModelStatusTypesEnum.error)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
self._resolve_failure(file_name)
|
self._resolve_failure(file_name)
|
||||||
self.requestor.send_data(
|
self._send_state(file_name, ModelStatusTypesEnum.downloaded)
|
||||||
UPDATE_MODEL_STATE,
|
|
||||||
{
|
|
||||||
"model": f"{self.model_name}-{file_name}",
|
|
||||||
"state": ModelStatusTypesEnum.downloaded,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.complete_func:
|
if self.complete_func:
|
||||||
self.complete_func()
|
self.complete_func()
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
type ApiMockOverrides,
|
type ApiMockOverrides,
|
||||||
} from "../helpers/api-mocker";
|
} from "../helpers/api-mocker";
|
||||||
import { WsMocker } from "../helpers/ws-mocker";
|
import { WsMocker } from "../helpers/ws-mocker";
|
||||||
|
import { statsFactory } from "./mock-data/stats";
|
||||||
import { installErrorCollector, type ErrorCollector } from "./error-collector";
|
import { installErrorCollector, type ErrorCollector } from "./error-collector";
|
||||||
import { GLOBAL_ALLOWLIST } from "./error-allowlist";
|
import { GLOBAL_ALLOWLIST } from "./error-allowlist";
|
||||||
|
|
||||||
@@ -53,7 +54,7 @@ export class FrigateApp {
|
|||||||
return route.fallback();
|
return route.fallback();
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.ws.install(this.page);
|
await this.ws.install(this.page, statsFactory(overrides?.stats));
|
||||||
await this.api.install(overrides);
|
await this.api.install(overrides);
|
||||||
// media goes last so its per-event routes win over the broader
|
// media goes last so its per-event routes win over the broader
|
||||||
// `**/api/events**` list route, which otherwise answers thumbnail and
|
// `**/api/events**` list route, which otherwise answers thumbnail and
|
||||||
|
|||||||
@@ -41,6 +41,13 @@ export const BASE_STATS = {
|
|||||||
},
|
},
|
||||||
gpu_usages: {},
|
gpu_usages: {},
|
||||||
npu_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: {},
|
processes: {},
|
||||||
service: {
|
service: {
|
||||||
last_updated: Date.now() / 1000,
|
last_updated: Date.now() / 1000,
|
||||||
@@ -57,6 +64,13 @@ export const BASE_STATS = {
|
|||||||
used: 500000000,
|
used: 500000000,
|
||||||
mount_type: "tmpfs",
|
mount_type: "tmpfs",
|
||||||
},
|
},
|
||||||
|
"/dev/shm": {
|
||||||
|
free: 98,
|
||||||
|
total: 128,
|
||||||
|
used: 30,
|
||||||
|
mount_type: "tmpfs",
|
||||||
|
min_shm: 64,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
uptime: 86400,
|
uptime: 86400,
|
||||||
latest_version: "0.15.0",
|
latest_version: "0.15.0",
|
||||||
|
|||||||
@@ -50,8 +50,29 @@ export interface ApiMockOverrides {
|
|||||||
users?: { username: string; role: string }[];
|
users?: { username: string; role: string }[];
|
||||||
notices?: unknown[];
|
notices?: unknown[];
|
||||||
noticeStats?: 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 {
|
export class ApiMocker {
|
||||||
private page: Page;
|
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
|
// 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.
|
// for /api/notices/stats; the list glob does not match a sub-path anyway.
|
||||||
await this.page.route("**/api/notices", (route) =>
|
await this.page.route("**/api/notices", (route) =>
|
||||||
|
|||||||
@@ -12,12 +12,16 @@ import { cameraActivityPayload } from "../fixtures/mock-data/camera-activity";
|
|||||||
export class WsMocker {
|
export class WsMocker {
|
||||||
private mockWs: WebSocketRoute | null = null;
|
private mockWs: WebSocketRoute | null = null;
|
||||||
private cameras: string[];
|
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"]) {
|
constructor(cameras: string[] = ["front_door", "backyard", "garage"]) {
|
||||||
this.cameras = cameras;
|
this.cameras = cameras;
|
||||||
}
|
}
|
||||||
|
|
||||||
async install(page: Page) {
|
async install(page: Page, stats?: unknown) {
|
||||||
|
this.stats = stats;
|
||||||
await page.routeWebSocket("**/ws", (ws) => {
|
await page.routeWebSocket("**/ws", (ws) => {
|
||||||
this.mockWs = ws;
|
this.mockWs = ws;
|
||||||
|
|
||||||
@@ -42,35 +46,37 @@ export class WsMocker {
|
|||||||
// Send initial stats
|
// Send initial stats
|
||||||
this.send(
|
this.send(
|
||||||
"stats",
|
"stats",
|
||||||
JSON.stringify({
|
JSON.stringify(
|
||||||
cameras: Object.fromEntries(
|
this.stats ?? {
|
||||||
this.cameras.map((c) => [
|
cameras: Object.fromEntries(
|
||||||
c,
|
this.cameras.map((c) => [
|
||||||
{
|
c,
|
||||||
camera_fps: 5,
|
{
|
||||||
detection_fps: 5,
|
camera_fps: 5,
|
||||||
process_fps: 5,
|
detection_fps: 5,
|
||||||
skipped_fps: 0,
|
process_fps: 5,
|
||||||
detection_enabled: 1,
|
skipped_fps: 0,
|
||||||
connection_quality: "excellent",
|
detection_enabled: 1,
|
||||||
},
|
connection_quality: "excellent",
|
||||||
]),
|
},
|
||||||
),
|
]),
|
||||||
service: {
|
),
|
||||||
last_updated: Date.now() / 1000,
|
service: {
|
||||||
uptime: 86400,
|
last_updated: Date.now() / 1000,
|
||||||
version: "0.15.0-test",
|
uptime: 86400,
|
||||||
latest_version: "0.15.0",
|
version: "0.15.0-test",
|
||||||
storage: {},
|
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);
|
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 = {
|
const STATE_NOTICE = {
|
||||||
id: "ffmpeg_crash_loop:front_door",
|
id: "ffmpeg_crash_loop:front_door",
|
||||||
kind: "ffmpeg_crash_loop",
|
kind: "ffmpeg_crash_loop",
|
||||||
@@ -41,6 +44,7 @@ test.describe("System — Health tab @medium", () => {
|
|||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.installDefaults({
|
await frigateApp.installDefaults({
|
||||||
|
stats: QUIET_STATS,
|
||||||
notices: [STATE_NOTICE, EVENT_NOTICE],
|
notices: [STATE_NOTICE, EVENT_NOTICE],
|
||||||
noticeStats: [
|
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)).toHaveAttribute("data-severity", "error");
|
||||||
await expect(rows.nth(0)).toContainText("ffmpeg has crashed 6 times");
|
await expect(rows.nth(0)).toContainText("ffmpeg has crashed 6 times");
|
||||||
await expect(rows.nth(0)).toContainText("14 times since");
|
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(
|
await expect(
|
||||||
rows.nth(0).getByRole("button", { name: "Dismiss" }),
|
rows.nth(0).getByRole("button", { name: "Dismiss" }),
|
||||||
).toHaveCount(0);
|
).toHaveCount(0);
|
||||||
@@ -78,7 +80,10 @@ test.describe("System — Health tab @medium", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("dismiss posts and removes the row", async ({ frigateApp }) => {
|
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
|
// the list shrinks after the dismiss so the refetch shows the row gone
|
||||||
let dismissed = false;
|
let dismissed = false;
|
||||||
@@ -105,14 +110,18 @@ test.describe("System — Health tab @medium", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
frigateApp.page.locator("[data-testid^='health-problem-']"),
|
frigateApp.page.locator("[data-testid^='health-problem-']"),
|
||||||
).toHaveCount(0, { timeout: 5_000 });
|
).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 }) => {
|
test("empty state with no notices", async ({ frigateApp }) => {
|
||||||
await frigateApp.installDefaults();
|
await frigateApp.installDefaults({ stats: QUIET_STATS });
|
||||||
await frigateApp.goto("/system#health");
|
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,
|
timeout: 15_000,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -121,6 +130,7 @@ test.describe("System — Health tab @medium", () => {
|
|||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.installDefaults({
|
await frigateApp.installDefaults({
|
||||||
|
stats: QUIET_STATS,
|
||||||
notices: [
|
notices: [
|
||||||
{
|
{
|
||||||
id: "update_available",
|
id: "update_available",
|
||||||
@@ -157,7 +167,10 @@ test.describe("System — Health tab mobile @medium @mobile", () => {
|
|||||||
test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only");
|
test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only");
|
||||||
|
|
||||||
test("notices render at mobile viewport", async ({ frigateApp }) => {
|
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 frigateApp.goto("/system#health");
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -167,3 +180,648 @@ test.describe("System — Health tab mobile @medium @mobile", () => {
|
|||||||
).toBeVisible({ timeout: 15_000 });
|
).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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
"title": "Health",
|
"title": "Health",
|
||||||
"notices": {
|
"notices": {
|
||||||
"title": "Notices",
|
"title": "Notices",
|
||||||
"empty": "No notices",
|
"empty": "Your Frigate installation is healthy",
|
||||||
"dismiss": "Dismiss",
|
"dismiss": "Dismiss",
|
||||||
"openSettings": "Open settings",
|
"openSettings": "Open settings",
|
||||||
"openLink": "Open link",
|
"openLink": "Open link",
|
||||||
@@ -31,7 +31,61 @@
|
|||||||
"model_download_failed": "Downloading {{file}} failed: {{error}}",
|
"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)",
|
"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"
|
"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": {
|
"logs": {
|
||||||
|
|||||||
@@ -187,10 +187,20 @@ export default function Statusbar() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="no-scrollbar flex h-full max-w-[50%] items-center gap-2 overflow-x-auto">
|
<div className="no-scrollbar flex h-full max-w-[50%] items-center gap-2 overflow-x-auto">
|
||||||
{Object.entries(messages).length === 0 ? (
|
{Object.entries(messages).length === 0 ? (
|
||||||
<div className="flex items-center gap-2 text-sm">
|
isAdmin ? (
|
||||||
<FaCheck className="size-3 text-green-500" />
|
<Link
|
||||||
{t("stats.healthy")}
|
to="/system#health"
|
||||||
</div>
|
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]) => (
|
Object.entries(messages).map(([key, messageArray]) => (
|
||||||
<div key={key} className="flex h-full items-center gap-2">
|
<div key={key} className="flex h-full items-center gap-2">
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const audio: SectionConfigOverrides = {
|
|||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
key: "no-audio-role",
|
key: "no-audio-role",
|
||||||
|
health: (ctx) => ctx.fullCameraConfig?.audio?.enabled === true,
|
||||||
messageKey: "configMessages.audio.noAudioRole",
|
messageKey: "configMessages.audio.noAudioRole",
|
||||||
severity: "warning",
|
severity: "warning",
|
||||||
condition: (ctx) => {
|
condition: (ctx) => {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ const audioTranscription: SectionConfigOverrides = {
|
|||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
key: "audio-detection-disabled",
|
key: "audio-detection-disabled",
|
||||||
|
health: (ctx) =>
|
||||||
|
ctx.fullCameraConfig?.audio_transcription?.enabled === true,
|
||||||
messageKey: "configMessages.audioTranscription.audioDetectionDisabled",
|
messageKey: "configMessages.audioTranscription.audioDetectionDisabled",
|
||||||
severity: "warning",
|
severity: "warning",
|
||||||
condition: (ctx) => {
|
condition: (ctx) => {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const detect: SectionConfigOverrides = {
|
|||||||
fieldMessages: [
|
fieldMessages: [
|
||||||
{
|
{
|
||||||
key: "detect-resolution-not-multiple-of-four",
|
key: "detect-resolution-not-multiple-of-four",
|
||||||
|
health: true,
|
||||||
field: "width",
|
field: "width",
|
||||||
position: "before",
|
position: "before",
|
||||||
messageKey: "configMessages.detect.resolutionShouldBeMultipleOfFour",
|
messageKey: "configMessages.detect.resolutionShouldBeMultipleOfFour",
|
||||||
@@ -46,6 +47,7 @@ const detect: SectionConfigOverrides = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "detect-resolution-high",
|
key: "detect-resolution-high",
|
||||||
|
health: true,
|
||||||
field: "width",
|
field: "width",
|
||||||
position: "before",
|
position: "before",
|
||||||
messageKey: "configMessages.detect.resolutionHigh",
|
messageKey: "configMessages.detect.resolutionHigh",
|
||||||
@@ -61,6 +63,7 @@ const detect: SectionConfigOverrides = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "detect-square-resolution",
|
key: "detect-square-resolution",
|
||||||
|
health: true,
|
||||||
field: "width",
|
field: "width",
|
||||||
position: "before",
|
position: "before",
|
||||||
messageKey: "configMessages.detect.squareResolution",
|
messageKey: "configMessages.detect.squareResolution",
|
||||||
@@ -112,6 +115,7 @@ const detect: SectionConfigOverrides = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "detect-scene-without-model",
|
key: "detect-scene-without-model",
|
||||||
|
health: true,
|
||||||
field: "scene",
|
field: "scene",
|
||||||
position: "after",
|
position: "after",
|
||||||
messageKey: "configMessages.detect.sceneWithoutModel",
|
messageKey: "configMessages.detect.sceneWithoutModel",
|
||||||
@@ -127,6 +131,7 @@ const detect: SectionConfigOverrides = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "fps-greater-than-five",
|
key: "fps-greater-than-five",
|
||||||
|
health: true,
|
||||||
field: "fps",
|
field: "fps",
|
||||||
messageKey: "configMessages.detect.fpsGreaterThanFive",
|
messageKey: "configMessages.detect.fpsGreaterThanFive",
|
||||||
severity: "info",
|
severity: "info",
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ const faceRecognition: SectionConfigOverrides = {
|
|||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
key: "global-disabled",
|
key: "global-disabled",
|
||||||
|
health: (ctx) =>
|
||||||
|
ctx.fullCameraConfig?.face_recognition?.enabled === true,
|
||||||
messageKey: "configMessages.faceRecognition.globalDisabled",
|
messageKey: "configMessages.faceRecognition.globalDisabled",
|
||||||
severity: "warning",
|
severity: "warning",
|
||||||
condition: (ctx) => {
|
condition: (ctx) => {
|
||||||
@@ -15,6 +17,9 @@ const faceRecognition: SectionConfigOverrides = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "person-not-tracked",
|
key: "person-not-tracked",
|
||||||
|
health: (ctx) =>
|
||||||
|
ctx.fullCameraConfig?.face_recognition?.enabled === true &&
|
||||||
|
ctx.fullConfig.face_recognition?.enabled === true,
|
||||||
messageKey: "configMessages.faceRecognition.personNotTracked",
|
messageKey: "configMessages.faceRecognition.personNotTracked",
|
||||||
severity: "info",
|
severity: "info",
|
||||||
condition: (ctx) => {
|
condition: (ctx) => {
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ const ffmpeg: SectionConfigOverrides = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "inputs-missing-go2rtc-stream",
|
key: "inputs-missing-go2rtc-stream",
|
||||||
|
health: true,
|
||||||
field: "inputs",
|
field: "inputs",
|
||||||
position: "before",
|
position: "before",
|
||||||
messageKey: "configMessages.ffmpeg.inputsMissingGo2rtcStream",
|
messageKey: "configMessages.ffmpeg.inputsMissingGo2rtcStream",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const lpr: SectionConfigOverrides = {
|
|||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
key: "global-disabled",
|
key: "global-disabled",
|
||||||
|
health: (ctx) => ctx.fullCameraConfig?.lpr?.enabled === true,
|
||||||
messageKey: "configMessages.lpr.globalDisabled",
|
messageKey: "configMessages.lpr.globalDisabled",
|
||||||
severity: "warning",
|
severity: "warning",
|
||||||
condition: (ctx) => {
|
condition: (ctx) => {
|
||||||
@@ -16,6 +17,9 @@ const lpr: SectionConfigOverrides = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "vehicle-not-tracked",
|
key: "vehicle-not-tracked",
|
||||||
|
health: (ctx) =>
|
||||||
|
ctx.fullCameraConfig?.lpr?.enabled === true &&
|
||||||
|
ctx.fullConfig.lpr?.enabled === true,
|
||||||
messageKey: "configMessages.lpr.vehicleNotTracked",
|
messageKey: "configMessages.lpr.vehicleNotTracked",
|
||||||
severity: "info",
|
severity: "info",
|
||||||
condition: (ctx) => {
|
condition: (ctx) => {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const models: SectionConfigOverrides = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "model-input-dimensions-not-detect-resolution",
|
key: "model-input-dimensions-not-detect-resolution",
|
||||||
|
health: true,
|
||||||
field: "height",
|
field: "height",
|
||||||
position: "after",
|
position: "after",
|
||||||
messageKey: "configMessages.model.inputDimensionsNotDetectResolution",
|
messageKey: "configMessages.model.inputDimensionsNotDetectResolution",
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ const objects: SectionConfigOverrides = {
|
|||||||
fieldMessages: [
|
fieldMessages: [
|
||||||
{
|
{
|
||||||
key: "genai-no-descriptions-provider",
|
key: "genai-no-descriptions-provider",
|
||||||
|
health: (ctx) =>
|
||||||
|
(ctx.formData as { genai?: { enabled?: boolean } })?.genai
|
||||||
|
?.enabled === true,
|
||||||
field: "genai.enabled",
|
field: "genai.enabled",
|
||||||
messageKey: "configMessages.objects.genaiNoDescriptionsProvider",
|
messageKey: "configMessages.objects.genaiNoDescriptionsProvider",
|
||||||
severity: "warning",
|
severity: "warning",
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ const onvif: SectionConfigOverrides = {
|
|||||||
fieldMessages: [
|
fieldMessages: [
|
||||||
{
|
{
|
||||||
key: "autotracking-no-zones",
|
key: "autotracking-no-zones",
|
||||||
|
health: (ctx) =>
|
||||||
|
ctx.fullCameraConfig?.onvif?.autotracking?.enabled === true,
|
||||||
field: "autotracking.required_zones",
|
field: "autotracking.required_zones",
|
||||||
messageKey: "configMessages.onvif.autotrackingNoZones",
|
messageKey: "configMessages.onvif.autotrackingNoZones",
|
||||||
severity: "error",
|
severity: "error",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const record: SectionConfigOverrides = {
|
|||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
key: "no-record-role",
|
key: "no-record-role",
|
||||||
|
health: (ctx) => ctx.fullCameraConfig?.record?.enabled === true,
|
||||||
messageKey: "configMessages.record.noRecordRole",
|
messageKey: "configMessages.record.noRecordRole",
|
||||||
severity: "warning",
|
severity: "warning",
|
||||||
condition: (ctx) => {
|
condition: (ctx) => {
|
||||||
@@ -17,6 +18,7 @@ const record: SectionConfigOverrides = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "no-record-sub-role",
|
key: "no-record-sub-role",
|
||||||
|
health: (ctx) => ctx.fullCameraConfig?.record?.sub?.enabled === true,
|
||||||
messageKey: "configMessages.record.noRecordSubRole",
|
messageKey: "configMessages.record.noRecordSubRole",
|
||||||
severity: "warning",
|
severity: "warning",
|
||||||
condition: (ctx) => {
|
condition: (ctx) => {
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ const review: SectionConfigOverrides = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "genai-no-descriptions-provider",
|
key: "genai-no-descriptions-provider",
|
||||||
|
health: (ctx) =>
|
||||||
|
(ctx.formData as { genai?: { enabled?: boolean } })?.genai
|
||||||
|
?.enabled === true,
|
||||||
field: "genai.enabled",
|
field: "genai.enabled",
|
||||||
messageKey: "configMessages.objects.genaiNoDescriptionsProvider",
|
messageKey: "configMessages.objects.genaiNoDescriptionsProvider",
|
||||||
severity: "warning",
|
severity: "warning",
|
||||||
@@ -57,6 +60,7 @@ const review: SectionConfigOverrides = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "genai-image-source-recordings-record-disabled",
|
key: "genai-image-source-recordings-record-disabled",
|
||||||
|
health: true,
|
||||||
field: "genai.image_source",
|
field: "genai.image_source",
|
||||||
messageKey:
|
messageKey:
|
||||||
"configMessages.review.genaiImageSourceRecordingsRecordDisabled",
|
"configMessages.review.genaiImageSourceRecordingsRecordDisabled",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const semanticSearch: SectionConfigOverrides = {
|
|||||||
fieldMessages: [
|
fieldMessages: [
|
||||||
{
|
{
|
||||||
key: "jinav2-small-model-size",
|
key: "jinav2-small-model-size",
|
||||||
|
health: (ctx) => ctx.fullConfig.semantic_search?.enabled === true,
|
||||||
field: "model_size",
|
field: "model_size",
|
||||||
messageKey: "configMessages.semanticSearch.jinav2SmallModelSize",
|
messageKey: "configMessages.semanticSearch.jinav2SmallModelSize",
|
||||||
severity: "warning",
|
severity: "warning",
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ export type ConditionalMessage = {
|
|||||||
values?: Record<string, unknown>;
|
values?: Record<string, unknown>;
|
||||||
/** Optional documentation path (e.g. "/configuration/object_detectors#model"). */
|
/** Optional documentation path (e.g. "/configuration/object_detectors#model"). */
|
||||||
docLink?: string;
|
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 */
|
/** 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,
|
pathMatchesHiddenPattern,
|
||||||
} from "@/utils/configUtil";
|
} from "@/utils/configUtil";
|
||||||
import { useOverrideFieldLabel } from "./useOverrideFieldLabel";
|
import { useOverrideFieldLabel } from "./useOverrideFieldLabel";
|
||||||
|
import { CAMERA_PAGE_BY_SECTION } from "@/components/config-form/sectionPages";
|
||||||
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",
|
|
||||||
};
|
|
||||||
|
|
||||||
const MAX_FIELDS_PER_CAMERA = 5;
|
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,
|
LuSlidersHorizontal,
|
||||||
LuX,
|
LuX,
|
||||||
} from "react-icons/lu";
|
} from "react-icons/lu";
|
||||||
|
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
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";
|
import type { HealthProblem } from "@/types/health";
|
||||||
|
|
||||||
type HealthProblemRowProps = {
|
type HealthProblemRowProps = {
|
||||||
problem: HealthProblem;
|
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) {
|
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 (
|
return (
|
||||||
<div
|
<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-testid={`health-problem-${problem.id}`}
|
||||||
data-severity={problem.severity}
|
data-severity={problem.severity}
|
||||||
>
|
>
|
||||||
<div className="mt-0.5 flex shrink-0">
|
<div className="flex shrink-0 self-start pt-0.5">
|
||||||
{problem.severity === "error" && <LuX className="size-4 text-danger" />}
|
{problem.pending ? (
|
||||||
{problem.severity === "warning" && (
|
<ActivityIndicator className="" size={16} />
|
||||||
<FaTriangleExclamation className="size-4 text-yellow-500" />
|
) : (
|
||||||
)}
|
<>
|
||||||
{problem.severity === "info" && (
|
{problem.severity === "error" && (
|
||||||
<LuInfo className="size-4 text-selected" />
|
<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>
|
</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 className="min-w-0 flex-1">
|
||||||
<div>{problem.text}</div>
|
<div>{problem.text}</div>
|
||||||
{problem.meta && (
|
{hasDetails && (
|
||||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||||
{problem.meta}
|
{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>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2 text-muted-foreground">
|
{hasActions && (
|
||||||
{problem.onDismiss && (
|
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||||
<Button
|
{problem.link && (
|
||||||
variant="ghost"
|
<RowAction label={t("health.notices.openSettings")}>
|
||||||
size="sm"
|
<Button
|
||||||
className="h-6 px-2"
|
asChild
|
||||||
onClick={problem.onDismiss}
|
variant="ghost"
|
||||||
aria-label={t("health.notices.dismiss")}
|
size="icon"
|
||||||
>
|
className={ICON_BUTTON_CLASS}
|
||||||
{t("health.notices.dismiss")}
|
>
|
||||||
</Button>
|
<Link
|
||||||
)}
|
to={problem.link}
|
||||||
{problem.link && (
|
aria-label={t("health.notices.openSettings")}
|
||||||
<Link
|
>
|
||||||
to={problem.link}
|
<LuSlidersHorizontal className="size-3.5" />
|
||||||
aria-label={t("health.notices.openSettings")}
|
</Link>
|
||||||
className="hover:text-primary"
|
</Button>
|
||||||
>
|
</RowAction>
|
||||||
<LuSlidersHorizontal className="size-4" />
|
)}
|
||||||
</Link>
|
{problem.docLink && (
|
||||||
)}
|
<RowAction label={t("readTheDocumentation", { ns: "common" })}>
|
||||||
{problem.externalLink && (
|
<Button
|
||||||
<a
|
asChild
|
||||||
href={problem.externalLink}
|
variant="ghost"
|
||||||
target="_blank"
|
size="icon"
|
||||||
rel="noreferrer"
|
className={ICON_BUTTON_CLASS}
|
||||||
aria-label={t("health.notices.openLink")}
|
>
|
||||||
className="hover:text-primary"
|
<a
|
||||||
>
|
href={getLocaleDocUrl(problem.docLink)}
|
||||||
<LuExternalLink className="size-4" />
|
target="_blank"
|
||||||
</a>
|
rel="noreferrer"
|
||||||
)}
|
aria-label={t("readTheDocumentation", { ns: "common" })}
|
||||||
</div>
|
>
|
||||||
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,14 @@ import { Skeleton } from "@/components/ui/skeleton";
|
|||||||
import { useNotices } from "@/hooks/use-notices";
|
import { useNotices } from "@/hooks/use-notices";
|
||||||
import { useDateLocale } from "@/hooks/use-date-locale";
|
import { useDateLocale } from "@/hooks/use-date-locale";
|
||||||
import { useTimezone } from "@/hooks/use-date-utils";
|
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 { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||||
import { releaseUrl } from "@/utils/versionUtil";
|
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 { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import type { HealthProblem } from "@/types/health";
|
import type { HealthProblem } from "@/types/health";
|
||||||
import type { Notice, NoticeKind, NoticeStats } from "@/types/notice";
|
import type { Notice, NoticeKind, NoticeStats } from "@/types/notice";
|
||||||
@@ -75,6 +81,7 @@ function useNoticeProblems(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: `notice:${notice.id}`,
|
id: `notice:${notice.id}`,
|
||||||
|
source: "registry" as const,
|
||||||
severity: notice.severity,
|
severity: notice.severity,
|
||||||
scope: notice.scope ?? undefined,
|
scope: notice.scope ?? undefined,
|
||||||
scopeIsCamera: notice.category === "camera",
|
scopeIsCamera: notice.category === "camera",
|
||||||
@@ -93,19 +100,73 @@ function useNoticeProblems(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function NoticesPane() {
|
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 { 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 (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div>
|
<div className="text-md font-medium text-primary-variant">
|
||||||
<div className="text-md font-medium text-primary-variant">
|
{t("health.notices.title")}
|
||||||
{t("health.notices.title")}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
|
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
|
||||||
{notices === undefined ? (
|
{loading ? (
|
||||||
<Skeleton className="h-24 w-full" />
|
<Skeleton className="h-24 w-full" />
|
||||||
) : problems.length === 0 ? (
|
) : problems.length === 0 ? (
|
||||||
<div className="flex items-center gap-2 px-1 py-2 text-sm">
|
<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 { LuX } from "react-icons/lu";
|
||||||
import { Card, CardContent } from "../../ui/card";
|
import { Card, CardContent } from "../../ui/card";
|
||||||
import { maskUri } from "@/utils/cameraUtil";
|
import { maskUri } from "@/utils/cameraUtil";
|
||||||
|
import { ffprobeToTestResult, getStreamIssues } from "@/utils/streamIssues";
|
||||||
|
|
||||||
type Step4ValidationProps = {
|
type Step4ValidationProps = {
|
||||||
wizardData: Partial<WizardFormData>;
|
wizardData: Partial<WizardFormData>;
|
||||||
@@ -74,44 +75,7 @@ export default function Step4Validation({
|
|||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.data?.[0]?.return_code === 0) {
|
return ffprobeToTestResult(response.data?.[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 };
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const axiosError = error as {
|
const axiosError = error as {
|
||||||
response?: { data?: { message?: string; detail?: string } };
|
response?: { data?: { message?: string; detail?: string } };
|
||||||
@@ -528,149 +492,21 @@ function StreamIssues({
|
|||||||
}: StreamIssuesProps) {
|
}: StreamIssuesProps) {
|
||||||
const { t } = useTranslation(["views/settings"]);
|
const { t } = useTranslation(["views/settings"]);
|
||||||
|
|
||||||
const issues = useMemo(() => {
|
const issues = useMemo(
|
||||||
const result: Array<{
|
() =>
|
||||||
type: "good" | "warning" | "error";
|
getStreamIssues(
|
||||||
message: string;
|
{
|
||||||
}> = [];
|
url: stream.url,
|
||||||
|
roles: stream.roles,
|
||||||
if (wizardData.brandTemplate === "reolink") {
|
brand: wizardData.brandTemplate,
|
||||||
const streamUrl = stream.url.toLowerCase();
|
useFfmpeg: stream.useFfmpeg,
|
||||||
if (streamUrl.startsWith("rtsp://")) {
|
restream: stream.restream,
|
||||||
result.push({
|
testResult: stream.testResult,
|
||||||
type: "warning",
|
},
|
||||||
message: t("cameraWizard.step4.issues.brands.reolink-rtsp"),
|
t,
|
||||||
});
|
),
|
||||||
}
|
[stream, wizardData, t],
|
||||||
|
);
|
||||||
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]);
|
|
||||||
|
|
||||||
if (issues.length === 0) {
|
if (issues.length === 0) {
|
||||||
return null;
|
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,
|
CameraFfmpegThreshold,
|
||||||
InferenceThreshold,
|
InferenceThreshold,
|
||||||
} from "@/types/graph";
|
} from "@/types/graph";
|
||||||
import { FrigateStats, PotentialProblem } from "@/types/stats";
|
import { FrigateStats, PotentialProblem, ProblemSeverity } from "@/types/stats";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import useDeepMemo from "./use-deep-memo";
|
import useDeepMemo from "./use-deep-memo";
|
||||||
@@ -15,6 +15,22 @@ import { useIsAdmin } from "./use-is-admin";
|
|||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
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) {
|
export default function useStats(stats: FrigateStats | undefined) {
|
||||||
const { t } = useTranslation(["views/system"]);
|
const { t } = useTranslation(["views/system"]);
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
@@ -48,36 +64,42 @@ export default function useStats(stats: FrigateStats | undefined) {
|
|||||||
// check shm level
|
// check shm level
|
||||||
const shm = memoizedStats.service.storage["/dev/shm"];
|
const shm = memoizedStats.service.storage["/dev/shm"];
|
||||||
if (shm?.total && shm?.min_shm && shm.total < shm.min_shm) {
|
if (shm?.total && shm?.min_shm && shm.total < shm.min_shm) {
|
||||||
problems.push({
|
problems.push(
|
||||||
text: t("stats.shmTooLow", {
|
problem(
|
||||||
total: shm.total,
|
"error",
|
||||||
min: shm.min_shm,
|
t("stats.shmTooLow", {
|
||||||
}),
|
total: shm.total,
|
||||||
color: "text-danger",
|
min: shm.min_shm,
|
||||||
relevantLink: "/system#storage",
|
}),
|
||||||
});
|
"/system#storage",
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// check detectors for high inference speeds
|
// check detectors for high inference speeds
|
||||||
Object.entries(memoizedStats["detectors"]).forEach(([key, det]) => {
|
Object.entries(memoizedStats["detectors"]).forEach(([key, det]) => {
|
||||||
if (det["inference_speed"] > InferenceThreshold.error) {
|
if (det["inference_speed"] > InferenceThreshold.error) {
|
||||||
problems.push({
|
problems.push(
|
||||||
text: t("stats.detectIsVerySlow", {
|
problem(
|
||||||
detect: capitalizeFirstLetter(key),
|
"error",
|
||||||
speed: det["inference_speed"],
|
t("stats.detectIsVerySlow", {
|
||||||
}),
|
detect: capitalizeFirstLetter(key),
|
||||||
color: "text-danger",
|
speed: det["inference_speed"],
|
||||||
relevantLink: "/system#general",
|
}),
|
||||||
});
|
"/system#general",
|
||||||
|
),
|
||||||
|
);
|
||||||
} else if (det["inference_speed"] > InferenceThreshold.warning) {
|
} else if (det["inference_speed"] > InferenceThreshold.warning) {
|
||||||
problems.push({
|
problems.push(
|
||||||
text: t("stats.detectIsSlow", {
|
problem(
|
||||||
detect: capitalizeFirstLetter(key),
|
"warning",
|
||||||
speed: det["inference_speed"],
|
t("stats.detectIsSlow", {
|
||||||
}),
|
detect: capitalizeFirstLetter(key),
|
||||||
color: "text-orange-400",
|
speed: det["inference_speed"],
|
||||||
relevantLink: "/system#general",
|
}),
|
||||||
});
|
"/system#general",
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -94,13 +116,15 @@ export default function useStats(stats: FrigateStats | undefined) {
|
|||||||
|
|
||||||
const cameraName = config.cameras?.[name]?.friendly_name ?? name;
|
const cameraName = config.cameras?.[name]?.friendly_name ?? name;
|
||||||
if (config.cameras?.[name]?.enabled && cam["camera_fps"] == 0) {
|
if (config.cameras?.[name]?.enabled && cam["camera_fps"] == 0) {
|
||||||
problems.push({
|
problems.push(
|
||||||
text: t("stats.cameraIsOffline", {
|
problem(
|
||||||
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
|
"error",
|
||||||
}),
|
t("stats.cameraIsOffline", {
|
||||||
color: "text-danger",
|
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
|
||||||
relevantLink: "logs",
|
}),
|
||||||
});
|
"logs",
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -121,37 +145,43 @@ export default function useStats(stats: FrigateStats | undefined) {
|
|||||||
const cameraName = config?.cameras?.[name]?.friendly_name ?? name;
|
const cameraName = config?.cameras?.[name]?.friendly_name ?? name;
|
||||||
|
|
||||||
if (!isNaN(ffmpegAvg) && ffmpegAvg >= CameraFfmpegThreshold.error) {
|
if (!isNaN(ffmpegAvg) && ffmpegAvg >= CameraFfmpegThreshold.error) {
|
||||||
problems.push({
|
problems.push(
|
||||||
text: t("stats.ffmpegHighCpuUsage", {
|
problem(
|
||||||
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
|
"error",
|
||||||
ffmpegAvg,
|
t("stats.ffmpegHighCpuUsage", {
|
||||||
}),
|
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
|
||||||
color: "text-danger",
|
ffmpegAvg,
|
||||||
relevantLink: "/system#cameras",
|
}),
|
||||||
});
|
"/system#cameras",
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isNaN(detectAvg) && detectAvg >= CameraDetectThreshold.error) {
|
if (!isNaN(detectAvg) && detectAvg >= CameraDetectThreshold.error) {
|
||||||
problems.push({
|
problems.push(
|
||||||
text: t("stats.detectHighCpuUsage", {
|
problem(
|
||||||
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
|
"error",
|
||||||
detectAvg,
|
t("stats.detectHighCpuUsage", {
|
||||||
}),
|
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
|
||||||
color: "text-danger",
|
detectAvg,
|
||||||
relevantLink: "/system#cameras",
|
}),
|
||||||
});
|
"/system#cameras",
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add message if debug replay is active
|
// Add message if debug replay is active
|
||||||
if (replayActive) {
|
if (replayActive) {
|
||||||
problems.push({
|
problems.push(
|
||||||
text: t("stats.debugReplayActive", {
|
problem(
|
||||||
defaultValue: "Debug replay session is active",
|
"info",
|
||||||
}),
|
t("stats.debugReplayActive", {
|
||||||
color: "text-selected",
|
defaultValue: "Debug replay session is active",
|
||||||
relevantLink: "/replay",
|
}),
|
||||||
});
|
"/replay",
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return problems;
|
return problems;
|
||||||
|
|||||||
@@ -705,7 +705,9 @@ export default function Settings() {
|
|||||||
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
|
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
const [selectedCamera, setSelectedCamera] = useState<string>("");
|
const [selectedCamera, setSelectedCamera] = useState<string>(
|
||||||
|
() => searchParams.get("camera") ?? "",
|
||||||
|
);
|
||||||
|
|
||||||
// Get all camera overrides for the selected camera
|
// Get all camera overrides for the selected camera
|
||||||
const cameraOverrides = useAllCameraOverrides(config, selectedCamera);
|
const cameraOverrides = useAllCameraOverrides(config, selectedCamera);
|
||||||
@@ -1164,6 +1166,12 @@ export default function Settings() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useSearchEffect("camera", (camera: string) => {
|
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);
|
const cameraNames = cameras.map((c) => c.name);
|
||||||
if (cameraNames.includes(camera)) {
|
if (cameraNames.includes(camera)) {
|
||||||
setSelectedCamera(camera);
|
setSelectedCamera(camera);
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ function System() {
|
|||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
|
|
||||||
<div className="flex h-full items-center">
|
<div className="flex h-full items-center">
|
||||||
{lastUpdated && (
|
{lastUpdated && pageToggle != "health" && (
|
||||||
<div className="h-full content-center text-sm text-muted-foreground">
|
<div className="h-full content-center text-sm text-muted-foreground">
|
||||||
{t("lastRefreshed")}
|
{t("lastRefreshed")}
|
||||||
<TimeAgo time={lastUpdated * 1000} dense />
|
<TimeAgo time={lastUpdated * 1000} dense />
|
||||||
|
|||||||
@@ -222,3 +222,25 @@ export type OnvifProbeResponse = {
|
|||||||
message?: string;
|
message?: string;
|
||||||
detail?: 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 {
|
export interface FaceRecognitionConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
device?: string | null;
|
||||||
model_size: SearchModelSize;
|
model_size: SearchModelSize;
|
||||||
unknown_score: number;
|
unknown_score: number;
|
||||||
detection_threshold: number;
|
detection_threshold: number;
|
||||||
recognition_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 type SearchModelSize = "small" | "large";
|
||||||
|
|
||||||
export interface CameraConfig {
|
export interface CameraConfig {
|
||||||
@@ -85,11 +87,11 @@ export interface CameraConfig {
|
|||||||
};
|
};
|
||||||
ffmpeg: {
|
ffmpeg: {
|
||||||
global_args: string[];
|
global_args: string[];
|
||||||
hwaccel_args: string;
|
hwaccel_args: string | string[];
|
||||||
input_args: string;
|
input_args: string;
|
||||||
inputs: {
|
inputs: {
|
||||||
global_args: string[];
|
global_args: string[];
|
||||||
hwaccel_args: string[];
|
hwaccel_args: string | string[];
|
||||||
input_args: string;
|
input_args: string;
|
||||||
path: string;
|
path: string;
|
||||||
roles: string[];
|
roles: string[];
|
||||||
@@ -448,6 +450,7 @@ export interface FrigateConfig {
|
|||||||
|
|
||||||
audio_transcription: {
|
audio_transcription: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
device: "GPU" | "CPU";
|
||||||
};
|
};
|
||||||
|
|
||||||
auth: {
|
auth: {
|
||||||
@@ -501,7 +504,7 @@ export interface FrigateConfig {
|
|||||||
|
|
||||||
ffmpeg: {
|
ffmpeg: {
|
||||||
global_args: string[];
|
global_args: string[];
|
||||||
hwaccel_args: string;
|
hwaccel_args: string | string[];
|
||||||
input_args: string;
|
input_args: string;
|
||||||
output_args: {
|
output_args: {
|
||||||
detect: string[];
|
detect: string[];
|
||||||
@@ -527,6 +530,7 @@ export interface FrigateConfig {
|
|||||||
|
|
||||||
lpr: {
|
lpr: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
device?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
logger: {
|
logger: {
|
||||||
@@ -615,6 +619,7 @@ export interface FrigateConfig {
|
|||||||
|
|
||||||
semantic_search: {
|
semantic_search: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
device?: string | null;
|
||||||
reindex: boolean;
|
reindex: boolean;
|
||||||
model: SearchModel;
|
model: SearchModel;
|
||||||
model_size: SearchModelSize;
|
model_size: SearchModelSize;
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export type HealthSeverity = "error" | "warning" | "info";
|
|||||||
*/
|
*/
|
||||||
export type HealthProblem = {
|
export type HealthProblem = {
|
||||||
id: string;
|
id: string;
|
||||||
|
/** which source produced the row; part of the sort order */
|
||||||
|
source: "registry" | "live" | "config" | "stream";
|
||||||
severity: HealthSeverity;
|
severity: HealthSeverity;
|
||||||
/** camera name or other scope shown as a chip before the text */
|
/** camera name or other scope shown as a chip before the text */
|
||||||
scope?: string;
|
scope?: string;
|
||||||
@@ -23,5 +25,7 @@ export type HealthProblem = {
|
|||||||
docLink?: string;
|
docLink?: string;
|
||||||
/** absolute URL rendered as an external link (the update notice's release page) */
|
/** absolute URL rendered as an external link (the update notice's release page) */
|
||||||
externalLink?: string;
|
externalLink?: string;
|
||||||
|
/** render with a spinner instead of the severity icon (stream check running) */
|
||||||
|
pending?: boolean;
|
||||||
onDismiss?: () => void;
|
onDismiss?: () => void;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export type EmbeddingsStats = {
|
|||||||
face_embedding_speed: number;
|
face_embedding_speed: number;
|
||||||
plate_recognition_speed: number;
|
plate_recognition_speed: number;
|
||||||
text_embedding_speed: number;
|
text_embedding_speed: number;
|
||||||
|
devices?: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ExtraProcessStats = {
|
export type ExtraProcessStats = {
|
||||||
@@ -119,8 +120,11 @@ export type CameraStorage = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ProblemSeverity = "error" | "warning" | "info";
|
||||||
|
|
||||||
export type PotentialProblem = {
|
export type PotentialProblem = {
|
||||||
text: string;
|
text: string;
|
||||||
|
severity: ProblemSeverity;
|
||||||
color: string;
|
color: string;
|
||||||
relevantLink?: string;
|
relevantLink?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 ?? ""),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -114,6 +114,11 @@ export default function EnrichmentMetrics({
|
|||||||
}
|
}
|
||||||
|
|
||||||
Object.entries(stats.embeddings).forEach(([rawKey, stat]) => {
|
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("_", " ");
|
const key = rawKey.replaceAll("_", " ");
|
||||||
if (!(key in series)) {
|
if (!(key in series)) {
|
||||||
const classificationIndex = rawKey.indexOf("_classification_");
|
const classificationIndex = rawKey.indexOf("_classification_");
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import HardwarePane from "@/components/health/HardwarePane";
|
||||||
import NoticesPane from "@/components/health/NoticesPane";
|
import NoticesPane from "@/components/health/NoticesPane";
|
||||||
|
|
||||||
export default function HealthMetrics() {
|
export default function HealthMetrics() {
|
||||||
return (
|
return (
|
||||||
<div className="scrollbar-container mt-4 flex size-full flex-col gap-4 overflow-y-auto">
|
<div className="scrollbar-container mt-4 flex size-full flex-col gap-4 overflow-y-auto">
|
||||||
<NoticesPane />
|
<NoticesPane />
|
||||||
|
<HardwarePane />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user