mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-08-31 07:27:57 +00:00
Implement UI for managing multiple models (#24023)
* Implement hardware detection and UI management * Cleanup Frigate+ detection * Don't count model as changed * Fixes for audio map error * Add descriptions * Enforce that all model must exist * Fix hardware picking * Docs fixes * WebUI cleanup * Cleanup handling of scenes * UI refinement * Cleanup recommended UI * test fixews
This commit is contained in:
parent
f5e398036e
commit
da135da0fb
@ -4,8 +4,8 @@ edgeTPU:
|
||||
- key: mobiledet
|
||||
label: Mobiledet
|
||||
recommended: true
|
||||
download: A TensorFlow Lite model is provided in the container at `/edgetpu_model.tflite` and is used by this detector type by default. To provide your own model, bind mount the file into the container and provide the path with `model.path`.
|
||||
ui: Navigate to **Settings > System > Detectors and model** and select **EdgeTPU** from the detector type dropdown and click **Add**, then set device to `usb`.
|
||||
download: A TensorFlow Lite model is provided in the container at `/edgetpu_model.tflite` and is used by this detector type by default. To provide your own model, bind mount the file into the container and provide the path with a model's `path`.
|
||||
ui: Navigate to **Settings > System > Detection models** and select **Coral EdgeTPU (USB)** from the **Hardware** dropdown.
|
||||
yaml: |-
|
||||
models:
|
||||
- devices:
|
||||
@ -13,9 +13,9 @@ edgeTPU:
|
||||
- key: yolov9
|
||||
label: YOLOv9
|
||||
recommended: false
|
||||
download: "[Download the model](https://github.com/dbro/frigate-detector-edgetpu-yolo9/releases/download/v1.0/yolov9-s-relu6-best_320_int8_edgetpu.tflite), bind mount the file into the container, and provide the path with `model.path`. Note that the linked model requires a 17-label [labelmap file](https://raw.githubusercontent.com/dbro/frigate-detector-edgetpu-yolo9/refs/heads/main/labels-coco17.txt) that includes only 17 COCO classes."
|
||||
download: "[Download the model](https://github.com/dbro/frigate-detector-edgetpu-yolo9/releases/download/v1.0/yolov9-s-relu6-best_320_int8_edgetpu.tflite), bind mount the file into the container, and provide the path with a model's `path`. Note that the linked model requires a 17-label [labelmap file](https://raw.githubusercontent.com/dbro/frigate-detector-edgetpu-yolo9/refs/heads/main/labels-coco17.txt) that includes only 17 COCO classes."
|
||||
ui: |-
|
||||
Navigate to **Settings > System > Detectors and model** and select **EdgeTPU** from the detector type dropdown and click **Add**, then set device to `usb`. Then on the same page, in the **Custom Model** tab, configure the model settings:
|
||||
Navigate to **Settings > System > Detection models** and select **Coral EdgeTPU (USB)** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure the model settings:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ----------------------------------------------------------------- |
|
||||
@ -44,7 +44,7 @@ hailo8l:
|
||||
recommended: true
|
||||
download: If no custom model path or URL is provided, the Hailo detector automatically downloads the default model (YOLOv6n) from the Hailo Model Zoo on first startup based on the detected hardware. Once cached under `/config/model_cache/hailo`, the model works fully offline.
|
||||
ui: |-
|
||||
Navigate to **Settings > System > Detectors and model** and select **Hailo-8/Hailo-8L** from the detector type dropdown and click **Add**, then set device to `PCIe`. Then on the same page, in the **Custom Model** tab, configure the model settings:
|
||||
Navigate to **Settings > System > Detection models** and select **Hailo** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure the model settings:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ----------------------- |
|
||||
@ -86,7 +86,7 @@ hailo8l:
|
||||
recommended: false
|
||||
download: For SSD-based models, provide either a model path or URL to your compiled SSD model. The integration will first check the local path before downloading if necessary. The model file is cached under `/config/model_cache/hailo`.
|
||||
ui: |-
|
||||
Navigate to **Settings > System > Detectors and model** and select **Hailo-8/Hailo-8L** from the detector type dropdown and click **Add**, then set device to `PCIe`. Then on the same page, in the **Custom Model** tab, configure the model settings:
|
||||
Navigate to **Settings > System > Detection models** and select **Hailo** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure the model settings:
|
||||
|
||||
| Field | Value |
|
||||
| --------------------------------------- | ------ |
|
||||
@ -143,7 +143,7 @@ openvino:
|
||||
EOF
|
||||
```
|
||||
ui: |-
|
||||
Navigate to **Settings > System > Detectors and model** and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU` (or `NPU`). Then on the same page, in the **Custom Model** tab, configure:
|
||||
Navigate to **Settings > System > Detection models** and select **Intel GPU** (or **Intel NPU**) from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | -------------------------------------------------------- |
|
||||
@ -171,7 +171,7 @@ openvino:
|
||||
recommended: false
|
||||
download: An OpenVINO model is provided in the container at `/openvino-model/ssdlite_mobilenet_v2.xml` and is used by this detector type by default. The model comes from Intel's Open Model Zoo [SSDLite MobileNet V2](https://github.com/openvinotoolkit/open_model_zoo/tree/master/models/public/ssdlite_mobilenet_v2) and is converted to an FP16 precision IR model.
|
||||
ui: |-
|
||||
Navigate to **Settings > System > Detectors and model** and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU` (or `NPU`). Then on the same page, in the **Custom Model** tab, configure:
|
||||
Navigate to **Settings > System > Detection models** and select **Intel GPU** (or **Intel NPU**) from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ------------------------------------------ |
|
||||
@ -206,7 +206,7 @@ openvino:
|
||||
python3 yolo_to_onnx.py -m yolov7-320
|
||||
```
|
||||
ui: |-
|
||||
Navigate to **Settings > System > Detectors and model** and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU` (or `NPU`). Then on the same page, in the **Custom Model** tab, configure:
|
||||
Navigate to **Settings > System > Detection models** and select **Intel GPU** (or **Intel NPU**) from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | -------------------------------------------------------- |
|
||||
@ -243,7 +243,7 @@ openvino:
|
||||
|
||||
The input image size in this notebook is set to 320x320. This results in lower CPU usage and faster inference times without impacting performance in most cases due to the way Frigate crops video frames to areas of interest before running detection. The notebook and config can be updated to 640x640 if desired.
|
||||
ui: |-
|
||||
Navigate to **Settings > System > Detectors and model** and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU`. Then on the same page, in the **Custom Model** tab, configure:
|
||||
Navigate to **Settings > System > Detection models** and select **Intel GPU** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ------------------------------------------------- |
|
||||
@ -271,7 +271,7 @@ openvino:
|
||||
recommended: false
|
||||
download: YOLOx models can be downloaded [from the YOLOx repo](https://github.com/Megvii-BaseDetection/YOLOX/tree/main/demo/ONNXRuntime).
|
||||
ui: |-
|
||||
Navigate to **Settings > System > Detectors and model** and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU`. Then on the same page, in the **Custom Model** tab, configure:
|
||||
Navigate to **Settings > System > Detection models** and select **Intel GPU** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ------------------------------------- | -------------------------------- |
|
||||
@ -308,7 +308,7 @@ openvino:
|
||||
EOF
|
||||
```
|
||||
ui: |-
|
||||
Navigate to **Settings > System > Detectors and model** and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU`. Then on the same page, in the **Custom Model** tab, configure:
|
||||
Navigate to **Settings > System > Detection models** and select **Intel GPU** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||
|
||||
| Field | Value |
|
||||
| --------------------------------------- | --------------------------------- |
|
||||
@ -402,7 +402,7 @@ openvino:
|
||||
EOF
|
||||
```
|
||||
ui: |-
|
||||
Navigate to **Settings > System > Detectors and model** and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `CPU`. Then on the same page, in the **Custom Model** tab, configure:
|
||||
Navigate to **Settings > System > Detection models** and select **CPU** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ---------------------------------- |
|
||||
@ -455,7 +455,7 @@ appleSilicon:
|
||||
EOF
|
||||
```
|
||||
ui: |-
|
||||
Navigate to **Settings > System > Detectors and model** and select **ZMQ IPC** from the detector type dropdown and click **Add**, then set the endpoint to `tcp://host.docker.internal:5555`. Then on the same page, in the **Custom Model** tab, configure:
|
||||
Navigate to **Settings > System > Detection models** and add a model. The ZMQ endpoint is not reported by the hardware probe, so set `devices` to `zmq:tcp://host.docker.internal:5555` in YAML. Then, on the same model, open the **Custom Model** tab and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | -------------------------------------------------------- |
|
||||
@ -491,7 +491,7 @@ appleSilicon:
|
||||
python3 yolo_to_onnx.py -m yolov7-320
|
||||
```
|
||||
ui: |-
|
||||
Navigate to **Settings > System > Detectors and model** and select **ZMQ IPC** from the detector type dropdown and click **Add**, then set the endpoint to `tcp://host.docker.internal:5555`. Then on the same page, in the **Custom Model** tab, configure:
|
||||
Navigate to **Settings > System > Detection models** and add a model. The ZMQ endpoint is not reported by the hardware probe, so set `devices` to `zmq:tcp://host.docker.internal:5555` in YAML. Then, on the same model, open the **Custom Model** tab and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | -------------------------------------------------------- |
|
||||
@ -544,7 +544,7 @@ onnx:
|
||||
EOF
|
||||
```
|
||||
ui: |-
|
||||
Navigate to **Settings > System > Detectors and model** and select **ONNX** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:
|
||||
Navigate to **Settings > System > Detection models** and select your GPU from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | -------------------------------------------------------- |
|
||||
@ -588,7 +588,7 @@ onnx:
|
||||
EOF
|
||||
```
|
||||
ui: |-
|
||||
Navigate to **Settings > System > Detectors and model** and select **ONNX** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:
|
||||
Navigate to **Settings > System > Detection models** and select your GPU from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||
|
||||
| Field | Value |
|
||||
| --------------------------------------- | --------------------------------- |
|
||||
@ -623,7 +623,7 @@ onnx:
|
||||
|
||||
The input image size in this notebook is set to 320x320. This results in lower CPU usage and faster inference times without impacting performance in most cases due to the way Frigate crops video frames to areas of interest before running detection. The notebook and config can be updated to 640x640 if desired.
|
||||
ui: |-
|
||||
Navigate to **Settings > System > Detectors and model** and select **ONNX** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:
|
||||
Navigate to **Settings > System > Detection models** and select your GPU from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ------------------------------------------------- |
|
||||
@ -651,7 +651,7 @@ onnx:
|
||||
recommended: false
|
||||
download: YOLOx models can be downloaded [from the YOLOx repo](https://github.com/Megvii-BaseDetection/YOLOX/tree/main/demo/ONNXRuntime).
|
||||
ui: |-
|
||||
Navigate to **Settings > System > Detectors and model** and select **ONNX** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:
|
||||
Navigate to **Settings > System > Detection models** and select your GPU from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | -------------------------------------------------------- |
|
||||
@ -747,7 +747,7 @@ onnx:
|
||||
EOF
|
||||
```
|
||||
ui: |-
|
||||
Navigate to **Settings > System > Detectors and model** and select **ONNX** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:
|
||||
Navigate to **Settings > System > Detection models** and select your GPU from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ------------------------------------------- |
|
||||
@ -783,7 +783,7 @@ onnx:
|
||||
python3 yolo_to_onnx.py -m yolov7-320
|
||||
```
|
||||
ui: |-
|
||||
Navigate to **Settings > System > Detectors and model** and select **ONNX** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:
|
||||
Navigate to **Settings > System > Detection models** and select your GPU from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | -------------------------------------------------------- |
|
||||
@ -812,9 +812,9 @@ cpu:
|
||||
- key: ssd
|
||||
label: MobileNet v2
|
||||
recommended: true
|
||||
download: A TensorFlow Lite model is provided in the container at `/cpu_model.tflite` and is used by this detector type by default. To provide your own model, bind mount the file into the container and provide the path with `model.path`.
|
||||
download: A TensorFlow Lite model is provided in the container at `/cpu_model.tflite` and is used by this detector type by default. To provide your own model, bind mount the file into the container and provide the path with a model's `path`.
|
||||
ui: |-
|
||||
Navigate to **Settings > System > Detectors and model** and select **CPU** from the detector type dropdown and click **Add**. Configure the number of threads and click **Add** again to add additional CPU detectors as needed (one per camera is recommended).
|
||||
Navigate to **Settings > System > Detection models** and select **CPU** from the **Hardware** dropdown and set **Detectors** to the number of detection processes to run (one per camera is recommended).
|
||||
|
||||
| Field | Value |
|
||||
| ----------------- | ----- |
|
||||
@ -832,7 +832,7 @@ deepstack:
|
||||
recommended: true
|
||||
download: This detector runs object detection over the network against a CodeProject.AI or DeepStack server, so no model is downloaded into Frigate itself. Visit the [CodeProject.AI official website](https://www.codeproject.com/Articles/5322557/CodeProject-AI-Server-AI-the-easy-way) to download and install the AI server on your preferred device (e.g. Raspberry Pi, Nvidia Jetson, or other compatible hardware) before configuring the detector.
|
||||
ui: |-
|
||||
Navigate to **Settings > System > Detectors and model** and select **DeepStack** from the detector type dropdown and click **Add**. Set the API URL to point to your CodeProject.AI server (e.g., `http://<your_codeproject_ai_server_ip>:<port>/v1/vision/detection`).
|
||||
Navigate to **Settings > System > Detection models** and add a model. The CodeProject.AI server is not reported by the hardware probe, so set `devices` to `deepstack:http://<your_codeproject_ai_server_ip>:<port>/v1/vision/detection` in YAML.
|
||||
|
||||
| Field | Value |
|
||||
| ------------- | ---------------------------------------------------------------------- |
|
||||
@ -859,7 +859,7 @@ memryx:
|
||||
|
||||
MemryX `.dfp` models are automatically downloaded at runtime, if enabled, to the container at `/memryx_models/model_folder/`.
|
||||
ui: |-
|
||||
Navigate to **Settings > System > Detectors and model** and select **MemryX** from the detector type dropdown and click **Add**, then set device to `PCIe:0`. Then on the same page, in the **Custom Model** tab, configure:
|
||||
Navigate to **Settings > System > Detection models** and select **MemryX MX3** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ------------------------------------------------- |
|
||||
@ -893,7 +893,7 @@ memryx:
|
||||
|
||||
MemryX `.dfp` models are automatically downloaded at runtime, if enabled, to the container at `/memryx_models/model_folder/`.
|
||||
ui: |-
|
||||
Navigate to **Settings > System > Detectors and model** and select **MemryX** from the detector type dropdown and click **Add**, then set device to `PCIe:0`. Then on the same page, in the **Custom Model** tab, configure:
|
||||
Navigate to **Settings > System > Detection models** and select **MemryX MX3** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ------------------------------------------------- |
|
||||
@ -926,7 +926,7 @@ memryx:
|
||||
|
||||
MemryX `.dfp` models are automatically downloaded at runtime, if enabled, to the container at `/memryx_models/model_folder/`.
|
||||
ui: |-
|
||||
Navigate to **Settings > System > Detectors and model** and select **MemryX** from the detector type dropdown and click **Add**, then set device to `PCIe:0`. Then on the same page, in the **Custom Model** tab, configure:
|
||||
Navigate to **Settings > System > Detection models** and select **MemryX MX3** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ----------------------- |
|
||||
@ -959,7 +959,7 @@ memryx:
|
||||
|
||||
MemryX `.dfp` models are automatically downloaded at runtime, if enabled, to the container at `/memryx_models/model_folder/`.
|
||||
ui: |-
|
||||
Navigate to **Settings > System > Detectors and model** and select **MemryX** from the detector type dropdown and click **Add**, then set device to `PCIe:0`. Then on the same page, in the **Custom Model** tab, configure:
|
||||
Navigate to **Settings > System > Detection models** and select **MemryX MX3** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ----------------------- |
|
||||
@ -1005,7 +1005,7 @@ tensorrt:
|
||||
- USE_FP16=false
|
||||
```
|
||||
ui: |-
|
||||
Navigate to **Settings > System > Detectors and model** and select **TensorRT** from the detector type dropdown and click **Add**, then set the device to `0` (the default GPU index). Then on the same page, in the **Custom Model** tab, configure:
|
||||
Navigate to **Settings > System > Detection models** and select **NVIDIA Jetson** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ------------------------------------------------------------ |
|
||||
@ -1035,7 +1035,7 @@ synaptics:
|
||||
recommended: true
|
||||
download: A synap model is provided in the container at `/mobilenet.synap` and is used by this detector type by default. The model comes from the [Synap-release Github](https://github.com/synaptics-astra/synap-release/tree/v1.5.0/models/dolphin/object_detection/coco/model/mobilenet224_full80).
|
||||
ui: |-
|
||||
Navigate to **Settings > System > Detectors and model** and select **Synaptics** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:
|
||||
Navigate to **Settings > System > Detection models** and select **Synaptics NPU** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ---------------------------- |
|
||||
@ -1068,7 +1068,7 @@ rknn:
|
||||
|
||||
You can also provide your own `.rknn` model. You should not save your own models in the `rknn_cache` folder, store them directly in the `model_cache` folder or another subfolder. To convert a model to `.rknn` format see the `rknn-toolkit2` (requires a x86 machine). Note, that there is only post-processing for the supported models.
|
||||
ui: |-
|
||||
Navigate to **Settings > System > Detectors and model** and, in the **Custom Model** tab, configure:
|
||||
Navigate to **Settings > System > Detection models** select **Rockchip NPU** from the **Hardware** dropdown, then open the **Custom Model** tab and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | -------------------------------------------------- |
|
||||
@ -1108,7 +1108,7 @@ rknn:
|
||||
|
||||
**Note:** The pre-trained YOLO-NAS weights from DeciAI are subject to their license and can't be used commercially. For more information, see: https://docs.deci.ai/super-gradients/latest/LICENSE.YOLONAS.html
|
||||
ui: |-
|
||||
Navigate to **Settings > System > Detectors and model** and, in the **Custom Model** tab, configure:
|
||||
Navigate to **Settings > System > Detection models** select **Rockchip NPU** from the **Hardware** dropdown, then open the **Custom Model** tab and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ----------------------------------------------------------------------- |
|
||||
@ -1145,7 +1145,7 @@ rknn:
|
||||
|
||||
You can also provide your own `.rknn` model. You should not save your own models in the `rknn_cache` folder, store them directly in the `model_cache` folder or another subfolder. To convert a model to `.rknn` format see the `rknn-toolkit2` (requires a x86 machine). Note, that there is only post-processing for the supported models.
|
||||
ui: |-
|
||||
Navigate to **Settings > System > Detectors and model** and, in the **Custom Model** tab, configure:
|
||||
Navigate to **Settings > System > Detection models** select **Rockchip NPU** from the **Hardware** dropdown, then open the **Custom Model** tab and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ---------------------------------------------- |
|
||||
@ -1182,7 +1182,7 @@ axengine:
|
||||
recommended: true
|
||||
download: A yolov9 axmodel is provided in the container at `/axmodels` and is used by this detector type by default. The AXEngine detector downloads its default model from HuggingFace on first startup; once cached, the model works fully offline.
|
||||
ui: |-
|
||||
Navigate to **Settings > System > Detectors and model** and select **AXEngine NPU** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:
|
||||
Navigate to **Settings > System > Detection models** and select **AXERA NPU** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ----------------------- |
|
||||
@ -1195,20 +1195,6 @@ axengine:
|
||||
| **Model Input D Type** | `int` |
|
||||
| **Object Detection Model Type** | `yolo-generic` |
|
||||
yaml: |-
|
||||
<<<<<<< HEAD
|
||||
detectors:
|
||||
axengine:
|
||||
type: axengine
|
||||
|
||||
model:
|
||||
path: frigate-yolov9-tiny
|
||||
model_type: yolo-generic
|
||||
width: 320
|
||||
height: 320
|
||||
input_dtype: int
|
||||
input_pixel_format: bgr
|
||||
labelmap_path: /labelmap/coco-80.txt
|
||||
=======
|
||||
models:
|
||||
- devices:
|
||||
- axengine
|
||||
@ -1219,85 +1205,3 @@ axengine:
|
||||
input_dtype: int
|
||||
input_pixel_format: bgr
|
||||
labelmap_path: /labelmap/coco-80.txt
|
||||
degirumAiServer:
|
||||
title: DeGirum AI Server
|
||||
models:
|
||||
- key: ai-server-inference
|
||||
label: AI Server Inference
|
||||
recommended: true
|
||||
download: |-
|
||||
Launch a DeGirum AI server as a Docker container, then point the detector at it. Add this to your `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
degirum_detector:
|
||||
container_name: degirum
|
||||
image: degirum/aiserver:latest
|
||||
privileged: true
|
||||
ports:
|
||||
- "8778:8778"
|
||||
```
|
||||
|
||||
Set `location` to the server's service name, container name, or `host:port`.
|
||||
ui: |
|
||||
Navigate to **Settings > System > Detectors and model** and select **DeGirum** from the detector type dropdown and click **Add**.
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| **Location** | `degirum` |
|
||||
| **Zoo** | `degirum/public` |
|
||||
| **Token** | your AI Hub token (optional for the public zoo) |
|
||||
yaml: |
|
||||
models:
|
||||
- devices:
|
||||
- degirum:degirum
|
||||
path: ./mobilenet_v2_ssd_coco--300x300_quant_n2x_orca1_1
|
||||
width: 300
|
||||
height: 300
|
||||
input_pixel_format: rgb
|
||||
degirumLocal:
|
||||
title: DeGirum Local
|
||||
models:
|
||||
- key: local-inference
|
||||
label: Local Inference
|
||||
recommended: true
|
||||
download: Run hardware directly inside the Frigate container with `@local`, removing the AI server hop. The matching device runtime (e.g. the Hailo runtime) must be installed in the container; confirm it with `degirum sys-info`.
|
||||
ui: |
|
||||
Navigate to **Settings > System > Detectors and model** and select **DeGirum** from the detector type dropdown and click **Add**.
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| **Location** | `@local` |
|
||||
| **Zoo** | `degirum/public` |
|
||||
| **Token** | your AI Hub token (optional for the public zoo) |
|
||||
yaml: |
|
||||
models:
|
||||
- devices:
|
||||
- degirum:@local
|
||||
path: ./mobilenet_v2_ssd_coco--300x300_quant_n2x_orca1_1
|
||||
width: 300
|
||||
height: 300
|
||||
input_pixel_format: rgb
|
||||
degirumCloud:
|
||||
title: DeGirum AI Hub Cloud
|
||||
models:
|
||||
- key: ai-hub-cloud-inference
|
||||
label: AI Hub Cloud Inference
|
||||
recommended: true
|
||||
download: Run inferences on DeGirum's [AI Hub](https://hub.degirum.com) cloud with `@cloud`. Sign up, create an access token, and set it as `token`. Network latency may require lowering your detection fps.
|
||||
ui: |
|
||||
Navigate to **Settings > System > Detectors and model** and select **DeGirum** from the detector type dropdown and click **Add**.
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| **Location** | `@cloud` |
|
||||
| **Zoo** | `degirum/public` |
|
||||
| **Token** | your AI Hub token (optional for the public zoo) |
|
||||
yaml: |
|
||||
models:
|
||||
- devices:
|
||||
- degirum:@cloud
|
||||
path: ./mobilenet_v2_ssd_coco--300x300_quant_n2x_orca1_1
|
||||
width: 300
|
||||
height: 300
|
||||
input_pixel_format: rgb
|
||||
>>>>>>> 34363affa (Refactor detector and model management)
|
||||
|
||||
@ -177,7 +177,7 @@ Custom models may also require different input tensor formats. The colorspace co
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and open the **Custom Model** tab to configure the model path, dimensions, and input format.
|
||||
Navigate to <NavPath path="Settings > System > Detection models" /> and, on the model you want to change, open the **Custom Model** tab to configure the model path, dimensions, and input format.
|
||||
|
||||
| Field | Description |
|
||||
| --------------------------------------------- | ------------------------------------ |
|
||||
|
||||
@ -154,7 +154,7 @@ Here are some common starter configuration examples. These can be configured thr
|
||||
|
||||
1. Navigate to <NavPath path="Settings > System > MQTT" /> and configure the MQTT connection to your Home Assistant Mosquitto broker
|
||||
2. Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `Raspberry Pi (H.264)`
|
||||
3. Navigate to <NavPath path="Settings > System > Detectors and model" /> and add a detector with **Type** `EdgeTPU` and **Device** `usb`
|
||||
3. Navigate to <NavPath path="Settings > System > Detection models" /> and select **Coral EdgeTPU (USB)** from the **Hardware** dropdown
|
||||
4. Navigate to <NavPath path="Settings > Global configuration > Recording" /> and set **Enable recording** to on, **Motion retention > Retention days** to `7`, **Alert retention > Event retention > Retention days** to `30`, **Alert retention > Event retention > Retention mode** to `motion`, **Detection retention > Event retention > Retention days** to `30`, **Detection retention > Event retention > Retention mode** to `motion`
|
||||
5. Navigate to <NavPath path="Settings > Global configuration > Snapshots" /> and set **Enable snapshots** to on, **Snapshot retention > Default retention** to `30`
|
||||
6. Navigate to <NavPath path="Settings > Global configuration > Camera management" /> and add your camera with the appropriate RTSP stream URL
|
||||
@ -232,7 +232,7 @@ cameras:
|
||||
|
||||
1. Navigate to <NavPath path="Settings > System > MQTT" /> and set **Enable MQTT** to off
|
||||
2. Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `VAAPI (Intel/AMD GPU)`
|
||||
3. Navigate to <NavPath path="Settings > System > Detectors and model" /> and add a detector with **Type** `EdgeTPU` and **Device** `usb`
|
||||
3. Navigate to <NavPath path="Settings > System > Detection models" /> and select **Coral EdgeTPU (USB)** from the **Hardware** dropdown
|
||||
4. Navigate to <NavPath path="Settings > Global configuration > Recording" /> and set **Enable recording** to on, **Motion retention > Retention days** to `7`, **Alert retention > Event retention > Retention days** to `30`, **Alert retention > Event retention > Retention mode** to `motion`, **Detection retention > Event retention > Retention days** to `30`, **Detection retention > Event retention > Retention mode** to `motion`
|
||||
5. Navigate to <NavPath path="Settings > Global configuration > Snapshots" /> and set **Enable snapshots** to on, **Snapshot retention > Default retention** to `30`
|
||||
6. Navigate to <NavPath path="Settings > Global configuration > Camera management" /> and add your camera with the appropriate RTSP stream URL
|
||||
@ -308,8 +308,8 @@ cameras:
|
||||
|
||||
1. Navigate to <NavPath path="Settings > System > MQTT" /> and configure the connection to your MQTT broker
|
||||
2. Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `VAAPI (Intel/AMD GPU)`
|
||||
3. Navigate to <NavPath path="Settings > System > Detectors and model" /> and add a detector with **Type** `openvino` and **Device** `AUTO`
|
||||
4. On the same page, in the **Custom Model** tab, configure the OpenVINO model path and settings
|
||||
3. Navigate to <NavPath path="Settings > System > Detection models" /> and select **Intel GPU** from the **Hardware** dropdown
|
||||
4. On the same model, open the **Custom Model** tab and configure the OpenVINO model path and settings
|
||||
5. Navigate to <NavPath path="Settings > Global configuration > Recording" /> and set **Enable recording** to on, **Motion retention > Retention days** to `7`, **Alert retention > Event retention > Retention days** to `30`, **Alert retention > Event retention > Retention mode** to `motion`, **Detection retention > Event retention > Retention days** to `30`, **Detection retention > Event retention > Retention mode** to `motion`
|
||||
6. Navigate to <NavPath path="Settings > Global configuration > Snapshots" /> and set **Enable snapshots** to on, **Snapshot retention > Default retention** to `30`
|
||||
7. Navigate to <NavPath path="Settings > Global configuration > Camera management" /> and add your camera with the appropriate RTSP stream URL
|
||||
|
||||
@ -165,7 +165,7 @@ See [common Edge TPU troubleshooting steps](/troubleshooting/edgetpu) if the Edg
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **EdgeTPU** from the detector type dropdown and click **Add**, then set device to `usb`.
|
||||
Navigate to <NavPath path="Settings > System > Detection models" /> and select **Coral EdgeTPU (USB)** from the **Hardware** dropdown.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@ -184,7 +184,7 @@ models:
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **EdgeTPU** from the detector type dropdown and click **Add** to add multiple detectors, specifying `usb:0` and `usb:1` as the device for each.
|
||||
Navigate to <NavPath path="Settings > System > Detection models" /> and select **Coral EdgeTPU (USB)** from the **Hardware** dropdown and check each Coral the model should run on.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@ -206,7 +206,7 @@ _warning: may have [compatibility issues](https://github.com/blakeblackshear/fri
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **EdgeTPU** from the detector type dropdown and click **Add**, then leave the device field empty.
|
||||
Navigate to <NavPath path="Settings > System > Detection models" /> and select the **Coral EdgeTPU** entry from the **Hardware** dropdown.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@ -225,7 +225,7 @@ models:
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **EdgeTPU** from the detector type dropdown and click **Add**, then set device to `pci`.
|
||||
Navigate to <NavPath path="Settings > System > Detection models" /> and select **Coral EdgeTPU (PCIe)** from the **Hardware** dropdown.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@ -244,7 +244,7 @@ models:
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **EdgeTPU** from the detector type dropdown and click **Add** to add multiple detectors, specifying `pci:0` and `pci:1` as the device for each.
|
||||
Navigate to <NavPath path="Settings > System > Detection models" /> and select **Coral EdgeTPU (PCIe)** from the **Hardware** dropdown and check each Coral the model should run on.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@ -264,7 +264,7 @@ models:
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **EdgeTPU** from the detector type dropdown and click **Add** to add multiple detectors with different device types (e.g., `usb` and `pci`).
|
||||
Navigate to <NavPath path="Settings > System > Detection models" /> and select **Coral EdgeTPU (USB)** from the **Hardware** dropdown. USB and PCIe Corals are listed as separate hardware, so mixing the two on one model has to be done in YAML.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@ -354,7 +354,7 @@ Intel NPUs cannot be used under Home Assistant OS, which does not include the NP
|
||||
|
||||
:::warning
|
||||
|
||||
The network-based detectors (Deepstack, DeGirum, and the Apple Silicon client) are being reworked. Their extra options no longer have a place in the config, so only the endpoint carried in the device string is honored right now: Deepstack ignores `api_key` and `api_timeout`, DeGirum ignores `zoo` and `token`, and the Apple Silicon client ignores `request_timeout_ms` and `linger_ms`. Anything else is dropped when your config is migrated.
|
||||
The network-based detectors (Deepstack and the Apple Silicon client) are being reworked. Their extra options no longer have a place in the config, so only the endpoint carried in the device string is honored right now: Deepstack ignores `api_key` and `api_timeout`, and the Apple Silicon client ignores `request_timeout_ms` and `linger_ms`. Anything else is dropped when your config is migrated.
|
||||
|
||||
:::
|
||||
|
||||
@ -536,7 +536,7 @@ When using CPU detectors, you can add one CPU detector per camera. Adding more d
|
||||
|
||||
:::warning
|
||||
|
||||
The network-based detectors (Deepstack, DeGirum, and the Apple Silicon client) are being reworked. Their extra options no longer have a place in the config, so only the endpoint carried in the device string is honored right now: Deepstack ignores `api_key` and `api_timeout`, DeGirum ignores `zoo` and `token`, and the Apple Silicon client ignores `request_timeout_ms` and `linger_ms`. Anything else is dropped when your config is migrated.
|
||||
The network-based detectors (Deepstack and the Apple Silicon client) are being reworked. Their extra options no longer have a place in the config, so only the endpoint carried in the device string is honored right now: Deepstack ignores `api_key` and `api_timeout`, and the Apple Silicon client ignores `request_timeout_ms` and `linger_ms`. Anything else is dropped when your config is migrated.
|
||||
|
||||
:::
|
||||
|
||||
@ -809,101 +809,6 @@ Explanation of the parameters:
|
||||
- **example**: Specifying `output_name = "frigate-{quant}-{input_basename}-{soc}-v{tk_version}"` could result in a model called `frigate-i8-my_model-rk3588-v2.3.0.rknn`.
|
||||
- `config`: Configuration passed to `rknn-toolkit2` for model conversion. For an explanation of all available parameters have a look at section "2.2. Model configuration" of [this manual](https://github.com/MarcA711/rknn-toolkit2/releases/download/v2.3.2/03_Rockchip_RKNPU_API_Reference_RKNN_Toolkit2_V2.3.2_EN.pdf).
|
||||
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
## DeGirum
|
||||
|
||||
:::warning
|
||||
|
||||
The network-based detectors (Deepstack, DeGirum, and the Apple Silicon client) are being reworked. Their extra options no longer have a place in the config, so only the endpoint carried in the device string is honored right now: Deepstack ignores `api_key` and `api_timeout`, DeGirum ignores `zoo` and `token`, and the Apple Silicon client ignores `request_timeout_ms` and `linger_ms`. Anything else is dropped when your config is migrated.
|
||||
|
||||
:::
|
||||
|
||||
DeGirum is a detector that can use any type of hardware listed on [their website](https://hub.degirum.com). DeGirum can be used with local hardware through a DeGirum AI Server, or through the use of `@local`. You can also connect directly to DeGirum's AI Hub to run inferences. **Please Note:** This detector _cannot_ be used for commercial purposes.
|
||||
|
||||
### Configuration {#configuration-degirum}
|
||||
|
||||
#### AI Server Inference
|
||||
|
||||
Before starting with the config file for this section, you must first launch an AI server. DeGirum has an AI server ready to use as a docker container. Add this to your `docker-compose.yml` to get started:
|
||||
|
||||
```yaml
|
||||
degirum_detector:
|
||||
container_name: degirum
|
||||
image: degirum/aiserver:latest
|
||||
privileged: true
|
||||
ports:
|
||||
- "8778:8778"
|
||||
```
|
||||
|
||||
All supported hardware will automatically be found on your AI server host as long as relevant runtimes and drivers are properly installed on your machine. Refer to [DeGirum's docs site](https://docs.degirum.com/pysdk/runtimes-and-drivers) if you have any trouble.
|
||||
|
||||
Once completed, configure the detector as follows:
|
||||
|
||||
<ModelConfigDropdown detectorTitle="DeGirum" models={objectDetectorsModels.degirumAiServer.models} />
|
||||
|
||||
The model is set on the same `models` entry as the DeGirum device. You can set it to:
|
||||
|
||||
- A model listed on the [AI Hub](https://hub.degirum.com)
|
||||
- If this is what you choose to do, the correct model will be downloaded onto your machine before running.
|
||||
- A local directory acting as a zoo. See DeGirum's docs site [for more information](https://docs.degirum.com/pysdk/user-guide-pysdk/organizing-models#model-zoo-directory-structure).
|
||||
- A path to some model.json.
|
||||
|
||||
```yaml
|
||||
models:
|
||||
- devices:
|
||||
- degirum:<location>
|
||||
path: ./mobilenet_v2_ssd_coco--300x300_quant_n2x_orca1_1 # directory to model .json and file
|
||||
width: 300 # width is in the model name as the first number in the "int"x"int" section
|
||||
height: 300 # height is in the model name as the second number in the "int"x"int" section
|
||||
input_pixel_format: rgb/bgr # look at the model.json to figure out which to put here
|
||||
```
|
||||
|
||||
#### Local Inference
|
||||
|
||||
It is also possible to eliminate the need for an AI server and run the hardware directly. The benefit of this approach is that you eliminate any bottlenecks that occur when transferring prediction results from the AI server docker container to the frigate one. However, the method of implementing local inference is different for every device and hardware combination, so it's usually more trouble than it's worth. A general guideline to achieve this would be:
|
||||
|
||||
1. Ensuring that the frigate docker container has the runtime you want to use. So for instance, running `@local` for Hailo means making sure the container you're using has the Hailo runtime installed.
|
||||
2. To double check the runtime is detected by the DeGirum detector, make sure the `degirum sys-info` command properly shows whatever runtimes you mean to install.
|
||||
3. Create a DeGirum detector in your configuration.
|
||||
|
||||
<ModelConfigDropdown detectorTitle="DeGirum" models={objectDetectorsModels.degirumLocal.models} />
|
||||
|
||||
Once the DeGirum device is set up, you can choose a model on the same `models` entry in the `config.yml` file.
|
||||
|
||||
```yaml
|
||||
models:
|
||||
- devices:
|
||||
- degirum:<location>
|
||||
path: mobilenet_v2_ssd_coco--300x300_quant_n2x_orca1_1
|
||||
width: 300 # width is in the model name as the first number in the "int"x"int" section
|
||||
height: 300 # height is in the model name as the second number in the "int"x"int" section
|
||||
input_pixel_format: rgb/bgr # look at the model.json to figure out which to put here
|
||||
```
|
||||
|
||||
#### AI Hub Cloud Inference
|
||||
|
||||
If you do not possess whatever hardware you want to run, there's also the option to run cloud inferences. Do note that your detection fps might need to be lowered as network latency does significantly slow down this method of detection. For use with Frigate, we highly recommend using a local AI server as described above. To set up cloud inferences,
|
||||
|
||||
1. Sign up at [DeGirum's AI Hub](https://hub.degirum.com).
|
||||
2. Get an access token.
|
||||
3. Create a DeGirum detector in your configuration.
|
||||
|
||||
<ModelConfigDropdown detectorTitle="DeGirum" models={objectDetectorsModels.degirumCloud.models} />
|
||||
|
||||
Once the DeGirum device is set up, you can choose a model on the same `models` entry in the `config.yml` file.
|
||||
|
||||
```yaml
|
||||
models:
|
||||
- devices:
|
||||
- degirum:<location>
|
||||
path: mobilenet_v2_ssd_coco--300x300_quant_n2x_orca1_1
|
||||
width: 300 # width is in the model name as the first number in the "int"x"int" section
|
||||
height: 300 # height is in the model name as the second number in the "int"x"int" section
|
||||
input_pixel_format: rgb/bgr # look at the model.json to figure out which to put here
|
||||
```
|
||||
|
||||
>>>>>>> 34363affa (Refactor detector and model management)
|
||||
## AXERA
|
||||
|
||||
Hardware accelerated object detection is supported on the following SoCs:
|
||||
|
||||
@ -204,8 +204,8 @@ You need to refer to **Configure hardware acceleration** above to enable the con
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
1. Navigate to <NavPath path="Settings > System > Detectors and model" /> and add a detector with **Type** `OpenVINO` and **Device** `GPU`
|
||||
2. On the same page, in the **Custom Model** tab, configure the model settings for OpenVINO:
|
||||
1. Navigate to <NavPath path="Settings > System > Detection models" /> and select **Intel GPU** from the **Hardware** dropdown
|
||||
2. On the same model, open the **Custom Model** tab and configure the model settings for OpenVINO:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ------------------------------------------ |
|
||||
@ -270,7 +270,7 @@ services:
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and add a detector with **Type** `EdgeTPU` and **Device** `usb`.
|
||||
Navigate to <NavPath path="Settings > System > Detection models" /> and select **Coral EdgeTPU (USB)** from the **Hardware** dropdown.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
|
||||
@ -59,7 +59,7 @@ You can view all of your submitted images at [https://plus.frigate.video](https:
|
||||
|
||||
Once you have [requested your first model](../plus/first_model.md) and gotten your own model ID, it can be used with a special model path. No other information needs to be configured for Frigate+ models because it fetches the remaining config from Frigate+ automatically.
|
||||
|
||||
You can either choose the new model from the <NavPath path="Settings > System > Detectors and model" /> pane in the Frigate UI (the **Frigate+ Model** tab), or manually set the model at the root level in your config:
|
||||
You can either choose the new model from the <NavPath path="Settings > System > Detection models" /> pane in the Frigate UI (on the **Frigate+** tab of the model you want to change), or set it on that model in your config:
|
||||
|
||||
```yaml
|
||||
models:
|
||||
|
||||
@ -30,7 +30,7 @@ Models available in Frigate+ can be used with a special model path. No other inf
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" />. In the **Detection Model** section, choose the **Frigate+** tab. Select your new Frigate+ model from the **Available Frigate+ models** dropdown, then click **Save**. Restart Frigate to apply the change.
|
||||
Navigate to <NavPath path="Settings > System > Detection models" />. On the model you want to change, choose the **Frigate+** tab and select your new Frigate+ model from the **Available Frigate+ models** dropdown, then click **Save**. Restart Frigate to apply the change.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
|
||||
@ -133,7 +133,7 @@ The process was killed by the CPU for executing an unsupported instruction. Ther
|
||||
|
||||
<FaqItem id="onnx-invalidprotobuf" question="ONNX Runtime InvalidProtobuf / failed to load model">
|
||||
|
||||
ONNX Runtime could not parse the model file. The file exists but its contents are not a valid ONNX model, usually a corrupted or interrupted download in `model_cache`, or the wrong file pointed at by `model.path`. Delete the cached model file so Frigate re-downloads it, and confirm `model.path` points at an actual `.onnx` model. See [ONNX detector configuration](/configuration/object_detectors#onnx).
|
||||
ONNX Runtime could not parse the model file. The file exists but its contents are not a valid ONNX model, usually a corrupted or interrupted download in `model_cache`, or the wrong file pointed at by a model's `path`. Delete the cached model file so Frigate re-downloads it, and confirm the model's `path` points at an actual `.onnx` model. See [ONNX detector configuration](/configuration/object_detectors#onnx).
|
||||
|
||||
</FaqItem>
|
||||
|
||||
|
||||
@ -63,8 +63,7 @@ SYSTEM_NAV: dict[str, tuple[str, str]] = {
|
||||
"environment_vars": ("System", "Environment variables"),
|
||||
"telemetry": ("System", "Telemetry"),
|
||||
"birdseye": ("System", "Birdseye"),
|
||||
"detectors": ("System", "Detectors and model"),
|
||||
"model": ("System", "Detectors and model"),
|
||||
"models": ("System", "Detection models"),
|
||||
}
|
||||
|
||||
# All known top-level config section keys
|
||||
|
||||
101
docs/static/frigate-api.yaml
vendored
101
docs/static/frigate-api.yaml
vendored
@ -4010,6 +4010,49 @@ paths:
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/hardware/probe:
|
||||
get:
|
||||
tags:
|
||||
- Hardware
|
||||
summary: Probe Hardware
|
||||
description: |-
|
||||
**Access:** Admin role required.
|
||||
|
||||
Get the object detection hardware attached to this system.
|
||||
|
||||
Args:
|
||||
refresh: Probe again instead of returning the cached result
|
||||
|
||||
Returns:
|
||||
Every kind of detection hardware that was found
|
||||
operationId: probe_hardware_hardware_probe_get
|
||||
parameters:
|
||||
- name: refresh
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
title: Refresh
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/DetectionHardware'
|
||||
title: Response Probe Hardware Hardware Probe Get
|
||||
'422':
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/events:
|
||||
get:
|
||||
tags:
|
||||
@ -7842,6 +7885,46 @@ components:
|
||||
required:
|
||||
- ids
|
||||
title: DeleteFaceImagesBody
|
||||
DetectionHardware:
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
title: Hardware key
|
||||
description: Stable identifier for this kind of hardware.
|
||||
detector:
|
||||
type: string
|
||||
title: Detector type
|
||||
description: The detector that drives this hardware.
|
||||
name:
|
||||
type: string
|
||||
title: Hardware name
|
||||
description: Human readable name for this kind of hardware.
|
||||
units:
|
||||
items:
|
||||
$ref: '#/components/schemas/HardwareUnit'
|
||||
type: array
|
||||
title: Units
|
||||
description: Each physical piece of this hardware that was found.
|
||||
count:
|
||||
type: integer
|
||||
title: Unit count
|
||||
description: How many units were found.
|
||||
unlimited:
|
||||
type: boolean
|
||||
title: Unlimited detectors
|
||||
description: Whether this hardware can run more inference processes
|
||||
than there are units.
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- detector
|
||||
- name
|
||||
- units
|
||||
- count
|
||||
- unlimited
|
||||
title: DetectionHardware
|
||||
description: A kind of detection hardware, and every unit of it that was
|
||||
found.
|
||||
EventCreateResponse:
|
||||
properties:
|
||||
success:
|
||||
@ -8569,6 +8652,24 @@ components:
|
||||
title: Detail
|
||||
type: object
|
||||
title: HTTPValidationError
|
||||
HardwareUnit:
|
||||
properties:
|
||||
device:
|
||||
type: string
|
||||
title: Device string
|
||||
description: The value to put in a model's devices list, for example
|
||||
'edgetpu:pci:1'.
|
||||
label:
|
||||
type: string
|
||||
title: Unit label
|
||||
description: How to identify this unit among others of the same kind,
|
||||
for example 'PCIe 1'.
|
||||
type: object
|
||||
required:
|
||||
- device
|
||||
- label
|
||||
title: HardwareUnit
|
||||
description: One physical piece of hardware.
|
||||
Last24HoursReview:
|
||||
properties:
|
||||
reviewed_alert:
|
||||
|
||||
@ -1330,8 +1330,18 @@ def categorized_object_names(
|
||||
|
||||
|
||||
@router.get("/audio_labels", dependencies=[Depends(allow_any_authenticated())])
|
||||
def get_audio_labels():
|
||||
def get_audio_labels(request: Request):
|
||||
labels = load_labels("/audio-labelmap.txt", prefill=521)
|
||||
|
||||
# configured overrides group several audio classes under one label, and the
|
||||
# detector merges them over the defaults at runtime. Offer them here too, or
|
||||
# a grouped label could never be picked in the UI.
|
||||
config: FrigateConfig = request.app.frigate_config
|
||||
labels.update(config.audio.labelmap)
|
||||
|
||||
for camera in config.cameras.values():
|
||||
labels.update(camera.audio.labelmap)
|
||||
|
||||
return JSONResponse(content=labels)
|
||||
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@ class Tags(Enum):
|
||||
chat = "Chat"
|
||||
events = "Events"
|
||||
export = "Export"
|
||||
hardware = "Hardware"
|
||||
classification = "Classification"
|
||||
logs = "Logs"
|
||||
media = "Media"
|
||||
|
||||
@ -21,6 +21,7 @@ from frigate.api import (
|
||||
debug_replay,
|
||||
event,
|
||||
export,
|
||||
hardware,
|
||||
media,
|
||||
motion_search,
|
||||
notification,
|
||||
@ -145,6 +146,7 @@ def create_fastapi_app(
|
||||
app.include_router(preview.router)
|
||||
app.include_router(notification.router)
|
||||
app.include_router(export.router)
|
||||
app.include_router(hardware.router)
|
||||
app.include_router(event.router)
|
||||
app.include_router(media.router)
|
||||
app.include_router(motion_search.router)
|
||||
|
||||
30
frigate/api/hardware.py
Normal file
30
frigate/api/hardware.py
Normal file
@ -0,0 +1,30 @@
|
||||
"""Hardware discovery APIs."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from frigate.api.auth import require_role
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.detectors.hardware import DetectionHardware, hardware_prober
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=[Tags.hardware])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/hardware/probe",
|
||||
response_model=list[DetectionHardware],
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
)
|
||||
def probe_hardware(refresh: bool = False) -> list[DetectionHardware]:
|
||||
"""Get the object detection hardware attached to this system.
|
||||
|
||||
Args:
|
||||
refresh: Probe again instead of returning the cached result
|
||||
|
||||
Returns:
|
||||
Every kind of detection hardware that was found
|
||||
"""
|
||||
return hardware_prober.probe(refresh=refresh)
|
||||
@ -62,10 +62,10 @@ class DetectConfig(FrigateBaseModel):
|
||||
title="Detect width",
|
||||
description="Width (pixels) of frames used for the detect stream; leave empty to use the native stream resolution.",
|
||||
)
|
||||
scene: SceneEnum | None = Field(
|
||||
default=None,
|
||||
scene: SceneEnum = Field(
|
||||
default=SceneEnum.all,
|
||||
title="Detect scene",
|
||||
description="The environment this camera looks at, used to pick which of the configured models runs on it. Defaults to the model with a scene of 'all'.",
|
||||
description="The environment this camera looks at, used to pick which of the configured models runs on it. Cameras left on 'all' run the model configured with a scene of 'all'.",
|
||||
)
|
||||
fps: int = Field(
|
||||
default=5,
|
||||
|
||||
@ -807,37 +807,37 @@ class FrigateConfig(FrigateBaseModel):
|
||||
}
|
||||
self._all_labels = labels
|
||||
|
||||
def _resolve_camera_model(self, name: str, scene: SceneEnum | None) -> ModelConfig:
|
||||
def _resolve_camera_model(self, name: str, scene: SceneEnum) -> ModelConfig:
|
||||
"""Resolve which model a camera runs on.
|
||||
|
||||
A camera may name a scene no model is configured for, which is valid as
|
||||
long as an 'all' model is there to fall back to.
|
||||
|
||||
Args:
|
||||
name: Name of the camera
|
||||
scene: The camera's configured detect scene, if any
|
||||
scene: The camera's detect scene, which defaults to 'all'
|
||||
|
||||
Returns:
|
||||
The model the camera runs on
|
||||
"""
|
||||
by_scene = {model.scene: model for model in self.models}
|
||||
model = by_scene.get(scene)
|
||||
|
||||
if scene is not None:
|
||||
model = by_scene.get(scene)
|
||||
|
||||
if model is None:
|
||||
raise ValueError(
|
||||
f"Camera '{name}' has a detect scene of '{scene.value}', but no model is configured for that scene."
|
||||
)
|
||||
|
||||
if model is not None:
|
||||
return model
|
||||
|
||||
default = by_scene.get(SceneEnum.all) or (
|
||||
self.models[0] if len(self.models) == 1 else None
|
||||
)
|
||||
default = by_scene.get(SceneEnum.all)
|
||||
|
||||
if default is None:
|
||||
raise ValueError(
|
||||
f"Camera '{name}' must set detect -> scene, because more than one model is configured and none of them uses a scene of 'all'."
|
||||
f"Camera '{name}' has a detect scene of '{scene.value}', but no model is configured for that scene or for 'all'."
|
||||
)
|
||||
|
||||
logger.warning(
|
||||
"Camera '%s' has a detect scene of '%s', but no model is configured for that scene, so the 'all' model is used",
|
||||
name,
|
||||
scene.value,
|
||||
)
|
||||
return default
|
||||
|
||||
@model_validator(mode="after")
|
||||
|
||||
368
frigate/detectors/hardware.py
Normal file
368
frigate/detectors/hardware.py
Normal file
@ -0,0 +1,368 @@
|
||||
"""Discovery of object detection hardware attached to the system.
|
||||
|
||||
Every probe here is a filesystem read. Nothing shells out, initializes a
|
||||
runtime, or opens a device, so this is cheap enough to run from the API process
|
||||
while detector children hold the hardware.
|
||||
|
||||
Hardware is reported whether or not this image ships a detector that can drive
|
||||
it. Matching hardware to an image is a separate concern.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from glob import glob
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from frigate.const import SUPPORTED_RK_SOCS
|
||||
from frigate.detectors.detector_types import config_types
|
||||
from frigate.util.services import enumerate_drm_devices
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# roots the probes read from, so tests can point them at a fixture tree
|
||||
SYS_ROOT = "/sys"
|
||||
DEV_ROOT = "/dev"
|
||||
PROC_ROOT = "/proc"
|
||||
ETC_ROOT = "/etc"
|
||||
|
||||
# a Coral reports as Global Unichip until its firmware is loaded, then as Google
|
||||
CORAL_USB_IDS = {("1a6e", "089a"), ("18d1", "9302")}
|
||||
|
||||
INTEL_DRM_DRIVERS = ("i915", "xe")
|
||||
AMD_DRM_DRIVERS = ("amdgpu",)
|
||||
|
||||
|
||||
class HardwareUnit(BaseModel):
|
||||
"""One physical piece of hardware."""
|
||||
|
||||
device: str = Field(
|
||||
title="Device string",
|
||||
description="The value to put in a model's devices list, for example 'edgetpu:pci:1'.",
|
||||
)
|
||||
label: str = Field(
|
||||
title="Unit label",
|
||||
description="How to identify this unit among others of the same kind, for example 'PCIe 1'.",
|
||||
)
|
||||
|
||||
|
||||
class DetectionHardware(BaseModel):
|
||||
"""A kind of detection hardware, and every unit of it that was found."""
|
||||
|
||||
key: str = Field(
|
||||
title="Hardware key",
|
||||
description="Stable identifier for this kind of hardware.",
|
||||
)
|
||||
detector: str = Field(
|
||||
title="Detector type",
|
||||
description="The detector that drives this hardware.",
|
||||
)
|
||||
name: str = Field(
|
||||
title="Hardware name",
|
||||
description="Human readable name for this kind of hardware.",
|
||||
)
|
||||
units: list[HardwareUnit] = Field(
|
||||
title="Units",
|
||||
description="Each physical piece of this hardware that was found.",
|
||||
)
|
||||
count: int = Field(
|
||||
title="Unit count",
|
||||
description="How many units were found.",
|
||||
)
|
||||
unlimited: bool = Field(
|
||||
title="Unlimited detectors",
|
||||
description="Whether this hardware can run more inference processes than there are units.",
|
||||
)
|
||||
|
||||
|
||||
def _read(path: str) -> str | None:
|
||||
"""Read a small file, returning None if it cannot be read."""
|
||||
try:
|
||||
with open(path) as f:
|
||||
return f.read().strip()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _is_shareable(detector: str) -> bool:
|
||||
"""Whether a detector lets the same device run more than one process."""
|
||||
config_class = config_types.get(detector)
|
||||
|
||||
# a detector missing from this image is assumed to behave like most of them
|
||||
return config_class.shareable if config_class else True
|
||||
|
||||
|
||||
def _hardware(
|
||||
key: str, detector: str, name: str, units: list[HardwareUnit]
|
||||
) -> DetectionHardware:
|
||||
return DetectionHardware(
|
||||
key=key,
|
||||
detector=detector,
|
||||
name=name,
|
||||
units=units,
|
||||
count=len(units),
|
||||
unlimited=_is_shareable(detector),
|
||||
)
|
||||
|
||||
|
||||
def detect_coral_pci() -> DetectionHardware | None:
|
||||
"""Find PCIe and M.2 Coral accelerators, which register as apex devices."""
|
||||
names = sorted(
|
||||
os.path.basename(path) for path in glob(f"{SYS_ROOT}/class/apex/apex_*")
|
||||
)
|
||||
|
||||
if not names:
|
||||
return None
|
||||
|
||||
units = [
|
||||
HardwareUnit(device=f"edgetpu:pci:{index}", label=f"PCIe {index}")
|
||||
for index in range(len(names))
|
||||
]
|
||||
return _hardware("edgetpu:pci", "edgetpu", "Coral EdgeTPU (PCIe)", units)
|
||||
|
||||
|
||||
def detect_coral_usb() -> DetectionHardware | None:
|
||||
"""Find USB Coral accelerators by their USB vendor and product ids."""
|
||||
found = 0
|
||||
|
||||
for device_dir in sorted(glob(f"{SYS_ROOT}/bus/usb/devices/*")):
|
||||
vendor = _read(os.path.join(device_dir, "idVendor"))
|
||||
product = _read(os.path.join(device_dir, "idProduct"))
|
||||
|
||||
if vendor and product and (vendor.lower(), product.lower()) in CORAL_USB_IDS:
|
||||
found += 1
|
||||
|
||||
if not found:
|
||||
return None
|
||||
|
||||
units = [
|
||||
HardwareUnit(device=f"edgetpu:usb:{index}", label=f"USB {index}")
|
||||
for index in range(found)
|
||||
]
|
||||
return _hardware("edgetpu:usb", "edgetpu", "Coral EdgeTPU (USB)", units)
|
||||
|
||||
|
||||
def _drm_devices(drivers: tuple[str, ...]) -> list[str]:
|
||||
"""PCI addresses of DRM devices bound to one of the given drivers."""
|
||||
return sorted(
|
||||
pdev for pdev, driver in enumerate_drm_devices().items() if driver in drivers
|
||||
)
|
||||
|
||||
|
||||
def detect_intel_gpu() -> DetectionHardware | None:
|
||||
"""Find Intel GPUs through their DRM driver."""
|
||||
pdevs = _drm_devices(INTEL_DRM_DRIVERS)
|
||||
|
||||
if not pdevs:
|
||||
return None
|
||||
|
||||
# OpenVINO reports a lone GPU as "GPU" and enumerates them as GPU.0, GPU.1
|
||||
# only when there is more than one
|
||||
if len(pdevs) == 1:
|
||||
units = [HardwareUnit(device="openvino:GPU", label=pdevs[0])]
|
||||
else:
|
||||
units = [
|
||||
HardwareUnit(device=f"openvino:GPU.{index}", label=pdev)
|
||||
for index, pdev in enumerate(pdevs)
|
||||
]
|
||||
|
||||
return _hardware("openvino:GPU", "openvino", "Intel GPU", units)
|
||||
|
||||
|
||||
def detect_intel_npu() -> DetectionHardware | None:
|
||||
"""Find Intel NPUs, which register as accel devices bound to intel_vpu."""
|
||||
units = []
|
||||
|
||||
for accel_path in sorted(glob(f"{SYS_ROOT}/class/accel/accel*")):
|
||||
try:
|
||||
driver = os.path.basename(os.readlink(f"{accel_path}/device/driver"))
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
if driver != "intel_vpu":
|
||||
continue
|
||||
|
||||
units.append(
|
||||
HardwareUnit(device="openvino:NPU", label=os.path.basename(accel_path))
|
||||
)
|
||||
|
||||
if not units:
|
||||
return None
|
||||
|
||||
# OpenVINO has no way to address a specific NPU, so only the first is usable
|
||||
return _hardware("openvino:NPU", "openvino", "Intel NPU", units[:1])
|
||||
|
||||
|
||||
def detect_amd_gpu() -> DetectionHardware | None:
|
||||
"""Find AMD GPUs through their DRM driver."""
|
||||
pdevs = _drm_devices(AMD_DRM_DRIVERS)
|
||||
|
||||
if not pdevs:
|
||||
return None
|
||||
|
||||
# ROCm runs through onnx, whose MIGraphX provider takes no device index, so
|
||||
# only one is addressable
|
||||
units = [HardwareUnit(device="onnx", label=pdevs[0])]
|
||||
return _hardware("onnx:amd", "onnx", "AMD GPU", units)
|
||||
|
||||
|
||||
def detect_nvidia_gpu() -> DetectionHardware | None:
|
||||
"""Find discrete Nvidia GPUs through the nvidia driver's proc entries."""
|
||||
units = []
|
||||
|
||||
for index, gpu_dir in enumerate(sorted(glob(f"{PROC_ROOT}/driver/nvidia/gpus/*"))):
|
||||
information = _read(os.path.join(gpu_dir, "information")) or ""
|
||||
name = f"GPU {index}"
|
||||
|
||||
for line in information.splitlines():
|
||||
if line.startswith("Model:"):
|
||||
name = line.split(":", 1)[1].strip()
|
||||
break
|
||||
|
||||
units.append(HardwareUnit(device=f"onnx:{index}", label=name))
|
||||
|
||||
if not units:
|
||||
return None
|
||||
|
||||
# the model name is more useful as the hardware name when there is only one
|
||||
name = units[0].label if len(units) == 1 else "NVIDIA GPU"
|
||||
return _hardware("onnx:nvidia", "onnx", name, units)
|
||||
|
||||
|
||||
def detect_jetson() -> DetectionHardware | None:
|
||||
"""Find an Nvidia Jetson, whose integrated GPU runs through tensorrt."""
|
||||
is_jetson = os.path.isfile(f"{ETC_ROOT}/nv_tegra_release") or os.path.exists(
|
||||
f"{SYS_ROOT}/devices/gpu.0/load"
|
||||
)
|
||||
|
||||
if not is_jetson:
|
||||
return None
|
||||
|
||||
units = [HardwareUnit(device="tensorrt:0", label="Integrated GPU")]
|
||||
return _hardware("tensorrt", "tensorrt", "NVIDIA Jetson", units)
|
||||
|
||||
|
||||
def _dev_units(pattern: str, device: str, label: str) -> list[HardwareUnit]:
|
||||
"""Build units from device nodes matching a glob."""
|
||||
return [
|
||||
HardwareUnit(device=device.format(index=index), label=f"{label} {index}")
|
||||
for index in range(len(glob(f"{DEV_ROOT}/{pattern}")))
|
||||
]
|
||||
|
||||
|
||||
def detect_hailo() -> DetectionHardware | None:
|
||||
"""Find Hailo accelerators by their device nodes."""
|
||||
nodes = sorted(glob(f"{DEV_ROOT}/hailo*"))
|
||||
|
||||
if not nodes:
|
||||
return None
|
||||
|
||||
# the hailo runtime schedules across every attached device itself, so there
|
||||
# is nothing to address individually
|
||||
units = [HardwareUnit(device="hailo8l:PCIe", label=os.path.basename(nodes[0]))]
|
||||
return _hardware("hailo8l", "hailo8l", "Hailo", units)
|
||||
|
||||
|
||||
def detect_memryx() -> DetectionHardware | None:
|
||||
"""Find MemryX accelerators by their device nodes."""
|
||||
units = _dev_units("memx*", "memryx:PCIe:{index}", "PCIe")
|
||||
|
||||
if not units:
|
||||
return None
|
||||
|
||||
return _hardware("memryx", "memryx", "MemryX MX3", units)
|
||||
|
||||
|
||||
def detect_rockchip() -> DetectionHardware | None:
|
||||
"""Find a Rockchip NPU by reading the SoC from the device tree."""
|
||||
compatible = _read(f"{PROC_ROOT}/device-tree/compatible")
|
||||
|
||||
if not compatible:
|
||||
return None
|
||||
|
||||
soc = compatible.split(",")[-1].strip("\x00")
|
||||
|
||||
if soc not in SUPPORTED_RK_SOCS:
|
||||
return None
|
||||
|
||||
units = [HardwareUnit(device="rknn", label=soc.upper())]
|
||||
return _hardware("rknn", "rknn", f"Rockchip NPU ({soc.upper()})", units)
|
||||
|
||||
|
||||
def detect_axengine() -> DetectionHardware | None:
|
||||
"""Find an AXERA accelerator by its control device node."""
|
||||
if not os.path.exists(f"{DEV_ROOT}/axcl_host"):
|
||||
return None
|
||||
|
||||
units = [HardwareUnit(device="axengine", label="AXERA")]
|
||||
return _hardware("axengine", "axengine", "AXERA NPU", units)
|
||||
|
||||
|
||||
def detect_synaptics() -> DetectionHardware | None:
|
||||
"""Find a Synaptics NPU by its device node."""
|
||||
if not os.path.exists(f"{DEV_ROOT}/synap"):
|
||||
return None
|
||||
|
||||
units = [HardwareUnit(device="synaptics", label="Synaptics")]
|
||||
return _hardware("synaptics", "synaptics", "Synaptics NPU", units)
|
||||
|
||||
|
||||
def detect_cpu() -> DetectionHardware:
|
||||
"""The CPU, which is always available."""
|
||||
units = [HardwareUnit(device="cpu", label="CPU")]
|
||||
return _hardware("cpu", "cpu", "CPU", units)
|
||||
|
||||
|
||||
# ordered so accelerators are offered ahead of the CPU fallback
|
||||
PROBES = (
|
||||
detect_coral_pci,
|
||||
detect_coral_usb,
|
||||
detect_hailo,
|
||||
detect_memryx,
|
||||
detect_intel_npu,
|
||||
detect_intel_gpu,
|
||||
detect_nvidia_gpu,
|
||||
detect_jetson,
|
||||
detect_amd_gpu,
|
||||
detect_rockchip,
|
||||
detect_axengine,
|
||||
detect_synaptics,
|
||||
detect_cpu,
|
||||
)
|
||||
|
||||
|
||||
class HardwareProber:
|
||||
"""Probes for detection hardware, caching the result for the process."""
|
||||
|
||||
_hardware: list[DetectionHardware] | None = None
|
||||
|
||||
def probe(self, refresh: bool = False) -> list[DetectionHardware]:
|
||||
"""Get the detection hardware attached to this system.
|
||||
|
||||
Args:
|
||||
refresh: Probe again instead of using the cached result
|
||||
|
||||
Returns:
|
||||
Every kind of detection hardware that was found
|
||||
"""
|
||||
if self._hardware is not None and not refresh:
|
||||
return self._hardware
|
||||
|
||||
found = []
|
||||
|
||||
for probe in PROBES:
|
||||
try:
|
||||
hardware = probe()
|
||||
except Exception:
|
||||
logger.warning("Failed to probe for %s", probe.__name__, exc_info=True)
|
||||
continue
|
||||
|
||||
if hardware is not None:
|
||||
found.append(hardware)
|
||||
|
||||
logger.debug("Detected hardware: %s", [h.key for h in found])
|
||||
self._hardware = found
|
||||
return found
|
||||
|
||||
|
||||
hardware_prober = HardwareProber()
|
||||
55
frigate/test/http_api/test_http_audio_labels.py
Normal file
55
frigate/test/http_api/test_http_audio_labels.py
Normal file
@ -0,0 +1,55 @@
|
||||
"""Tests for the audio labels API."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from frigate.models import Event
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
|
||||
|
||||
class TestHttpAudioLabels(BaseTestHttp):
|
||||
def setUp(self):
|
||||
super().setUp([Event])
|
||||
|
||||
def _labels(self, config: dict | None = None) -> dict[str, str]:
|
||||
if config:
|
||||
self.minimal_config.update(config)
|
||||
|
||||
app = self.create_app()
|
||||
|
||||
with patch(
|
||||
"frigate.api.app.load_labels", return_value={0: "speech", 1: "bark"}
|
||||
):
|
||||
with AuthTestClient(app) as client:
|
||||
response = client.get("/audio_labels")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
return response.json()
|
||||
|
||||
def test_the_default_labels_are_returned(self):
|
||||
self.assertEqual(self._labels(), {"0": "speech", "1": "bark"})
|
||||
|
||||
def test_a_global_labelmap_override_is_offered(self):
|
||||
# grouping several classes under one label makes that label selectable
|
||||
labels = self._labels({"audio": {"labelmap": {0: "noise", 1: "noise"}}})
|
||||
|
||||
self.assertEqual(set(labels.values()), {"noise"})
|
||||
|
||||
def test_a_camera_labelmap_override_is_offered(self):
|
||||
labels = self._labels(
|
||||
{
|
||||
"cameras": {
|
||||
"front_door": {
|
||||
**self.minimal_config["cameras"]["front_door"],
|
||||
"audio": {"labelmap": {1: "dogs"}},
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(labels["1"], "dogs")
|
||||
self.assertEqual(labels["0"], "speech")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@ -159,7 +159,7 @@ class TestConfig(unittest.TestCase):
|
||||
FrigateConfig(**(deep_merge(config, self.minimal)))
|
||||
|
||||
@patch("frigate.detectors.detector_config.load_labels")
|
||||
def test_camera_scene_must_match_a_model(self, mock_labels):
|
||||
def test_camera_scene_without_a_model_falls_back_to_all(self, mock_labels):
|
||||
mock_labels.return_value = {}
|
||||
config = {
|
||||
"models": [{"devices": ["cpu"]}],
|
||||
@ -175,6 +175,27 @@ class TestConfig(unittest.TestCase):
|
||||
},
|
||||
}
|
||||
|
||||
frigate_config = FrigateConfig(**(deep_merge(config, self.minimal)))
|
||||
|
||||
assert frigate_config.model_for_camera("back").scene == SceneEnum.all
|
||||
|
||||
@patch("frigate.detectors.detector_config.load_labels")
|
||||
def test_camera_scene_without_a_model_or_a_default(self, mock_labels):
|
||||
mock_labels.return_value = {}
|
||||
config = {
|
||||
"models": [{"scene": "indoor", "devices": ["cpu"]}],
|
||||
"cameras": {
|
||||
"back": {
|
||||
"detect": {"scene": "outdoor"},
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]},
|
||||
]
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
FrigateConfig(**(deep_merge(config, self.minimal)))
|
||||
|
||||
@ -1298,7 +1319,7 @@ class TestConfig(unittest.TestCase):
|
||||
70: "dogs",
|
||||
75: "dogs",
|
||||
}
|
||||
assert frigate_config.model.merged_labelmap[69] != "dogs"
|
||||
assert frigate_config.primary_model.merged_labelmap[69] != "dogs"
|
||||
|
||||
def test_default_labelmap_empty(self):
|
||||
config = {
|
||||
|
||||
247
frigate/test/test_detector_hardware.py
Normal file
247
frigate/test/test_detector_hardware.py
Normal file
@ -0,0 +1,247 @@
|
||||
"""Tests for detection hardware discovery."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from frigate.detectors import hardware
|
||||
from frigate.detectors.detector_types import config_types
|
||||
from frigate.detectors.hardware import HardwareProber
|
||||
|
||||
|
||||
def write(path: str, content: str = "") -> None:
|
||||
"""Create a file and any parent directories."""
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
|
||||
with open(path, "w") as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
class HardwareProbeTestCase(unittest.TestCase):
|
||||
"""Points every probe at an empty fixture tree, so nothing is found by default."""
|
||||
|
||||
def setUp(self):
|
||||
self.root = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.root.cleanup)
|
||||
|
||||
for name in ("SYS_ROOT", "DEV_ROOT", "PROC_ROOT", "ETC_ROOT"):
|
||||
sub = os.path.join(self.root.name, name.split("_")[0].lower())
|
||||
os.makedirs(sub, exist_ok=True)
|
||||
patcher = patch.object(hardware, name, sub)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
setattr(self, name.lower(), sub)
|
||||
|
||||
drm = patch.object(hardware, "enumerate_drm_devices", return_value={})
|
||||
self.drm = drm.start()
|
||||
self.addCleanup(drm.stop)
|
||||
|
||||
def probe(self) -> dict[str, hardware.DetectionHardware]:
|
||||
return {found.key: found for found in HardwareProber().probe()}
|
||||
|
||||
|
||||
class TestNoHardware(HardwareProbeTestCase):
|
||||
def test_only_the_cpu_is_reported(self):
|
||||
self.assertEqual(list(self.probe()), ["cpu"])
|
||||
|
||||
def test_the_cpu_is_unlimited(self):
|
||||
self.assertTrue(self.probe()["cpu"].unlimited)
|
||||
|
||||
|
||||
class TestCoral(HardwareProbeTestCase):
|
||||
def test_each_apex_device_is_a_unit(self):
|
||||
for name in ("apex_0", "apex_1"):
|
||||
os.makedirs(os.path.join(self.sys_root, "class", "apex", name))
|
||||
|
||||
coral = self.probe()["edgetpu:pci"]
|
||||
|
||||
self.assertEqual(coral.count, 2)
|
||||
self.assertEqual(
|
||||
[unit.device for unit in coral.units],
|
||||
["edgetpu:pci:0", "edgetpu:pci:1"],
|
||||
)
|
||||
|
||||
def test_a_coral_is_not_unlimited(self):
|
||||
os.makedirs(os.path.join(self.sys_root, "class", "apex", "apex_0"))
|
||||
|
||||
self.assertFalse(self.probe()["edgetpu:pci"].unlimited)
|
||||
|
||||
def test_usb_corals_are_found_by_their_usb_ids(self):
|
||||
usb = os.path.join(self.sys_root, "bus", "usb", "devices")
|
||||
# a Coral reports as Global Unichip before its firmware loads
|
||||
write(os.path.join(usb, "1-1", "idVendor"), "1a6e")
|
||||
write(os.path.join(usb, "1-1", "idProduct"), "089a")
|
||||
# and as Google afterwards
|
||||
write(os.path.join(usb, "1-2", "idVendor"), "18d1")
|
||||
write(os.path.join(usb, "1-2", "idProduct"), "9302")
|
||||
|
||||
coral = self.probe()["edgetpu:usb"]
|
||||
|
||||
self.assertEqual(coral.count, 2)
|
||||
self.assertEqual(coral.units[0].device, "edgetpu:usb:0")
|
||||
|
||||
def test_other_usb_devices_are_ignored(self):
|
||||
usb = os.path.join(self.sys_root, "bus", "usb", "devices")
|
||||
write(os.path.join(usb, "1-1", "idVendor"), "046d")
|
||||
write(os.path.join(usb, "1-1", "idProduct"), "0825")
|
||||
|
||||
self.assertNotIn("edgetpu:usb", self.probe())
|
||||
|
||||
|
||||
class TestGpus(HardwareProbeTestCase):
|
||||
def test_a_single_intel_gpu_is_the_unnumbered_device(self):
|
||||
self.drm.return_value = {"0000:00:02.0": "i915"}
|
||||
|
||||
gpu = self.probe()["openvino:GPU"]
|
||||
|
||||
self.assertEqual([unit.device for unit in gpu.units], ["openvino:GPU"])
|
||||
self.assertTrue(gpu.unlimited)
|
||||
|
||||
def test_multiple_intel_gpus_are_numbered(self):
|
||||
self.drm.return_value = {"0000:00:02.0": "i915", "0000:03:00.0": "xe"}
|
||||
|
||||
gpu = self.probe()["openvino:GPU"]
|
||||
|
||||
self.assertEqual(
|
||||
[unit.device for unit in gpu.units],
|
||||
["openvino:GPU.0", "openvino:GPU.1"],
|
||||
)
|
||||
|
||||
def test_non_gpu_drm_devices_are_ignored(self):
|
||||
self.drm.return_value = {"0000:00:02.0": "virtio-mmio"}
|
||||
|
||||
self.assertNotIn("openvino:GPU", self.probe())
|
||||
|
||||
def test_amd_gpus_run_through_onnx(self):
|
||||
self.drm.return_value = {"0000:03:00.0": "amdgpu"}
|
||||
|
||||
self.assertEqual(self.probe()["onnx:amd"].units[0].device, "onnx")
|
||||
|
||||
def test_an_intel_npu_is_found_by_its_driver(self):
|
||||
accel = os.path.join(self.sys_root, "class", "accel", "accel0", "device")
|
||||
os.makedirs(accel)
|
||||
os.symlink("/drivers/intel_vpu", os.path.join(accel, "driver"))
|
||||
|
||||
self.assertEqual(self.probe()["openvino:NPU"].units[0].device, "openvino:NPU")
|
||||
|
||||
def test_other_accel_devices_are_ignored(self):
|
||||
accel = os.path.join(self.sys_root, "class", "accel", "accel0", "device")
|
||||
os.makedirs(accel)
|
||||
os.symlink("/drivers/something_else", os.path.join(accel, "driver"))
|
||||
|
||||
self.assertNotIn("openvino:NPU", self.probe())
|
||||
|
||||
|
||||
class TestNvidia(HardwareProbeTestCase):
|
||||
def _add_gpu(self, address: str, model: str) -> None:
|
||||
write(
|
||||
os.path.join(
|
||||
self.proc_root, "driver", "nvidia", "gpus", address, "information"
|
||||
),
|
||||
f"Model: \t {model}\nIRQ: \t 62\n",
|
||||
)
|
||||
|
||||
def test_the_model_name_is_read_from_proc(self):
|
||||
self._add_gpu("0000:01:00.0", "NVIDIA GeForce RTX 3060")
|
||||
|
||||
gpu = self.probe()["onnx:nvidia"]
|
||||
|
||||
self.assertEqual(gpu.name, "NVIDIA GeForce RTX 3060")
|
||||
self.assertEqual(gpu.units[0].device, "onnx:0")
|
||||
|
||||
def test_multiple_gpus_are_indexed(self):
|
||||
self._add_gpu("0000:01:00.0", "NVIDIA GeForce RTX 3060")
|
||||
self._add_gpu("0000:02:00.0", "NVIDIA GeForce RTX 4090")
|
||||
|
||||
gpu = self.probe()["onnx:nvidia"]
|
||||
|
||||
self.assertEqual(gpu.name, "NVIDIA GPU")
|
||||
self.assertEqual([unit.device for unit in gpu.units], ["onnx:0", "onnx:1"])
|
||||
self.assertEqual(gpu.units[1].label, "NVIDIA GeForce RTX 4090")
|
||||
|
||||
def test_a_jetson_runs_through_tensorrt(self):
|
||||
write(os.path.join(self.etc_root, "nv_tegra_release"), "# R36 (release)")
|
||||
|
||||
self.assertEqual(self.probe()["tensorrt"].units[0].device, "tensorrt:0")
|
||||
|
||||
|
||||
class TestAccelerators(HardwareProbeTestCase):
|
||||
def test_hailo_is_found_by_its_device_node(self):
|
||||
write(os.path.join(self.dev_root, "hailo0"))
|
||||
|
||||
self.assertEqual(self.probe()["hailo8l"].units[0].device, "hailo8l:PCIe")
|
||||
|
||||
def test_each_memryx_node_is_a_unit(self):
|
||||
write(os.path.join(self.dev_root, "memx0"))
|
||||
write(os.path.join(self.dev_root, "memx1"))
|
||||
|
||||
memryx = self.probe()["memryx"]
|
||||
|
||||
self.assertEqual(
|
||||
[unit.device for unit in memryx.units],
|
||||
["memryx:PCIe:0", "memryx:PCIe:1"],
|
||||
)
|
||||
self.assertFalse(memryx.unlimited)
|
||||
|
||||
def test_a_supported_rockchip_soc_is_reported(self):
|
||||
write(
|
||||
os.path.join(self.proc_root, "device-tree", "compatible"),
|
||||
"rockchip,rk3588\x00",
|
||||
)
|
||||
|
||||
self.assertEqual(self.probe()["rknn"].units[0].device, "rknn")
|
||||
|
||||
def test_an_unsupported_soc_is_ignored(self):
|
||||
write(
|
||||
os.path.join(self.proc_root, "device-tree", "compatible"),
|
||||
"nvidia,tegra\x00",
|
||||
)
|
||||
|
||||
self.assertNotIn("rknn", self.probe())
|
||||
|
||||
def test_axengine_is_found_by_its_control_node(self):
|
||||
write(os.path.join(self.dev_root, "axcl_host"))
|
||||
|
||||
self.assertEqual(self.probe()["axengine"].units[0].device, "axengine")
|
||||
|
||||
def test_synaptics_is_found_by_its_device_node(self):
|
||||
write(os.path.join(self.dev_root, "synap"))
|
||||
|
||||
self.assertEqual(self.probe()["synaptics"].units[0].device, "synaptics")
|
||||
|
||||
|
||||
class TestProber(HardwareProbeTestCase):
|
||||
def test_the_result_is_cached_until_refreshed(self):
|
||||
prober = HardwareProber()
|
||||
self.assertNotIn("edgetpu:pci", {found.key for found in prober.probe()})
|
||||
|
||||
os.makedirs(os.path.join(self.sys_root, "class", "apex", "apex_0"))
|
||||
|
||||
self.assertNotIn("edgetpu:pci", {found.key for found in prober.probe()})
|
||||
self.assertIn(
|
||||
"edgetpu:pci", {found.key for found in prober.probe(refresh=True)}
|
||||
)
|
||||
|
||||
def test_a_failing_probe_does_not_break_the_rest(self):
|
||||
with patch.object(hardware, "detect_hailo", side_effect=OSError("boom")):
|
||||
self.assertIn("cpu", self.probe())
|
||||
|
||||
def test_unlimited_tracks_the_detector_shareable_flag(self):
|
||||
for name in ("apex_0",):
|
||||
os.makedirs(os.path.join(self.sys_root, "class", "apex", name))
|
||||
write(os.path.join(self.dev_root, "memx0"))
|
||||
self.drm.return_value = {"0000:00:02.0": "i915"}
|
||||
|
||||
for found in self.probe().values():
|
||||
config_class = config_types.get(found.detector)
|
||||
|
||||
if config_class is None:
|
||||
continue
|
||||
|
||||
with self.subTest(hardware=found.key):
|
||||
self.assertEqual(found.unlimited, config_class.shareable)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@ -108,7 +108,7 @@ class TestGpuStats(unittest.TestCase):
|
||||
@patch("frigate.util.services.time.sleep")
|
||||
@patch("frigate.util.services.time.monotonic")
|
||||
@patch("frigate.util.services._read_intel_drm_fdinfo")
|
||||
@patch("frigate.util.services._enumerate_drm_devices")
|
||||
@patch("frigate.util.services.enumerate_drm_devices")
|
||||
def test_intel_gpu_stats_fdinfo(
|
||||
self, drm_devices, read_fdinfo, monotonic, sleep, get_names
|
||||
):
|
||||
@ -187,7 +187,7 @@ class TestGpuStats(unittest.TestCase):
|
||||
@patch("frigate.util.services.time.sleep")
|
||||
@patch("frigate.util.services.time.monotonic")
|
||||
@patch("frigate.util.services._read_intel_drm_fdinfo")
|
||||
@patch("frigate.util.services._enumerate_drm_devices")
|
||||
@patch("frigate.util.services.enumerate_drm_devices")
|
||||
def test_intel_gpu_stats_xe_capacity(
|
||||
self, drm_devices, read_fdinfo, monotonic, sleep, get_names
|
||||
):
|
||||
@ -246,7 +246,7 @@ class TestGpuStats(unittest.TestCase):
|
||||
@patch("frigate.stats.intel_gpu_info.intel_gpu_name_resolver.get_names")
|
||||
@patch("frigate.util.services.time.sleep")
|
||||
@patch("frigate.util.services._read_intel_drm_fdinfo")
|
||||
@patch("frigate.util.services._enumerate_drm_devices")
|
||||
@patch("frigate.util.services.enumerate_drm_devices")
|
||||
def test_intel_gpu_stats_no_clients_reports_idle(
|
||||
self, drm_devices, read_fdinfo, sleep, get_names
|
||||
):
|
||||
@ -274,7 +274,7 @@ class TestGpuStats(unittest.TestCase):
|
||||
|
||||
@patch("frigate.util.services.time.sleep")
|
||||
@patch("frigate.util.services._read_intel_drm_fdinfo")
|
||||
@patch("frigate.util.services._enumerate_drm_devices")
|
||||
@patch("frigate.util.services.enumerate_drm_devices")
|
||||
def test_intel_gpu_stats_clients_without_engine_counters(
|
||||
self, drm_devices, read_fdinfo, sleep
|
||||
):
|
||||
@ -301,7 +301,7 @@ class TestGpuStats(unittest.TestCase):
|
||||
read_fdinfo.assert_called_once()
|
||||
|
||||
@patch("frigate.util.services._read_intel_drm_fdinfo")
|
||||
@patch("frigate.util.services._enumerate_drm_devices")
|
||||
@patch("frigate.util.services.enumerate_drm_devices")
|
||||
def test_intel_gpu_stats_no_intel_device(self, drm_devices, read_fdinfo):
|
||||
# Only a non-Intel GPU is visible in sysfs; /proc is never scanned
|
||||
drm_devices.return_value = {"0000:01:00.0": "nvidia"}
|
||||
@ -310,7 +310,7 @@ class TestGpuStats(unittest.TestCase):
|
||||
read_fdinfo.assert_not_called()
|
||||
|
||||
@patch("frigate.util.services._read_intel_drm_fdinfo")
|
||||
@patch("frigate.util.services._enumerate_drm_devices")
|
||||
@patch("frigate.util.services.enumerate_drm_devices")
|
||||
@patch("frigate.util.services._resolve_intel_gpu_pdev")
|
||||
def test_intel_gpu_stats_unresolvable_device_hint(
|
||||
self, resolve_pdev, drm_devices, read_fdinfo
|
||||
@ -324,7 +324,7 @@ class TestGpuStats(unittest.TestCase):
|
||||
read_fdinfo.assert_not_called()
|
||||
|
||||
@patch("frigate.util.services._read_intel_drm_fdinfo")
|
||||
@patch("frigate.util.services._enumerate_drm_devices")
|
||||
@patch("frigate.util.services.enumerate_drm_devices")
|
||||
@patch("frigate.util.services._resolve_intel_gpu_pdev")
|
||||
def test_intel_gpu_stats_hint_resolves_to_non_intel_gpu(
|
||||
self, resolve_pdev, drm_devices, read_fdinfo
|
||||
@ -342,7 +342,7 @@ class TestGpuStats(unittest.TestCase):
|
||||
read_fdinfo.assert_not_called()
|
||||
|
||||
@patch("frigate.util.services._read_intel_drm_fdinfo")
|
||||
@patch("frigate.util.services._enumerate_drm_devices")
|
||||
@patch("frigate.util.services.enumerate_drm_devices")
|
||||
def test_intel_gpu_stats_unreadable_proc(self, drm_devices, read_fdinfo):
|
||||
# A scan failure (None) is a different condition than a scan that
|
||||
# finds no clients ({}) and must not report idle
|
||||
@ -355,7 +355,7 @@ class TestGpuStats(unittest.TestCase):
|
||||
@patch("frigate.util.services.time.sleep")
|
||||
@patch("frigate.util.services.time.monotonic")
|
||||
@patch("frigate.util.services._read_intel_drm_fdinfo")
|
||||
@patch("frigate.util.services._enumerate_drm_devices")
|
||||
@patch("frigate.util.services.enumerate_drm_devices")
|
||||
def test_intel_gpu_stats_clients_lost_between_samples(
|
||||
self, drm_devices, read_fdinfo, monotonic, sleep, get_names
|
||||
):
|
||||
|
||||
@ -315,7 +315,7 @@ def _resolve_intel_gpu_pdev(device: str | None) -> str | None:
|
||||
return pdev if _PCI_ADDRESS_RE.match(pdev) else None
|
||||
|
||||
|
||||
def _enumerate_drm_devices() -> dict[str, str]:
|
||||
def enumerate_drm_devices() -> dict[str, str]:
|
||||
"""Map each PCI-attached DRM device to its bound kernel driver.
|
||||
|
||||
Reads /sys/class/drm, which reflects every GPU on the host even when only
|
||||
@ -517,7 +517,7 @@ def get_intel_gpu_stats(
|
||||
)
|
||||
return None
|
||||
|
||||
drm_devices = _enumerate_drm_devices()
|
||||
drm_devices = enumerate_drm_devices()
|
||||
intel_pdevs = {
|
||||
pdev: driver
|
||||
for pdev, driver in drm_devices.items()
|
||||
|
||||
@ -56,6 +56,7 @@ from frigate.api import (
|
||||
debug_replay,
|
||||
event,
|
||||
export,
|
||||
hardware,
|
||||
media,
|
||||
motion_search,
|
||||
notification,
|
||||
@ -152,6 +153,7 @@ def build_app() -> FastAPI:
|
||||
preview.router,
|
||||
notification.router,
|
||||
export.router,
|
||||
hardware.router,
|
||||
event.router,
|
||||
media.router,
|
||||
motion_search.router,
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
39
web/e2e/fixtures/mock-data/hardware.ts
Normal file
39
web/e2e/fixtures/mock-data/hardware.ts
Normal file
@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Detection hardware as reported by GET /api/hardware/probe.
|
||||
*
|
||||
* A mixed payload on purpose: two Corals exercise the per-unit checkboxes and
|
||||
* the "already used by another model" state, while the Intel GPU exercises the
|
||||
* unlimited detector-count dropdown.
|
||||
*/
|
||||
export const DETECTION_HARDWARE = [
|
||||
{
|
||||
key: "edgetpu:pci",
|
||||
detector: "edgetpu",
|
||||
name: "Coral EdgeTPU (PCIe)",
|
||||
units: [
|
||||
{ device: "edgetpu:pci:0", label: "PCIe 0" },
|
||||
{ device: "edgetpu:pci:1", label: "PCIe 1" },
|
||||
],
|
||||
count: 2,
|
||||
unlimited: false,
|
||||
},
|
||||
{
|
||||
key: "openvino:GPU",
|
||||
detector: "openvino",
|
||||
name: "Intel GPU",
|
||||
units: [
|
||||
{ device: "openvino:GPU.0", label: "0000:00:02.0" },
|
||||
{ device: "openvino:GPU.1", label: "0000:03:00.0" },
|
||||
],
|
||||
count: 2,
|
||||
unlimited: true,
|
||||
},
|
||||
{
|
||||
key: "cpu",
|
||||
detector: "cpu",
|
||||
name: "CPU",
|
||||
units: [{ device: "cpu", label: "CPU" }],
|
||||
count: 1,
|
||||
unlimited: true,
|
||||
},
|
||||
];
|
||||
@ -14,6 +14,7 @@ import {
|
||||
type DeepPartial,
|
||||
configFactory,
|
||||
} from "../fixtures/mock-data/config";
|
||||
import { DETECTION_HARDWARE } from "../fixtures/mock-data/hardware";
|
||||
import { adminProfile, type UserProfile } from "../fixtures/mock-data/profile";
|
||||
import { BASE_STATS, statsFactory } from "../fixtures/mock-data/stats";
|
||||
|
||||
@ -41,6 +42,7 @@ export interface ApiMockOverrides {
|
||||
faces?: Record<string, unknown>;
|
||||
configRaw?: string;
|
||||
configSchema?: Record<string, unknown>;
|
||||
hardware?: unknown[];
|
||||
}
|
||||
|
||||
export class ApiMocker {
|
||||
@ -178,6 +180,11 @@ export class ApiMocker {
|
||||
route.fulfill({ json: { success: true, require_restart: false } }),
|
||||
);
|
||||
|
||||
// Detection hardware discovery
|
||||
await this.page.route("**/api/hardware/probe**", (route) =>
|
||||
route.fulfill({ json: overrides?.hardware ?? DETECTION_HARDWARE }),
|
||||
);
|
||||
|
||||
// Go2RTC streams
|
||||
await this.page.route("**/api/go2rtc/streams**", (route) =>
|
||||
route.fulfill({ json: {} }),
|
||||
|
||||
404
web/e2e/specs/settings/detection-models.spec.ts
Normal file
404
web/e2e/specs/settings/detection-models.spec.ts
Normal file
@ -0,0 +1,404 @@
|
||||
/**
|
||||
* Detection models settings page tests -- HIGH tier.
|
||||
*
|
||||
* Covers picking hardware per model: exclusive units (Corals) are checkboxes
|
||||
* that can only be claimed by one model, unlimited hardware (a GPU) gets a
|
||||
* detector-count dropdown, and the whole models list saves in one PUT.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { test, expect } from "../../fixtures/frigate-test";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { configFactory } from "../../fixtures/mock-data/config";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_SCHEMA = JSON.parse(
|
||||
readFileSync(
|
||||
resolve(__dirname, "../../fixtures/mock-data/config-schema.json"),
|
||||
"utf-8",
|
||||
),
|
||||
);
|
||||
|
||||
const PAGE = "/settings?page=systemDetectorsAndModel";
|
||||
|
||||
type Model = {
|
||||
scene: string;
|
||||
devices: string[];
|
||||
path?: string | null;
|
||||
input_tensor?: string;
|
||||
input_pixel_format?: string;
|
||||
input_dtype?: string;
|
||||
model_type?: string;
|
||||
labelmap?: Record<string, string>;
|
||||
attributes_map?: Record<string, string[]>;
|
||||
plus?: { id: string; name: string } | null;
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
const PLUS_MODEL = {
|
||||
id: "abc123",
|
||||
name: "yolov9-s",
|
||||
baseModel: "yolov9",
|
||||
trainDate: "2026-01-02T03:04:05Z",
|
||||
isBaseModel: true,
|
||||
supportedDetectors: ["openvino"],
|
||||
width: 320,
|
||||
height: 320,
|
||||
};
|
||||
|
||||
type SavedConfig = { config_data?: { models?: Model[] } };
|
||||
|
||||
async function installRoutes(page: Page, models: Model[], plusEnabled = false) {
|
||||
const config = configFactory({
|
||||
models,
|
||||
plus: { enabled: plusEnabled },
|
||||
} as never);
|
||||
const saves: SavedConfig[] = [];
|
||||
|
||||
await page.route("**/api/config/schema.json", (route) =>
|
||||
route.fulfill({ json: CONFIG_SCHEMA }),
|
||||
);
|
||||
await page.route("**/api/config", (route) =>
|
||||
route.request().method() === "GET"
|
||||
? route.fulfill({ json: config })
|
||||
: route.fulfill({ json: { success: true } }),
|
||||
);
|
||||
await page.route("**/api/config/raw_paths", (route) =>
|
||||
route.fulfill({ json: { models } }),
|
||||
);
|
||||
await page.route("**/api/plus/models", (route) =>
|
||||
route.fulfill({ json: [PLUS_MODEL] }),
|
||||
);
|
||||
await page.route("**/api/config/set", async (route) => {
|
||||
saves.push(route.request().postDataJSON() as SavedConfig);
|
||||
await route.fulfill({ json: { success: true, require_restart: false } });
|
||||
});
|
||||
|
||||
return saves;
|
||||
}
|
||||
|
||||
const openPage = async (frigateApp: {
|
||||
goto: (url: string) => Promise<void>;
|
||||
}) => {
|
||||
await frigateApp.goto(PAGE);
|
||||
};
|
||||
|
||||
test.describe("Detection models settings @high", () => {
|
||||
test("renders a card per configured model", async ({ frigateApp }) => {
|
||||
await installRoutes(frigateApp.page, [
|
||||
{ scene: "all", devices: ["cpu"] },
|
||||
{ scene: "outdoor", devices: ["edgetpu:pci:0"] },
|
||||
]);
|
||||
await openPage(frigateApp);
|
||||
|
||||
const root = frigateApp.page.locator("#pageRoot");
|
||||
await expect(root).toContainText("All cameras");
|
||||
await expect(root).toContainText("Outdoor");
|
||||
});
|
||||
|
||||
test("unlimited hardware offers a detector count", async ({ frigateApp }) => {
|
||||
await installRoutes(frigateApp.page, [
|
||||
{ scene: "all", devices: ["openvino:GPU.0"] },
|
||||
]);
|
||||
await openPage(frigateApp);
|
||||
|
||||
await expect(
|
||||
frigateApp.page.getByText("Detectors", { exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
// the trigger shows the bare count; the recommendation is a second line on
|
||||
// the matching option, so the dropdown has to be open to see it
|
||||
await expect(
|
||||
frigateApp.page.locator("#models-0-detector-count"),
|
||||
).toHaveText("1");
|
||||
|
||||
await frigateApp.page.locator("#models-0-detector-count").click();
|
||||
|
||||
// three cameras in the mock config, so one detector is recommended
|
||||
await expect(
|
||||
frigateApp.page.getByRole("option", {
|
||||
name: /Recommended for 3 cameras/,
|
||||
}),
|
||||
).toHaveText(/^1/);
|
||||
});
|
||||
|
||||
test("a detector count above the recommendation is unlabelled", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installRoutes(frigateApp.page, [
|
||||
{ scene: "all", devices: ["openvino:GPU.0", "openvino:GPU.0"] },
|
||||
]);
|
||||
await openPage(frigateApp);
|
||||
|
||||
// two detectors are configured while one is recommended, so neither the
|
||||
// trigger nor the selected option carries a recommendation
|
||||
await expect(
|
||||
frigateApp.page.locator("#models-0-detector-count"),
|
||||
).toHaveText("2");
|
||||
|
||||
await frigateApp.page.locator("#models-0-detector-count").click();
|
||||
|
||||
await expect(
|
||||
frigateApp.page.getByRole("option", { name: /^2/ }),
|
||||
).not.toContainText("Recommended");
|
||||
});
|
||||
|
||||
test("exclusive hardware offers one checkbox per unit", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installRoutes(frigateApp.page, [
|
||||
{ scene: "all", devices: ["edgetpu:pci:0"] },
|
||||
]);
|
||||
await openPage(frigateApp);
|
||||
|
||||
await expect(
|
||||
frigateApp.page.locator("#models-0-edgetpu\\:pci\\:0"),
|
||||
).toBeChecked();
|
||||
await expect(
|
||||
frigateApp.page.locator("#models-0-edgetpu\\:pci\\:1"),
|
||||
).not.toBeChecked();
|
||||
});
|
||||
|
||||
test("a unit claimed by another model cannot be picked", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installRoutes(frigateApp.page, [
|
||||
{ scene: "all", devices: ["edgetpu:pci:0"] },
|
||||
{ scene: "outdoor", devices: ["edgetpu:pci:1"] },
|
||||
]);
|
||||
await openPage(frigateApp);
|
||||
|
||||
// the first card's checkbox for the unit the second model holds
|
||||
await expect(
|
||||
frigateApp.page.locator("#models-0-edgetpu\\:pci\\:1").first(),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test("adding a model appends a card with an unused scene", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installRoutes(frigateApp.page, [{ scene: "all", devices: ["cpu"] }]);
|
||||
await openPage(frigateApp);
|
||||
|
||||
await frigateApp.page.getByRole("button", { name: "Add model" }).click();
|
||||
|
||||
// "all" is taken, so the new card takes the next available scene
|
||||
await expect(frigateApp.page.locator("#pageRoot")).toContainText("Indoor");
|
||||
});
|
||||
|
||||
test("hardware is summarized rather than listed device by device", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installRoutes(frigateApp.page, [
|
||||
{ scene: "all", devices: ["openvino:GPU.0", "openvino:GPU.0"] },
|
||||
]);
|
||||
await openPage(frigateApp);
|
||||
|
||||
await expect(frigateApp.page.locator("#pageRoot")).toContainText(
|
||||
"Intel GPU (2) \u2022 3 cameras",
|
||||
);
|
||||
await expect(frigateApp.page.locator("#pageRoot")).not.toContainText(
|
||||
"openvino:GPU, openvino:GPU",
|
||||
);
|
||||
});
|
||||
|
||||
test("a saved Frigate+ model opens on the Frigate+ tab", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
// the backend resolves plus:// to a cache path before serving the config
|
||||
// back, so the plus metadata is the only signal the model is a Plus one
|
||||
await installRoutes(
|
||||
frigateApp.page,
|
||||
[
|
||||
{
|
||||
scene: "all",
|
||||
devices: ["openvino:GPU.0"],
|
||||
path: "/config/model_cache/abc123",
|
||||
plus: PLUS_MODEL,
|
||||
},
|
||||
],
|
||||
true,
|
||||
);
|
||||
await openPage(frigateApp);
|
||||
|
||||
await expect(
|
||||
frigateApp.page.getByRole("tab", { name: "Frigate+" }),
|
||||
).toHaveAttribute("data-state", "active");
|
||||
await expect(frigateApp.page.locator("#pageRoot")).toContainText(
|
||||
"yolov9-s",
|
||||
);
|
||||
});
|
||||
|
||||
test("picking a Frigate+ model stays on the tab and saves a plus path", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const saves = await installRoutes(
|
||||
frigateApp.page,
|
||||
[
|
||||
{
|
||||
scene: "all",
|
||||
devices: ["openvino:GPU.0"],
|
||||
path: "/config/custom.onnx",
|
||||
},
|
||||
],
|
||||
true,
|
||||
);
|
||||
await openPage(frigateApp);
|
||||
|
||||
await frigateApp.page.getByRole("tab", { name: "Frigate+" }).click();
|
||||
await frigateApp.page.getByRole("combobox").last().click();
|
||||
await frigateApp.page.getByRole("option").first().click();
|
||||
|
||||
await expect(
|
||||
frigateApp.page.getByRole("tab", { name: "Frigate+" }),
|
||||
).toHaveAttribute("data-state", "active");
|
||||
|
||||
await frigateApp.page.getByRole("button", { name: /^Save$/ }).click();
|
||||
await expect.poll(() => saves.length).toBeGreaterThan(0);
|
||||
|
||||
expect(saves.at(-1)?.config_data?.models?.[0].path).toBe("plus://abc123");
|
||||
});
|
||||
|
||||
test("a freshly opened page is not reported as modified", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
// `/api/config` serializes with exclude_none, so a nullable field such as
|
||||
// labelmap_path is absent rather than null. The form materializes it, and
|
||||
// that must not read as an edit.
|
||||
await installRoutes(frigateApp.page, [
|
||||
{
|
||||
scene: "all",
|
||||
devices: ["openvino:GPU.0", "openvino:GPU.0"],
|
||||
path: "/config/model_cache/abc123",
|
||||
width: 320,
|
||||
height: 320,
|
||||
input_tensor: "nchw",
|
||||
input_pixel_format: "rgb",
|
||||
input_dtype: "float",
|
||||
model_type: "yolo-generic",
|
||||
labelmap: {},
|
||||
attributes_map: {},
|
||||
},
|
||||
]);
|
||||
await openPage(frigateApp);
|
||||
|
||||
await expect(
|
||||
frigateApp.page.getByRole("button", { name: /^Save$/ }),
|
||||
).toBeVisible();
|
||||
await expect(frigateApp.page.getByText("Modified")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("the scene, hardware and detector count fields are described", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installRoutes(frigateApp.page, [
|
||||
{ scene: "all", devices: ["openvino:GPU.0"] },
|
||||
]);
|
||||
await openPage(frigateApp);
|
||||
|
||||
const root = frigateApp.page.locator("#pageRoot");
|
||||
await expect(root).toContainText("The environment this model is for");
|
||||
await expect(root).toContainText(
|
||||
"The hardware this model runs its detection on",
|
||||
);
|
||||
await expect(root).toContainText("How many detection processes to run");
|
||||
});
|
||||
|
||||
test("per unit hardware explains why a claimed unit is unavailable", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installRoutes(frigateApp.page, [
|
||||
{ scene: "all", devices: ["edgetpu:pci:0"] },
|
||||
]);
|
||||
await openPage(frigateApp);
|
||||
|
||||
// the count dropdown is replaced by checkboxes, so it gets its own copy
|
||||
await expect(frigateApp.page.locator("#pageRoot")).toContainText(
|
||||
"Each unit runs its own detection process",
|
||||
);
|
||||
});
|
||||
|
||||
test("removing the default model blocks saving", async ({ frigateApp }) => {
|
||||
// a camera that names no scene runs the "all" model, so deleting it would
|
||||
// leave those cameras with nothing to fall back to
|
||||
await installRoutes(frigateApp.page, [
|
||||
{ scene: "all", devices: ["cpu"] },
|
||||
{ scene: "outdoor", devices: ["edgetpu:pci:0"] },
|
||||
]);
|
||||
await openPage(frigateApp);
|
||||
|
||||
await frigateApp.page
|
||||
.getByRole("button", { name: "Delete" })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await expect(frigateApp.page.locator("#pageRoot")).toContainText(
|
||||
"One model must use a scene of 'All cameras'",
|
||||
);
|
||||
await expect(
|
||||
frigateApp.page.getByRole("button", { name: /^Save$/ }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test("a second GPU can be assigned to a model", async ({ frigateApp }) => {
|
||||
// shareable hardware can report several addressable units; every one of
|
||||
// them must be reachable, not just the first
|
||||
const saves = await installRoutes(frigateApp.page, [
|
||||
{ scene: "all", devices: ["openvino:GPU.0"] },
|
||||
]);
|
||||
await openPage(frigateApp);
|
||||
|
||||
await frigateApp.page.locator("#models-0-openvino\\:GPU\\.1").click();
|
||||
await frigateApp.page.getByRole("button", { name: /^Save$/ }).click();
|
||||
await expect.poll(() => saves.length).toBeGreaterThan(0);
|
||||
|
||||
expect(saves.at(-1)?.config_data?.models?.[0].devices).toEqual([
|
||||
"openvino:GPU.0",
|
||||
"openvino:GPU.1",
|
||||
]);
|
||||
});
|
||||
|
||||
test("detectors are spread across every selected GPU", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const saves = await installRoutes(frigateApp.page, [
|
||||
{ scene: "all", devices: ["openvino:GPU.0", "openvino:GPU.1"] },
|
||||
]);
|
||||
await openPage(frigateApp);
|
||||
|
||||
await frigateApp.page.locator("#models-0-detector-count").click();
|
||||
await frigateApp.page
|
||||
.getByRole("option", { name: "4", exact: true })
|
||||
.click();
|
||||
await frigateApp.page.getByRole("button", { name: /^Save$/ }).click();
|
||||
await expect.poll(() => saves.length).toBeGreaterThan(0);
|
||||
|
||||
expect(saves.at(-1)?.config_data?.models?.[0].devices).toEqual([
|
||||
"openvino:GPU.0",
|
||||
"openvino:GPU.1",
|
||||
"openvino:GPU.0",
|
||||
"openvino:GPU.1",
|
||||
]);
|
||||
});
|
||||
|
||||
test("saving writes the whole models list in one request", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const saves = await installRoutes(frigateApp.page, [
|
||||
{ scene: "all", devices: ["edgetpu:pci:0"] },
|
||||
]);
|
||||
await openPage(frigateApp);
|
||||
|
||||
await frigateApp.page.locator("#models-0-edgetpu\\:pci\\:1").click();
|
||||
await frigateApp.page.getByRole("button", { name: /^Save$/ }).click();
|
||||
|
||||
await expect.poll(() => saves.length).toBeGreaterThan(0);
|
||||
|
||||
const models = saves.at(-1)?.config_data?.models;
|
||||
expect(models).toHaveLength(1);
|
||||
expect(models?.[0].devices).toEqual(["edgetpu:pci:0", "edgetpu:pci:1"]);
|
||||
});
|
||||
});
|
||||
@ -1,58 +0,0 @@
|
||||
/**
|
||||
* Detectors and model settings page tests -- HIGH tier.
|
||||
*
|
||||
* Tests rendering of the merged page and navigation from the Frigate+ page.
|
||||
*/
|
||||
|
||||
import { test, expect } from "../../fixtures/frigate-test";
|
||||
|
||||
// The settings page still reads the removed `detectors` and `model` config
|
||||
// keys, so it cannot render against a `models` config. Re-enable these once
|
||||
// the page is rebuilt around the models list.
|
||||
test.describe.skip("Detectors and model Settings @high", () => {
|
||||
test("page renders with detector and model cards", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/settings?page=systemDetectorsAndModel");
|
||||
await frigateApp.page.waitForTimeout(2000);
|
||||
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
|
||||
|
||||
const text = await frigateApp.page.textContent("#pageRoot");
|
||||
expect(text).toContain("Detectors and model");
|
||||
expect(text?.toLowerCase()).toContain("detector hardware");
|
||||
expect(text?.toLowerCase()).toContain("detection model");
|
||||
});
|
||||
|
||||
test("Frigate+ page links to the merged page", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/settings?page=frigateplus");
|
||||
await frigateApp.page.waitForTimeout(2000);
|
||||
|
||||
const button = frigateApp.page.getByRole("button", {
|
||||
name: /Change in Detectors and model/,
|
||||
});
|
||||
|
||||
// Button only appears when Frigate+ is enabled in the test config; skip
|
||||
// the click assertion if it's not present.
|
||||
if ((await button.count()) > 0) {
|
||||
await button.first().click();
|
||||
await frigateApp.page.waitForURL(/page=systemDetectorsAndModel/);
|
||||
await expect(frigateApp.page.locator("#pageRoot")).toContainText(
|
||||
"Detectors and model",
|
||||
);
|
||||
} else {
|
||||
test.skip(
|
||||
true,
|
||||
"Frigate+ not enabled in this test config; skipping link assertion",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("old systemDetectionModel deep-link no longer routes here", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto("/settings?page=systemDetectionModel");
|
||||
await frigateApp.page.waitForTimeout(2000);
|
||||
// The old page key is no longer in allSettingsViews; the router
|
||||
// falls back to its default settings page (uiSettings).
|
||||
const text = await frigateApp.page.textContent("#pageRoot");
|
||||
expect(text).not.toContain("Detection model");
|
||||
});
|
||||
});
|
||||
@ -129,6 +129,8 @@
|
||||
"saving": "Saving…",
|
||||
"cancel": "Cancel",
|
||||
"close": "Close",
|
||||
"expand": "Expand",
|
||||
"collapse": "Collapse",
|
||||
"copy": "Copy",
|
||||
"copiedToClipboard": "Copied to clipboard",
|
||||
"back": "Back",
|
||||
|
||||
@ -100,7 +100,7 @@
|
||||
},
|
||||
"scene": {
|
||||
"label": "Detect scene",
|
||||
"description": "The environment this camera looks at, used to pick which of the configured models runs on it. Defaults to the model with a scene of 'all'."
|
||||
"description": "The environment this camera looks at, used to pick which of the configured models runs on it. Cameras left on 'all' run the model configured with a scene of 'all'."
|
||||
},
|
||||
"fps": {
|
||||
"label": "Detect FPS",
|
||||
|
||||
@ -468,7 +468,7 @@
|
||||
},
|
||||
"scene": {
|
||||
"label": "Detect scene",
|
||||
"description": "The environment this camera looks at, used to pick which of the configured models runs on it. Defaults to the model with a scene of 'all'."
|
||||
"description": "The environment this camera looks at, used to pick which of the configured models runs on it. Cameras left on 'all' run the model configured with a scene of 'all'."
|
||||
},
|
||||
"fps": {
|
||||
"label": "Detect FPS",
|
||||
|
||||
@ -31,5 +31,8 @@
|
||||
},
|
||||
"detect": {
|
||||
"dimensionMustBeEven": "Must be an even number."
|
||||
},
|
||||
"models": {
|
||||
"defaultRequired": "One model must use a scene of 'All cameras'. Without it, any camera that does not choose a scene has no model to fall back to."
|
||||
}
|
||||
}
|
||||
|
||||
@ -75,7 +75,7 @@
|
||||
"systemTelemetry": "Telemetry",
|
||||
"systemBirdseye": "Birdseye",
|
||||
"systemFfmpeg": "FFmpeg",
|
||||
"systemDetectorsAndModel": "Detectors and model",
|
||||
"systemDetectorsAndModel": "Detection models",
|
||||
"systemMqtt": "MQTT",
|
||||
"systemGo2rtcStreams": "go2rtc streams",
|
||||
"integrationSemanticSearch": "Semantic search",
|
||||
@ -1272,31 +1272,6 @@
|
||||
"error": "Failed to save config changes: {{errorMessage}}"
|
||||
}
|
||||
},
|
||||
"detectorsAndModel": {
|
||||
"title": "Detectors and model",
|
||||
"description": "Configure the detector backend that runs object detection and the model it uses. Changes are saved together so the detector and model stay in sync.",
|
||||
"cardTitles": {
|
||||
"detector": "Detector Hardware",
|
||||
"model": "Detection Model"
|
||||
},
|
||||
"tabs": {
|
||||
"plus": "Frigate+",
|
||||
"custom": "Custom Model"
|
||||
},
|
||||
"mismatch": {
|
||||
"warning": "The current Frigate+ model \"{{model}}\" requires the {{required}} detector. Pick a compatible model below or switch to Custom Model before saving."
|
||||
},
|
||||
"plusModel": {
|
||||
"requiresDetector": "Requires: {{detector}}",
|
||||
"noModelSelected": "Select a Frigate+ model"
|
||||
},
|
||||
"toast": {
|
||||
"saveSuccess": "Detectors and model settings saved. Restart Frigate to apply changes.",
|
||||
"saveError": "Failed to save detector and model settings"
|
||||
},
|
||||
"unsavedChanges": "Unsaved detector and model changes",
|
||||
"restartRequired": "Restart required (detector or model changed)"
|
||||
},
|
||||
"triggers": {
|
||||
"documentTitle": "Triggers",
|
||||
"semanticSearch": {
|
||||
@ -1616,16 +1591,6 @@
|
||||
"detect": {
|
||||
"title": "Detection Settings"
|
||||
},
|
||||
"detectors": {
|
||||
"title": "Detector Settings",
|
||||
"singleType": "Only one {{type}} detector is allowed.",
|
||||
"keyRequired": "Detector name is required.",
|
||||
"keyDuplicate": "Detector name already exists.",
|
||||
"noSchema": "No detector schemas are available.",
|
||||
"none": "No detector instances configured.",
|
||||
"add": "Add detector",
|
||||
"addCustomKey": "Add custom key"
|
||||
},
|
||||
"record": {
|
||||
"title": "Recording Settings"
|
||||
},
|
||||
@ -1943,6 +1908,7 @@
|
||||
"detect": {
|
||||
"fpsGreaterThanFive": "Setting the detect FPS higher than 5 is not recommended. Higher values may cause performance issues and will not provide any benefit.",
|
||||
"disabled": "Object detection is disabled. Snapshots, review items, and enrichments such as face recognition, license plate recognition, and Generative AI will not function.",
|
||||
"sceneWithoutModel": "No detection model is configured for this scene, so this camera falls back to the model with a scene of 'All cameras'. Add a model for this scene to give the camera its own.",
|
||||
"resolutionShouldBeMultipleOfFour": "For best results, detect width and height should be multiples of 4. Other even values may produce visual artifacts or slight distortion in the detect stream.",
|
||||
"aspectRatioMismatch": "The width and height you've entered don't match the aspect ratio of your current detect resolution. This may produce a stretched or distorted image.",
|
||||
"maxFramesSet": "Setting max frames overrides default behavior and disables stationary object tracking. There are very few situations where this is needed, use with caution.",
|
||||
@ -1981,15 +1947,50 @@
|
||||
"snapshots": {
|
||||
"detectDisabled": "Object detection is disabled. Snapshots are generated from tracked objects and will not be created."
|
||||
},
|
||||
"detectors": {
|
||||
"mixedTypes": "All detectors must use the same type. Remove existing detectors to use a different type.",
|
||||
"mixedTypesSuggestion": "All detectors must use the same type. Remove existing detectors or select {{type}}."
|
||||
},
|
||||
"semanticSearch": {
|
||||
"jinav2SmallModelSize": "The 'small' size with the Jina V2 model has high RAM and inference cost. The 'large' model with a discrete GPU is recommended."
|
||||
},
|
||||
"onvif": {
|
||||
"autotrackingNoZones": "Autotracking requires at least one zone. Define a zone for this camera in Masks / Zones, then set it as a required zone below."
|
||||
}
|
||||
},
|
||||
"detectionModels": {
|
||||
"title": "Detection models",
|
||||
"description": "Configure the object detection models and the hardware each one runs on. Cameras choose a model by their scene in the camera's detect settings.",
|
||||
"addModel": "Add model",
|
||||
"cameras_one": "{{count}} camera",
|
||||
"cameras_other": "{{count}} cameras",
|
||||
"scene": {
|
||||
"label": "Scene",
|
||||
"description": "The environment this model is for. Cameras pick a model by setting the same scene in their detect settings, and the model with a scene of all is used by any camera that does not set one."
|
||||
},
|
||||
"scenes": {
|
||||
"all": "All cameras",
|
||||
"indoor": "Indoor",
|
||||
"outdoor": "Outdoor",
|
||||
"indoor_thermal": "Indoor thermal",
|
||||
"outdoor_thermal": "Outdoor thermal"
|
||||
},
|
||||
"hardware": {
|
||||
"label": "Hardware",
|
||||
"placeholder": "Select hardware",
|
||||
"loading": "Looking for detection hardware...",
|
||||
"none": "No hardware selected",
|
||||
"claimedBy": "used by {{scene}}",
|
||||
"detectorCount": "Detectors",
|
||||
"countRecommended_one": "Recommended for {{count}} camera",
|
||||
"countRecommended_other": "Recommended for {{count}} cameras",
|
||||
"unrecognized": "This model is configured for hardware that was not found on this system: {{devices}}",
|
||||
"description": "The hardware this model runs its detection on.",
|
||||
"detectorCountDescription": "How many detection processes to run on this hardware. More detectors keep up with more cameras, at the cost of extra device memory.",
|
||||
"unitsDescription": "Each unit runs its own detection process. A unit already used by another model can not be selected."
|
||||
},
|
||||
"tabs": {
|
||||
"plus": "Frigate+",
|
||||
"custom": "Custom Model"
|
||||
},
|
||||
"plusModel": {
|
||||
"noModelSelected": "Select a Frigate+ model"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -110,6 +110,21 @@ const detect: SectionConfigOverrides = {
|
||||
return Math.abs(newRatio - savedRatio) > 0.01;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "detect-scene-without-model",
|
||||
field: "scene",
|
||||
position: "after",
|
||||
messageKey: "configMessages.detect.sceneWithoutModel",
|
||||
severity: "warning",
|
||||
docLink: "/configuration/object_detectors#running-more-than-one-model",
|
||||
condition: (ctx) => {
|
||||
const scene = ctx.formData?.scene as string | undefined;
|
||||
if (!scene || scene === "all") return false;
|
||||
const models = ctx.fullConfig?.models;
|
||||
if (!models) return false;
|
||||
return !models.some((model) => model.scene === scene);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "fps-greater-than-five",
|
||||
field: "fps",
|
||||
@ -153,6 +168,7 @@ const detect: SectionConfigOverrides = {
|
||||
],
|
||||
fieldOrder: [
|
||||
"enabled",
|
||||
"scene",
|
||||
"width",
|
||||
"height",
|
||||
"fps",
|
||||
@ -170,6 +186,11 @@ const detect: SectionConfigOverrides = {
|
||||
tracking: ["min_initialized", "max_disappeared"],
|
||||
},
|
||||
uiSchema: {
|
||||
scene: {
|
||||
"ui:options": {
|
||||
enumI18nPrefix: "detectionModels.scenes",
|
||||
},
|
||||
},
|
||||
annotation_offset: {
|
||||
"ui:options": {
|
||||
signed: true,
|
||||
@ -186,6 +207,7 @@ const detect: SectionConfigOverrides = {
|
||||
},
|
||||
global: {
|
||||
restartRequired: [
|
||||
"scene",
|
||||
"fps",
|
||||
"width",
|
||||
"height",
|
||||
@ -195,6 +217,7 @@ const detect: SectionConfigOverrides = {
|
||||
},
|
||||
camera: {
|
||||
restartRequired: [
|
||||
"scene",
|
||||
"fps",
|
||||
"width",
|
||||
"height",
|
||||
@ -211,6 +234,7 @@ const detect: SectionConfigOverrides = {
|
||||
hiddenFields: [
|
||||
"enabled",
|
||||
"enabled_in_config",
|
||||
"scene",
|
||||
"min_initialized",
|
||||
"max_disappeared",
|
||||
"annotation_offset",
|
||||
|
||||
@ -1,28 +0,0 @@
|
||||
import type { SectionConfigOverrides } from "./types";
|
||||
|
||||
const detectorHiddenFields = [
|
||||
"*.model.labelmap",
|
||||
"*.model.attributes_map",
|
||||
"*.model",
|
||||
"*.model_path",
|
||||
];
|
||||
|
||||
const detectors: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/object_detectors",
|
||||
fieldOrder: [],
|
||||
advancedFields: [],
|
||||
hiddenFields: detectorHiddenFields,
|
||||
uiSchema: {
|
||||
"ui:field": "DetectorHardwareField",
|
||||
"ui:options": {
|
||||
multiInstanceTypes: ["cpu", "onnx", "openvino", "edgetpu"],
|
||||
typeOrder: ["onnx", "openvino", "edgetpu"],
|
||||
hiddenByType: {},
|
||||
hiddenFields: detectorHiddenFields,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default detectors;
|
||||
@ -1,8 +1,24 @@
|
||||
import type { SectionConfigOverrides } from "./types";
|
||||
|
||||
const model: SectionConfigOverrides = {
|
||||
// scene and devices are rendered by ModelsField itself; the rest of each model
|
||||
// is delegated back to the schema form
|
||||
const modelFields = [
|
||||
"path",
|
||||
"labelmap_path",
|
||||
"width",
|
||||
"height",
|
||||
"input_pixel_format",
|
||||
"input_tensor",
|
||||
"input_dtype",
|
||||
"model_type",
|
||||
];
|
||||
|
||||
const models: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/object_detectors#model",
|
||||
sectionDocs: "/configuration/object_detectors",
|
||||
// the default-model rule must be enforced as the list is edited, not
|
||||
// only when the form is submitted
|
||||
liveValidate: true,
|
||||
fieldMessages: [
|
||||
{
|
||||
key: "model-optimized-for-320",
|
||||
@ -36,51 +52,46 @@ const model: SectionConfigOverrides = {
|
||||
},
|
||||
},
|
||||
],
|
||||
// every model field takes effect only when the detection processes restart
|
||||
restartRequired: [
|
||||
"path",
|
||||
"labelmap_path",
|
||||
"width",
|
||||
"height",
|
||||
"scene",
|
||||
"devices",
|
||||
...modelFields,
|
||||
"labelmap",
|
||||
"attributes_map",
|
||||
"input_tensor",
|
||||
"input_pixel_format",
|
||||
"input_dtype",
|
||||
"model_type",
|
||||
],
|
||||
fieldOrder: [
|
||||
"path",
|
||||
"labelmap_path",
|
||||
"width",
|
||||
"height",
|
||||
"input_pixel_format",
|
||||
"input_tensor",
|
||||
"input_dtype",
|
||||
"model_type",
|
||||
],
|
||||
advancedFields: [
|
||||
"input_pixel_format",
|
||||
"input_tensor",
|
||||
"input_dtype",
|
||||
"model_type",
|
||||
],
|
||||
].map((field) => `*.${field}`),
|
||||
hiddenFields: [
|
||||
"labelmap",
|
||||
"attributes_map",
|
||||
"colormap",
|
||||
"all_attributes",
|
||||
"non_logo_attributes",
|
||||
"plus",
|
||||
"*.labelmap",
|
||||
"*.attributes_map",
|
||||
"*.colormap",
|
||||
"*.all_attributes",
|
||||
"*.non_logo_attributes",
|
||||
"*.plus",
|
||||
],
|
||||
uiSchema: {
|
||||
path: {
|
||||
"ui:options": { size: "md" },
|
||||
},
|
||||
labelmap_path: {
|
||||
"ui:options": { size: "md" },
|
||||
"ui:field": "ModelsField",
|
||||
items: {
|
||||
path: {
|
||||
"ui:options": { size: "md" },
|
||||
},
|
||||
labelmap_path: {
|
||||
"ui:options": { size: "md" },
|
||||
},
|
||||
input_pixel_format: {
|
||||
"ui:options": { advanced: true, size: "xs" },
|
||||
},
|
||||
input_tensor: {
|
||||
"ui:options": { advanced: true, size: "xs" },
|
||||
},
|
||||
input_dtype: {
|
||||
"ui:options": { advanced: true, size: "xs" },
|
||||
},
|
||||
model_type: {
|
||||
"ui:options": { advanced: true, size: "xs" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default model;
|
||||
export default models;
|
||||
@ -2,6 +2,7 @@ import type { FormValidation } from "@rjsf/utils";
|
||||
import type { TFunction } from "i18next";
|
||||
import { validateDetectDimensions } from "./detect";
|
||||
import { validateFfmpegInputRoles } from "./ffmpeg";
|
||||
import { validateDefaultModelExists } from "./models";
|
||||
import { validateProxyRoleHeader } from "./proxy";
|
||||
|
||||
export type SectionValidation = (
|
||||
@ -28,6 +29,11 @@ export function getSectionValidation({
|
||||
return (formData, errors) => validateFfmpegInputRoles(formData, errors, t);
|
||||
}
|
||||
|
||||
if (sectionPath === "models") {
|
||||
return (formData, errors) =>
|
||||
validateDefaultModelExists(formData, errors, t);
|
||||
}
|
||||
|
||||
if (sectionPath === "proxy" && level === "global") {
|
||||
return (formData, errors) => validateProxyRoleHeader(formData, errors, t);
|
||||
}
|
||||
|
||||
31
web/src/components/config-form/section-validations/models.ts
Normal file
31
web/src/components/config-form/section-validations/models.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import type { FormValidation } from "@rjsf/utils";
|
||||
import type { TFunction } from "i18next";
|
||||
import { isJsonObject } from "@/lib/utils";
|
||||
|
||||
const DEFAULT_SCENE = "all";
|
||||
|
||||
/**
|
||||
* A camera that names no scene runs the model whose scene is `all`. Without one
|
||||
* the backend rejects the config outright once a second model exists, and with
|
||||
* a single model it silently runs every camera on whatever that model is. Both
|
||||
* are surprising, so require the default to be present.
|
||||
*/
|
||||
export function validateDefaultModelExists(
|
||||
formData: unknown,
|
||||
errors: FormValidation,
|
||||
t: TFunction,
|
||||
): FormValidation {
|
||||
if (!Array.isArray(formData) || formData.length === 0) {
|
||||
return errors;
|
||||
}
|
||||
|
||||
const hasDefault = formData.some(
|
||||
(model) => isJsonObject(model) && model.scene === DEFAULT_SCENE,
|
||||
);
|
||||
|
||||
if (!hasDefault) {
|
||||
errors.addError?.(t("models.defaultRequired", { ns: "config/validation" }));
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
@ -23,7 +23,6 @@ import birdseye from "./section-configs/birdseye";
|
||||
import classification from "./section-configs/classification";
|
||||
import database from "./section-configs/database";
|
||||
import detect from "./section-configs/detect";
|
||||
import detectors from "./section-configs/detectors";
|
||||
import environmentVars from "./section-configs/environment_vars";
|
||||
import faceRecognition from "./section-configs/face_recognition";
|
||||
import ffmpeg from "./section-configs/ffmpeg";
|
||||
@ -31,7 +30,7 @@ import genai from "./section-configs/genai";
|
||||
import live from "./section-configs/live";
|
||||
import logger from "./section-configs/logger";
|
||||
import lpr from "./section-configs/lpr";
|
||||
import model from "./section-configs/model";
|
||||
import models from "./section-configs/models";
|
||||
import motion from "./section-configs/motion";
|
||||
import mqtt from "./section-configs/mqtt";
|
||||
import networking from "./section-configs/networking";
|
||||
@ -76,8 +75,7 @@ export const sectionConfigs: Record<string, SectionConfigOverrides> = {
|
||||
logger,
|
||||
environment_vars: environmentVars,
|
||||
telemetry,
|
||||
detectors,
|
||||
model,
|
||||
models,
|
||||
genai,
|
||||
classification,
|
||||
};
|
||||
|
||||
@ -72,8 +72,7 @@ const SECTIONS_WITHOUT_OVERRIDE_BADGE = new Set([
|
||||
"environment_vars",
|
||||
"telemetry",
|
||||
"birdseye",
|
||||
"detectors",
|
||||
"model",
|
||||
"models",
|
||||
]);
|
||||
|
||||
type CameraEntryProps = {
|
||||
|
||||
@ -16,7 +16,7 @@ import { getEffectiveAttributeLabels } from "@/utils/configUtil";
|
||||
* Sections that require special handling at the global level.
|
||||
* Add new section paths here as needed.
|
||||
*/
|
||||
const SPECIAL_CASE_SECTIONS = ["motion", "detectors", "genai"] as const;
|
||||
const SPECIAL_CASE_SECTIONS = ["motion", "genai"] as const;
|
||||
|
||||
/**
|
||||
* Check if a section requires special case handling.
|
||||
@ -36,8 +36,6 @@ export function isSpecialCaseSection(
|
||||
/**
|
||||
* Modify schema for sections that need defaults stripped or other modifications.
|
||||
*
|
||||
* - detectors: Strip the "default" field to prevent RJSF from merging the
|
||||
* default {"cpu": {"type": "cpu"}} with stored detector keys.
|
||||
* - genai: Inject a default provider value on the additionalProperties shape.
|
||||
* - objects: Promote tracked attribute labels (face, license_plate, courier
|
||||
* logos) from `filters.additionalProperties` to explicit
|
||||
@ -63,12 +61,6 @@ export function modifySchemaForSection(
|
||||
return schema;
|
||||
}
|
||||
|
||||
// detectors: Remove default to prevent merging with stored keys
|
||||
if (sectionPath === "detectors" && "default" in schema) {
|
||||
const { default: _, ...schemaWithoutDefault } = schema;
|
||||
return schemaWithoutDefault;
|
||||
}
|
||||
|
||||
if (sectionPath === "genai") {
|
||||
const additional = schema.additionalProperties;
|
||||
if (
|
||||
@ -270,8 +262,6 @@ function modifyObjectsSchema(
|
||||
* - motion: Has anyOf schema with [null, MotionConfig]. When stored value is
|
||||
* null, derive defaults from the non-null anyOf branch to avoid showing
|
||||
* changes when navigating to the page.
|
||||
* - detectors: Return empty object since the schema default would add unwanted
|
||||
* keys to the stored configuration.
|
||||
*/
|
||||
export function getEffectiveDefaultsForSection(
|
||||
sectionPath: string,
|
||||
@ -305,11 +295,6 @@ export function getEffectiveDefaultsForSection(
|
||||
return applySchemaDefaults(motionSchema as RJSFSchema, {});
|
||||
}
|
||||
|
||||
// detectors: Return empty object to avoid adding default keys
|
||||
if (sectionPath === "detectors") {
|
||||
return {};
|
||||
}
|
||||
|
||||
return schemaDefaults;
|
||||
}
|
||||
|
||||
@ -424,27 +409,6 @@ export function sanitizeOverridesForSection(
|
||||
return flattened;
|
||||
};
|
||||
|
||||
// detectors: Strip readonly model fields that are generated on startup
|
||||
// and should never be persisted back to the config file.
|
||||
if (sectionPath === "detectors") {
|
||||
const overridesObj = overrides as JsonObject;
|
||||
const cleaned: JsonObject = {};
|
||||
|
||||
Object.entries(overridesObj).forEach(([key, value]) => {
|
||||
if (!isJsonObject(value)) {
|
||||
cleaned[key] = value;
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanedValue = { ...value } as JsonObject;
|
||||
delete cleanedValue.model;
|
||||
delete cleanedValue.model_path;
|
||||
cleaned[key] = cleanedValue;
|
||||
});
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
if (sectionPath === "logger") {
|
||||
const overridesObj = overrides as JsonObject;
|
||||
const logs = overridesObj.logs;
|
||||
|
||||
@ -1,956 +0,0 @@
|
||||
import type {
|
||||
ErrorSchema,
|
||||
FieldPathList,
|
||||
FieldProps,
|
||||
RJSFSchema,
|
||||
UiSchema,
|
||||
} from "@rjsf/utils";
|
||||
import { toFieldPathId } from "@rjsf/utils";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
LuChevronDown,
|
||||
LuChevronRight,
|
||||
LuPlus,
|
||||
LuTrash2,
|
||||
} from "react-icons/lu";
|
||||
import { applySchemaDefaults } from "@/lib/config-schema";
|
||||
import { cn, isJsonObject, mergeUiSchema } from "@/lib/utils";
|
||||
import { ConfigFormContext, JsonObject } from "@/types/configForm";
|
||||
import { requiresRestartForFieldPath } from "@/utils/configUtil";
|
||||
import RestartRequiredIndicator from "@/components/indicators/RestartRequiredIndicator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { humanizeKey } from "../utils/i18n";
|
||||
|
||||
type DetectorHardwareFieldOptions = {
|
||||
multiInstanceTypes?: string[];
|
||||
hiddenByType?: Record<string, string[]>;
|
||||
hiddenFields?: string[];
|
||||
typeOrder?: string[];
|
||||
};
|
||||
|
||||
type DetectorSchemaEntry = {
|
||||
type: string;
|
||||
schema: RJSFSchema;
|
||||
};
|
||||
|
||||
const DEFAULT_MULTI_INSTANCE_TYPES = ["cpu", "onnx", "openvino"];
|
||||
const EMPTY_HIDDEN_BY_TYPE: Record<string, string[]> = {};
|
||||
const EMPTY_HIDDEN_FIELDS: string[] = [];
|
||||
const EMPTY_TYPE_ORDER: string[] = [];
|
||||
|
||||
const isSchemaObject = (schema: unknown): schema is RJSFSchema =>
|
||||
typeof schema === "object" && schema !== null;
|
||||
|
||||
const getUnionSchemas = (schema?: RJSFSchema): RJSFSchema[] => {
|
||||
if (!schema) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const schemaObj = schema as Record<string, unknown>;
|
||||
const union = schemaObj.oneOf ?? schemaObj.anyOf;
|
||||
if (Array.isArray(union)) {
|
||||
return union.filter(isSchemaObject) as RJSFSchema[];
|
||||
}
|
||||
|
||||
return [schema];
|
||||
};
|
||||
|
||||
const getTypeValues = (schema: RJSFSchema): string[] => {
|
||||
const schemaObj = schema as Record<string, unknown>;
|
||||
const properties = schemaObj.properties as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const typeSchema = properties?.type as Record<string, unknown> | undefined;
|
||||
const values: string[] = [];
|
||||
|
||||
if (typeof typeSchema?.const === "string") {
|
||||
values.push(typeSchema.const);
|
||||
}
|
||||
|
||||
if (Array.isArray(typeSchema?.enum)) {
|
||||
typeSchema.enum.forEach((value) => {
|
||||
if (typeof value === "string") {
|
||||
values.push(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return values;
|
||||
};
|
||||
|
||||
const buildHiddenUiSchema = (paths: string[]): UiSchema => {
|
||||
const result: UiSchema = {};
|
||||
|
||||
paths.forEach((path) => {
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
|
||||
const segments = path.split(".").filter(Boolean);
|
||||
if (segments.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cursor = result;
|
||||
segments.forEach((segment, index) => {
|
||||
if (index === segments.length - 1) {
|
||||
cursor[segment] = {
|
||||
...(cursor[segment] as UiSchema | undefined),
|
||||
"ui:widget": "hidden",
|
||||
} as UiSchema;
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = (cursor[segment] as UiSchema | undefined) ?? {};
|
||||
cursor[segment] = existing;
|
||||
cursor = existing;
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const getInstanceType = (value: unknown): string | undefined => {
|
||||
if (!isJsonObject(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const typeValue = value.type;
|
||||
return typeof typeValue === "string" && typeValue.length > 0
|
||||
? typeValue
|
||||
: undefined;
|
||||
};
|
||||
|
||||
export function DetectorHardwareField(props: FieldProps) {
|
||||
const {
|
||||
schema,
|
||||
uiSchema,
|
||||
registry,
|
||||
fieldPathId,
|
||||
formData: rawFormData,
|
||||
errorSchema,
|
||||
disabled,
|
||||
readonly,
|
||||
hideError,
|
||||
onBlur,
|
||||
onFocus,
|
||||
onChange,
|
||||
} = props;
|
||||
|
||||
const formContext = registry.formContext as ConfigFormContext | undefined;
|
||||
const configNamespace =
|
||||
formContext?.i18nNamespace ??
|
||||
(formContext?.level === "camera" ? "config/cameras" : "config/global");
|
||||
const { t: fallbackT } = useTranslation(["common", configNamespace]);
|
||||
const t = formContext?.t ?? fallbackT;
|
||||
const sectionPrefix = formContext?.sectionI18nPrefix ?? "detectors";
|
||||
const restartRequired = formContext?.restartRequired;
|
||||
const defaultRequiresRestart = formContext?.requiresRestart ?? true;
|
||||
|
||||
const options =
|
||||
(uiSchema?.["ui:options"] as DetectorHardwareFieldOptions | undefined) ??
|
||||
{};
|
||||
const multiInstanceTypes =
|
||||
options.multiInstanceTypes ?? DEFAULT_MULTI_INSTANCE_TYPES;
|
||||
const hiddenByType = options.hiddenByType ?? EMPTY_HIDDEN_BY_TYPE;
|
||||
const hiddenFields = options.hiddenFields ?? EMPTY_HIDDEN_FIELDS;
|
||||
const typeOrder = options.typeOrder ?? EMPTY_TYPE_ORDER;
|
||||
const multiInstanceSet = useMemo(
|
||||
() => new Set(multiInstanceTypes),
|
||||
[multiInstanceTypes],
|
||||
);
|
||||
const globalHiddenFields = useMemo(
|
||||
() =>
|
||||
hiddenFields
|
||||
.map((path) => (path.startsWith("*.") ? path.slice(2) : path))
|
||||
.filter((path) => path.length > 0),
|
||||
[hiddenFields],
|
||||
);
|
||||
|
||||
const detectorConfigSchema = useMemo(() => {
|
||||
const additional = (schema as RJSFSchema | undefined)?.additionalProperties;
|
||||
if (isSchemaObject(additional)) {
|
||||
return additional as RJSFSchema;
|
||||
}
|
||||
|
||||
const rootSchema = registry.rootSchema as Record<string, unknown>;
|
||||
const defs =
|
||||
(rootSchema?.$defs as Record<string, unknown> | undefined) ??
|
||||
(rootSchema?.definitions as Record<string, unknown> | undefined);
|
||||
const fallback = defs?.DetectorConfig;
|
||||
|
||||
return isSchemaObject(fallback) ? (fallback as RJSFSchema) : undefined;
|
||||
}, [schema, registry.rootSchema]);
|
||||
|
||||
const detectorSchemas = useMemo<DetectorSchemaEntry[]>(() => {
|
||||
const entries: DetectorSchemaEntry[] = [];
|
||||
getUnionSchemas(detectorConfigSchema).forEach((schema) => {
|
||||
const types = getTypeValues(schema);
|
||||
types.forEach((type) => {
|
||||
entries.push({ type, schema });
|
||||
});
|
||||
});
|
||||
return entries;
|
||||
}, [detectorConfigSchema]);
|
||||
|
||||
const detectorSchemaByType = useMemo(() => {
|
||||
const map = new Map<string, RJSFSchema>();
|
||||
detectorSchemas.forEach(({ type, schema }) => {
|
||||
if (!map.has(type)) {
|
||||
map.set(type, schema);
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [detectorSchemas]);
|
||||
|
||||
const availableTypes = useMemo(
|
||||
() => detectorSchemas.map((entry) => entry.type),
|
||||
[detectorSchemas],
|
||||
);
|
||||
|
||||
const orderedTypes = useMemo(() => {
|
||||
if (!typeOrder.length) {
|
||||
return availableTypes;
|
||||
}
|
||||
|
||||
const availableSet = new Set(availableTypes);
|
||||
const ordered = typeOrder.filter((type) => availableSet.has(type));
|
||||
const orderedSet = new Set(ordered);
|
||||
const remaining = availableTypes.filter((type) => !orderedSet.has(type));
|
||||
return [...ordered, ...remaining];
|
||||
}, [availableTypes, typeOrder]);
|
||||
|
||||
const formData = isJsonObject(rawFormData) ? rawFormData : {};
|
||||
const detectors = formData as JsonObject;
|
||||
|
||||
const [addType, setAddType] = useState<string | undefined>(orderedTypes[0]);
|
||||
const [addError, setAddError] = useState<string | undefined>();
|
||||
const [renameDrafts, setRenameDrafts] = useState<Record<string, string>>({});
|
||||
const [renameErrors, setRenameErrors] = useState<Record<string, string>>({});
|
||||
const [typeErrors, setTypeErrors] = useState<Record<string, string>>({});
|
||||
const [openKeys, setOpenKeys] = useState<Set<string>>(
|
||||
() => new Set(Object.keys(detectors)),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!orderedTypes.length) {
|
||||
setAddType(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!addType || !orderedTypes.includes(addType)) {
|
||||
setAddType(orderedTypes[0]);
|
||||
}
|
||||
}, [orderedTypes, addType]);
|
||||
|
||||
useEffect(() => {
|
||||
setOpenKeys((prev) => {
|
||||
const next = new Set<string>();
|
||||
Object.keys(detectors).forEach((key) => {
|
||||
if (prev.has(key)) {
|
||||
next.add(key);
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
|
||||
setRenameDrafts((prev) => {
|
||||
const next: Record<string, string> = {};
|
||||
Object.keys(detectors).forEach((key) => {
|
||||
if (prev[key] !== undefined) {
|
||||
next[key] = prev[key];
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
|
||||
setRenameErrors((prev) => {
|
||||
const next: Record<string, string> = {};
|
||||
Object.keys(detectors).forEach((key) => {
|
||||
if (prev[key] !== undefined) {
|
||||
next[key] = prev[key];
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
|
||||
setTypeErrors((prev) => {
|
||||
const next: Record<string, string> = {};
|
||||
Object.keys(detectors).forEach((key) => {
|
||||
if (prev[key] !== undefined) {
|
||||
next[key] = prev[key];
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}, [detectors]);
|
||||
|
||||
const updateDetectors = useCallback(
|
||||
(nextDetectors: JsonObject, path?: FieldPathList) => {
|
||||
onChange(nextDetectors as unknown, path ?? fieldPathId.path);
|
||||
},
|
||||
[fieldPathId.path, onChange],
|
||||
);
|
||||
|
||||
const getTypeLabel = useCallback(
|
||||
(type: string) =>
|
||||
t(`${sectionPrefix}.${type}.label`, {
|
||||
ns: configNamespace,
|
||||
defaultValue: humanizeKey(type),
|
||||
}),
|
||||
[t, sectionPrefix, configNamespace],
|
||||
);
|
||||
|
||||
const getTypeDescription = useCallback(
|
||||
(type: string) =>
|
||||
t(`${sectionPrefix}.${type}.description`, {
|
||||
ns: configNamespace,
|
||||
defaultValue: "",
|
||||
}),
|
||||
[t, sectionPrefix, configNamespace],
|
||||
);
|
||||
|
||||
const shouldShowRestartForPath = useCallback(
|
||||
(path: Array<string | number>) =>
|
||||
requiresRestartForFieldPath(
|
||||
path,
|
||||
restartRequired,
|
||||
defaultRequiresRestart,
|
||||
),
|
||||
[defaultRequiresRestart, restartRequired],
|
||||
);
|
||||
|
||||
const renderRestartIcon = (isRequired: boolean) => {
|
||||
if (!isRequired) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <RestartRequiredIndicator className="ml-2" />;
|
||||
};
|
||||
|
||||
const isSingleInstanceType = useCallback(
|
||||
(type: string) => !multiInstanceSet.has(type),
|
||||
[multiInstanceSet],
|
||||
);
|
||||
|
||||
const getDetectorDefaults = useCallback(
|
||||
(type: string) => {
|
||||
const schema = detectorSchemaByType.get(type);
|
||||
if (!schema) {
|
||||
return { type };
|
||||
}
|
||||
|
||||
const base = { type } as Record<string, unknown>;
|
||||
const withDefaults = applySchemaDefaults(schema, base);
|
||||
return { ...withDefaults, type } as Record<string, unknown>;
|
||||
},
|
||||
[detectorSchemaByType],
|
||||
);
|
||||
|
||||
const resolveDuplicateType = useCallback(
|
||||
(targetType: string, excludeKey?: string) => {
|
||||
return Object.entries(detectors).some(([key, value]) => {
|
||||
if (excludeKey && key === excludeKey) {
|
||||
return false;
|
||||
}
|
||||
return getInstanceType(value) === targetType;
|
||||
});
|
||||
},
|
||||
[detectors],
|
||||
);
|
||||
|
||||
const getExistingType = useCallback(
|
||||
(excludeKey?: string): string | undefined => {
|
||||
for (const [key, value] of Object.entries(detectors)) {
|
||||
if (excludeKey && key === excludeKey) continue;
|
||||
const type = getInstanceType(value);
|
||||
if (type) return type;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
[detectors],
|
||||
);
|
||||
|
||||
const handleAdd = useCallback(() => {
|
||||
if (!addType) {
|
||||
setAddError(
|
||||
t("selectItem", {
|
||||
ns: "common",
|
||||
defaultValue: "Select {{item}}",
|
||||
item: t("detectors.type.label", {
|
||||
ns: configNamespace,
|
||||
defaultValue: "Type",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSingleInstanceType(addType) && resolveDuplicateType(addType)) {
|
||||
setAddError(
|
||||
t("configForm.detectors.singleType", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Only one {{type}} detector is allowed.",
|
||||
type: getTypeLabel(addType),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const existingType = getExistingType();
|
||||
if (existingType && existingType !== addType) {
|
||||
const canAddExisting =
|
||||
multiInstanceSet.has(existingType) ||
|
||||
!resolveDuplicateType(existingType);
|
||||
setAddError(
|
||||
canAddExisting
|
||||
? t("configMessages.detectors.mixedTypesSuggestion", {
|
||||
ns: "views/settings",
|
||||
defaultValue:
|
||||
"All detectors must use the same type. Remove existing detectors or select {{type}}.",
|
||||
type: getTypeLabel(existingType),
|
||||
})
|
||||
: t("configMessages.detectors.mixedTypes", {
|
||||
ns: "views/settings",
|
||||
defaultValue:
|
||||
"All detectors must use the same type. Remove existing detectors to use a different type.",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const baseKey = addType;
|
||||
let nextKey = baseKey;
|
||||
let index = 2;
|
||||
while (Object.prototype.hasOwnProperty.call(detectors, nextKey)) {
|
||||
nextKey = `${baseKey}${index}`;
|
||||
index += 1;
|
||||
}
|
||||
|
||||
const nextDetectors = {
|
||||
...detectors,
|
||||
[nextKey]: getDetectorDefaults(addType),
|
||||
} as JsonObject;
|
||||
|
||||
setAddError(undefined);
|
||||
setOpenKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(nextKey);
|
||||
return next;
|
||||
});
|
||||
|
||||
updateDetectors(nextDetectors);
|
||||
}, [
|
||||
addType,
|
||||
t,
|
||||
configNamespace,
|
||||
detectors,
|
||||
getDetectorDefaults,
|
||||
getExistingType,
|
||||
getTypeLabel,
|
||||
isSingleInstanceType,
|
||||
multiInstanceSet,
|
||||
resolveDuplicateType,
|
||||
updateDetectors,
|
||||
]);
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(key: string) => {
|
||||
const { [key]: _, ...rest } = detectors;
|
||||
updateDetectors(rest as JsonObject);
|
||||
setOpenKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[detectors, updateDetectors],
|
||||
);
|
||||
|
||||
const commitRename = useCallback(
|
||||
(key: string, nextKey: string) => {
|
||||
const trimmed = nextKey.trim();
|
||||
if (!trimmed) {
|
||||
setRenameErrors((prev) => ({
|
||||
...prev,
|
||||
[key]: t("configForm.detectors.keyRequired", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Detector name is required.",
|
||||
}),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmed !== key && detectors[trimmed] !== undefined) {
|
||||
setRenameErrors((prev) => ({
|
||||
...prev,
|
||||
[key]: t("configForm.detectors.keyDuplicate", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Detector name already exists.",
|
||||
}),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
setRenameErrors((prev) => {
|
||||
const { [key]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
|
||||
setRenameDrafts((prev) => {
|
||||
const { [key]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
|
||||
if (trimmed === key) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { [key]: value, ...rest } = detectors;
|
||||
const nextDetectors = { ...rest, [trimmed]: value } as JsonObject;
|
||||
|
||||
setOpenKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.delete(key)) {
|
||||
next.add(trimmed);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
updateDetectors(nextDetectors);
|
||||
},
|
||||
[detectors, t, updateDetectors],
|
||||
);
|
||||
|
||||
const handleTypeChange = useCallback(
|
||||
(key: string, nextType: string) => {
|
||||
const currentType = getInstanceType(detectors[key]);
|
||||
if (!nextType || nextType === currentType) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
isSingleInstanceType(nextType) &&
|
||||
resolveDuplicateType(nextType, key)
|
||||
) {
|
||||
setTypeErrors((prev) => ({
|
||||
...prev,
|
||||
[key]: t("configForm.detectors.singleType", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Only one {{type}} detector is allowed.",
|
||||
type: getTypeLabel(nextType),
|
||||
}),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const existingType = getExistingType(key);
|
||||
if (existingType && existingType !== nextType) {
|
||||
const canAddExisting =
|
||||
multiInstanceSet.has(existingType) ||
|
||||
!resolveDuplicateType(existingType, key);
|
||||
setTypeErrors((prev) => ({
|
||||
...prev,
|
||||
[key]: canAddExisting
|
||||
? t("configMessages.detectors.mixedTypesSuggestion", {
|
||||
ns: "views/settings",
|
||||
defaultValue:
|
||||
"All detectors must use the same type. Remove existing detectors or select {{type}}.",
|
||||
type: getTypeLabel(existingType),
|
||||
})
|
||||
: t("configMessages.detectors.mixedTypes", {
|
||||
ns: "views/settings",
|
||||
defaultValue:
|
||||
"All detectors must use the same type. Remove existing detectors to use a different type.",
|
||||
}),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
setTypeErrors((prev) => {
|
||||
const { [key]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
|
||||
const nextDetectors = {
|
||||
...detectors,
|
||||
[key]: getDetectorDefaults(nextType),
|
||||
} as JsonObject;
|
||||
|
||||
updateDetectors(nextDetectors);
|
||||
},
|
||||
[
|
||||
detectors,
|
||||
getDetectorDefaults,
|
||||
getExistingType,
|
||||
getTypeLabel,
|
||||
isSingleInstanceType,
|
||||
multiInstanceSet,
|
||||
resolveDuplicateType,
|
||||
t,
|
||||
updateDetectors,
|
||||
],
|
||||
);
|
||||
|
||||
const getInstanceUiSchema = useCallback(
|
||||
(type: string) => {
|
||||
const baseUiSchema =
|
||||
(uiSchema?.additionalProperties as UiSchema | undefined) ?? {};
|
||||
const globalHidden = buildHiddenUiSchema(globalHiddenFields);
|
||||
const hiddenOverrides = buildHiddenUiSchema(hiddenByType[type] ?? []);
|
||||
const typeHidden = { type: { "ui:widget": "hidden" } } as UiSchema;
|
||||
const nestedOverrides = {
|
||||
"ui:options": {
|
||||
disableNestedCard: true,
|
||||
addButtonText: t("configForm.detectors.addCustomKey", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Add custom key",
|
||||
}),
|
||||
},
|
||||
} as UiSchema;
|
||||
|
||||
const withGlobalHidden = mergeUiSchema(baseUiSchema, globalHidden);
|
||||
const withTypeHidden = mergeUiSchema(withGlobalHidden, hiddenOverrides);
|
||||
const withTypeHiddenAndOptions = mergeUiSchema(
|
||||
withTypeHidden,
|
||||
typeHidden,
|
||||
);
|
||||
return mergeUiSchema(withTypeHiddenAndOptions, nestedOverrides);
|
||||
},
|
||||
[globalHiddenFields, hiddenByType, t, uiSchema?.additionalProperties],
|
||||
);
|
||||
|
||||
const renderInstanceForm = useCallback(
|
||||
(key: string, value: unknown) => {
|
||||
const SchemaField = registry.fields.SchemaField;
|
||||
const type = getInstanceType(value);
|
||||
const schema = type ? detectorSchemaByType.get(type) : undefined;
|
||||
|
||||
if (!SchemaField || !schema || !type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const instanceUiSchema = getInstanceUiSchema(type);
|
||||
const instanceFieldPathId = toFieldPathId(
|
||||
key,
|
||||
registry.globalFormOptions,
|
||||
fieldPathId.path,
|
||||
);
|
||||
|
||||
const instanceErrorSchema = (
|
||||
errorSchema as Record<string, ErrorSchema> | undefined
|
||||
)?.[key];
|
||||
|
||||
const handleInstanceChange = (
|
||||
nextValue: unknown,
|
||||
path: FieldPathList,
|
||||
errors?: ErrorSchema,
|
||||
id?: string,
|
||||
) => {
|
||||
onChange(nextValue, path, errors, id);
|
||||
};
|
||||
|
||||
return (
|
||||
<SchemaField
|
||||
name={key}
|
||||
schema={schema}
|
||||
uiSchema={instanceUiSchema}
|
||||
fieldPathId={instanceFieldPathId}
|
||||
formData={value}
|
||||
errorSchema={instanceErrorSchema}
|
||||
onChange={handleInstanceChange}
|
||||
onBlur={onBlur}
|
||||
onFocus={onFocus}
|
||||
registry={registry}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
hideError={hideError}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[
|
||||
detectorSchemaByType,
|
||||
getInstanceUiSchema,
|
||||
disabled,
|
||||
errorSchema,
|
||||
fieldPathId,
|
||||
hideError,
|
||||
onChange,
|
||||
onBlur,
|
||||
onFocus,
|
||||
readonly,
|
||||
registry,
|
||||
],
|
||||
);
|
||||
|
||||
if (!availableTypes.length) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("configForm.detectors.noSchema", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "No detector schemas are available.",
|
||||
})}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const detectorEntries = Object.entries(detectors);
|
||||
const isDisabled = Boolean(disabled || readonly);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{detectorEntries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("configForm.detectors.none", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "No detector instances configured.",
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{detectorEntries.map(([key, value]) => {
|
||||
const type = getInstanceType(value) ?? "";
|
||||
const typeLabel = type ? getTypeLabel(type) : key;
|
||||
const typeDescription = type ? getTypeDescription(type) : "";
|
||||
const isOpen = openKeys.has(key);
|
||||
const renameDraft = renameDrafts[key] ?? key;
|
||||
const detectorPath = [...fieldPathId.path, key];
|
||||
const detectorTypePath = [...detectorPath, "type"];
|
||||
const detectorTypeRequiresRestart =
|
||||
shouldShowRestartForPath(detectorTypePath);
|
||||
|
||||
return (
|
||||
<div key={key} className="rounded-lg border bg-card">
|
||||
<Collapsible
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
setOpenKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (open) {
|
||||
next.add(key);
|
||||
} else {
|
||||
next.delete(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="mt-0.5"
|
||||
>
|
||||
{isOpen ? (
|
||||
<LuChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<LuChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<div>
|
||||
<div className="flex items-center text-sm font-medium">
|
||||
{typeLabel}
|
||||
{renderRestartIcon(detectorTypeRequiresRestart)}
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{key}
|
||||
</span>
|
||||
</div>
|
||||
{typeDescription && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{typeDescription}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => handleRemove(key)}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<LuTrash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<CollapsibleContent>
|
||||
<div className="space-y-4 border-t p-4">
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center">
|
||||
{t("label.ID", {
|
||||
ns: "common",
|
||||
defaultValue: "ID",
|
||||
})}
|
||||
</Label>
|
||||
<Input
|
||||
value={renameDraft}
|
||||
disabled={isDisabled}
|
||||
onChange={(event) => {
|
||||
setRenameDrafts((prev) => ({
|
||||
...prev,
|
||||
[key]: event.target.value,
|
||||
}));
|
||||
}}
|
||||
onBlur={(event) =>
|
||||
commitRename(key, event.target.value)
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
commitRename(key, renameDraft);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("field.internalID", {
|
||||
ns: "common",
|
||||
defaultValue:
|
||||
"The Internal ID Frigate uses in the configuration and database",
|
||||
})}
|
||||
</p>
|
||||
{renameErrors[key] && (
|
||||
<p className="text-xs text-danger">
|
||||
{renameErrors[key]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-3 space-y-2">
|
||||
<Label className="flex items-center">
|
||||
{t("detectors.type.label", {
|
||||
ns: configNamespace,
|
||||
defaultValue: "Type",
|
||||
})}
|
||||
</Label>
|
||||
<Select
|
||||
value={type}
|
||||
onValueChange={(value) =>
|
||||
handleTypeChange(key, value)
|
||||
}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue
|
||||
placeholder={t("selectItem", {
|
||||
ns: "common",
|
||||
defaultValue: "Select {{item}}",
|
||||
item: t("detectors.type.label", {
|
||||
ns: configNamespace,
|
||||
defaultValue: "Type",
|
||||
}),
|
||||
})}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{orderedTypes.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{getTypeLabel(option)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{typeErrors[key] && (
|
||||
<p className="text-xs text-danger">
|
||||
{typeErrors[key]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn(readonly && "opacity-90")}>
|
||||
{renderInstanceForm(key, value)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-start pt-5">
|
||||
<div className="w-full max-w-lg rounded-lg border bg-card p-4">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
{t("configForm.detectors.add", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Add detector",
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-col gap-3 md:flex-row md:items-end">
|
||||
<div className="flex-1 space-y-2">
|
||||
<Label>
|
||||
{t("detectors.type.label", {
|
||||
ns: configNamespace,
|
||||
defaultValue: "Type",
|
||||
})}
|
||||
</Label>
|
||||
<Select
|
||||
value={addType ?? ""}
|
||||
onValueChange={(value) => {
|
||||
setAddError(undefined);
|
||||
setAddType(value);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue
|
||||
placeholder={t("selectItem", {
|
||||
ns: "common",
|
||||
defaultValue: "Select {{item}}",
|
||||
item: t("detectors.type.label", {
|
||||
ns: configNamespace,
|
||||
defaultValue: "Type",
|
||||
}),
|
||||
})}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{orderedTypes.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{getTypeLabel(type)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{addError && <p className="text-xs text-danger">{addError}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleAdd}
|
||||
disabled={isDisabled}
|
||||
className="gap-2"
|
||||
>
|
||||
<LuPlus className="h-4 w-4" />
|
||||
{t("button.add", {
|
||||
ns: "common",
|
||||
defaultValue: "Add",
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
266
web/src/components/config-form/theme/fields/HardwarePicker.tsx
Normal file
266
web/src/components/config-form/theme/fields/HardwarePicker.tsx
Normal file
@ -0,0 +1,266 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useSWR from "swr";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { DetectionHardware } from "@/types/hardware";
|
||||
import {
|
||||
hardwareForDevices,
|
||||
MAX_DETECTORS,
|
||||
recommendedDetectorCount,
|
||||
} from "@/utils/detectionHardware";
|
||||
|
||||
type HardwarePickerProps = {
|
||||
// scopes the unit checkbox ids, since several models can list the same unit
|
||||
idPrefix: string;
|
||||
devices: string[];
|
||||
// device strings already taken by another model, mapped to that model's scene
|
||||
claimedElsewhere: Record<string, string>;
|
||||
cameraCount: number;
|
||||
disabled?: boolean;
|
||||
onChange: (devices: string[]) => void;
|
||||
};
|
||||
|
||||
export function HardwarePicker({
|
||||
idPrefix,
|
||||
devices,
|
||||
claimedElsewhere,
|
||||
cameraCount,
|
||||
disabled,
|
||||
onChange,
|
||||
}: HardwarePickerProps) {
|
||||
const { t } = useTranslation(["views/settings", "common"]);
|
||||
|
||||
const { data: hardware, isLoading } =
|
||||
useSWR<DetectionHardware[]>("hardware/probe");
|
||||
|
||||
const selected = useMemo(
|
||||
() => hardwareForDevices(hardware ?? [], devices),
|
||||
[hardware, devices],
|
||||
);
|
||||
|
||||
const recommended = useMemo(
|
||||
() => recommendedDetectorCount(cameraCount),
|
||||
[cameraCount],
|
||||
);
|
||||
|
||||
// the units a model is assigned to, in the order the probe reports them. A
|
||||
// shareable unit repeats in `devices` once per inference process, so the
|
||||
// distinct entries are what is selected.
|
||||
const selectedUnits = useMemo(() => {
|
||||
if (!selected) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return selected.units
|
||||
.map((unit) => unit.device)
|
||||
.filter((device) => devices.includes(device));
|
||||
}, [selected, devices]);
|
||||
|
||||
/** Spread `count` detectors round robin over the selected units. */
|
||||
const buildDevices = useCallback((units: string[], count: number) => {
|
||||
if (units.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(
|
||||
{ length: Math.max(count, units.length) },
|
||||
(_, index) => units[index % units.length],
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleHardwareChange = useCallback(
|
||||
(key: string) => {
|
||||
const entry = hardware?.find((candidate) => candidate.key === key);
|
||||
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
// start with the first unit no other model has taken
|
||||
const free = entry.units.find((unit) => !claimedElsewhere[unit.device]);
|
||||
|
||||
if (!free) {
|
||||
onChange([]);
|
||||
return;
|
||||
}
|
||||
|
||||
onChange(
|
||||
entry.unlimited
|
||||
? buildDevices([free.device], recommended)
|
||||
: [free.device],
|
||||
);
|
||||
},
|
||||
[hardware, claimedElsewhere, recommended, buildDevices, onChange],
|
||||
);
|
||||
|
||||
const handleUnitToggle = useCallback(
|
||||
(device: string, checked: boolean) => {
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const units = selected.units
|
||||
.map((unit) => unit.device)
|
||||
.filter((candidate) =>
|
||||
candidate === device ? checked : selectedUnits.includes(candidate),
|
||||
);
|
||||
|
||||
if (!selected.unlimited) {
|
||||
onChange(units);
|
||||
return;
|
||||
}
|
||||
|
||||
// keep the detector count while the set of units changes
|
||||
onChange(buildDevices(units, devices.length));
|
||||
},
|
||||
[selected, selectedUnits, devices.length, buildDevices, onChange],
|
||||
);
|
||||
|
||||
const handleCountChange = useCallback(
|
||||
(value: string) => {
|
||||
onChange(buildDevices(selectedUnits, Number(value)));
|
||||
},
|
||||
[selectedUnits, buildDevices, onChange],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("detectionModels.hardware.loading")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
// a hand-written config can name hardware this system does not report
|
||||
const unrecognized = devices.length > 0 && !selected;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-1">
|
||||
<Label>{t("detectionModels.hardware.label")}</Label>
|
||||
<Select
|
||||
value={selected?.key ?? ""}
|
||||
onValueChange={handleHardwareChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="max-w-xs">
|
||||
<SelectValue
|
||||
placeholder={t("detectionModels.hardware.placeholder")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(hardware ?? []).map((entry) => (
|
||||
<SelectItem key={entry.key} value={entry.key}>
|
||||
{entry.name}
|
||||
{entry.count > 1 ? ` (${entry.count})` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("detectionModels.hardware.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{unrecognized ? (
|
||||
<p className="text-sm text-danger">
|
||||
{t("detectionModels.hardware.unrecognized", {
|
||||
devices: devices.join(", "),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{selected && (selected.units.length > 1 || !selected.unlimited) ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("detectionModels.hardware.unitsDescription")}
|
||||
</p>
|
||||
{selected.units.map((unit) => {
|
||||
const claimedBy = claimedElsewhere[unit.device];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={unit.device}
|
||||
className="mb-3 flex flex-row items-center space-x-3 space-y-0 last:mb-0"
|
||||
>
|
||||
<Checkbox
|
||||
id={`${idPrefix}-${unit.device}`}
|
||||
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
|
||||
checked={devices.includes(unit.device)}
|
||||
disabled={disabled || Boolean(claimedBy)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleUnitToggle(unit.device, checked === true)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`${idPrefix}-${unit.device}`}
|
||||
className="cursor-pointer font-normal"
|
||||
>
|
||||
{unit.label}
|
||||
{claimedBy ? (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{t("detectionModels.hardware.claimedBy", {
|
||||
scene: claimedBy,
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selected?.unlimited ? (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`${idPrefix}-detector-count`}>
|
||||
{t("detectionModels.hardware.detectorCount")}
|
||||
</Label>
|
||||
<Select
|
||||
value={String(devices.length || 1)}
|
||||
onValueChange={handleCountChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={`${idPrefix}-detector-count`}
|
||||
className="max-w-xs"
|
||||
>
|
||||
{String(devices.length || 1)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Array.from({ length: MAX_DETECTORS }, (_, index) => index + 1)
|
||||
.filter((count) => count >= Math.max(selectedUnits.length, 1))
|
||||
.map((count) => (
|
||||
<SelectItem key={count} value={String(count)}>
|
||||
<div className="flex h-max flex-col justify-between">
|
||||
<div>{count}</div>
|
||||
{count === recommended ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("detectionModels.hardware.countRecommended", {
|
||||
count: cameraCount,
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("detectionModels.hardware.detectorCountDescription")}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HardwarePicker;
|
||||
@ -0,0 +1,175 @@
|
||||
import { ReactNode, useMemo, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import useSWR from "swr";
|
||||
import axios from "axios";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
|
||||
export type FrigatePlusModel = {
|
||||
id: string;
|
||||
name: string;
|
||||
baseModel: string;
|
||||
trainDate: string;
|
||||
isBaseModel: boolean;
|
||||
supportedDetectors: string[];
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
const PLUS_PREFIX = "plus://";
|
||||
|
||||
/** The Frigate+ model id a path refers to, if it is a Frigate+ path at all. */
|
||||
function plusModelId(path: unknown): string | undefined {
|
||||
return typeof path === "string" && path.startsWith(PLUS_PREFIX)
|
||||
? path.slice(PLUS_PREFIX.length)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
type ModelSourcePickerProps = {
|
||||
path: unknown;
|
||||
// Frigate+ metadata the backend attaches to a saved model, and the only
|
||||
// reliable signal that one is active: it resolves `plus://<id>` to a local
|
||||
// cache path before serving the config back
|
||||
plus?: { id: string } | null;
|
||||
// the detector this model runs on, used to filter incompatible Plus models
|
||||
detector?: string;
|
||||
disabled?: boolean;
|
||||
onPathChange: (path: string | undefined) => void;
|
||||
// the schema-driven fields for a custom model
|
||||
customFields: ReactNode;
|
||||
};
|
||||
|
||||
export function ModelSourcePicker({
|
||||
path,
|
||||
plus,
|
||||
detector,
|
||||
disabled,
|
||||
onPathChange,
|
||||
customFields,
|
||||
}: ModelSourcePickerProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
const plusEnabled = Boolean(config?.plus?.enabled);
|
||||
// an unsaved pick still carries the plus:// path, which wins over the
|
||||
// metadata of whatever model was saved before it
|
||||
const selectedId = plusModelId(path) ?? plus?.id;
|
||||
|
||||
const { data: availableModels, isLoading } = useSWR<
|
||||
Record<string, FrigatePlusModel>
|
||||
>(plusEnabled ? "/plus/models" : null, {
|
||||
fetcher: async (url) => {
|
||||
const res = await axios.get(url, { withCredentials: true });
|
||||
return res.data.reduce(
|
||||
(obj: Record<string, FrigatePlusModel>, model: FrigatePlusModel) => {
|
||||
obj[model.id] = model;
|
||||
return obj;
|
||||
},
|
||||
{},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const entries = useMemo(
|
||||
() => Object.entries(availableModels ?? {}),
|
||||
[availableModels],
|
||||
);
|
||||
|
||||
// the tab cannot be derived from the path alone: switching to Frigate+
|
||||
// leaves the path untouched until a model is picked
|
||||
const [tab, setTab] = useState<"plus" | "custom">(
|
||||
selectedId ? "plus" : "custom",
|
||||
);
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
setTab(value as "plus" | "custom");
|
||||
|
||||
// a resolved Frigate+ path is meaningless as a custom path, so drop it
|
||||
if (value === "custom" && selectedId) {
|
||||
onPathChange(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const isCompatible = (model: FrigatePlusModel) =>
|
||||
!detector || model.supportedDetectors.includes(detector);
|
||||
|
||||
if (!plusEnabled) {
|
||||
return <div className="space-y-6">{customFields}</div>;
|
||||
}
|
||||
|
||||
const describe = (model: FrigatePlusModel) =>
|
||||
`${new Date(model.trainDate).toLocaleString()} ${model.baseModel} (${
|
||||
model.isBaseModel
|
||||
? t("frigatePlus.modelInfo.plusModelType.baseModel")
|
||||
: t("frigatePlus.modelInfo.plusModelType.userModel")
|
||||
}) ${model.name} (${model.width}x${model.height})`;
|
||||
|
||||
return (
|
||||
<Tabs value={tab} onValueChange={handleTabChange}>
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="plus">{t("detectionModels.tabs.plus")}</TabsTrigger>
|
||||
<TabsTrigger value="custom">
|
||||
{t("detectionModels.tabs.custom")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="plus" className="space-y-1">
|
||||
<Label>{t("frigatePlus.modelInfo.availableModels")}</Label>
|
||||
<Select
|
||||
value={selectedId ?? ""}
|
||||
onValueChange={(id) => onPathChange(`${PLUS_PREFIX}${id}`)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="w-full max-w-2xl">
|
||||
{selectedId && availableModels?.[selectedId]
|
||||
? describe(availableModels[selectedId])
|
||||
: isLoading
|
||||
? t("frigatePlus.modelInfo.loadingAvailableModels")
|
||||
: t("detectionModels.plusModel.noModelSelected")}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{entries.length === 0 ? (
|
||||
<div className="px-4 py-3 text-center text-sm text-muted-foreground">
|
||||
{t("frigatePlus.modelInfo.noModelsAvailable")}
|
||||
</div>
|
||||
) : (
|
||||
entries.map(([id, model]) => (
|
||||
<SelectItem
|
||||
key={id}
|
||||
className="cursor-pointer"
|
||||
value={id}
|
||||
disabled={!isCompatible(model)}
|
||||
>
|
||||
<div>{describe(model)}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("frigatePlus.modelInfo.supportedDetectors")}:{" "}
|
||||
{model.supportedDetectors.join(", ")}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<Trans ns="views/settings">frigatePlus.modelInfo.modelSelect</Trans>
|
||||
</p>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="custom" className="space-y-6">
|
||||
{customFields}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
export default ModelSourcePicker;
|
||||
460
web/src/components/config-form/theme/fields/ModelsField.tsx
Normal file
460
web/src/components/config-form/theme/fields/ModelsField.tsx
Normal file
@ -0,0 +1,460 @@
|
||||
import type {
|
||||
ErrorSchema,
|
||||
FieldProps,
|
||||
RJSFSchema,
|
||||
UiSchema,
|
||||
} from "@rjsf/utils";
|
||||
import { toFieldPathId } from "@rjsf/utils";
|
||||
import { cloneDeep } from "lodash";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
LuChevronDown,
|
||||
LuChevronRight,
|
||||
LuPlus,
|
||||
LuTrash2,
|
||||
} from "react-icons/lu";
|
||||
import { applySchemaDefaults } from "@/lib/config-schema";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { ConfigFormContext } from "@/types/configForm";
|
||||
import useSWR from "swr";
|
||||
import { DetectionHardware } from "@/types/hardware";
|
||||
import { summarizeDevices } from "@/utils/detectionHardware";
|
||||
import { HardwarePicker } from "./HardwarePicker";
|
||||
import { ModelSourcePicker } from "./ModelSourcePicker";
|
||||
|
||||
type DetectionModel = {
|
||||
scene?: string;
|
||||
devices?: string[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
// scene and devices get dedicated controls; everything else is the model itself
|
||||
const CUSTOM_MODEL_FIELDS = [
|
||||
"path",
|
||||
"labelmap_path",
|
||||
"width",
|
||||
"height",
|
||||
"input_pixel_format",
|
||||
"input_tensor",
|
||||
"input_dtype",
|
||||
"model_type",
|
||||
];
|
||||
|
||||
/** The detector a model runs on, which is the prefix of its device strings. */
|
||||
const detectorForModel = (model: DetectionModel): string | undefined =>
|
||||
model.devices?.[0]?.split(":")[0];
|
||||
|
||||
const asModelList = (formData: unknown): DetectionModel[] => {
|
||||
if (!Array.isArray(formData)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return formData.filter(
|
||||
(item): item is DetectionModel => typeof item === "object" && item !== null,
|
||||
);
|
||||
};
|
||||
|
||||
const getItemSchema = (schema: RJSFSchema): RJSFSchema | undefined => {
|
||||
const items = schema.items;
|
||||
|
||||
if (!items || typeof items !== "object" || Array.isArray(items)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return items as RJSFSchema;
|
||||
};
|
||||
|
||||
const getItemProperties = (
|
||||
schema: RJSFSchema | undefined,
|
||||
): Record<string, RJSFSchema> => {
|
||||
if (!schema || typeof schema.properties !== "object" || !schema.properties) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return schema.properties as Record<string, RJSFSchema>;
|
||||
};
|
||||
|
||||
const getSceneOptions = (itemSchema: RJSFSchema | undefined): string[] => {
|
||||
const scene = getItemProperties(itemSchema).scene as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const values = scene?.enum;
|
||||
|
||||
return Array.isArray(values)
|
||||
? values.filter((v): v is string => typeof v === "string")
|
||||
: [];
|
||||
};
|
||||
|
||||
export function ModelsField(props: FieldProps) {
|
||||
const {
|
||||
schema,
|
||||
uiSchema,
|
||||
formData,
|
||||
onChange,
|
||||
fieldPathId,
|
||||
registry,
|
||||
idSchema,
|
||||
errorSchema,
|
||||
disabled,
|
||||
readonly,
|
||||
hideError,
|
||||
onBlur,
|
||||
onFocus,
|
||||
} = props;
|
||||
|
||||
const { t } = useTranslation(["views/settings", "common"]);
|
||||
const formContext = registry?.formContext as ConfigFormContext | undefined;
|
||||
|
||||
const models = useMemo(() => asModelList(formData), [formData]);
|
||||
const itemSchema = useMemo(
|
||||
() => getItemSchema(schema as RJSFSchema),
|
||||
[schema],
|
||||
);
|
||||
const itemProperties = useMemo(
|
||||
() => getItemProperties(itemSchema),
|
||||
[itemSchema],
|
||||
);
|
||||
const itemUiSchema = useMemo(
|
||||
() =>
|
||||
((uiSchema as { items?: UiSchema } | undefined)?.items ?? {}) as UiSchema,
|
||||
[uiSchema],
|
||||
);
|
||||
const sceneOptions = useMemo(() => getSceneOptions(itemSchema), [itemSchema]);
|
||||
const SchemaField = registry.fields.SchemaField;
|
||||
|
||||
const [openByIndex, setOpenByIndex] = useState<Record<number, boolean>>({});
|
||||
|
||||
// shared with HardwarePicker through the SWR cache, so this is not a second
|
||||
// request
|
||||
const { data: hardware } = useSWR<DetectionHardware[]>("hardware/probe");
|
||||
|
||||
useEffect(() => {
|
||||
setOpenByIndex((previous) => {
|
||||
const next: Record<number, boolean> = {};
|
||||
for (let index = 0; index < models.length; index += 1) {
|
||||
next[index] = previous[index] ?? true;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [models.length]);
|
||||
|
||||
const cameras = formContext?.fullConfig?.cameras;
|
||||
const savedModels = formContext?.fullConfig?.models;
|
||||
|
||||
// `plus` is a readonly field stripped from the form data, so read it from the
|
||||
// full config. Match on scene rather than index, which shifts when a model is
|
||||
// added or removed.
|
||||
const savedPlusForScene = useCallback(
|
||||
(scene: string | undefined) =>
|
||||
savedModels?.find((saved) => saved.scene === scene)?.plus,
|
||||
[savedModels],
|
||||
);
|
||||
|
||||
// a model serves the cameras naming its scene, plus every camera that names
|
||||
// no scene at all when it is the "all" model
|
||||
const cameraCountForScene = useCallback(
|
||||
(scene: string | undefined): number => {
|
||||
if (!cameras) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Object.values(cameras).filter((camera) => {
|
||||
const cameraScene = camera?.detect?.scene;
|
||||
return cameraScene ? cameraScene === scene : scene === "all";
|
||||
}).length;
|
||||
},
|
||||
[cameras],
|
||||
);
|
||||
|
||||
const claimedByOtherModels = useCallback(
|
||||
(index: number): Record<string, string> => {
|
||||
const claimed: Record<string, string> = {};
|
||||
|
||||
models.forEach((model, currentIndex) => {
|
||||
if (currentIndex === index) {
|
||||
return;
|
||||
}
|
||||
|
||||
(model.devices ?? []).forEach((device) => {
|
||||
claimed[device] = model.scene ?? String(currentIndex + 1);
|
||||
});
|
||||
});
|
||||
|
||||
return claimed;
|
||||
},
|
||||
[models],
|
||||
);
|
||||
|
||||
const updateModel = useCallback(
|
||||
(index: number, partial: Partial<DetectionModel>) => {
|
||||
const next = cloneDeep(models);
|
||||
next[index] = { ...next[index], ...partial };
|
||||
onChange(next, fieldPathId.path);
|
||||
},
|
||||
[models, onChange, fieldPathId.path],
|
||||
);
|
||||
|
||||
const handleAddModel = useCallback(() => {
|
||||
const base = itemSchema
|
||||
? (applySchemaDefaults(itemSchema) as DetectionModel)
|
||||
: ({} as DetectionModel);
|
||||
const taken = new Set(models.map((model) => model.scene));
|
||||
const scene = sceneOptions.find((option) => !taken.has(option));
|
||||
|
||||
onChange([...models, { ...base, scene, devices: [] }], fieldPathId.path);
|
||||
setOpenByIndex((previous) => ({ ...previous, [models.length]: true }));
|
||||
}, [models, itemSchema, sceneOptions, onChange, fieldPathId.path]);
|
||||
|
||||
const handleRemoveModel = useCallback(
|
||||
(index: number) => {
|
||||
onChange(
|
||||
models.filter((_, currentIndex) => currentIndex !== index),
|
||||
fieldPathId.path,
|
||||
);
|
||||
|
||||
setOpenByIndex((previous) => {
|
||||
const next: Record<number, boolean> = {};
|
||||
Object.entries(previous).forEach(([key, value]) => {
|
||||
const current = Number(key);
|
||||
if (Number.isNaN(current) || current === index) {
|
||||
return;
|
||||
}
|
||||
next[current > index ? current - 1 : current] = value;
|
||||
});
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[models, onChange, fieldPathId.path],
|
||||
);
|
||||
|
||||
const renderField = useCallback(
|
||||
(index: number, fieldName: string) => {
|
||||
const fieldSchema = itemProperties[fieldName];
|
||||
|
||||
if (!SchemaField || !fieldSchema) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const itemFieldPathId = toFieldPathId(
|
||||
fieldName,
|
||||
registry.globalFormOptions,
|
||||
[...fieldPathId.path, index],
|
||||
);
|
||||
const itemErrors = (
|
||||
errorSchema as Record<string, ErrorSchema> | undefined
|
||||
)?.[index] as Record<string, ErrorSchema> | undefined;
|
||||
|
||||
return (
|
||||
<SchemaField
|
||||
key={fieldName}
|
||||
name={fieldName}
|
||||
schema={fieldSchema}
|
||||
uiSchema={(itemUiSchema[fieldName] as UiSchema | undefined) ?? {}}
|
||||
fieldPathId={itemFieldPathId}
|
||||
formData={(models[index] as Record<string, unknown>)?.[fieldName]}
|
||||
errorSchema={itemErrors?.[fieldName]}
|
||||
onChange={(nextValue: unknown) =>
|
||||
updateModel(index, { [fieldName]: nextValue })
|
||||
}
|
||||
onBlur={onBlur}
|
||||
onFocus={onFocus}
|
||||
registry={registry}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
hideError={hideError}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[
|
||||
SchemaField,
|
||||
itemProperties,
|
||||
itemUiSchema,
|
||||
models,
|
||||
registry,
|
||||
fieldPathId.path,
|
||||
errorSchema,
|
||||
updateModel,
|
||||
onBlur,
|
||||
onFocus,
|
||||
disabled,
|
||||
readonly,
|
||||
hideError,
|
||||
],
|
||||
);
|
||||
|
||||
const baseId = idSchema?.$id ?? "models";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{models.map((model, index) => {
|
||||
const open = openByIndex[index] ?? true;
|
||||
const takenScenes = new Set(
|
||||
models
|
||||
.filter((_, currentIndex) => currentIndex !== index)
|
||||
.map((other) => other.scene),
|
||||
);
|
||||
|
||||
return (
|
||||
<Card key={`${baseId}-${index}`} className="w-full">
|
||||
<Collapsible
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) =>
|
||||
setOpenByIndex((previous) => ({
|
||||
...previous,
|
||||
[index]: nextOpen,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<CardHeader className="p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<CollapsibleTrigger asChild>
|
||||
<CardTitle className="flex-1 cursor-pointer text-sm">
|
||||
<span>
|
||||
{t(`detectionModels.scenes.${model.scene ?? "all"}`)}
|
||||
</span>
|
||||
<span className="mt-1 block text-xs font-normal text-muted-foreground">
|
||||
{summarizeDevices(
|
||||
hardware ?? [],
|
||||
model.devices ?? [],
|
||||
) ?? t("detectionModels.hardware.none")}
|
||||
{" • "}
|
||||
{t("detectionModels.cameras", {
|
||||
count: cameraCountForScene(model.scene),
|
||||
})}
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CollapsibleTrigger>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{models.length > 1 ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveModel(index)}
|
||||
disabled={disabled || readonly}
|
||||
aria-label={t("button.delete", { ns: "common" })}
|
||||
>
|
||||
<LuTrash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("button.delete", { ns: "common" })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t(
|
||||
open ? "button.collapse" : "button.expand",
|
||||
{ ns: "common" },
|
||||
)}
|
||||
>
|
||||
{open ? (
|
||||
<LuChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<LuChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CollapsibleContent>
|
||||
<CardContent className="space-y-6 p-4 pt-0">
|
||||
<div className="space-y-1">
|
||||
<Label>{t("detectionModels.scene.label")}</Label>
|
||||
<Select
|
||||
value={model.scene ?? ""}
|
||||
onValueChange={(scene) => updateModel(index, { scene })}
|
||||
disabled={disabled || readonly}
|
||||
>
|
||||
<SelectTrigger className="max-w-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sceneOptions.map((scene) => (
|
||||
<SelectItem
|
||||
key={scene}
|
||||
value={scene}
|
||||
disabled={takenScenes.has(scene)}
|
||||
>
|
||||
{t(`detectionModels.scenes.${scene}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("detectionModels.scene.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<HardwarePicker
|
||||
idPrefix={`${baseId}-${index}`}
|
||||
devices={model.devices ?? []}
|
||||
claimedElsewhere={claimedByOtherModels(index)}
|
||||
cameraCount={cameraCountForScene(model.scene)}
|
||||
disabled={disabled || readonly}
|
||||
onChange={(devices) => updateModel(index, { devices })}
|
||||
/>
|
||||
|
||||
<ModelSourcePicker
|
||||
path={model.path}
|
||||
plus={savedPlusForScene(model.scene)}
|
||||
detector={detectorForModel(model)}
|
||||
disabled={disabled || readonly}
|
||||
onPathChange={(path) => updateModel(index, { path })}
|
||||
customFields={CUSTOM_MODEL_FIELDS.map((fieldName) =>
|
||||
renderField(index, fieldName),
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
{models.length < sceneOptions.length ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddModel}
|
||||
disabled={disabled || readonly}
|
||||
className="gap-2"
|
||||
>
|
||||
<LuPlus className="h-4 w-4" />
|
||||
{t("detectionModels.addModel")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ModelsField;
|
||||
@ -1,5 +1,5 @@
|
||||
// Custom RJSF Fields
|
||||
export { LayoutGridField } from "./LayoutGridField";
|
||||
export { DetectorHardwareField } from "./DetectorHardwareField";
|
||||
export { ModelsField } from "./ModelsField";
|
||||
export { ReplaceRulesField } from "./ReplaceRulesField";
|
||||
export { LiveStreamsField } from "./LiveStreamsField";
|
||||
|
||||
@ -49,7 +49,7 @@ import { MultiSchemaFieldTemplate } from "./templates/MultiSchemaFieldTemplate";
|
||||
import { WrapIfAdditionalTemplate } from "./templates/WrapIfAdditionalTemplate";
|
||||
|
||||
import { LayoutGridField } from "./fields/LayoutGridField";
|
||||
import { DetectorHardwareField } from "./fields/DetectorHardwareField";
|
||||
import { ModelsField } from "./fields/ModelsField";
|
||||
import { ReplaceRulesField } from "./fields/ReplaceRulesField";
|
||||
import { CameraInputsField } from "./fields/CameraInputsField";
|
||||
import { DictAsYamlField } from "./fields/DictAsYamlField";
|
||||
@ -111,7 +111,7 @@ export const frigateTheme: FrigateTheme = {
|
||||
},
|
||||
fields: {
|
||||
LayoutGridField: LayoutGridField,
|
||||
DetectorHardwareField: DetectorHardwareField,
|
||||
ModelsField: ModelsField,
|
||||
ReplaceRulesField: ReplaceRulesField,
|
||||
CameraInputsField: CameraInputsField,
|
||||
DictAsYamlField: DictAsYamlField,
|
||||
|
||||
@ -111,11 +111,7 @@ const resolveErrorFieldLabel = ({
|
||||
? "config/cameras"
|
||||
: formContext?.i18nNamespace;
|
||||
|
||||
const translationPath = buildTranslationPath(
|
||||
stringSegments,
|
||||
sectionI18nPrefix,
|
||||
formContext,
|
||||
);
|
||||
const translationPath = buildTranslationPath(stringSegments, formContext);
|
||||
|
||||
if (effectiveNamespace && translationPath) {
|
||||
const translated = resolveConfigTranslation(
|
||||
|
||||
@ -168,11 +168,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
||||
(!isArrayItemInAdditionalProp || showArrayItemDescription) &&
|
||||
!suppressDescription;
|
||||
|
||||
const translationPath = buildTranslationPath(
|
||||
pathSegments,
|
||||
sectionI18nPrefix,
|
||||
formContext,
|
||||
);
|
||||
const translationPath = buildTranslationPath(pathSegments, formContext);
|
||||
const fieldPath = fieldPathId.path;
|
||||
const overrides = formContext?.overrides;
|
||||
const baselineFormData = formContext?.baselineFormData;
|
||||
|
||||
@ -252,11 +252,7 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
||||
? getTranslatedLabel(filterObjectLabel, isAudioLabels ? "audio" : "object")
|
||||
: undefined;
|
||||
if (path) {
|
||||
translationPath = buildTranslationPath(
|
||||
path,
|
||||
sectionI18nPrefix,
|
||||
formContext,
|
||||
);
|
||||
translationPath = buildTranslationPath(path, formContext);
|
||||
// Also get the last property name for fallback label generation
|
||||
for (let i = path.length - 1; i >= 0; i -= 1) {
|
||||
const segment = path[i];
|
||||
|
||||
@ -8,62 +8,20 @@
|
||||
import type { ConfigFormContext } from "@/types/configForm";
|
||||
import { getEffectiveAttributeLabels } from "@/utils/configUtil";
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null;
|
||||
|
||||
const resolveDetectorType = (
|
||||
detectorConfig: unknown,
|
||||
detectorKey?: string,
|
||||
): string | undefined => {
|
||||
if (!detectorKey || !isRecord(detectorConfig)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const entry = detectorConfig[detectorKey];
|
||||
if (!isRecord(entry)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const typeValue = entry.type;
|
||||
return typeof typeValue === "string" && typeValue.length > 0
|
||||
? typeValue
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const resolveDetectorTypeFromContext = (
|
||||
formContext: ConfigFormContext | undefined,
|
||||
detectorKey?: string,
|
||||
): string | undefined => {
|
||||
const formData = formContext?.formData;
|
||||
if (!detectorKey || !isRecord(formData)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const detectorConfig = isRecord(formData.detectors)
|
||||
? formData.detectors
|
||||
: formData;
|
||||
|
||||
return resolveDetectorType(detectorConfig, detectorKey);
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the i18n translation key path for nested fields using the field path
|
||||
* provided by RJSF. This avoids ambiguity with underscores in field names and
|
||||
* normalizes dynamic segments like filter object names or detector names.
|
||||
* normalizes dynamic segments like filter object names.
|
||||
*
|
||||
* @param segments Array of path segments (strings and/or numbers)
|
||||
* @param sectionI18nPrefix Optional section prefix for specialized sections
|
||||
* @param formContext Optional form context for resolving detector types
|
||||
* @param formContext Optional form context for resolving attribute labels
|
||||
* @returns Normalized translation key path as a dot-separated string
|
||||
*
|
||||
* @example
|
||||
* buildTranslationPath(["filters", "person", "threshold"]) => "filters.threshold"
|
||||
* buildTranslationPath(["detectors", "ov1", "type"]) => "detectors.openvino.type"
|
||||
* buildTranslationPath(["ov1", "type"], "detectors") => "openvino.type"
|
||||
*/
|
||||
export function buildTranslationPath(
|
||||
segments: Array<string | number>,
|
||||
sectionI18nPrefix?: string,
|
||||
formContext?: ConfigFormContext,
|
||||
): string {
|
||||
// Filter out numeric indices to get string segments only
|
||||
@ -97,46 +55,6 @@ export function buildTranslationPath(
|
||||
return normalized.join(".");
|
||||
}
|
||||
|
||||
// Handle detectors section - resolve the detector type when available
|
||||
// Example: detectors.ov1.type -> detectors.openvino.type
|
||||
const detectorsIndex = stringSegments.indexOf("detectors");
|
||||
if (detectorsIndex !== -1 && stringSegments.length > detectorsIndex + 2) {
|
||||
const detectorKey = stringSegments[detectorsIndex + 1];
|
||||
const detectorType = resolveDetectorTypeFromContext(
|
||||
formContext,
|
||||
detectorKey,
|
||||
);
|
||||
if (detectorType) {
|
||||
const normalized = [
|
||||
...stringSegments.slice(0, detectorsIndex + 1),
|
||||
detectorType,
|
||||
...stringSegments.slice(detectorsIndex + 2),
|
||||
];
|
||||
return normalized.join(".");
|
||||
}
|
||||
|
||||
const normalized = [
|
||||
...stringSegments.slice(0, detectorsIndex + 1),
|
||||
...stringSegments.slice(detectorsIndex + 2),
|
||||
];
|
||||
return normalized.join(".");
|
||||
}
|
||||
|
||||
// Handle specialized sections like detectors where the first segment is dynamic
|
||||
// Example: (sectionI18nPrefix="detectors") "ov1.type" -> "openvino.type"
|
||||
if (sectionI18nPrefix === "detectors" && stringSegments.length > 1) {
|
||||
const detectorKey = stringSegments[0];
|
||||
const detectorType = resolveDetectorTypeFromContext(
|
||||
formContext,
|
||||
detectorKey,
|
||||
);
|
||||
if (detectorType) {
|
||||
return [detectorType, ...stringSegments.slice(1)].join(".");
|
||||
}
|
||||
|
||||
return stringSegments.slice(1).join(".");
|
||||
}
|
||||
|
||||
return stringSegments.join(".");
|
||||
}
|
||||
|
||||
|
||||
@ -736,6 +736,13 @@ export function applySchemaDefaults(
|
||||
schema: RJSFSchema,
|
||||
formData: Record<string, unknown> = {},
|
||||
): Record<string, unknown> {
|
||||
// An array section (models) carries its defaults on the item schema, not
|
||||
// here. Spreading an array below would turn it into an object keyed by
|
||||
// index, so hand it back untouched.
|
||||
if (Array.isArray(formData)) {
|
||||
return formData as unknown as Record<string, unknown>;
|
||||
}
|
||||
|
||||
const result = { ...formData };
|
||||
const schemaObj = schema as Record<string, unknown>;
|
||||
|
||||
|
||||
@ -51,7 +51,6 @@ import FrigatePlusSettingsView from "@/views/settings/FrigatePlusSettingsView";
|
||||
import MediaSyncSettingsView from "@/views/settings/MediaSyncSettingsView";
|
||||
import RegionGridSettingsView from "@/views/settings/RegionGridSettingsView";
|
||||
import Go2RtcStreamsSettingsView from "@/views/settings/Go2RtcStreamsSettingsView";
|
||||
import DetectorsAndModelSettingsView from "@/views/settings/DetectorsAndModelSettingsView";
|
||||
import {
|
||||
SingleSectionPage,
|
||||
type SettingsPageProps,
|
||||
@ -96,14 +95,10 @@ import { mutate } from "swr";
|
||||
import { RJSFSchema } from "@rjsf/utils";
|
||||
import {
|
||||
buildConfigDataForPath,
|
||||
buildHiddenFieldContext,
|
||||
flattenOverrides,
|
||||
getSectionConfig,
|
||||
parseProfileFromSectionPath,
|
||||
prepareSectionSavePayload,
|
||||
PROFILE_ELIGIBLE_SECTIONS,
|
||||
resolveHiddenFieldEntries,
|
||||
sanitizeSectionData,
|
||||
} from "@/utils/configUtil";
|
||||
import type { ProfileState, ProfilesApiResponse } from "@/types/profile";
|
||||
import { getProfileColor } from "@/utils/profileColors";
|
||||
@ -115,7 +110,6 @@ import SaveAllPreviewPopover, {
|
||||
type SaveAllPreviewItem,
|
||||
} from "@/components/overlay/detail/SaveAllPreviewPopover";
|
||||
import { useRestart } from "@/api/ws";
|
||||
import { getPrimaryModel } from "@/utils/modelUtil";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@ -246,6 +240,7 @@ const SystemEnvironmentVariablesSettingsPage = createSectionPage(
|
||||
);
|
||||
const SystemTelemetrySettingsPage = createSectionPage("telemetry", "global");
|
||||
const SystemBirdseyeSettingsPage = createSectionPage("birdseye", "global");
|
||||
const SystemDetectionModelsPage = createSectionPage("models", "global");
|
||||
const NotificationsSettingsPage = createSectionPage("notifications", "global");
|
||||
|
||||
const SystemMqttSettingsPage = createSectionPage("mqtt", "global");
|
||||
@ -408,7 +403,7 @@ const settingsGroups = [
|
||||
},
|
||||
{
|
||||
key: "systemDetectorsAndModel",
|
||||
component: DetectorsAndModelSettingsView,
|
||||
component: SystemDetectionModelsPage,
|
||||
},
|
||||
{ key: "systemDatabase", component: SystemDatabaseSettingsPage },
|
||||
{ key: "systemMqtt", component: SystemMqttSettingsPage },
|
||||
@ -560,8 +555,7 @@ const SYSTEM_SECTION_MAPPING: Record<string, SettingsType> = {
|
||||
environment_vars: "systemEnvironmentVariables",
|
||||
telemetry: "systemTelemetry",
|
||||
birdseye: "systemBirdseye",
|
||||
detectors: "systemDetectorsAndModel",
|
||||
model: "systemDetectorsAndModel",
|
||||
models: "systemDetectorsAndModel",
|
||||
};
|
||||
|
||||
const CAMERA_SECTION_KEYS = new Set<SettingsType>(
|
||||
@ -877,8 +871,7 @@ export default function Settings() {
|
||||
|
||||
// Show save/undo all buttons only when at least one pending change lives
|
||||
// outside the currently visible page. Map each pending key to its menu key
|
||||
// (e.g. both `detectors` and `model` collapse to `systemDetectorsAndModel`)
|
||||
// so a composite page with two pending config-sections still counts as one.
|
||||
// so a page hosting several config-sections still counts as one.
|
||||
const showSaveAllButtons = useMemo(() => {
|
||||
const pendingKeys = Object.keys(pendingDataBySection);
|
||||
if (pendingKeys.length === 0) return false;
|
||||
@ -912,111 +905,6 @@ export default function Settings() {
|
||||
// after `mutate("config")` resolves
|
||||
const keysToClear: string[] = [];
|
||||
|
||||
// `detectors` and `model` are owned by DetectorsAndModelSettingsView
|
||||
const hasPendingDetectors = "detectors" in pendingDataBySection;
|
||||
const hasPendingModel = "model" in pendingDataBySection;
|
||||
if (hasPendingDetectors || hasPendingModel) {
|
||||
try {
|
||||
const pendingDetectors = hasPendingDetectors
|
||||
? pendingDataBySection.detectors
|
||||
: undefined;
|
||||
const pendingModel = hasPendingModel
|
||||
? pendingDataBySection.model
|
||||
: undefined;
|
||||
|
||||
// Hidden-field lists come from the section configs themselves so
|
||||
// they stay in sync with what the embedded forms strip on render
|
||||
const detectorHiddenFields = resolveHiddenFieldEntries(
|
||||
getSectionConfig("detectors", "global").hiddenFields,
|
||||
buildHiddenFieldContext(config, "global"),
|
||||
);
|
||||
const modelHiddenFields = resolveHiddenFieldEntries(
|
||||
getSectionConfig("model", "global").hiddenFields,
|
||||
buildHiddenFieldContext(config, "global"),
|
||||
);
|
||||
const sanitizedDetectors =
|
||||
pendingDetectors !== undefined
|
||||
? sanitizeSectionData(pendingDetectors, detectorHiddenFields)
|
||||
: undefined;
|
||||
const sanitizedModel =
|
||||
pendingModel !== undefined
|
||||
? sanitizeSectionData(pendingModel, modelHiddenFields)
|
||||
: undefined;
|
||||
|
||||
// Pre-clear conditions: detector keys differ from saved config (rename
|
||||
// or add/remove), OR the model save flips between Plus and Custom modes
|
||||
let detectorKeysChanged = false;
|
||||
if (sanitizedDetectors && typeof sanitizedDetectors === "object") {
|
||||
const pendingKeySet = Object.keys(
|
||||
sanitizedDetectors as JsonObject,
|
||||
).sort();
|
||||
const savedKeySet = [
|
||||
...(getPrimaryModel(config)?.devices ?? []),
|
||||
].sort();
|
||||
detectorKeysChanged =
|
||||
JSON.stringify(pendingKeySet) !== JSON.stringify(savedKeySet);
|
||||
}
|
||||
let modelTabChanged = false;
|
||||
if (sanitizedModel && typeof sanitizedModel === "object") {
|
||||
const newPath = (sanitizedModel as { path?: string }).path;
|
||||
const oldPath = getPrimaryModel(config)?.path;
|
||||
const newIsPlus =
|
||||
typeof newPath === "string" && newPath.startsWith("plus://");
|
||||
const oldIsPlus =
|
||||
typeof oldPath === "string" && oldPath.startsWith("plus://");
|
||||
modelTabChanged = newIsPlus !== oldIsPlus;
|
||||
}
|
||||
|
||||
if (detectorKeysChanged || modelTabChanged) {
|
||||
try {
|
||||
await axios.put("config/set", {
|
||||
requires_restart: 0,
|
||||
config_data: { detectors: null, model: null },
|
||||
});
|
||||
} catch {
|
||||
// best-effort cleanup; the merge-write below will surface any
|
||||
// real error.
|
||||
}
|
||||
}
|
||||
|
||||
const combinedConfigData: Record<string, unknown> = {};
|
||||
if (sanitizedDetectors !== undefined) {
|
||||
combinedConfigData.detectors = sanitizedDetectors;
|
||||
}
|
||||
if (sanitizedModel !== undefined) {
|
||||
combinedConfigData.model = sanitizedModel;
|
||||
}
|
||||
|
||||
await axios.put("config/set", {
|
||||
requires_restart: 0,
|
||||
config_data: combinedConfigData,
|
||||
});
|
||||
|
||||
if (hasPendingDetectors) {
|
||||
keysToClear.push("detectors");
|
||||
savedKeys.push("detectors");
|
||||
}
|
||||
if (hasPendingModel) {
|
||||
keysToClear.push("model");
|
||||
savedKeys.push("model");
|
||||
}
|
||||
|
||||
if (hasPendingDetectors || hasPendingModel) {
|
||||
successCount++;
|
||||
anyNeedsRestart = true;
|
||||
}
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
"Save All – error saving detectors/model atomically",
|
||||
error,
|
||||
);
|
||||
if (hasPendingDetectors || hasPendingModel) {
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// go2rtc streams are owned by Go2RtcStreamsSettingsView
|
||||
if ("go2rtc_streams" in pendingDataBySection) {
|
||||
try {
|
||||
@ -1067,8 +955,7 @@ export default function Settings() {
|
||||
}
|
||||
|
||||
const pendingKeys = Object.keys(pendingDataBySection).filter(
|
||||
(key) =>
|
||||
key !== "detectors" && key !== "model" && key !== "go2rtc_streams",
|
||||
(key) => key !== "go2rtc_streams",
|
||||
);
|
||||
|
||||
for (const key of pendingKeys) {
|
||||
|
||||
@ -66,7 +66,7 @@ export interface CameraConfig {
|
||||
height: number;
|
||||
max_disappeared: number;
|
||||
min_initialized: number;
|
||||
scene: string | null;
|
||||
scene: string;
|
||||
stationary: {
|
||||
interval: number;
|
||||
max_frames: {
|
||||
|
||||
13
web/src/types/hardware.ts
Normal file
13
web/src/types/hardware.ts
Normal file
@ -0,0 +1,13 @@
|
||||
export type HardwareUnit = {
|
||||
device: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type DetectionHardware = {
|
||||
key: string;
|
||||
detector: string;
|
||||
name: string;
|
||||
units: HardwareUnit[];
|
||||
count: number;
|
||||
unlimited: boolean;
|
||||
};
|
||||
@ -220,6 +220,26 @@ export function buildOverrides(
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Same-length arrays get compared element by element rather than by
|
||||
// identity, so an item carrying an explicit null where the base simply
|
||||
// omits the key does not read as a change. `/api/config` serializes with
|
||||
// exclude_none, so every nullable field a form materializes would
|
||||
// otherwise look edited the moment the page opens.
|
||||
if (Array.isArray(base) && base.length === current.length) {
|
||||
const baseItems = base;
|
||||
const defaultItems = Array.isArray(defaults) ? defaults : undefined;
|
||||
const unchanged = current.every(
|
||||
(item, index) =>
|
||||
buildOverrides(item, baseItems[index], defaultItems?.[index]) ===
|
||||
undefined,
|
||||
);
|
||||
|
||||
if (unchanged) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
|
||||
62
web/src/utils/detectionHardware.ts
Normal file
62
web/src/utils/detectionHardware.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { DetectionHardware } from "@/types/hardware";
|
||||
|
||||
// one detector per this many cameras, so the recommendation grows with the
|
||||
// install without spawning a process per camera
|
||||
const CAMERAS_PER_DETECTOR = 8;
|
||||
|
||||
export const MAX_DETECTORS = 8;
|
||||
|
||||
/** How many detectors to suggest for a model serving this many cameras. */
|
||||
export function recommendedDetectorCount(cameraCount: number): number {
|
||||
const scaled = Math.ceil(cameraCount / CAMERAS_PER_DETECTOR);
|
||||
return Math.min(Math.max(scaled, 1), MAX_DETECTORS);
|
||||
}
|
||||
|
||||
/** The hardware whose units cover every one of these device strings. */
|
||||
export function hardwareForDevices(
|
||||
hardware: DetectionHardware[],
|
||||
devices: string[],
|
||||
): DetectionHardware | undefined {
|
||||
if (devices.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return hardware.find((entry) => {
|
||||
const known = new Set(entry.units.map((unit) => unit.device));
|
||||
return devices.every((device) => known.has(device));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A short summary of the hardware a model runs on.
|
||||
*
|
||||
* Repeating a device is how extra inference processes are configured, so the
|
||||
* raw list reads as "openvino:NPU, openvino:NPU". Collapse it to a name and a
|
||||
* count instead.
|
||||
*/
|
||||
export function summarizeDevices(
|
||||
hardware: DetectionHardware[],
|
||||
devices: string[],
|
||||
): string | undefined {
|
||||
if (devices.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const known = hardwareForDevices(hardware, devices);
|
||||
|
||||
if (known) {
|
||||
return devices.length > 1
|
||||
? `${known.name} (${devices.length})`
|
||||
: known.name;
|
||||
}
|
||||
|
||||
// hardware this system does not report, so fall back to the raw strings
|
||||
const counts = new Map<string, number>();
|
||||
devices.forEach((device) =>
|
||||
counts.set(device, (counts.get(device) ?? 0) + 1),
|
||||
);
|
||||
|
||||
return [...counts.entries()]
|
||||
.map(([device, count]) => (count > 1 ? `${device} ×${count}` : device))
|
||||
.join(", ");
|
||||
}
|
||||
@ -1,921 +0,0 @@
|
||||
import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { LuExternalLink, LuFilter } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import axios from "axios";
|
||||
import useSWR from "swr";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useRestart } from "@/api/ws";
|
||||
import RestartDialog from "@/components/overlay/dialog/RestartDialog";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import Heading from "@/components/ui/heading";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@/components/ui/select";
|
||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
import type {
|
||||
SectionStatus,
|
||||
SettingsPageProps,
|
||||
} from "@/views/settings/SingleSectionPage";
|
||||
import type { ConfigSectionData } from "@/types/configForm";
|
||||
import {
|
||||
SettingsGroupCard,
|
||||
SplitCardRow,
|
||||
} from "@/components/card/SettingsGroupCard";
|
||||
import { ConfigSectionTemplate } from "@/components/config-form/sections";
|
||||
import { ConfigMessageBanner } from "@/components/config-form/ConfigMessageBanner";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { getPrimaryModel } from "@/utils/modelUtil";
|
||||
import {
|
||||
buildHiddenFieldContext,
|
||||
getSectionConfig,
|
||||
resolveHiddenFieldEntries,
|
||||
sanitizeSectionData,
|
||||
} from "@/utils/configUtil";
|
||||
|
||||
type ModelTab = "plus" | "custom";
|
||||
|
||||
type PageState = {
|
||||
detectors: ConfigSectionData;
|
||||
modelTab: ModelTab;
|
||||
plusModelId: string | undefined;
|
||||
customModel: ConfigSectionData;
|
||||
};
|
||||
|
||||
type FrigatePlusModel = {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
isBaseModel: boolean;
|
||||
supportedDetectors: string[];
|
||||
trainDate: string;
|
||||
baseModel: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
const TYPE_MODEL_DEFAULTS: Record<string, ConfigSectionData> = {
|
||||
cpu: {
|
||||
path: "/cpu_model.tflite",
|
||||
labelmap_path: "/labelmap.txt",
|
||||
width: 320,
|
||||
height: 320,
|
||||
input_tensor: "nhwc",
|
||||
input_pixel_format: "rgb",
|
||||
input_dtype: "int",
|
||||
model_type: "ssd",
|
||||
},
|
||||
edgetpu: {
|
||||
path: "/edgetpu_model.tflite",
|
||||
labelmap_path: "/labelmap.txt",
|
||||
width: 320,
|
||||
height: 320,
|
||||
input_tensor: "nhwc",
|
||||
input_pixel_format: "rgb",
|
||||
input_dtype: "int",
|
||||
model_type: "ssd",
|
||||
},
|
||||
openvino: {
|
||||
path: "/openvino-model/ssdlite_mobilenet_v2.xml",
|
||||
labelmap_path: "/openvino-model/coco_91cl_bkgr.txt",
|
||||
width: 300,
|
||||
height: 300,
|
||||
input_tensor: "nhwc",
|
||||
input_pixel_format: "bgr",
|
||||
input_dtype: "int",
|
||||
model_type: "ssd",
|
||||
},
|
||||
};
|
||||
|
||||
const STATUS_BAR_KEY = "detectors_and_model";
|
||||
|
||||
const EMPTY_PENDING: Record<string, ConfigSectionData> = {};
|
||||
|
||||
const deriveInitialState = (config: FrigateConfig): PageState => {
|
||||
const primaryModel = getPrimaryModel(config);
|
||||
const plusModelId = primaryModel?.plus?.id;
|
||||
const modelPath = primaryModel?.path;
|
||||
const plusEnabled = Boolean(config.plus?.enabled);
|
||||
|
||||
// The reliable signal that a Plus model is currently active is the
|
||||
// `model.plus.id` metadata
|
||||
let modelTab: ModelTab;
|
||||
if (plusModelId) {
|
||||
modelTab = "plus";
|
||||
} else if (typeof modelPath === "string" && modelPath.length > 0) {
|
||||
modelTab = "custom";
|
||||
} else if (plusEnabled) {
|
||||
modelTab = "plus";
|
||||
} else {
|
||||
modelTab = "custom";
|
||||
}
|
||||
// Fallback: if Plus is not enabled, prefer Custom regardless of saved state
|
||||
if (!plusEnabled && modelTab === "plus") {
|
||||
modelTab = "custom";
|
||||
}
|
||||
|
||||
const {
|
||||
plus: _plus,
|
||||
scene: _scene,
|
||||
devices: _devices,
|
||||
...modelWithoutPlus
|
||||
} = (primaryModel ?? {}) as Record<string, unknown>;
|
||||
// If a Plus model is active, the resolved `model.path` is auto-derived from
|
||||
// `plus.id` — drop it so the Custom tab starts clean and doesn't silently
|
||||
// re-save the same Plus model when the user thinks they switched modes.
|
||||
if (plusModelId) {
|
||||
delete modelWithoutPlus.path;
|
||||
}
|
||||
|
||||
return {
|
||||
detectors: { devices: primaryModel?.devices ?? [] } as ConfigSectionData,
|
||||
modelTab,
|
||||
plusModelId: plusModelId ?? undefined,
|
||||
customModel: modelWithoutPlus as ConfigSectionData,
|
||||
};
|
||||
};
|
||||
|
||||
export default function DetectorsAndModelSettingsView({
|
||||
setUnsavedChanges,
|
||||
pendingDataBySection,
|
||||
onPendingDataChange,
|
||||
onSectionStatusChange,
|
||||
isSavingAll,
|
||||
onSectionSavingChange,
|
||||
}: SettingsPageProps) {
|
||||
const { t } = useTranslation(["views/settings", "common"]);
|
||||
const { getLocaleDocUrl } = useDocDomain();
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const { mutate: globalMutate } = useSWRConfig();
|
||||
const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!;
|
||||
|
||||
// track the saved config
|
||||
const snapshot = useMemo<PageState | null>(
|
||||
() => (config ? deriveInitialState(config) : null),
|
||||
[config],
|
||||
);
|
||||
const [state, setState] = useState<PageState | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [resetKey, setResetKey] = useState(0);
|
||||
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
|
||||
const { send: sendRestart } = useRestart();
|
||||
const childPending = pendingDataBySection ?? EMPTY_PENDING;
|
||||
const [detectorStatus, setDetectorStatus] = useState<SectionStatus>({
|
||||
hasChanges: false,
|
||||
isOverridden: false,
|
||||
hasValidationErrors: false,
|
||||
});
|
||||
const [modelStatus, setModelStatus] = useState<SectionStatus>({
|
||||
hasChanges: false,
|
||||
isOverridden: false,
|
||||
hasValidationErrors: false,
|
||||
});
|
||||
|
||||
const [showBaseModels, setShowBaseModels] = useState(true);
|
||||
const [showFineTunedModels, setShowFineTunedModels] = useState(true);
|
||||
|
||||
const plusEnabled = Boolean(config?.plus?.enabled);
|
||||
|
||||
const { data: availableModels = {}, isLoading: isLoadingModels } = useSWR<
|
||||
Record<string, FrigatePlusModel>
|
||||
>(plusEnabled ? "/plus/models" : null, {
|
||||
fallbackData: {},
|
||||
fetcher: async (url) => {
|
||||
const res = await axios.get(url, { withCredentials: true });
|
||||
return res.data.reduce(
|
||||
(obj: Record<string, FrigatePlusModel>, model: FrigatePlusModel) => {
|
||||
obj[model.id] = model;
|
||||
return obj;
|
||||
},
|
||||
{},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const filteredModelEntries = useMemo(
|
||||
() =>
|
||||
Object.entries(availableModels || {}).filter(([, model]) =>
|
||||
model.isBaseModel ? showBaseModels : showFineTunedModels,
|
||||
),
|
||||
[availableModels, showBaseModels, showFineTunedModels],
|
||||
);
|
||||
|
||||
const isFilterActive = !showBaseModels || !showFineTunedModels;
|
||||
|
||||
const detectorHiddenFields = useMemo(
|
||||
() =>
|
||||
resolveHiddenFieldEntries(
|
||||
getSectionConfig("detectors", "global").hiddenFields,
|
||||
buildHiddenFieldContext(config, "global"),
|
||||
),
|
||||
[config],
|
||||
);
|
||||
const modelHiddenFields = useMemo(
|
||||
() =>
|
||||
resolveHiddenFieldEntries(
|
||||
getSectionConfig("model", "global").hiddenFields,
|
||||
buildHiddenFieldContext(config, "global"),
|
||||
),
|
||||
[config],
|
||||
);
|
||||
|
||||
const liveDetectors = useMemo(
|
||||
() => childPending["detectors"] ?? snapshot?.detectors,
|
||||
[childPending, snapshot],
|
||||
);
|
||||
const liveCustomModel = useMemo(
|
||||
() => childPending["model"] ?? snapshot?.customModel,
|
||||
[childPending, snapshot],
|
||||
);
|
||||
|
||||
const currentDetectorType = useMemo(() => {
|
||||
const values = Object.values(liveDetectors ?? {});
|
||||
if (values.length === 0) return undefined;
|
||||
const first = values[0] as { type?: string } | undefined;
|
||||
return first?.type;
|
||||
}, [liveDetectors]);
|
||||
|
||||
// fill in defaults when detector type changes
|
||||
const prevDetectorTypeRef = useRef<string | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
const newType = currentDetectorType;
|
||||
const prevType = prevDetectorTypeRef.current;
|
||||
prevDetectorTypeRef.current = newType;
|
||||
if (prevType === undefined || prevType === newType) return;
|
||||
if (!newType || !(newType in TYPE_MODEL_DEFAULTS)) return;
|
||||
|
||||
const defaults = TYPE_MODEL_DEFAULTS[newType];
|
||||
onPendingDataChange?.("model", undefined, defaults);
|
||||
|
||||
if (newType === "openvino") {
|
||||
const detectorsCurrent = (childPending.detectors ??
|
||||
state?.detectors ??
|
||||
{}) as {
|
||||
[key: string]: { device?: string };
|
||||
};
|
||||
const entries = Object.entries(detectorsCurrent);
|
||||
if (entries.length > 0) {
|
||||
const [firstKey, firstValue] = entries[0];
|
||||
if (!firstValue?.device) {
|
||||
onPendingDataChange?.("detectors", undefined, {
|
||||
...detectorsCurrent,
|
||||
[firstKey]: { ...firstValue, device: "CPU" },
|
||||
} as ConfigSectionData);
|
||||
}
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentDetectorType]);
|
||||
|
||||
const isModelCompatible = useCallback(
|
||||
(model: FrigatePlusModel) =>
|
||||
currentDetectorType
|
||||
? model.supportedDetectors.includes(currentDetectorType)
|
||||
: true,
|
||||
[currentDetectorType],
|
||||
);
|
||||
|
||||
const selectedPlusModel = state?.plusModelId
|
||||
? availableModels?.[state.plusModelId]
|
||||
: undefined;
|
||||
|
||||
const plusMismatch =
|
||||
state?.modelTab === "plus" &&
|
||||
selectedPlusModel !== undefined &&
|
||||
currentDetectorType !== undefined &&
|
||||
!isModelCompatible(selectedPlusModel);
|
||||
|
||||
const plusModelMissing = state?.modelTab === "plus" && !state?.plusModelId;
|
||||
|
||||
const handleDetectorStatusChange = useCallback(
|
||||
(status: SectionStatus) => {
|
||||
setDetectorStatus(status);
|
||||
onSectionStatusChange?.("detectors", "global", status);
|
||||
},
|
||||
[onSectionStatusChange],
|
||||
);
|
||||
|
||||
// BaseSection drives `modelStatus` only when the Custom tab is mounted
|
||||
const handleModelStatusChange = useCallback(
|
||||
(status: SectionStatus) => setModelStatus(status),
|
||||
[],
|
||||
);
|
||||
|
||||
// report the *combined* model-section status to the parent
|
||||
useEffect(() => {
|
||||
if (!state || !snapshot) return;
|
||||
const tabChanged = state.modelTab !== snapshot.modelTab;
|
||||
const plusIdChanged =
|
||||
state.modelTab === "plus" && state.plusModelId !== snapshot.plusModelId;
|
||||
const pageLevelDirty = tabChanged || plusIdChanged;
|
||||
onSectionStatusChange?.("model", "global", {
|
||||
hasChanges: modelStatus.hasChanges || pageLevelDirty,
|
||||
isOverridden: modelStatus.isOverridden,
|
||||
overrideSource: modelStatus.overrideSource,
|
||||
hasValidationErrors: modelStatus.hasValidationErrors,
|
||||
});
|
||||
}, [state, snapshot, modelStatus, onSectionStatusChange]);
|
||||
|
||||
// Tab toggle and Plus-model selection are page-local UI, but Save All and the
|
||||
// sidebar dot live on `pendingDataBySection["model"]` and section status from
|
||||
// the parent. These handlers mirror Plus-tab changes into both so a Plus-only
|
||||
// edit (no custom-form typing) is still dirty and survives navigation.
|
||||
const handleModelTabChange = useCallback(
|
||||
(newTab: ModelTab) => {
|
||||
setState((prev) => (prev ? { ...prev, modelTab: newTab } : prev));
|
||||
if (!snapshot) return;
|
||||
|
||||
if (newTab === "plus") {
|
||||
if (state?.plusModelId) {
|
||||
onPendingDataChange?.("model", undefined, {
|
||||
path: `plus://${state.plusModelId}`,
|
||||
} as ConfigSectionData);
|
||||
} else {
|
||||
// No Plus model selected — clear any stale pending so the save
|
||||
// action is correctly disabled until the user picks one.
|
||||
onPendingDataChange?.("model", undefined, null);
|
||||
}
|
||||
} else {
|
||||
// Switching to Custom: if pending["model"] still holds a plus path
|
||||
// from a previous Plus selection, swap it for the snapshot's custom
|
||||
// model so Save All writes the correct payload. Don't overwrite
|
||||
// genuine custom-form edits the user typed earlier.
|
||||
const currentPath = (
|
||||
pendingDataBySection?.["model"] as { path?: string } | undefined
|
||||
)?.path;
|
||||
if (
|
||||
typeof currentPath === "string" &&
|
||||
currentPath.startsWith("plus://")
|
||||
) {
|
||||
onPendingDataChange?.(
|
||||
"model",
|
||||
undefined,
|
||||
snapshot.customModel as ConfigSectionData,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[state?.plusModelId, snapshot, pendingDataBySection, onPendingDataChange],
|
||||
);
|
||||
|
||||
const handlePlusModelIdChange = useCallback(
|
||||
(newId: string) => {
|
||||
setState((prev) => (prev ? { ...prev, plusModelId: newId } : prev));
|
||||
onPendingDataChange?.("model", undefined, {
|
||||
path: `plus://${newId}`,
|
||||
} as ConfigSectionData);
|
||||
},
|
||||
[onPendingDataChange],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config || state !== null) return;
|
||||
const initial = deriveInitialState(config);
|
||||
|
||||
// Restore Plus-tab UI state from any prior pending edits the user made
|
||||
// before navigating away. `pendingDataBySection["model"]` is the source of
|
||||
// truth for Save All; infer modelTab/plusModelId from it so the UI lines up.
|
||||
const pendingModel = pendingDataBySection?.["model"] as
|
||||
| { path?: string }
|
||||
| undefined;
|
||||
const pendingPath = pendingModel?.path;
|
||||
if (typeof pendingPath === "string" && pendingPath.startsWith("plus://")) {
|
||||
setState({
|
||||
...initial,
|
||||
modelTab: "plus",
|
||||
plusModelId: pendingPath.slice("plus://".length) || undefined,
|
||||
});
|
||||
} else if (pendingModel && initial.modelTab === "plus") {
|
||||
// There's a pending custom-model edit while the saved tab was Plus —
|
||||
// means the user already switched to Custom before navigating away.
|
||||
setState({ ...initial, modelTab: "custom" });
|
||||
} else {
|
||||
setState(initial);
|
||||
}
|
||||
}, [config, state, pendingDataBySection]);
|
||||
|
||||
const isDirty = useMemo(() => {
|
||||
if (!state || !snapshot) return false;
|
||||
if (state.modelTab !== snapshot.modelTab) return true;
|
||||
if (state.plusModelId !== snapshot.plusModelId) return true;
|
||||
if ("detectors" in childPending) return true;
|
||||
if ("model" in childPending) return true;
|
||||
return false;
|
||||
}, [state, snapshot, childPending]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDirty) {
|
||||
addMessage(
|
||||
STATUS_BAR_KEY,
|
||||
t("detectorsAndModel.unsavedChanges"),
|
||||
undefined,
|
||||
STATUS_BAR_KEY,
|
||||
);
|
||||
} else {
|
||||
removeMessage(STATUS_BAR_KEY, STATUS_BAR_KEY);
|
||||
}
|
||||
setUnsavedChanges?.(isDirty);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isDirty]);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = t("documentTitle.detectorsAndModel");
|
||||
}, [t]);
|
||||
|
||||
const onSave = useCallback(async () => {
|
||||
if (!state || !snapshot) return;
|
||||
|
||||
const tabChanged = state.modelTab !== snapshot.modelTab;
|
||||
|
||||
// Strip computed/merged fields that the backend populates in /config
|
||||
// responses but doesn't accept back on /config/set.
|
||||
const sanitizedDetectors = sanitizeSectionData(
|
||||
liveDetectors ?? {},
|
||||
detectorHiddenFields,
|
||||
);
|
||||
const sanitizedCustomModel = sanitizeSectionData(
|
||||
liveCustomModel ?? {},
|
||||
modelHiddenFields,
|
||||
);
|
||||
|
||||
const modelPayload =
|
||||
state.modelTab === "plus"
|
||||
? { path: `plus://${state.plusModelId}` }
|
||||
: sanitizedCustomModel;
|
||||
|
||||
const detectorKeysChanged =
|
||||
JSON.stringify(Object.keys(liveDetectors ?? {}).sort()) !==
|
||||
JSON.stringify(Object.keys(snapshot.detectors).sort());
|
||||
|
||||
setIsSaving(true);
|
||||
onSectionSavingChange?.(true);
|
||||
let preCleared = false;
|
||||
try {
|
||||
// Pre-clear both `detectors` and `model` together when renaming
|
||||
if (tabChanged || detectorKeysChanged) {
|
||||
try {
|
||||
await axios.put("config/set", {
|
||||
requires_restart: 0,
|
||||
config_data: { detectors: null, model: null },
|
||||
});
|
||||
preCleared = true;
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
}
|
||||
|
||||
await axios.put("config/set", {
|
||||
requires_restart: 0,
|
||||
config_data: {
|
||||
detectors: sanitizedDetectors,
|
||||
model: modelPayload,
|
||||
},
|
||||
});
|
||||
|
||||
await globalMutate("config");
|
||||
await globalMutate("config/raw_paths");
|
||||
|
||||
// `snapshot` is derived from `config` via useMemo, so the awaited mutate
|
||||
// above has already refreshed it. Just clear the pending entries — that
|
||||
// resets isDirty since state should now match snapshot.
|
||||
onPendingDataChange?.("detectors", undefined, null);
|
||||
onPendingDataChange?.("model", undefined, null);
|
||||
setResetKey((k) => k + 1);
|
||||
|
||||
addMessage(
|
||||
"detectors_and_model_restart",
|
||||
t("detectorsAndModel.restartRequired"),
|
||||
undefined,
|
||||
"detectors_and_model_restart",
|
||||
);
|
||||
|
||||
toast.success(t("detectorsAndModel.toast.saveSuccess"), {
|
||||
position: "top-center",
|
||||
duration: 10000,
|
||||
action: (
|
||||
<Button onClick={() => setRestartDialogOpen(true)}>
|
||||
{t("restart.button", { ns: "components/dialog" })}
|
||||
</Button>
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
const err = error as {
|
||||
response?: { data?: { message?: string; detail?: string } };
|
||||
};
|
||||
const message =
|
||||
err.response?.data?.message ||
|
||||
err.response?.data?.detail ||
|
||||
t("detectorsAndModel.toast.saveError");
|
||||
toast.error(message, { position: "top-center" });
|
||||
|
||||
if (preCleared) {
|
||||
const restoreModel =
|
||||
snapshot.modelTab === "plus" && snapshot.plusModelId
|
||||
? { path: `plus://${snapshot.plusModelId}` }
|
||||
: sanitizeSectionData(snapshot.customModel, modelHiddenFields);
|
||||
try {
|
||||
await axios.put("config/set", {
|
||||
requires_restart: 0,
|
||||
config_data: {
|
||||
detectors: sanitizeSectionData(
|
||||
snapshot.detectors,
|
||||
detectorHiddenFields,
|
||||
),
|
||||
model: restoreModel,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
// Re-sync the config cache to reflect whatever state the backend
|
||||
// landed on after the failure (and any restore attempt).
|
||||
await globalMutate("config");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
onSectionSavingChange?.(false);
|
||||
}
|
||||
}, [
|
||||
state,
|
||||
snapshot,
|
||||
liveDetectors,
|
||||
liveCustomModel,
|
||||
detectorHiddenFields,
|
||||
modelHiddenFields,
|
||||
globalMutate,
|
||||
onSectionSavingChange,
|
||||
addMessage,
|
||||
onPendingDataChange,
|
||||
t,
|
||||
]);
|
||||
|
||||
const onUndo = useCallback(() => {
|
||||
if (snapshot) {
|
||||
setState(snapshot);
|
||||
onPendingDataChange?.("detectors", undefined, null);
|
||||
onPendingDataChange?.("model", undefined, null);
|
||||
// Force the embedded forms to re-mount so their internal dirty/baseline
|
||||
// state is rebuilt from the current config — clearing pending alone
|
||||
// doesn't reset BaseSection's internal tracking.
|
||||
setResetKey((k) => k + 1);
|
||||
}
|
||||
}, [snapshot, onPendingDataChange]);
|
||||
|
||||
if (!config || !state) {
|
||||
return <ActivityIndicator />;
|
||||
}
|
||||
|
||||
const saveDisabled =
|
||||
!isDirty ||
|
||||
isSaving ||
|
||||
isSavingAll ||
|
||||
detectorStatus.hasValidationErrors ||
|
||||
(state.modelTab === "custom" && modelStatus.hasValidationErrors) ||
|
||||
plusMismatch ||
|
||||
plusModelMissing;
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col md:pr-2">
|
||||
<div className="mb-1 flex items-center justify-between gap-4 pt-2">
|
||||
<div className="flex max-w-5xl flex-col">
|
||||
<Heading as="h4">{t("detectorsAndModel.title")}</Heading>
|
||||
<div className="my-1 text-sm text-muted-foreground">
|
||||
{t("detectorsAndModel.description")}
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-primary-variant">
|
||||
<Link
|
||||
to={getLocaleDocUrl("/configuration/object_detectors")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
{t("readTheDocumentation", { ns: "common" })}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
{isDirty && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="cursor-default bg-unsaved text-xs text-black hover:bg-unsaved"
|
||||
>
|
||||
{t("button.modified", { ns: "common", defaultValue: "Modified" })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full max-w-5xl space-y-6 pt-4">
|
||||
<div className="space-y-6">
|
||||
<SettingsGroupCard title={t("detectorsAndModel.cardTitles.detector")}>
|
||||
<ConfigSectionTemplate
|
||||
key={`detectors-${resetKey}`}
|
||||
sectionKey="detectors"
|
||||
level="global"
|
||||
showOverrideIndicator={false}
|
||||
showTitle={false}
|
||||
embedded
|
||||
pendingDataBySection={childPending}
|
||||
onPendingDataChange={onPendingDataChange}
|
||||
onStatusChange={handleDetectorStatusChange}
|
||||
/>
|
||||
</SettingsGroupCard>
|
||||
{plusMismatch && selectedPlusModel && (
|
||||
<ConfigMessageBanner
|
||||
messages={[
|
||||
{
|
||||
key: "plus-mismatch",
|
||||
messageKey: "detectorsAndModel.mismatch.warning",
|
||||
severity: "warning",
|
||||
condition: () => true,
|
||||
values: {
|
||||
model: selectedPlusModel.name,
|
||||
required: selectedPlusModel.supportedDetectors.join(", "),
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<SettingsGroupCard title={t("detectorsAndModel.cardTitles.model")}>
|
||||
{plusEnabled ? (
|
||||
<Tabs
|
||||
value={state.modelTab}
|
||||
onValueChange={(value) =>
|
||||
handleModelTabChange(value as ModelTab)
|
||||
}
|
||||
>
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="plus">
|
||||
{t("detectorsAndModel.tabs.plus")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="custom">
|
||||
{t("detectorsAndModel.tabs.custom")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="plus">
|
||||
<SplitCardRow
|
||||
label={t("frigatePlus.modelInfo.availableModels")}
|
||||
description={
|
||||
<Trans ns="views/settings">
|
||||
frigatePlus.modelInfo.modelSelect
|
||||
</Trans>
|
||||
}
|
||||
content={
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Select
|
||||
value={state.plusModelId}
|
||||
onValueChange={handlePlusModelIdChange}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
{state.plusModelId &&
|
||||
availableModels?.[state.plusModelId]
|
||||
? new Date(
|
||||
availableModels[state.plusModelId].trainDate,
|
||||
).toLocaleString() +
|
||||
" " +
|
||||
availableModels[state.plusModelId].baseModel +
|
||||
" (" +
|
||||
(availableModels[state.plusModelId].isBaseModel
|
||||
? t(
|
||||
"frigatePlus.modelInfo.plusModelType.baseModel",
|
||||
)
|
||||
: t(
|
||||
"frigatePlus.modelInfo.plusModelType.userModel",
|
||||
)) +
|
||||
") " +
|
||||
availableModels[state.plusModelId].name +
|
||||
" (" +
|
||||
availableModels[state.plusModelId].width +
|
||||
"x" +
|
||||
availableModels[state.plusModelId].height +
|
||||
")"
|
||||
: isLoadingModels
|
||||
? t(
|
||||
"frigatePlus.modelInfo.loadingAvailableModels",
|
||||
)
|
||||
: t(
|
||||
"detectorsAndModel.plusModel.noModelSelected",
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{filteredModelEntries.length === 0 ? (
|
||||
<div className="px-4 py-3 text-center text-sm text-muted-foreground">
|
||||
{t("frigatePlus.modelInfo.noModelsAvailable")}
|
||||
</div>
|
||||
) : (
|
||||
filteredModelEntries.map(([id, model]) => (
|
||||
<SelectItem
|
||||
key={id}
|
||||
className="cursor-pointer"
|
||||
value={id}
|
||||
disabled={!isModelCompatible(model)}
|
||||
>
|
||||
{new Date(model.trainDate).toLocaleString()}{" "}
|
||||
<div>
|
||||
{model.baseModel} {" ("}
|
||||
{model.isBaseModel
|
||||
? t(
|
||||
"frigatePlus.modelInfo.plusModelType.baseModel",
|
||||
)
|
||||
: t(
|
||||
"frigatePlus.modelInfo.plusModelType.userModel",
|
||||
)}
|
||||
{")"}
|
||||
</div>
|
||||
<div>
|
||||
{model.name} (
|
||||
{model.width + "x" + model.height})
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"frigatePlus.modelInfo.supportedDetectors",
|
||||
)}
|
||||
: {model.supportedDetectors.join(", ")}
|
||||
</div>
|
||||
{!isModelCompatible(model) && (
|
||||
<div className="text-xs text-danger">
|
||||
{t(
|
||||
"detectorsAndModel.plusModel.requiresDetector",
|
||||
{
|
||||
detector:
|
||||
model.supportedDetectors.join(
|
||||
", ",
|
||||
),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{id}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="focus:outline-none"
|
||||
aria-label={t(
|
||||
"frigatePlus.modelInfo.filter.ariaLabel",
|
||||
)}
|
||||
>
|
||||
<LuFilter
|
||||
className={cn(
|
||||
"size-4",
|
||||
isFilterActive
|
||||
? "text-selected"
|
||||
: "text-secondary-foreground",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-56">
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-primary-variant">
|
||||
{t("frigatePlus.modelInfo.filter.ariaLabel")}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label
|
||||
htmlFor="filterBaseModels"
|
||||
className="cursor-pointer text-primary"
|
||||
>
|
||||
{t("frigatePlus.modelInfo.filter.baseModels")}
|
||||
</Label>
|
||||
<Switch
|
||||
id="filterBaseModels"
|
||||
checked={showBaseModels}
|
||||
onCheckedChange={setShowBaseModels}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label
|
||||
htmlFor="filterFineTunedModels"
|
||||
className="cursor-pointer text-primary"
|
||||
>
|
||||
{t(
|
||||
"frigatePlus.modelInfo.filter.fineTunedModels",
|
||||
)}
|
||||
</Label>
|
||||
<Switch
|
||||
id="filterFineTunedModels"
|
||||
checked={showFineTunedModels}
|
||||
onCheckedChange={setShowFineTunedModels}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="custom">
|
||||
<ConfigSectionTemplate
|
||||
key={`model-${resetKey}`}
|
||||
sectionKey="model"
|
||||
level="global"
|
||||
showOverrideIndicator={false}
|
||||
showTitle={false}
|
||||
embedded
|
||||
pendingDataBySection={childPending}
|
||||
onPendingDataChange={onPendingDataChange}
|
||||
onStatusChange={handleModelStatusChange}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
<ConfigSectionTemplate
|
||||
key={`model-${resetKey}`}
|
||||
sectionKey="model"
|
||||
level="global"
|
||||
showOverrideIndicator={false}
|
||||
showTitle={false}
|
||||
embedded
|
||||
pendingDataBySection={childPending}
|
||||
onPendingDataChange={onPendingDataChange}
|
||||
onStatusChange={handleModelStatusChange}
|
||||
/>
|
||||
)}
|
||||
</SettingsGroupCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sticky bottom-0 z-50 mt-6 w-full border-t border-secondary bg-background pt-0">
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-4 pt-2 md:flex-row",
|
||||
isDirty ? "justify-between" : "justify-end",
|
||||
)}
|
||||
>
|
||||
{isDirty && (
|
||||
<span className="text-sm text-unsaved">
|
||||
{t("unsavedChanges", { ns: "views/settings" })}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center md:w-auto">
|
||||
{isDirty && (
|
||||
<Button
|
||||
onClick={onUndo}
|
||||
variant="outline"
|
||||
disabled={isSaving}
|
||||
className="flex min-w-36 flex-1 gap-2"
|
||||
>
|
||||
{t("button.undo", { ns: "common" })}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={onSave}
|
||||
variant="select"
|
||||
disabled={saveDisabled}
|
||||
className="flex min-w-36 flex-1 gap-2"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<ActivityIndicator className="h-4 w-4" />
|
||||
{t("button.saving", { ns: "common" })}
|
||||
</>
|
||||
) : (
|
||||
t("button.save", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<RestartDialog
|
||||
isOpen={restartDialogOpen}
|
||||
onClose={() => setRestartDialogOpen(false)}
|
||||
onRestart={() => sendRestart("restart")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user