Compare commits

..
19 Commits
Author SHA1 Message Date
Josh Hawkins 6fce18f156 update network requirements docs 2026-09-22 12:29:40 -05:00
Josh Hawkins 7001623ba2 fixes 2026-09-22 12:15:33 -05:00
Josh Hawkins f8c180319c change wording 2026-09-22 11:03:24 -05:00
Josh Hawkins 8b5dc77891 Document anonymous analytics 2026-09-22 08:34:34 -05:00
Josh Hawkins 3990a64988 Show the analytics preview in telemetry settings 2026-09-22 08:31:00 -05:00
Josh Hawkins f185e64379 Record the image variant in each Dockerfile 2026-09-22 08:25:58 -05:00
Josh Hawkins 635aee552e Generate the analytics schema and check it in CI 2026-09-22 08:24:01 -05:00
Josh Hawkins 1834d9a22e Add the analytics preview API 2026-09-22 08:22:45 -05:00
Josh Hawkins f0d070899f Send the daily analytics report from the main process 2026-09-22 08:20:24 -05:00
Josh Hawkins 92e2a8b444 Add the analytics report transport 2026-09-22 08:18:03 -05:00
Josh Hawkins a8ed8b8592 Build analytics reports from the section collectors 2026-09-22 08:17:01 -05:00
Josh Hawkins 7f2fd71d12 Add the health analytics collector 2026-09-22 08:16:01 -05:00
Josh Hawkins 274277b85e Add the features analytics collector 2026-09-22 08:15:19 -05:00
Josh Hawkins faa0b68bc4 Add the cameras analytics collector 2026-09-22 08:14:20 -05:00
Josh Hawkins c252e07ffb Add the detection analytics collector 2026-09-22 08:13:13 -05:00
Josh Hawkins 90e1fa756d Add the install and hardware analytics collectors 2026-09-22 08:12:37 -05:00
Josh Hawkins ab43524fe2 Add the analytics state file 2026-09-22 08:10:10 -05:00
Josh Hawkins 51eb5058b3 Add the analytics report schema 2026-09-22 08:09:44 -05:00
Josh Hawkins aa72e555f5 Add analytics opt-in setting, prompt notice kind, and notice watermarks 2026-09-22 08:08:23 -05:00
114 changed files with 5450 additions and 1756 deletions
+2
View File
@@ -124,5 +124,7 @@ jobs:
run: devcontainer exec --workspace-folder . bash -lc "python3 -u -m mypy --config-file frigate/mypy.ini frigate" run: devcontainer exec --workspace-folder . bash -lc "python3 -u -m mypy --config-file frigate/mypy.ini frigate"
- name: Check API spec is up to date - name: Check API spec is up to date
run: devcontainer exec --workspace-folder . bash -lc "python3 generate_api_auth_spec.py --check" run: devcontainer exec --workspace-folder . bash -lc "python3 generate_api_auth_spec.py --check"
- name: Check analytics schema is up to date
run: devcontainer exec --workspace-folder . bash -lc "python3 generate_analytics_schema.py --check"
- name: Run unit tests in devcontainer - name: Run unit tests in devcontainer
run: devcontainer exec --workspace-folder . bash -lc "python3 -u -m unittest" run: devcontainer exec --workspace-folder . bash -lc "python3 -u -m unittest"
+1
View File
@@ -381,6 +381,7 @@ FROM deps AS frigate
WORKDIR /opt/frigate/ WORKDIR /opt/frigate/
COPY --from=rootfs / / COPY --from=rootfs / /
ENV FRIGATE_IMAGE_VARIANT=standard
# Pre-compile bytecode so a read-only rootfs doesn't force re-parsing the # Pre-compile bytecode so a read-only rootfs doesn't force re-parsing the
# source tree on every boot (pip-installed packages are already compiled) # source tree on every boot (pip-installed packages are already compiled)
+3 -3
View File
@@ -27,7 +27,7 @@ pydantic == 2.10.*
git+https://github.com/fbcotter/py3nvml#egg=py3nvml git+https://github.com/fbcotter/py3nvml#egg=py3nvml
pytz == 2025.* pytz == 2025.*
pyzmq == 27.1.* pyzmq == 27.1.*
ruamel.yaml == 0.19.* ruamel.yaml == 0.18.*
tzlocal == 5.2 tzlocal == 5.2
requests == 2.33.* requests == 2.33.*
types-requests == 2.32.* types-requests == 2.32.*
@@ -43,7 +43,7 @@ opencv-contrib-python == 4.11.0.*
scipy == 1.16.* scipy == 1.16.*
# OpenVino & ONNX # OpenVino & ONNX
openvino == 2025.4.* openvino == 2025.4.*
onnxruntime == 1.30.* onnxruntime == 1.22.*
# Embeddings # Embeddings
transformers == 4.45.* transformers == 4.45.*
# Generative AI # Generative AI
@@ -58,7 +58,7 @@ pyclipper == 1.4.*
shapely == 2.0.* shapely == 2.0.*
rapidfuzz==3.12.* rapidfuzz==3.12.*
# HailoRT # HailoRT
argcomplete==3.7.* argcomplete==2.0.*
contextlib2==0.6.* contextlib2==0.6.*
future==0.18.* future==0.18.*
netaddr==1.3.* netaddr==1.3.*
+1
View File
@@ -25,6 +25,7 @@ RUN --mount=type=bind,from=rk-wheels,source=/rk-wheels,target=/deps/rk-wheels \
WORKDIR /opt/frigate/ WORKDIR /opt/frigate/
COPY --from=rootfs / / COPY --from=rootfs / /
ENV FRIGATE_IMAGE_VARIANT=rk
COPY docker/rockchip/COCO /COCO COPY docker/rockchip/COCO /COCO
COPY docker/rockchip/conv2rknn.py /opt/conv2rknn.py COPY docker/rockchip/conv2rknn.py /opt/conv2rknn.py
+1
View File
@@ -44,6 +44,7 @@ RUN echo "deb http://deb.debian.org/debian trixie main" > /etc/apt/sources.list.
WORKDIR /opt/frigate WORKDIR /opt/frigate
COPY --from=rootfs / / COPY --from=rootfs / /
ENV FRIGATE_IMAGE_VARIANT=rocm
RUN wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \ RUN wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \
&& sed -i 's/args.append("setuptools")/args.append("setuptools==77.0.3")/' get-pip.py \ && sed -i 's/args.append("setuptools")/args.append("setuptools==77.0.3")/' get-pip.py \
+1
View File
@@ -15,3 +15,4 @@ ENV INCLUDED_FFMPEG_VERSIONS="${DEFAULT_FFMPEG_VERSION}:${INCLUDED_FFMPEG_VERSIO
WORKDIR /opt/frigate/ WORKDIR /opt/frigate/
COPY --from=rootfs / / COPY --from=rootfs / /
ENV FRIGATE_IMAGE_VARIANT=rpi
+1
View File
@@ -22,6 +22,7 @@ pip3 install --no-deps -U /deps/synap-wheels/*.whl
WORKDIR /opt/frigate/ WORKDIR /opt/frigate/
COPY --from=rootfs / / COPY --from=rootfs / /
ENV FRIGATE_IMAGE_VARIANT=synaptics
COPY --from=synap1680-wheels /rootfs/usr/local/lib/*.so /usr/lib COPY --from=synap1680-wheels /rootfs/usr/local/lib/*.so /usr/lib
+1
View File
@@ -25,6 +25,7 @@ RUN --mount=type=bind,from=trt-wheels,source=/trt-wheels,target=/deps/trt-wheels
&& pip3 install -U /deps/trt-wheels/*.whl && pip3 install -U /deps/trt-wheels/*.whl
COPY --from=rootfs / / COPY --from=rootfs / /
ENV FRIGATE_IMAGE_VARIANT=tensorrt
COPY docker/tensorrt/detector/rootfs/etc/ld.so.conf.d /etc/ld.so.conf.d COPY docker/tensorrt/detector/rootfs/etc/ld.so.conf.d /etc/ld.so.conf.d
RUN ldconfig RUN ldconfig
+1
View File
@@ -151,6 +151,7 @@ RUN --mount=type=bind,from=trt-wheels,source=/trt-wheels,target=/deps/trt-wheels
WORKDIR /opt/frigate/ WORKDIR /opt/frigate/
COPY --from=rootfs / / COPY --from=rootfs / /
ENV FRIGATE_IMAGE_VARIANT=tensorrt-jp6
# Fixes "Error importing detector runtime: /usr/lib/aarch64-linux-gnu/libstdc++.so.6: cannot allocate memory in static TLS block" # Fixes "Error importing detector runtime: /usr/lib/aarch64-linux-gnu/libstdc++.so.6: cannot allocate memory in static TLS block"
ENV LD_PRELOAD /usr/lib/aarch64-linux-gnu/libstdc++.so.6 ENV LD_PRELOAD /usr/lib/aarch64-linux-gnu/libstdc++.so.6
@@ -0,0 +1,41 @@
---
id: analytics
title: Anonymous Analytics
---
import AnalyticsFields from "@site/src/components/AnalyticsFields";
import NavPath from "@site/src/components/NavPath";
Frigate can send one anonymous usage report a day. The reports show the maintainers which hardware to support, which features people use, and how releases perform. Sharing is off until you turn it on.
## Turning it on
Enable **Share anonymous analytics** at <NavPath path="Settings > System > Telemetry" />, or set it in your config:
```yaml
telemetry:
analytics: true
```
The same page has a **Preview the report** button that shows exactly what the next report contains.
## How it's sent
- Once a day, as a JSON POST to `https://analytics.frigate.video/report`
- The server looks up your country and region from your IP address and never stores the address
- Raw reports are kept for 60 days; only aggregate totals are published
- A random install ID, stored in `/config/.analytics.json`, keeps your install from being counted twice. Turning sharing off deletes it
## What's never sent
- Camera, zone, group, profile, or user names
- Object labels, face names, or license plate text
- IP addresses, hostnames, URLs, or stream paths
- Credentials or API keys
- Events, recordings, or anything from them
## Every field
Fields marked public appear in the published totals. The machine-readable schema is [frigate-analytics-schema.json](pathname:///frigate-analytics-schema.json).
<AnalyticsFields />
@@ -1194,6 +1194,9 @@ ui:
# Optional: Telemetry configuration # Optional: Telemetry configuration
telemetry: telemetry:
# Optional: Share one anonymous usage report a day (default: shown below)
# NOTE: See https://docs.frigate.video/configuration/advanced/analytics for what is sent
analytics: False
# Optional: Enabled network interfaces for bandwidth stats monitoring (default: empty list, let nethogs search all) # Optional: Enabled network interfaces for bandwidth stats monitoring (default: empty list, let nethogs search all)
network_interfaces: network_interfaces:
- eth - eth
+1 -3
View File
@@ -18,11 +18,9 @@ Hardware acceleration arguments tell FFmpeg to decode your camera's video stream
See [the hardware acceleration docs](/configuration/hardware_acceleration_video.md) for details on setting up hardware acceleration for your GPU / iGPU, then select the preset that matches your hardware. See [the hardware acceleration docs](/configuration/hardware_acceleration_video.md) for details on setting up hardware acceleration for your GPU / iGPU, then select the preset that matches your hardware.
| Preset (YAML config) | UI Label | Usage | Notes | | Preset (YAML config) | UI Label | Usage | Notes |
| ------------------------- | ----------------------- | --------------------------------------------- | --------------------------------------------------------------- | | --------------------- | ----------------------- | --------------------------------- | --------------------------------------------------------------- |
| preset-rpi-64-h264 | Raspberry Pi (H.264) | 64-bit Raspberry Pi, H.264 stream | | | preset-rpi-64-h264 | Raspberry Pi (H.264) | 64-bit Raspberry Pi, H.264 stream | |
| preset-rpi-64-h265 | Raspberry Pi (H.265) | 64-bit Raspberry Pi, H.265 stream | | | preset-rpi-64-h265 | Raspberry Pi (H.265) | 64-bit Raspberry Pi, H.265 stream | |
| preset-apple-silicon-h264 | Apple Silicon (H.264) | Apple Silicon Mac under lighter, H.264 stream | Needs the `lighter.sh/video` device |
| preset-apple-silicon-h265 | Apple Silicon (H.265) | Apple Silicon Mac under lighter, H.265 stream | Needs the `lighter.sh/video` device |
| preset-vaapi | VAAPI (Intel/AMD GPU) | Intel or AMD GPU via VAAPI | Check the hwaccel docs to ensure the correct driver is selected | | preset-vaapi | VAAPI (Intel/AMD GPU) | Intel or AMD GPU via VAAPI | Check the hwaccel docs to ensure the correct driver is selected |
| preset-intel-qsv-h264 | Intel QuickSync (H.264) | Intel QuickSync, H.264 stream | If you have issues, use the VAAPI preset instead | | preset-intel-qsv-h264 | Intel QuickSync (H.264) | Intel QuickSync, H.264 stream | If you have issues, use the VAAPI preset instead |
| preset-intel-qsv-h265 | Intel QuickSync (H.265) | Intel QuickSync, H.265 stream | If you have issues, use the VAAPI preset instead | | preset-intel-qsv-h265 | Intel QuickSync (H.265) | Intel QuickSync, H.265 stream | If you have issues, use the VAAPI preset instead |
@@ -43,10 +43,6 @@ Frigate supports presets for optimal hardware accelerated video decoding:
- [RKNN](#rockchip-platform): Frigate can utilize the media engine in RockChip SOCs to accelerate video decoding. - [RKNN](#rockchip-platform): Frigate can utilize the media engine in RockChip SOCs to accelerate video decoding.
**Apple Silicon Mac** <CommunityBadge />
- [lighter](#apple-silicon-mac-lighter): Frigate can utilize the media engine in Apple Silicon Macs to accelerate video decoding, when running under the lighter container runtime.
**Other Hardware** **Other Hardware**
Depending on your system, these presets may not be compatible, and you may need to use manual hwaccel args to take advantage of your hardware. More information on hardware accelerated decoding for ffmpeg can be found here: https://trac.ffmpeg.org/wiki/HWAccelIntro Depending on your system, these presets may not be compatible, and you may need to use manual hwaccel args to take advantage of your hardware. More information on hardware accelerated decoding for ffmpeg can be found here: https://trac.ffmpeg.org/wiki/HWAccelIntro
@@ -537,35 +533,3 @@ output_args:
Make sure that your SoC supports hardware acceleration for your input stream and your input stream is h264 encoding. For example, if your camera streams with h264 encoding, your SoC must be able to de- and encode with it. If you are unsure whether your SoC meets the requirements, take a look at the datasheet. Make sure that your SoC supports hardware acceleration for your input stream and your input stream is h264 encoding. For example, if your camera streams with h264 encoding, your SoC must be able to de- and encode with it. If you are unsure whether your SoC meets the requirements, take a look at the datasheet.
::: :::
## Apple Silicon Mac (lighter)
[lighter](https://github.com/fieldwork-ai/lighter) is an open-source container runtime for macOS. It gives a container the Mac's media engine as a standard V4L2 decoder, backed by VideoToolbox, so Frigate decodes H.264 and H.265 streams in hardware with the ffmpeg it already ships. It works on M1 and newer Macs with lighter 0.9.2 or newer.
Give the container the video device. With Docker Compose:
```yaml {4-5}
services:
frigate:
...
devices:
- lighter.sh/video=all
```
Or with `docker run`, add `--device lighter.sh/video=all`.
Then set the preset for the codec your cameras stream. The decoder is specific to the codec, so if your cameras mix H.264 and H.265, set the preset for the most common codec globally and override it on the other cameras:
```yaml
ffmpeg:
hwaccel_args: preset-apple-silicon-h264
cameras:
garage: # an H.265 camera
ffmpeg:
hwaccel_args: preset-apple-silicon-h265
```
The presets decode on the media engine and encode the Birdseye restream and timelapses there too. Scaling to the detect resolution runs on the CPU, as ffmpeg's V4L2 decoders cannot scale.
lighter can also run object detection on the Mac's Neural Engine; see [Apple Neural Engine (lighter)](object_detectors.md#apple-neural-engine-lighter).
+1 -21
View File
@@ -34,7 +34,6 @@ Frigate supports multiple different detectors that work on different types of ha
**Apple Silicon** **Apple Silicon**
- [Apple Silicon](#apple-silicon-detector): Apple Silicon can run on M1 and newer Apple Silicon devices. - [Apple Silicon](#apple-silicon-detector): Apple Silicon can run on M1 and newer Apple Silicon devices.
- <CommunityBadge /> [ONNX](#apple-neural-engine-lighter): the ONNX detector runs on the Neural Engine of M1 and newer Macs when Frigate runs under the lighter container runtime.
**Intel** **Intel**
@@ -485,7 +484,7 @@ See [ONNX supported models](#onnx) for supported models, there are some caveats:
## ONNX ## ONNX
ONNX is an open format for building machine learning models, Frigate supports running ONNX models on CPU, OpenVINO, ROCm, TensorRT, and a Mac's Neural Engine. On startup Frigate will automatically try to use a GPU if one is available. ONNX is an open format for building machine learning models, Frigate supports running ONNX models on CPU, OpenVINO, ROCm, and TensorRT. On startup Frigate will automatically try to use a GPU if one is available.
:::info :::info
@@ -501,9 +500,6 @@ If the correct build is used for your GPU then the GPU will be detected and used
- Nvidia GPUs will automatically be detected and used with the ONNX detector in the `-tensorrt` Frigate image. - Nvidia GPUs will automatically be detected and used with the ONNX detector in the `-tensorrt` Frigate image.
- Jetson devices will automatically be detected and used with the ONNX detector in the `-tensorrt-jp6` Frigate image. - Jetson devices will automatically be detected and used with the ONNX detector in the `-tensorrt-jp6` Frigate image.
- **Apple Silicon Mac** <CommunityBadge />
- The Neural Engine will automatically be detected and used with the ONNX detector when Frigate runs under lighter with its Neural Engine device. See [Apple Neural Engine (lighter)](#apple-neural-engine-lighter).
::: :::
:::tip :::tip
@@ -519,22 +515,6 @@ models:
::: :::
### Apple Neural Engine (lighter) {#apple-neural-engine-lighter}
[lighter](https://github.com/fieldwork-ai/lighter) is an open-source container runtime for macOS. A container started with its `lighter.sh/ane` device gets an ONNX Runtime execution provider that runs models on the Mac's Neural Engine, and the ONNX detector uses it automatically, with the same models and configuration as on any other hardware. It works on M1 and newer Macs with lighter 0.9.2 or newer.
Give the Frigate container the Neural Engine device. With Docker Compose:
```yaml
services:
frigate:
image: ghcr.io/blakeblackshear/frigate:stable-standard-arm64
devices:
- lighter.sh/ane=all
```
Or with `docker run`, add `--device lighter.sh/ane=all`. Frigate then reports the Neural Engine under **Settings > System > Detection models**, and the ONNX detector's model loads on it. lighter can also decode camera streams on the Mac's media engine; see [Video Decoding](hardware_acceleration_video.md#apple-silicon-mac-lighter).
### Configuration {#configuration-onnx} ### Configuration {#configuration-onnx}
<ModelConfigDropdown detectorTitle="ONNX" models={objectDetectorsModels.onnx.models} /> <ModelConfigDropdown detectorTitle="ONNX" models={objectDetectorsModels.onnx.models} />
+1 -11
View File
@@ -78,10 +78,6 @@ Frigate supports multiple different detectors that work on different types of ha
**Apple Silicon** **Apple Silicon**
- [ONNX via lighter](#apple-silicon): The ONNX detector runs on the Neural Engine of M1 and newer Macs when Frigate runs in the lighter container runtime
- [Supports the same model architectures as the ONNX detector](../../configuration/object_detectors#apple-neural-engine-lighter)
- Runs inside the Frigate container, with no separate detector process to set up
- The recommended way to run Frigate on a Mac
- [Apple Silicon](#apple-silicon): Apple Silicon is usable on all M1 and newer Apple Silicon devices to provide efficient and fast object detection - [Apple Silicon](#apple-silicon): Apple Silicon is usable on all M1 and newer Apple Silicon devices to provide efficient and fast object detection
- [Supports primarily ssdlite and mobilenet model architectures](../../configuration/object_detectors#apple-silicon-detector) - [Supports primarily ssdlite and mobilenet model architectures](../../configuration/object_detectors#apple-silicon-detector)
- Runs well with any size models including large - Runs well with any size models including large
@@ -215,13 +211,7 @@ Inference is done with the `onnx` detector type. Speeds will vary greatly depend
### Apple Silicon ### Apple Silicon
Frigate on a Mac is best run in the [lighter](https://github.com/fieldwork-ai/lighter) container runtime, where the [ONNX detector](../configuration/object_detectors.md#apple-neural-engine-lighter) runs on the Neural Engine of M1 and newer Macs from inside the Frigate container. There is no separate detector process to install or keep running, and the same container can decode video on the Mac's media engine. With the [Apple Silicon](../configuration/object_detectors.md#apple-silicon-detector) detector Frigate can take advantage of the NPU in M1 and newer Apple Silicon.
| Name | YOLOv9 Inference Time | YOLO-NAS Inference Time | RF-DETR Inference Time |
| ---- | -------------------------------------- | ----------------------- | ---------------------- |
| M1 | t-320: 3.3 ms s-320: 7 ms s-640: 13 ms | 320: 6.6 ms | Nano-320: 38 ms |
Alternatively, with the [Apple Silicon](../configuration/object_detectors.md#apple-silicon-detector) detector Frigate can take advantage of the NPU in M1 and newer Apple Silicon.
:::warning :::warning
+10 -1
View File
@@ -134,6 +134,15 @@ telemetry:
version_check: false version_check: false
``` ```
### Anonymous Analytics
If [anonymous analytics](/configuration/advanced/analytics) sharing is turned on, Frigate sends one report a day to `https://analytics.frigate.video`. It's off by default, so no outbound connection happens unless you enable it:
```yaml
telemetry:
analytics: true
```
### Push Notifications ### Push Notifications
When [notifications](/configuration/notifications) are enabled and users have registered for push notifications in the web UI, Frigate sends push messages through the browser vendor's push service (e.g., Google FCM, Mozilla autopush). This requires internet access from the Frigate server to these push endpoints. When [notifications](/configuration/notifications) are enabled and users have registered for push notifications in the web UI, Frigate sends push messages through the browser vendor's push service (e.g., Google FCM, Mozilla autopush). This requires internet access from the Frigate server to these push endpoints.
@@ -170,7 +179,7 @@ To run Frigate in an air-gapped or offline environment:
2. **Pre-download the training base weights**: If you plan to train custom classification models, set `TF_KERAS_MOBILENET_V2_WEIGHTS_URL` before training, then run one training job while online. Without this variable the base weights are cached outside `/config/` and are lost whenever the container is recreated, so a later training run will fail offline. If the machine never has internet access, copy the weights in manually as described below. 2. **Pre-download the training base weights**: If you plan to train custom classification models, set `TF_KERAS_MOBILENET_V2_WEIGHTS_URL` before training, then run one training job while online. Without this variable the base weights are cached outside `/config/` and are lost whenever the container is recreated, so a later training run will fail offline. If the machine never has internet access, copy the weights in manually as described below.
3. **Disable version check**: Set `telemetry.version_check: false` in your configuration. 3. **Disable version check**: Set `telemetry.version_check: false` in your configuration.
4. **Block outbound model requests**: Set the `HF_HUB_OFFLINE=1` and `TRANSFORMERS_OFFLINE=1` environment variables to prevent HuggingFace and Transformers from attempting any network requests. 4. **Block outbound model requests**: Set the `HF_HUB_OFFLINE=1` and `TRANSFORMERS_OFFLINE=1` environment variables to prevent HuggingFace and Transformers from attempting any network requests.
5. **Avoid cloud features**: Do not configure Frigate+, Generative AI providers that require internet, or cloud MQTT brokers. 5. **Avoid cloud features**: Do not configure Frigate+, Generative AI providers that require internet, or cloud MQTT brokers, and leave anonymous analytics off (its default).
6. **Use local model mirrors**: If limited internet is available, set the `HF_ENDPOINT`, `GITHUB_ENDPOINT`, `GITHUB_RAW_ENDPOINT`, and `TF_KERAS_MOBILENET_V2_WEIGHTS_URL` environment variables to point to local mirrors. 6. **Use local model mirrors**: If limited internet is available, set the `HF_ENDPOINT`, `GITHUB_ENDPOINT`, `GITHUB_RAW_ENDPOINT`, and `TF_KERAS_MOBILENET_V2_WEIGHTS_URL` environment variables to point to local mirrors.
After these steps, Frigate will operate with no outbound internet connections. After these steps, Frigate will operate with no outbound internet connections.
+1 -1
View File
@@ -184,6 +184,6 @@ Filters and masks only hide the incorrect result - they don't teach Frigate what
### Where do I see problems Frigate has detected? ### Where do I see problems Frigate has detected?
Open System > Health. The Notices list keeps a record of problems Frigate has found. Acknowledge an entry to hide it until the problem happens again, or mute it to hide it for good. Hidden entries stay listed under Show hidden in the filter. Ongoing conditions, such as an offline camera or recordings deleted before their retention period, appear in the status bar for admins until they clear, and the status bar links to the Notices list while it has entries showing. On mobile, tap the warning icon in the bottom navigation bar to see them. Open System > Health. The Notices list keeps a record of problems Frigate has found, and you can dismiss any entry to acknowledge it. Ongoing conditions, such as an offline camera or recordings deleted before their retention period, appear in the status bar for admins until they clear, and the status bar links to the Notices list while it has undismissed entries. On mobile, tap the warning icon in the bottom navigation bar to see them.
The Hardware section below the notices shows whether the detection hardware, hardware acceleration, and enrichment devices in your config were found and are being used, so a GPU that silently fell back to the CPU shows up as a warning. Run stream checks to probe every camera's streams for the same problems the camera wizard reports. The Hardware section below the notices shows whether the detection hardware, hardware acceleration, and enrichment devices in your config were found and are being used, so a GPU that silently fell back to the CPU shows up as a warning. Run stream checks to probe every camera's streams for the same problems the camera wizard reports.
+4 -4
View File
@@ -11375,15 +11375,15 @@
} }
}, },
"node_modules/image-size": { "node_modules/image-size": {
"version": "2.0.4", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.4.tgz", "resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz",
"integrity": "sha512-QRUkFFsRV/6fuESxb9Vkq+a0LkSrgKXuc2NEqfikiXxxN/G3tjWt5EVUlMaImRBZRZK/jRBEbYvpPYZL8t08Zw==", "integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==",
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"image-size": "bin/image-size.js" "image-size": "bin/image-size.js"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=16.x"
} }
}, },
"node_modules/immer": { "node_modules/immer": {
+1
View File
@@ -130,6 +130,7 @@ const sidebars: SidebarsConfig = {
label: "Advanced Configuration", label: "Advanced Configuration",
items: [ items: [
"configuration/advanced/system", "configuration/advanced/system",
"configuration/advanced/analytics",
"configuration/advanced/reference", "configuration/advanced/reference",
{ {
type: "link", type: "link",
@@ -0,0 +1,60 @@
import React from "react";
import schema from "@site/static/frigate-analytics-schema.json";
function resolve(node) {
if (!node) return node;
if (node.$ref) return schema.$defs[node.$ref.split("/").pop()];
if (node.anyOf) {
const inner = node.anyOf.find((option) => option.type !== "null");
return inner ? resolve(inner) : node;
}
return node;
}
function rows(properties, prefix = "") {
return Object.entries(properties).flatMap(([name, field]) => {
const path = prefix ? `${prefix}.${name}` : name;
const row = {
path,
description: field.description,
isPublic: field["x-public"],
};
const target = resolve(field);
if (target?.properties) return [row, ...rows(target.properties, path)];
const values = resolve(target?.additionalProperties);
if (values?.properties)
return [row, ...rows(values.properties, `${path}.<key>`)];
const items = resolve(target?.items);
if (items?.properties) return [row, ...rows(items.properties, `${path}[]`)];
return [row];
});
}
export default function AnalyticsFields() {
return (
<table>
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Public</th>
</tr>
</thead>
<tbody>
{rows(schema.properties).map((row) => (
<tr key={row.path}>
<td>
<code>{row.path}</code>
</td>
<td>{row.description}</td>
<td>{row.isPublic ? "Yes" : "No"}</td>
</tr>
))}
</tbody>
</table>
);
}
File diff suppressed because it is too large Load Diff
+35 -82
View File
@@ -10,6 +10,25 @@ servers:
- url: https://demo.frigate.video/api - url: https://demo.frigate.video/api
- url: http://localhost:5001/api - url: http://localhost:5001/api
paths: paths:
/analytics/preview:
get:
tags:
- Analytics
summary: Get Analytics Preview
description: |-
**Access:** Admin role required.
Get the analytics report Frigate would send next, without sending it.
operationId: get_analytics_preview_analytics_preview_get
responses:
'200':
description: Successful Response
content:
application/json:
schema: {}
security:
- frigateAdminAuth: []
x-required-role: admin
/auth/first_time_login: /auth/first_time_login:
get: get:
tags: tags:
@@ -4174,20 +4193,19 @@ paths:
Get notices, most severe first. Get notices, most severe first.
Args: Args:
include_hidden: Also return acknowledged and muted notices, for the include_dismissed: Also return dismissed notices, for the history view
hidden list
Returns: Returns:
The notices The notices
operationId: get_notices_notices_get operationId: get_notices_notices_get
parameters: parameters:
- name: include_hidden - name: include_dismissed
in: query in: query
required: false required: false
schema: schema:
type: boolean type: boolean
default: false default: false
title: Include Hidden title: Include Dismissed
responses: responses:
'200': '200':
description: Successful Response description: Successful Response
@@ -4222,16 +4240,16 @@ paths:
security: security:
- frigateAdminAuth: [] - frigateAdminAuth: []
x-required-role: admin x-required-role: admin
/notices/muted_checks: /notices/dismissed_checks:
get: get:
tags: tags:
- Notices - Notices
summary: Get Muted Checks summary: Get Dismissed Checks
description: |- description: |-
**Access:** Admin role required. **Access:** Admin role required.
Get the muted config and stream check rows, newest first. Get the dismissed config and stream check rows, newest first.
operationId: get_muted_checks_notices_muted_checks_get operationId: get_dismissed_checks_notices_dismissed_checks_get
responses: responses:
'200': '200':
description: Successful Response description: Successful Response
@@ -4241,16 +4259,16 @@ paths:
security: security:
- frigateAdminAuth: [] - frigateAdminAuth: []
x-required-role: admin x-required-role: admin
/notices/hidden: /notices/dismissed:
delete: delete:
tags: tags:
- Notices - Notices
summary: Unhide All Notices summary: Purge Dismissed
description: |- description: |-
**Access:** Admin role required. **Access:** Admin role required.
Show every acknowledged and muted notice and check row again. Delete every dismissed notice and check row so each can show again.
operationId: unhide_all_notices_notices_hidden_delete operationId: purge_dismissed_notices_dismissed_delete
responses: responses:
'200': '200':
description: Successful Response description: Successful Response
@@ -4260,83 +4278,18 @@ paths:
security: security:
- frigateAdminAuth: [] - frigateAdminAuth: []
x-required-role: admin x-required-role: admin
/notices/{notice_id}/acknowledge: /notices/{notice_id}/dismiss:
post: post:
tags: tags:
- Notices - Notices
summary: Acknowledge Notice summary: Dismiss Notice
description: |- description: |-
**Access:** Admin role required. **Access:** Admin role required.
Hide a notice until it happens again. Hide a notice or a config or stream check row.
Config and stream check rows and the update notice never repeat, so they It stays hidden if the same problem happens again.
can only be muted. operationId: dismiss_notice_notices__notice_id__dismiss_post
operationId: acknowledge_notice_notices__notice_id__acknowledge_post
parameters:
- name: notice_id
in: path
required: true
schema:
type: string
title: Notice Id
responses:
'200':
description: Successful Response
content:
application/json:
schema: {}
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
security:
- frigateAdminAuth: []
x-required-role: admin
/notices/{notice_id}/mute:
post:
tags:
- Notices
summary: Mute Notice
description: |-
**Access:** Admin role required.
Hide a notice or a config or stream check row for good.
operationId: mute_notice_notices__notice_id__mute_post
parameters:
- name: notice_id
in: path
required: true
schema:
type: string
title: Notice Id
responses:
'200':
description: Successful Response
content:
application/json:
schema: {}
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
security:
- frigateAdminAuth: []
x-required-role: admin
/notices/{notice_id}/hidden:
delete:
tags:
- Notices
summary: Unhide Notice
description: |-
**Access:** Admin role required.
Show an acknowledged or muted notice or check row again.
operationId: unhide_notice_notices__notice_id__hidden_delete
parameters: parameters:
- name: notice_id - name: notice_id
in: path in: path
+1
View File
@@ -0,0 +1 @@
"""Opt-in anonymous analytics reports."""
+1
View File
@@ -0,0 +1 @@
"""One collector per report section."""
+223
View File
@@ -0,0 +1,223 @@
"""Cameras section: counts and histograms across cameras, never per camera."""
from collections import Counter
from typing import Any
from urllib.parse import urlsplit
from frigate.analytics.collectors.common import closed, histogram
from frigate.analytics.context import ReportContext
from frigate.analytics.schema import (
CamerasSection,
ConnectionQuality,
FpsBucket,
HeightBucket,
HwaccelKey,
InputPresetKey,
RetainBucket,
RetainDays,
)
from frigate.config import CameraConfig
from frigate.config.camera.camera import CameraTypeEnum
from frigate.config.camera.ffmpeg import CameraInput, CameraRoleEnum
from frigate.const import REPLAY_CAMERA_PREFIX
# the upper edge of every height bucket but the last
HEIGHT_BUCKETS = (
(360, HeightBucket.le_360),
(540, HeightBucket.h480),
(900, HeightBucket.h720),
(1260, HeightBucket.h1080),
(1800, HeightBucket.h1440),
)
RESTREAM_HOSTS = frozenset({"127.0.0.1", "localhost"})
RESTREAM_PORT = 8554
QUALITIES = frozenset(ConnectionQuality)
def height_bucket(height: int) -> HeightBucket:
for limit, bucket in HEIGHT_BUCKETS:
if height <= limit:
return bucket
return HeightBucket.ge_2160
def fps_bucket(fps: int) -> FpsBucket:
if fps <= 5:
return FpsBucket.le_5
if fps <= 10:
return FpsBucket.f6_10
return FpsBucket.gt_10
def retain_bucket(days: float) -> RetainBucket:
if days <= 0:
return RetainBucket.zero
if days <= 7:
return RetainBucket.d1_7
if days <= 30:
return RetainBucket.d8_30
return RetainBucket.gt_30
def preset_key(args: str | list[str], enum: Any) -> Any:
"""The preset's name, custom for hand written args, or none."""
if not args:
return enum("none")
if isinstance(args, str) and args.startswith("preset-"):
return closed(enum, args.removeprefix("preset-"), enum("custom"))
return enum("custom")
def is_restream(path: str) -> bool:
try:
url = urlsplit(path)
return url.hostname in RESTREAM_HOSTS and url.port == RESTREAM_PORT
except ValueError:
return False
def role_input(camera: CameraConfig, role: CameraRoleEnum) -> CameraInput | None:
return next((i for i in camera.ffmpeg.inputs if role in i.roles), None)
def object_masks(camera: CameraConfig) -> int:
# parsing copies camera-wide masks into every filter as global_<id>
own = sum(1 for mask in camera.objects.mask.values() if mask is not None)
per_label = sum(
1
for label_filter in camera.objects.filters.values()
for mask_id, mask in label_filter.mask.items()
if mask is not None and not mask_id.startswith("global_")
)
return own + per_label
def collect(ctx: ReportContext) -> CamerasSection:
cameras = {
name: camera
for name, camera in ctx.config.cameras.items()
if not name.startswith(REPLAY_CAMERA_PREFIX)
}
camera_stats = ctx.stats.get("cameras", {})
flags: Counter[str] = Counter()
types: Counter[CameraTypeEnum] = Counter()
heights: Counter[HeightBucket] = Counter()
fps: Counter[FpsBucket] = Counter()
hwaccel: Counter[Any] = Counter()
input_presets: Counter[Any] = Counter()
quality: Counter[ConnectionQuality] = Counter()
retain: dict[str, Counter[RetainBucket]] = {
period: Counter() for period in ("continuous", "motion", "alerts", "detections")
}
for name, camera in cameras.items():
detect_input = role_input(camera, CameraRoleEnum.detect)
record_input = role_input(camera, CameraRoleEnum.record)
# config validation requires a detect input, so this never skips
if detect_input is None:
continue
types[camera.type] += 1
fps[fps_bucket(camera.detect.fps)] += 1
hwaccel[
preset_key(
detect_input.hwaccel_args or camera.ffmpeg.hwaccel_args, HwaccelKey
)
] += 1
input_presets[
preset_key(
detect_input.input_args or camera.ffmpeg.input_args, InputPresetKey
)
] += 1
if camera.detect.height:
heights[height_bucket(camera.detect.height)] += 1
state = camera_stats.get(name, {}).get("connection_quality")
if state in QUALITIES:
quality[ConnectionQuality(state)] += 1
if camera.record.enabled:
retain["continuous"][retain_bucket(camera.record.continuous.days)] += 1
retain["motion"][retain_bucket(camera.record.motion.days)] += 1
retain["alerts"][retain_bucket(camera.record.alerts.retain.days)] += 1
retain["detections"][
retain_bucket(camera.record.detections.retain.days)
] += 1
zones = len(camera.zones)
flags["enabled"] += camera.enabled
flags["go2rtc_restream"] += any(
is_restream(i.path) for i in camera.ffmpeg.inputs
)
flags["separate_detect_stream"] += (
record_input is not None and record_input.path != detect_input.path
)
flags["detect"] += camera.detect.enabled
flags["record"] += camera.record.enabled
flags["sub_stream_record"] += camera.record.sub.enabled
flags["snapshots"] += camera.snapshots.enabled
flags["audio"] += camera.audio.enabled
flags["audio_transcription"] += camera.audio_transcription.enabled
flags["birdseye"] += camera.birdseye.enabled
flags["onvif"] += bool(camera.onvif.host)
flags["autotracking"] += camera.onvif.autotracking.enabled
flags["face_recognition"] += camera.face_recognition.enabled
flags["lpr"] += camera.lpr.enabled
flags["review_genai"] += camera.review.genai.enabled
flags["object_genai"] += camera.objects.genai.enabled
flags["notifications"] += camera.notifications.enabled
flags["zones"] += zones
flags["cameras_with_zones"] += zones > 0
flags["motion_masks"] += sum(
1 for mask in camera.motion.mask.values() if mask is not None
)
flags["object_masks"] += object_masks(camera)
return CamerasSection(
total=len(cameras),
enabled=flags["enabled"],
types=histogram(types),
detect_height=histogram(heights),
detect_fps=histogram(fps),
hwaccel=histogram(hwaccel),
input_preset=histogram(input_presets),
go2rtc_restream=flags["go2rtc_restream"],
separate_detect_stream=flags["separate_detect_stream"],
detect=flags["detect"],
record=flags["record"],
sub_stream_record=flags["sub_stream_record"],
snapshots=flags["snapshots"],
audio=flags["audio"],
audio_transcription=flags["audio_transcription"],
birdseye=flags["birdseye"],
onvif=flags["onvif"],
autotracking=flags["autotracking"],
face_recognition=flags["face_recognition"],
lpr=flags["lpr"],
review_genai=flags["review_genai"],
object_genai=flags["object_genai"],
notifications=flags["notifications"],
zones=flags["zones"],
cameras_with_zones=flags["cameras_with_zones"],
motion_masks=flags["motion_masks"],
object_masks=flags["object_masks"],
connection_quality=histogram(quality),
retain_days=RetainDays(
continuous=histogram(retain["continuous"]),
motion=histogram(retain["motion"]),
alerts=histogram(retain["alerts"]),
detections=histogram(retain["detections"]),
),
)
+37
View File
@@ -0,0 +1,37 @@
"""Helpers the section collectors share."""
import math
from collections import Counter
from enum import Enum
from typing import Any, TypeVar
E = TypeVar("E", bound=Enum)
def closed(enum: type[E], value: Any, fallback: E) -> E:
"""The member for a value, or the fallback for one the enum doesn't know."""
try:
return enum(str(value))
except ValueError:
return fallback
def rate(value: Any) -> float:
"""A finite, non-negative number rounded to 2 decimals, else 0.
A NaN would serialize as null and fail the schema's number type.
"""
try:
number = float(value)
except (TypeError, ValueError):
return 0.0
if not math.isfinite(number):
return 0.0
return round(max(number, 0.0), 2)
def histogram(counter: "Counter[E]") -> dict[E, int]:
"""Drop the empty buckets, since an absent key means zero."""
return {key: total for key, total in counter.items() if total > 0}
+65
View File
@@ -0,0 +1,65 @@
"""Detection section: models, the detectors they run on, and inference speed."""
import os
from frigate.analytics.collectors.common import rate
from frigate.analytics.context import ReportContext
from frigate.analytics.schema import DetectionModel, DetectionSection, ModelSource
from frigate.config.config import DEFAULT_MODEL
from frigate.const import MODEL_CACHE_DIR
from frigate.detectors.detector_config import SceneEnum
from frigate.detectors.detector_types import DetectorTypeEnum
from frigate.detectors.device import runner_names
# the paths FrigateConfig fills in for a model that sets none
BUNDLED_MODEL_PATHS = frozenset(
{"/cpu_model.tflite", "/edgetpu_model.tflite", str(DEFAULT_MODEL["path"])}
)
def model_source(path: str | None) -> ModelSource:
"""Default, Frigate+ (a cached model next to its info file), or custom.
Parsing rewrites plus://<id> to the model cache, so the prefix is gone by now.
"""
if path is None or path in BUNDLED_MODEL_PATHS:
return ModelSource.default
if path.startswith(f"{MODEL_CACHE_DIR}/") and os.path.isfile(f"{path}.json"):
return ModelSource.plus
return ModelSource.custom
def collect(ctx: ReportContext) -> DetectionSection:
config = ctx.config
detectors = ctx.stats.get("detectors", {})
model_specs = [(model, config.devices_for_model(model)) for model in config.models]
# FrigateApp.start_detectors names the processes in this same order
names = iter(runner_names([spec for _, specs in model_specs for spec in specs]))
models: dict[SceneEnum, DetectionModel] = {}
for model, specs in model_specs:
speeds: list[float] = []
for _ in specs:
speed = detectors.get(next(names), {}).get("inference_speed")
if isinstance(speed, int | float) and speed > 0:
speeds.append(float(speed))
models[model.scene] = DetectionModel(
detector=DetectorTypeEnum(specs[0].detector),
devices=len(specs),
model_type=model.model_type,
input=f"{model.width}x{model.height}",
source=model_source(model.path),
inference_ms=rate(sum(speeds) / len(speeds)) if speeds else None,
)
return DetectionSection(
models=models,
detection_fps=rate(ctx.stats.get("detection_fps")),
skipped_fps=rate(ctx.stats.get("skipped_fps")),
)
+145
View File
@@ -0,0 +1,145 @@
"""Features section: enrichments, GenAI, integrations, and users."""
from collections import Counter
from frigate.analytics.collectors.common import closed, histogram
from frigate.analytics.context import ReportContext
from frigate.analytics.schema import (
BirdseyeUsage,
ClassificationUsage,
EnrichmentDevice,
EnrichmentUsage,
FeaturesSection,
GenAIUsage,
SemanticSearchModel,
SemanticSearchUsage,
TranscriptionModel,
TranscriptionUsage,
UserRole,
)
from frigate.config.camera.genai import GenAIProviderEnum, GenAIRoleEnum
from frigate.const import REPLAY_CAMERA_PREFIX
from frigate.models import User
RUNTIME_DEVICES = {
"cpu": EnrichmentDevice.cpu,
"cuda": EnrichmentDevice.cuda,
"tensorrt": EnrichmentDevice.tensorrt,
"migraphx": EnrichmentDevice.migraphx,
}
OPENVINO_DEVICES = {
"cpu": EnrichmentDevice.openvino_cpu,
"gpu": EnrichmentDevice.openvino_gpu,
"npu": EnrichmentDevice.openvino_npu,
}
def enrichment_device(label: object) -> EnrichmentDevice | None:
"""Map a runner's device label, like "CUDA" or "OpenVINO GPU.0,CPU"."""
if not isinstance(label, str) or not label:
return None
runtime, _, target = label.partition(" ")
if runtime == "OpenVINO":
first = target.split(",")[0].split(".")[0].strip().lower()
return OPENVINO_DEVICES.get(first, EnrichmentDevice.other)
return RUNTIME_DEVICES.get(label.lower(), EnrichmentDevice.other)
def model_name(model: object) -> str | None:
if model is None:
return None
return str(getattr(model, "value", model))
def semantic_model(model: object) -> SemanticSearchModel | None:
# any string that isn't a built-in model names a GenAI provider
name = model_name(model)
if name is None:
return None
if name in ("jinav1", "jinav2"):
return SemanticSearchModel(name)
return SemanticSearchModel.genai
def transcription_model(model: object) -> TranscriptionModel | None:
name = model_name(model)
if name is None:
return None
return TranscriptionModel.whisper if name == "whisper" else TranscriptionModel.genai
def users() -> dict[UserRole, int]:
roles: Counter[UserRole] = Counter(
closed(UserRole, user.role, UserRole.custom) for user in User.select(User.role)
)
return histogram(roles)
def collect(ctx: ReportContext) -> FeaturesSection:
config = ctx.config
devices = ctx.stats.get("embeddings", {}).get("devices", {})
cameras = [
camera
for name, camera in config.cameras.items()
if not name.startswith(REPLAY_CAMERA_PREFIX)
]
providers: Counter[GenAIProviderEnum] = Counter(
genai.provider for genai in config.genai.values()
)
roles: Counter[GenAIRoleEnum] = Counter(
role for genai in config.genai.values() for role in genai.roles
)
custom = list(config.classification.custom.values())
return FeaturesSection(
face_recognition=EnrichmentUsage(
enabled=config.face_recognition.enabled,
model_size=config.face_recognition.model_size,
device=enrichment_device(devices.get("face_recognition")),
),
lpr=EnrichmentUsage(
enabled=config.lpr.enabled,
model_size=config.lpr.model_size,
device=enrichment_device(devices.get("lpr")),
),
semantic_search=SemanticSearchUsage(
enabled=config.semantic_search.enabled,
model=semantic_model(config.semantic_search.model),
model_size=config.semantic_search.model_size,
device=enrichment_device(devices.get("semantic_search")),
triggers=sum(len(camera.semantic_search.triggers) for camera in cameras),
),
audio_transcription=TranscriptionUsage(
enabled=config.audio_transcription.enabled,
model=transcription_model(config.audio_transcription.model),
model_size=config.audio_transcription.model_size,
),
genai=GenAIUsage(providers=histogram(providers), roles=histogram(roles)),
classification_models=ClassificationUsage(
state=sum(1 for model in custom if model.state_config is not None),
object=sum(1 for model in custom if model.object_config is not None),
),
birdseye=BirdseyeUsage(
enabled=config.birdseye.enabled,
modes=list(config.birdseye.modes),
restream=config.birdseye.restream,
),
mqtt=config.mqtt.enabled,
notifications=config.notifications.enabled,
auth=config.auth.enabled,
proxy_auth=config.proxy.header_map.user is not None,
tls=config.tls.enabled,
users=users(),
camera_groups=len(config.camera_groups),
profiles=len(config.profiles),
plus_api_key=config.plus_api.is_active(),
)
+107
View File
@@ -0,0 +1,107 @@
"""Hardware section: CPU, memory, GPUs, decode and detection hardware, storage."""
import os
from collections import Counter
from typing import Any
import psutil
from frigate.analytics.collectors.common import closed, histogram
from frigate.analytics.context import ReportContext
from frigate.analytics.schema import (
DecodeFamily,
GpuInfo,
GpuVendor,
HardwareKey,
HardwareSection,
StorageInfo,
)
from frigate.const import RECORD_DIR
from frigate.detectors.hardware import hardware_prober
from frigate.util.hwaccel import hwaccel_options
DEVICE_TREE_MODEL = "/proc/device-tree/model"
CPUINFO = "/proc/cpuinfo"
def cpu_model() -> str:
"""The board model on ARM boards, else the CPU's model name."""
try:
with open(DEVICE_TREE_MODEL) as f:
board = f.read().strip("\x00\n ")
if board:
return board[:64]
except OSError:
pass
try:
with open(CPUINFO) as f:
for line in f:
key, _, value = line.partition(":")
if key.strip() == "model name" and value.strip():
return value.strip()[:64]
except OSError:
pass
return "unknown"
def gpus(stats: dict[str, Any]) -> list[GpuInfo]:
found: list[GpuInfo] = []
for name, entry in stats.get("gpu_usages", {}).items():
vendor = entry.get("vendor") if isinstance(entry, dict) else None
found.append(
GpuInfo(
vendor=closed(GpuVendor, vendor, GpuVendor.other),
name=str(name)[:64],
)
)
return found
def decode_families() -> list[DecodeFamily]:
_, available = hwaccel_options()
families = [
closed(DecodeFamily, family.key, DecodeFamily.other) for family in available
]
return list(dict.fromkeys(families))
def detection_hardware() -> dict[HardwareKey, int]:
units: Counter[HardwareKey] = Counter()
for found in hardware_prober.probe():
units[closed(HardwareKey, found.key, HardwareKey.other)] += found.count
return histogram(units)
def storage(stats: dict[str, Any]) -> StorageInfo:
# stats report sizes in MB
entry = stats.get("service", {}).get("storage", {}).get(RECORD_DIR) or {}
total_mb = float(entry.get("total") or 0)
used_mb = float(entry.get("used") or 0)
return StorageInfo(
record_fs=str(entry.get("mount_type") or "unknown")[:16],
record_total_gb=round(total_mb / 1024),
record_used_pct=min(round(used_mb / total_mb * 100), 100)
if total_mb > 0
else 0,
)
def collect(ctx: ReportContext) -> HardwareSection:
return HardwareSection(
cpu_model=cpu_model(),
cpu_cores=os.cpu_count() or 0,
memory_gb=round(psutil.virtual_memory().total / 2**30),
gpus=gpus(ctx.stats),
decode_families=decode_families(),
detection_hardware=detection_hardware(),
storage=storage(ctx.stats),
)
+59
View File
@@ -0,0 +1,59 @@
"""Health section: uptime, CPU, enrichment speed, and notice counts."""
from typing import Any
from frigate.analytics.collectors.common import rate
from frigate.analytics.context import ReportContext
from frigate.analytics.schema import (
EnrichmentTiming,
HealthSection,
NoticeCounts,
NoticeKindKey,
)
TIMING_STATS = {
EnrichmentTiming.face: "face_recognition_speed",
EnrichmentTiming.lpr: "plate_recognition_speed",
EnrichmentTiming.plate_detection: "yolov9_plate_detection_speed",
EnrichmentTiming.image_embedding: "image_embedding_speed",
EnrichmentTiming.text_embedding: "text_embedding_speed",
EnrichmentTiming.review_description: "review_description_speed",
EnrichmentTiming.object_description: "object_description_speed",
}
REPORTABLE_KINDS = frozenset(key.value for key in NoticeKindKey)
def notice_deltas(notice_stats: list[dict[str, Any]]) -> dict[Any, NoticeCounts]:
"""What changed since the last accepted report, per reportable kind."""
deltas: dict[Any, NoticeCounts] = {}
for row in notice_stats:
if row["kind"] not in REPORTABLE_KINDS:
continue
occurrences = max(row["occurrences"] - row["reported_occurrences"], 0)
dismissals = max(row["dismissals"] - row["reported_dismissals"], 0)
if occurrences or dismissals:
deltas[NoticeKindKey(row["kind"])] = NoticeCounts(
occurrences=occurrences, dismissals=dismissals
)
return deltas
def collect(ctx: ReportContext) -> HealthSection:
service = ctx.stats.get("service", {})
embeddings = ctx.stats.get("embeddings", {})
cpu = ctx.stats.get("cpu_usages", {}).get("frigate.full_system", {}).get("cpu")
timings = {
timing: rate(embeddings.get(key)) for timing, key in TIMING_STATS.items()
}
return HealthSection(
uptime_hours=int(rate(service.get("uptime")) // 3600),
cpu_percent=min(round(rate(cpu)), 100),
enrichment_ms={timing: value for timing, value in timings.items() if value > 0},
retention_unmet=bool(service.get("retention_unmet", False)),
notices=notice_deltas(ctx.notice_stats),
)
+63
View File
@@ -0,0 +1,63 @@
"""Install section: version, image variant, install type, platform."""
import os
import platform
import re
from frigate.analytics.collectors.common import closed
from frigate.analytics.context import ReportContext
from frigate.analytics.schema import Arch, ImageVariant, InstallSection, InstallType
from frigate.version import VERSION
KERNEL_PATTERN = re.compile(r"^(\d{1,3})\.(\d{1,3})")
def image_variant(value: str | None) -> ImageVariant:
"""The published image from the build-time FRIGATE_IMAGE_VARIANT, dev when unset."""
if not value:
return ImageVariant.dev
return closed(ImageVariant, value, ImageVariant.other)
def install_type() -> InstallType:
# the add-on is a container too, so it has to be checked first
if os.path.isfile("/data/options.json"):
return InstallType.ha_addon
if os.environ.get("KUBERNETES_SERVICE_HOST"):
return InstallType.kubernetes
if os.path.exists("/run/.containerenv"):
return InstallType.podman
if os.path.exists("/.dockerenv"):
return InstallType.docker
return InstallType.unknown
def arch(machine: str) -> Arch:
match machine.lower():
case "x86_64" | "amd64":
return Arch.x86_64
case "aarch64" | "arm64":
return Arch.aarch64
case _:
return Arch.other
def kernel(release: str) -> str:
match = KERNEL_PATTERN.match(release)
return f"{match.group(1)}.{match.group(2)}" if match else "unknown"
def collect(ctx: ReportContext) -> InstallSection:
return InstallSection(
version=VERSION[:32],
image_variant=image_variant(os.environ.get("FRIGATE_IMAGE_VARIANT")),
install_type=install_type(),
arch=arch(platform.machine()),
kernel=kernel(platform.release()),
run_as_root=os.geteuid() == 0,
)
+13
View File
@@ -0,0 +1,13 @@
"""What the collectors read, gathered once per report."""
from dataclasses import dataclass, field
from typing import Any
from frigate.config import FrigateConfig
@dataclass(frozen=True)
class ReportContext:
config: FrigateConfig
stats: dict[str, Any]
notice_stats: list[dict[str, Any]] = field(default_factory=list)
+88
View File
@@ -0,0 +1,88 @@
"""Build a report from the section collectors."""
import logging
import time
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
from uuid import uuid4
from pydantic import BaseModel
from frigate.analytics.collectors import (
cameras,
detection,
features,
hardware,
health,
install,
)
from frigate.analytics.context import ReportContext
from frigate.analytics.schema import SCHEMA_VERSION, AnalyticsReport
from frigate.analytics.state import load_state
from frigate.config import FrigateConfig
if TYPE_CHECKING:
from frigate.notices.registry import NoticeRegistry
from frigate.stats.emitter import StatsEmitter
logger = logging.getLogger(__name__)
COLLECTORS: dict[str, Callable[[ReportContext], BaseModel | None]] = {
"install": install.collect,
"hardware": hardware.collect,
"detection": detection.collect,
"cameras": cameras.collect,
"features": features.collect,
"health": health.collect,
}
# shown in the preview until the first report creates a real ID
PREVIEW_INSTALL_ID = "0" * 32
def build_report(
ctx: ReportContext, install_id: str, sent_at: int | None = None
) -> AnalyticsReport:
"""Run every collector; one that fails sends its section as null."""
sections: dict[str, Any] = {}
for name, collect in COLLECTORS.items():
try:
sections[name] = collect(ctx)
except Exception:
# a collector bug must cost one section, never the whole report
logger.warning("Analytics %s section failed", name, exc_info=True)
sections[name] = None
return AnalyticsReport.model_validate(
{
"schema_version": SCHEMA_VERSION,
"install_id": install_id,
"report_id": str(uuid4()),
"sent_at": int(time.time()) if sent_at is None else sent_at,
**sections,
}
)
def gather_context(
config: FrigateConfig,
stats_emitter: "StatsEmitter | None",
notice_registry: "NoticeRegistry | None",
) -> ReportContext:
return ReportContext(
config=config,
stats=stats_emitter.get_latest_stats() if stats_emitter is not None else {},
notice_stats=notice_registry.stats() if notice_registry is not None else [],
)
def preview_report(
config: FrigateConfig,
stats_emitter: "StatsEmitter | None",
notice_registry: "NoticeRegistry | None",
) -> AnalyticsReport:
"""The report the next send would carry, without sending it."""
state = load_state()
ctx = gather_context(config, stats_emitter, notice_registry)
return build_report(ctx, state.install_id if state else PREVIEW_INSTALL_ID)
+177
View File
@@ -0,0 +1,177 @@
"""Send one analytics report a day while the admin has opted in."""
import logging
import random
import threading
import time
from collections.abc import Callable
from multiprocessing.synchronize import Event as MpEvent
from typing import TYPE_CHECKING
from frigate.analytics.context import ReportContext
from frigate.analytics.report import build_report
from frigate.analytics.state import (
STATE_PATH,
AnalyticsState,
delete_state,
load_state,
new_state,
save_state,
)
from frigate.analytics.transport import SendOutcome, send_report
from frigate.config import FrigateConfig
from frigate.config.holder import ConfigHolder
from frigate.const import ANALYTICS_URL
from frigate.notices import raise_notice, resolve_notice
if TYPE_CHECKING:
from frigate.notices.registry import NoticeRegistry
from frigate.stats.emitter import StatsEmitter
logger = logging.getLogger(__name__)
WAKE_S = 10 * 60
INTERVAL_S = 24 * 60 * 60
JITTER_S = 60 * 60
FIRST_DELAY_S = (15 * 60, 45 * 60)
PROMPT_KIND = "analytics_prompt"
class AnalyticsReporter(threading.Thread):
"""Follows the live config, so a settings save needs no restart."""
def __init__(
self,
config_holder: ConfigHolder,
stats_emitter: "StatsEmitter",
notice_registry: "NoticeRegistry",
stop_event: MpEvent | threading.Event,
*,
state_path: str = STATE_PATH,
url: str = ANALYTICS_URL,
send: Callable[[str, str], SendOutcome] = send_report,
clock: Callable[[], float] = time.time,
rng: random.Random | None = None,
) -> None:
super().__init__(name="analytics_reporter", daemon=True)
self.config_holder = config_holder
self.stats_emitter = stats_emitter
self.notice_registry = notice_registry
self.stop_event = stop_event
self.state_path = state_path
self.url = url
self.send = send
self.clock = clock
self.rng = rng or random.Random()
self.first_due = clock() + self.rng.uniform(*FIRST_DELAY_S)
self.interval = self._next_interval()
self.opted_in: bool | None = None
self.warned_unwritable = False
# a settings save runs on an API thread while a wake may be mid-attempt
self._lock = threading.Lock()
config_holder.subscribe(self._on_config)
def _next_interval(self) -> float:
return INTERVAL_S + self.rng.uniform(-JITTER_S, JITTER_S)
def run(self) -> None:
while True:
try:
self.tick()
except Exception:
logger.exception("Analytics reporter failed")
if self.stop_event.wait(WAKE_S):
break
def _on_config(self, config: FrigateConfig) -> None:
# a save can turn sharing off and back on between two wakes, so an
# opt-out is handled when it's saved rather than at the next wake
with self._lock:
if not config.safe_mode:
self._apply_consent(config.telemetry.analytics)
def _apply_consent(self, opted_in: bool) -> None:
# called with the lock held
if opted_in == self.opted_in:
return
self.opted_in = opted_in
if opted_in:
resolve_notice(PROMPT_KIND)
else:
raise_notice(PROMPT_KIND)
delete_state(self.state_path)
def tick(self) -> None:
with self._lock:
config = self.config_holder.config
# safe mode parses a default config where analytics reads as off,
# and handling that as an opt-out would delete the install ID
if config.safe_mode:
return
self._apply_consent(config.telemetry.analytics)
if not config.telemetry.analytics:
return
now = self.clock()
if now < self.first_due:
return
state = load_state(self.state_path) or new_state()
if not self._due(state.last_attempt_at, now):
return
# saved before sending, so a failing endpoint or a crash mid-send
# still waits a full interval; without saved state every boot would
# send under a new install ID
if not save_state(AnalyticsState(state.install_id, now), self.state_path):
if not self.warned_unwritable:
logger.warning(
"Analytics is on, but %s isn't writable, so no report is sent",
self.state_path,
)
self.warned_unwritable = True
return
self.interval = self._next_interval()
# the lock stays free during the request, so a save never waits on it
self._send(state.install_id, now)
def _due(self, last_attempt_at: float, now: float) -> bool:
# a last attempt stamped in the future came from a wrong clock
return (
now >= last_attempt_at + self.interval or last_attempt_at > now + INTERVAL_S
)
def _send(self, install_id: str, now: float) -> None:
notice_stats = self.notice_registry.stats()
ctx = ReportContext(
config=self.config_holder.config,
stats=self.stats_emitter.get_latest_stats(),
notice_stats=notice_stats,
)
report = build_report(ctx, install_id, sent_at=int(now))
body = report.model_dump_json()
# consent can be withdrawn while the report builds
if not self.config_holder.config.telemetry.analytics:
return
if self.send(self.url, body) is not SendOutcome.accepted:
return
# a failed health section sent no notice counts, so they stay pending
if report.health is not None:
self.notice_registry.mark_reported(notice_stats)
logger.info("Sent the daily analytics report")
logger.debug("Analytics report: %s", body)
+407
View File
@@ -0,0 +1,407 @@
"""Models for the analytics report, the contract with the ingest endpoint.
Every field carries a description and an x-public flag, and no field accepts
user-entered text, so a report can't carry camera names or other free text.
"""
from enum import StrEnum
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from frigate.config.camera.birdseye import BirdseyeModeEnum
from frigate.config.camera.camera import CameraTypeEnum
from frigate.config.camera.genai import GenAIProviderEnum, GenAIRoleEnum
from frigate.config.classification import ModelSizeEnum
from frigate.detectors.detector_config import ModelTypeEnum, SceneEnum
from frigate.detectors.detector_types import DetectorTypeEnum
from frigate.ffmpeg_presets import PRESETS_HW_ACCEL_DECODE, PRESETS_INPUT
from frigate.notices.types import NOTICE_KINDS
SCHEMA_VERSION = 1
class ImageVariant(StrEnum):
standard = "standard"
rpi = "rpi"
tensorrt = "tensorrt"
tensorrt_jp6 = "tensorrt-jp6"
rocm = "rocm"
rk = "rk"
synaptics = "synaptics"
dev = "dev"
other = "other"
class InstallType(StrEnum):
ha_addon = "ha_addon"
docker = "docker"
podman = "podman"
kubernetes = "kubernetes"
unknown = "unknown"
class Arch(StrEnum):
x86_64 = "x86_64"
aarch64 = "aarch64"
other = "other"
class GpuVendor(StrEnum):
intel = "intel"
amd = "amd"
nvidia = "nvidia"
rockchip = "rockchip"
rpi = "rpi"
other = "other"
class DecodeFamily(StrEnum):
nvidia = "nvidia"
vaapi = "vaapi"
rkmpp = "rkmpp"
intel_qsv = "intel-qsv"
jetson = "jetson"
rpi = "rpi"
other = "other"
class HardwareKey(StrEnum):
edgetpu_pci = "edgetpu:pci"
edgetpu_usb = "edgetpu:usb"
openvino_gpu = "openvino:GPU"
openvino_npu = "openvino:NPU"
onnx_amd = "onnx:amd"
onnx_nvidia = "onnx:nvidia"
tensorrt = "tensorrt"
hailo = "hailo"
memryx = "memryx"
deepx = "deepx"
rknn = "rknn"
axengine = "axengine"
synaptics = "synaptics"
cpu = "cpu"
other = "other"
class ModelSource(StrEnum):
default = "default"
plus = "plus"
custom = "custom"
class HeightBucket(StrEnum):
le_360 = "le_360"
h480 = "480"
h720 = "720"
h1080 = "1080"
h1440 = "1440"
ge_2160 = "ge_2160"
class FpsBucket(StrEnum):
le_5 = "le_5"
f6_10 = "6_10"
gt_10 = "gt_10"
class RetainBucket(StrEnum):
zero = "0"
d1_7 = "1_7"
d8_30 = "8_30"
gt_30 = "gt_30"
class ConnectionQuality(StrEnum):
excellent = "excellent"
fair = "fair"
poor = "poor"
unusable = "unusable"
class EnrichmentDevice(StrEnum):
cpu = "cpu"
cuda = "cuda"
tensorrt = "tensorrt"
migraphx = "migraphx"
openvino_cpu = "openvino_cpu"
openvino_gpu = "openvino_gpu"
openvino_npu = "openvino_npu"
other = "other"
class SemanticSearchModel(StrEnum):
jinav1 = "jinav1"
jinav2 = "jinav2"
genai = "genai"
class TranscriptionModel(StrEnum):
whisper = "whisper"
genai = "genai"
class UserRole(StrEnum):
admin = "admin"
viewer = "viewer"
custom = "custom"
class EnrichmentTiming(StrEnum):
face = "face"
lpr = "lpr"
plate_detection = "plate_detection"
image_embedding = "image_embedding"
text_embedding = "text_embedding"
review_description = "review_description"
object_description = "object_description"
def _preset_keys(presets: dict[str, Any]) -> dict[str, str]:
keys = [name.removeprefix("preset-") for name in presets]
return {key: key for key in [*keys, "custom", "none"]}
# built from the registries they mirror, so a new preset or notice kind reaches
# the schema through generate_analytics_schema.py instead of a hand edit
HwaccelKey = StrEnum("HwaccelKey", _preset_keys(PRESETS_HW_ACCEL_DECODE)) # type: ignore[misc]
InputPresetKey = StrEnum("InputPresetKey", _preset_keys(PRESETS_INPUT)) # type: ignore[misc]
NoticeKindKey = StrEnum( # type: ignore[misc]
"NoticeKindKey",
{key: key for key, kind in NOTICE_KINDS.items() if kind.reportable},
)
class AnalyticsModel(BaseModel):
model_config = ConfigDict(extra="forbid")
def metric(description: str, *, public: bool = True, **kwargs: Any) -> Any:
"""Declare a report field; the description and flag land in the schema."""
return Field(
description=description, json_schema_extra={"x-public": public}, **kwargs
)
def count(description: str) -> Any:
return metric(description, ge=0)
class InstallSection(AnalyticsModel):
version: str = metric("Frigate version string", max_length=32)
image_variant: ImageVariant = metric("Published image the install runs")
install_type: InstallType = metric("How Frigate is installed")
arch: Arch = metric("CPU architecture")
kernel: str = metric(
"Host kernel as major.minor", pattern=r"^(\d{1,3}\.\d{1,3}|unknown)$"
)
run_as_root: bool = metric("Whether the main process runs as root")
class GpuInfo(AnalyticsModel):
vendor: GpuVendor = metric("GPU vendor")
name: str = metric("GPU name as the hardware reports it", max_length=64)
class StorageInfo(AnalyticsModel):
record_fs: str = metric("Filesystem of the recordings volume", max_length=16)
record_total_gb: int = count("Size of the recordings volume in GB")
record_used_pct: int = metric(
"Percent of the recordings volume in use", ge=0, le=100
)
class HardwareSection(AnalyticsModel):
cpu_model: str = metric(
"CPU or board model as the hardware reports it", max_length=64
)
cpu_cores: int = count("Logical CPU count")
memory_gb: int = count("Total memory in GB")
gpus: list[GpuInfo] = metric("GPUs the stats collector found")
decode_families: list[DecodeFamily] = metric(
"Hardware decode families this system can use"
)
detection_hardware: dict[HardwareKey, int] = metric(
"Detection hardware found, as unit counts by kind"
)
storage: StorageInfo = metric("Recordings storage")
class DetectionModel(AnalyticsModel):
detector: DetectorTypeEnum = metric("Detector type the model runs on")
devices: int = count("Devices the model runs on")
model_type: ModelTypeEnum = metric("Model architecture")
input: str = metric("Model input size as WxH", pattern=r"^\d{1,5}x\d{1,5}$")
source: ModelSource = metric("Where the model came from", public=False)
inference_ms: float | None = metric(
"Mean inference time across the model's detector processes, null before the first stats",
ge=0,
)
class DetectionSection(AnalyticsModel):
models: dict[SceneEnum, DetectionModel] = metric("Detection models keyed by scene")
detection_fps: float = metric("Detections per second across cameras", ge=0)
skipped_fps: float = metric("Frames per second skipped across cameras", ge=0)
class RetainDays(AnalyticsModel):
continuous: dict[RetainBucket, int] = metric(
"Recording cameras by continuous retention in days"
)
motion: dict[RetainBucket, int] = metric(
"Recording cameras by motion retention in days"
)
alerts: dict[RetainBucket, int] = metric(
"Recording cameras by alert retention in days"
)
detections: dict[RetainBucket, int] = metric(
"Recording cameras by detection retention in days"
)
class CamerasSection(AnalyticsModel):
total: int = count("Configured cameras")
enabled: int = count("Enabled cameras")
types: dict[CameraTypeEnum, int] = metric("Cameras by type")
detect_height: dict[HeightBucket, int] = metric(
"Cameras by detect resolution height"
)
detect_fps: dict[FpsBucket, int] = metric("Cameras by detect fps")
hwaccel: dict[HwaccelKey, int] = metric(
"Cameras by the resolved hwaccel preset of the detect input"
)
input_preset: dict[InputPresetKey, int] = metric(
"Cameras by the input preset of the detect input"
)
go2rtc_restream: int = count("Cameras with an input from the go2rtc restream")
separate_detect_stream: int = count(
"Cameras whose detect input differs from their record input"
)
detect: int = count("Cameras with detection on")
record: int = count("Cameras with recording on")
sub_stream_record: int = count("Cameras with sub stream recording on")
snapshots: int = count("Cameras with snapshots on")
audio: int = count("Cameras with audio detection on")
audio_transcription: int = count("Cameras with audio transcription on")
birdseye: int = count("Cameras in birdseye")
onvif: int = count("Cameras with an ONVIF host")
autotracking: int = count("Cameras with PTZ autotracking on")
face_recognition: int = count("Cameras with face recognition on")
lpr: int = count("Cameras with license plate recognition on")
review_genai: int = count("Cameras with GenAI review summaries on")
object_genai: int = count("Cameras with GenAI object descriptions on")
notifications: int = count("Cameras with notifications on")
zones: int = count("Zones across cameras")
cameras_with_zones: int = count("Cameras with at least one zone")
motion_masks: int = count("Motion masks across cameras")
object_masks: int = count("Object masks across cameras")
connection_quality: dict[ConnectionQuality, int] = metric(
"Cameras by connection quality at send time"
)
retain_days: RetainDays = metric("Recording retention")
class EnrichmentUsage(AnalyticsModel):
enabled: bool = metric("Whether the enrichment is on")
model_size: ModelSizeEnum = metric("Configured model size")
device: EnrichmentDevice | None = metric(
"Device the model loaded on, null when it isn't loaded"
)
class SemanticSearchUsage(AnalyticsModel):
enabled: bool = metric("Whether semantic search is on")
model: SemanticSearchModel | None = metric(
"Embedding model, genai for a GenAI provider"
)
model_size: ModelSizeEnum = metric("Configured model size")
device: EnrichmentDevice | None = metric(
"Device the model loaded on, null when it isn't loaded"
)
triggers: int = count("Semantic search triggers across cameras")
class TranscriptionUsage(AnalyticsModel):
enabled: bool = metric("Whether audio transcription is on")
model: TranscriptionModel | None = metric(
"Transcription model, genai for a GenAI provider"
)
model_size: ModelSizeEnum = metric("Configured model size")
class GenAIUsage(AnalyticsModel):
providers: dict[GenAIProviderEnum, int] = metric(
"Configured GenAI providers by type"
)
roles: dict[GenAIRoleEnum, int] = metric("Configured GenAI providers by role")
class ClassificationUsage(AnalyticsModel):
state: int = count("Custom state classification models")
object: int = count("Custom object classification models")
class BirdseyeUsage(AnalyticsModel):
enabled: bool = metric("Whether birdseye is on")
modes: list[BirdseyeModeEnum] = metric("Birdseye modes")
restream: bool = metric("Whether birdseye is restreamed")
class FeaturesSection(AnalyticsModel):
face_recognition: EnrichmentUsage = metric("Face recognition")
lpr: EnrichmentUsage = metric("License plate recognition")
semantic_search: SemanticSearchUsage = metric("Semantic search")
audio_transcription: TranscriptionUsage = metric("Audio transcription")
genai: GenAIUsage = metric("Generative AI providers")
classification_models: ClassificationUsage = metric("Custom classification models")
birdseye: BirdseyeUsage = metric("Birdseye")
mqtt: bool = metric("Whether MQTT is on")
notifications: bool = metric("Whether web push notifications are on")
auth: bool = metric("Whether authentication is on")
proxy_auth: bool = metric("Whether a proxy supplies the user header")
tls: bool = metric("Whether TLS is on")
users: dict[UserRole, int] = metric("Users by role")
camera_groups: int = count("Camera groups")
profiles: int = count("Profiles")
plus_api_key: bool = metric("Whether a Frigate+ API key is set", public=False)
class NoticeCounts(AnalyticsModel):
occurrences: int = count("Occurrences since the last accepted report")
dismissals: int = count("Dismissals since the last accepted report")
class HealthSection(AnalyticsModel):
uptime_hours: int = count("Hours since Frigate started")
cpu_percent: int = metric("System CPU use at send time", ge=0, le=100)
enrichment_ms: dict[EnrichmentTiming, float] = metric(
"Mean enrichment inference times in milliseconds"
)
retention_unmet: bool = metric(
"Whether storage can't keep the configured retention"
)
notices: dict[NoticeKindKey, NoticeCounts] = metric(
"Notice counts by kind since the last accepted report", public=False
)
class AnalyticsReport(AnalyticsModel):
schema_version: int = metric("Report format version", ge=1)
install_id: str = metric(
"Random install identifier", public=False, pattern=r"^[0-9a-f]{32}$"
)
report_id: str = metric(
"Random identifier of this report",
public=False,
pattern=r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
)
sent_at: int = count("Unix time the report was built")
install: InstallSection | None = metric("Install, null if its collector failed")
hardware: HardwareSection | None = metric("Hardware, null if its collector failed")
detection: DetectionSection | None = metric(
"Object detection, null if its collector failed"
)
cameras: CamerasSection | None = metric("Cameras, null if its collector failed")
features: FeaturesSection | None = metric("Features, null if its collector failed")
health: HealthSection | None = metric("Health, null if its collector failed")
+85
View File
@@ -0,0 +1,85 @@
"""The install's analytics identity and last attempt time, kept under /config."""
import json
import logging
import os
import re
from dataclasses import dataclass
from uuid import uuid4
from frigate.const import CONFIG_DIR
logger = logging.getLogger(__name__)
STATE_PATH = os.path.join(CONFIG_DIR, ".analytics.json")
INSTALL_ID_PATTERN = re.compile(r"[0-9a-f]{32}")
@dataclass(frozen=True)
class AnalyticsState:
install_id: str
last_attempt_at: float
def new_state() -> AnalyticsState:
return AnalyticsState(install_id=uuid4().hex, last_attempt_at=0.0)
def load_state(path: str = STATE_PATH) -> AnalyticsState | None:
"""The saved state, or None when the file is missing or unusable."""
try:
with open(path) as f:
data = json.load(f)
except FileNotFoundError:
return None
except (OSError, ValueError):
logger.warning("Ignoring unreadable analytics state at %s", path)
return None
if not isinstance(data, dict):
return None
install_id = data.get("install_id")
last_attempt_at = data.get("last_attempt_at")
if (
not isinstance(install_id, str)
or not INSTALL_ID_PATTERN.fullmatch(install_id)
or isinstance(last_attempt_at, bool)
or not isinstance(last_attempt_at, int | float)
):
logger.warning("Ignoring invalid analytics state at %s", path)
return None
return AnalyticsState(install_id=install_id, last_attempt_at=float(last_attempt_at))
def save_state(state: AnalyticsState, path: str = STATE_PATH) -> bool:
"""Write the state atomically, returning False when it can't be written."""
temp_path = f"{path}.tmp"
try:
with open(temp_path, "w") as f:
json.dump(
{
"install_id": state.install_id,
"last_attempt_at": state.last_attempt_at,
},
f,
)
os.replace(temp_path, path)
except OSError:
return False
return True
def delete_state(path: str = STATE_PATH) -> None:
"""Forget the install ID, so a later opt-in starts a fresh identity."""
try:
os.remove(path)
except FileNotFoundError:
pass
except OSError:
logger.warning("Unable to delete analytics state at %s", path)
+54
View File
@@ -0,0 +1,54 @@
"""POST a report to the ingest endpoint."""
import logging
from enum import Enum
import requests
from frigate.version import VERSION
logger = logging.getLogger(__name__)
TIMEOUT_S = 30
class SendOutcome(Enum):
accepted = "accepted"
rejected = "rejected"
rate_limited = "rate_limited"
failed = "failed"
def send_report(url: str, body: str) -> SendOutcome:
"""Send one report. Never raises; the outcome says what happened."""
try:
# a redirect would turn the POST into a GET, so it counts as a failure
response = requests.post(
url,
data=body.encode(),
headers={
"Content-Type": "application/json",
"User-Agent": f"Frigate/{VERSION}",
},
timeout=TIMEOUT_S,
allow_redirects=False,
)
except requests.RequestException as err:
logger.warning("Unable to send the analytics report: %s", err)
return SendOutcome.failed
status = response.status_code
if 200 <= status < 300:
return SendOutcome.accepted
if status == 400:
logger.warning("The analytics report was rejected: %s", response.text[:200])
return SendOutcome.rejected
if status == 429:
logger.debug("The analytics endpoint is rate limiting this install")
return SendOutcome.rate_limited
logger.warning("The analytics endpoint returned %s", status)
return SendOutcome.failed
+25
View File
@@ -0,0 +1,25 @@
"""Analytics APIs."""
import logging
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
from frigate.analytics.report import preview_report
from frigate.api.auth import require_role
from frigate.api.defs.tags import Tags
logger = logging.getLogger(__name__)
router = APIRouter(tags=[Tags.analytics])
@router.get("/analytics/preview", dependencies=[Depends(require_role(["admin"]))])
def get_analytics_preview(request: Request) -> JSONResponse:
"""Get the analytics report Frigate would send next, without sending it."""
report = preview_report(
request.app.frigate_config,
request.app.stats_emitter,
request.app.notice_registry,
)
return JSONResponse(content=report.model_dump(mode="json"))
-2
View File
@@ -429,8 +429,6 @@ def ffmpeg_presets():
hwaccel_presets = [ hwaccel_presets = [
"preset-rpi-64-h264", "preset-rpi-64-h264",
"preset-rpi-64-h265", "preset-rpi-64-h265",
"preset-apple-silicon-h264",
"preset-apple-silicon-h265",
"preset-jetson-h264", "preset-jetson-h264",
"preset-jetson-h265", "preset-jetson-h265",
"preset-rkmpp", "preset-rkmpp",
+1
View File
@@ -2,6 +2,7 @@ from enum import Enum
class Tags(Enum): class Tags(Enum):
analytics = "Analytics"
app = "App" app = "App"
auth = "Auth" auth = "Auth"
camera = "Camera" camera = "Camera"
+3 -1
View File
@@ -12,8 +12,8 @@ from slowapi.middleware import SlowAPIMiddleware
from starlette_context import middleware, plugins from starlette_context import middleware, plugins
from starlette_context.plugins import Plugin from starlette_context.plugins import Plugin
from frigate.api import app as main_app
from frigate.api import ( from frigate.api import (
analytics,
auth, auth,
camera, camera,
chat, chat,
@@ -30,6 +30,7 @@ from frigate.api import (
record, record,
review, review,
) )
from frigate.api import app as main_app
from frigate.api.auth import get_jwt_secret, limiter, require_admin_by_default from frigate.api.auth import get_jwt_secret, limiter, require_admin_by_default
from frigate.comms.dispatcher import Dispatcher from frigate.comms.dispatcher import Dispatcher
from frigate.comms.event_metadata_updater import ( from frigate.comms.event_metadata_updater import (
@@ -140,6 +141,7 @@ def create_fastapi_app(
# Routes # Routes
# Order of include_router matters: https://fastapi.tiangolo.com/tutorial/path-params/#order-matters # Order of include_router matters: https://fastapi.tiangolo.com/tutorial/path-params/#order-matters
app.include_router(analytics.router)
app.include_router(auth.router) app.include_router(auth.router)
app.include_router(camera.router) app.include_router(camera.router)
app.include_router(chat.router) app.include_router(chat.router)
+22 -50
View File
@@ -14,18 +14,17 @@ router = APIRouter(tags=[Tags.notices])
@router.get("/notices", dependencies=[Depends(require_role(["admin"]))]) @router.get("/notices", dependencies=[Depends(require_role(["admin"]))])
def get_notices(request: Request, include_hidden: bool = False) -> JSONResponse: def get_notices(request: Request, include_dismissed: bool = False) -> JSONResponse:
"""Get notices, most severe first. """Get notices, most severe first.
Args: Args:
include_hidden: Also return acknowledged and muted notices, for the include_dismissed: Also return dismissed notices, for the history view
hidden list
Returns: Returns:
The notices The notices
""" """
return JSONResponse( return JSONResponse(
content=request.app.notice_registry.active(include_hidden=include_hidden) content=request.app.notice_registry.active(include_dismissed=include_dismissed)
) )
@@ -35,64 +34,37 @@ def get_notice_stats(request: Request) -> JSONResponse:
return JSONResponse(content=request.app.notice_registry.stats()) return JSONResponse(content=request.app.notice_registry.stats())
@router.get("/notices/muted_checks", dependencies=[Depends(require_role(["admin"]))]) @router.get(
def get_muted_checks(request: Request) -> JSONResponse: "/notices/dismissed_checks", dependencies=[Depends(require_role(["admin"]))]
"""Get the muted config and stream check rows, newest first.""" )
return JSONResponse(content=request.app.notice_registry.muted_checks()) def get_dismissed_checks(request: Request) -> JSONResponse:
"""Get the dismissed config and stream check rows, newest first."""
return JSONResponse(content=request.app.notice_registry.dismissed_checks())
@router.delete("/notices/hidden", dependencies=[Depends(require_role(["admin"]))]) @router.delete("/notices/dismissed", dependencies=[Depends(require_role(["admin"]))])
def unhide_all_notices(request: Request) -> JSONResponse: def purge_dismissed(request: Request) -> JSONResponse:
"""Show every acknowledged and muted notice and check row again.""" """Delete every dismissed notice and check row so each can show again."""
request.app.notice_registry.unhide_all() request.app.notice_registry.purge_dismissed()
return JSONResponse(content={"success": True, "message": "Notices shown again"}) return JSONResponse(
content={"success": True, "message": "Dismissed notices cleared"}
)
# model notice ids contain a slash, so the id is a path parameter # model notice ids contain a slash, so the id is a path parameter
@router.post( @router.post(
"/notices/{notice_id:path}/acknowledge", "/notices/{notice_id:path}/dismiss",
dependencies=[Depends(require_role(["admin"]))], dependencies=[Depends(require_role(["admin"]))],
) )
def acknowledge_notice(request: Request, notice_id: str) -> JSONResponse: def dismiss_notice(request: Request, notice_id: str) -> JSONResponse:
"""Hide a notice until it happens again. """Hide a notice or a config or stream check row.
Config and stream check rows and the update notice never repeat, so they It stays hidden if the same problem happens again.
can only be muted.
""" """
if not request.app.notice_registry.acknowledge(notice_id): if not request.app.notice_registry.dismiss(notice_id):
return JSONResponse( return JSONResponse(
content={"success": False, "message": "Notice not found"}, content={"success": False, "message": "Notice not found"},
status_code=404, status_code=404,
) )
return JSONResponse(content={"success": True, "message": "Notice acknowledged"}) return JSONResponse(content={"success": True, "message": "Notice dismissed"})
@router.post(
"/notices/{notice_id:path}/mute",
dependencies=[Depends(require_role(["admin"]))],
)
def mute_notice(request: Request, notice_id: str) -> JSONResponse:
"""Hide a notice or a config or stream check row for good."""
if not request.app.notice_registry.mute(notice_id):
return JSONResponse(
content={"success": False, "message": "Notice not found"},
status_code=404,
)
return JSONResponse(content={"success": True, "message": "Notice muted"})
@router.delete(
"/notices/{notice_id:path}/hidden",
dependencies=[Depends(require_role(["admin"]))],
)
def unhide_notice(request: Request, notice_id: str) -> JSONResponse:
"""Show an acknowledged or muted notice or check row again."""
if not request.app.notice_registry.unhide(notice_id):
return JSONResponse(
content={"success": False, "message": "Notice not found"},
status_code=404,
)
return JSONResponse(content={"success": True, "message": "Notice shown again"})
+13
View File
@@ -15,6 +15,7 @@ import uvicorn
from peewee_migrate import Router from peewee_migrate import Router
from playhouse.sqlite_ext import SqliteExtDatabase from playhouse.sqlite_ext import SqliteExtDatabase
from frigate.analytics.reporter import AnalyticsReporter
from frigate.api.auth import hash_password from frigate.api.auth import hash_password
from frigate.api.fastapi_app import create_fastapi_app from frigate.api.fastapi_app import create_fastapi_app
from frigate.camera import CameraMetrics, PTZMetrics from frigate.camera import CameraMetrics, PTZMetrics
@@ -529,6 +530,15 @@ class FrigateApp:
) )
self.stats_emitter.start() self.stats_emitter.start()
def start_analytics_reporter(self) -> None:
self.analytics_reporter = AnalyticsReporter(
self.config_holder,
self.stats_emitter,
self.notice_registry,
self.stop_event,
)
self.analytics_reporter.start()
def start_watchdog(self) -> None: def start_watchdog(self) -> None:
self.frigate_watchdog = FrigateWatchdog(self.detectors, self.stop_event) self.frigate_watchdog = FrigateWatchdog(self.detectors, self.stop_event)
@@ -680,6 +690,7 @@ class FrigateApp:
self.start_audio_processor() self.start_audio_processor()
self.start_storage_maintainer() self.start_storage_maintainer()
self.start_stats_emitter() self.start_stats_emitter()
self.start_analytics_reporter()
self.start_timeline_processor() self.start_timeline_processor()
self.start_event_processor() self.start_event_processor()
self.start_event_cleanup() self.start_event_cleanup()
@@ -779,6 +790,8 @@ class FrigateApp:
self.event_cleanup.join() self.event_cleanup.join()
self.record_cleanup.join() self.record_cleanup.join()
self.stats_emitter.join() self.stats_emitter.join()
# a send in flight can hold the thread for the whole request timeout
self.analytics_reporter.join(timeout=5)
self.frigate_watchdog.join() self.frigate_watchdog.join()
self.camera_maintainer.join() self.camera_maintainer.join()
self.db.stop() self.db.stop()
+17
View File
@@ -1,9 +1,14 @@
"""Shared handle on the config object that is current for this instance.""" """Shared handle on the config object that is current for this instance."""
import logging
from collections.abc import Callable
from .config import FrigateConfig from .config import FrigateConfig
__all__ = ["ConfigHolder"] __all__ = ["ConfigHolder"]
logger = logging.getLogger(__name__)
class ConfigHolder: class ConfigHolder:
"""Indirection for the most recently parsed config. """Indirection for the most recently parsed config.
@@ -23,12 +28,24 @@ class ConfigHolder:
def __init__(self, config: FrigateConfig) -> None: def __init__(self, config: FrigateConfig) -> None:
self._config = config self._config = config
self._listeners: list[Callable[[FrigateConfig], None]] = []
@property @property
def config(self) -> FrigateConfig: def config(self) -> FrigateConfig:
"""The config as of the most recent successful save.""" """The config as of the most recent successful save."""
return self._config return self._config
def subscribe(self, listener: Callable[[FrigateConfig], None]) -> None:
"""Call listener on the saving thread with each config installed later."""
self._listeners.append(listener)
def set(self, config: FrigateConfig) -> None: def set(self, config: FrigateConfig) -> None:
"""Install a freshly parsed config as the current one.""" """Install a freshly parsed config as the current one."""
self._config = config self._config = config
for listener in self._listeners:
try:
listener(config)
except Exception:
# a listener bug must not fail the save that has already applied
logger.exception("Config listener failed")
+5
View File
@@ -29,6 +29,11 @@ class StatsConfig(FrigateBaseModel):
class TelemetryConfig(FrigateBaseModel): class TelemetryConfig(FrigateBaseModel):
analytics: bool = Field(
default=False,
title="Share anonymous analytics",
description="Send one anonymous usage report a day to help the Frigate maintainers decide what to support. Nothing is sent until this is on.",
)
network_interfaces: list[str] = Field( network_interfaces: list[str] = Field(
default=[], default=[],
title="Network interfaces", title="Network interfaces",
+3
View File
@@ -101,6 +101,9 @@ MAX_WAL_SIZE = 10 # MB
DEFAULT_FFMPEG_VERSION = os.environ.get("DEFAULT_FFMPEG_VERSION", "") DEFAULT_FFMPEG_VERSION = os.environ.get("DEFAULT_FFMPEG_VERSION", "")
INCLUDED_FFMPEG_VERSIONS = os.environ.get("INCLUDED_FFMPEG_VERSIONS", "").split(":") INCLUDED_FFMPEG_VERSIONS = os.environ.get("INCLUDED_FFMPEG_VERSIONS", "").split(":")
ANALYTICS_URL = os.environ.get(
"FRIGATE_ANALYTICS_URL", "https://analytics.frigate.video/report"
)
LIBAVFORMAT_VERSION_MAJOR = int(os.environ.get("LIBAVFORMAT_VERSION_MAJOR", "59")) LIBAVFORMAT_VERSION_MAJOR = int(os.environ.get("LIBAVFORMAT_VERSION_MAJOR", "59"))
FFMPEG_HWACCEL_NVIDIA = "preset-nvidia" FFMPEG_HWACCEL_NVIDIA = "preset-nvidia"
FFMPEG_HWACCEL_VAAPI = "preset-vaapi" FFMPEG_HWACCEL_VAAPI = "preset-vaapi"
-51
View File
@@ -46,42 +46,8 @@ _PROVIDER_LABELS = {
"MIGraphXExecutionProvider": "MIGraphX", "MIGraphXExecutionProvider": "MIGraphX",
"OpenVINOExecutionProvider": "OpenVINO", "OpenVINOExecutionProvider": "OpenVINO",
"CPUExecutionProvider": "CPU", "CPUExecutionProvider": "CPU",
"LighterANE": "Neural Engine",
} }
# lighter (https://github.com/fieldwork-ai/lighter) places an ONNX Runtime plugin
# execution provider in a container started with --device lighter.sh/ane=all,
# which runs models on a Mac's Neural Engine; LIGHTER_ANE_EP names where it is
LIGHTER_ANE_EP_NAME = "LighterANE"
LIGHTER_ANE_LIBRARY = "/usr/lib/lighter/liblighter_ane_ep.so"
def get_lighter_ane_devices() -> list[Any]:
"""Get the Neural Engine devices lighter's provider offers, registering it once.
Returns:
The provider's ONNX Runtime devices, or an empty list without lighter's device
"""
library = os.environ.get("LIGHTER_ANE_EP", LIGHTER_ANE_LIBRARY)
if not os.path.exists(library):
return []
devices = [d for d in ort.get_ep_devices() if d.ep_name == LIGHTER_ANE_EP_NAME]
if not devices:
try:
ort.register_execution_provider_library(LIGHTER_ANE_EP_NAME, library)
except Exception as e:
logger.warning(
f"Failed to load the Neural Engine provider from {library}: {e}"
)
return []
devices = [d for d in ort.get_ep_devices() if d.ep_name == LIGHTER_ANE_EP_NAME]
return devices
def is_arm64_platform() -> bool: def is_arm64_platform() -> bool:
"""Check if we're running on an ARM platform.""" """Check if we're running on an ARM platform."""
@@ -723,23 +689,6 @@ def get_optimized_runner(
if rknn_path: if rknn_path:
return _record_runner(model_path, model_type, RKNNModelRunner(rknn_path)) return _record_runner(model_path, model_type, RKNNModelRunner(rknn_path))
if device != "CPU" and (ane_devices := get_lighter_ane_devices()):
sess_options = get_ort_session_options(model_type) or ort.SessionOptions()
sess_options.add_provider_for_devices(ane_devices, {})
try:
session = ort.InferenceSession(model_path, sess_options=sess_options)
except Exception as e:
logger.warning(
f"Failed to load {model_path} on the Neural Engine, using the default providers: {e}"
)
else:
return _record_runner(
model_path,
model_type,
ONNXModelRunner(session, model_type=model_type),
)
providers, options = get_ort_providers(device == "CPU", device, **kwargs) providers, options = get_ort_providers(device == "CPU", device, **kwargs)
if providers[0] == "CPUExecutionProvider": if providers[0] == "CPUExecutionProvider":
-15
View File
@@ -25,7 +25,6 @@ SYS_ROOT = "/sys"
DEV_ROOT = "/dev" DEV_ROOT = "/dev"
PROC_ROOT = "/proc" PROC_ROOT = "/proc"
ETC_ROOT = "/etc" ETC_ROOT = "/etc"
LIB_ROOT = "/usr/lib"
# a Coral reports as Global Unichip until its firmware is loaded, then as Google # a Coral reports as Global Unichip until its firmware is loaded, then as Google
CORAL_USB_IDS = {("1a6e", "089a"), ("18d1", "9302")} CORAL_USB_IDS = {("1a6e", "089a"), ("18d1", "9302")}
@@ -318,19 +317,6 @@ def detect_synaptics() -> DetectionHardware | None:
return _hardware("synaptics", "synaptics", "Synaptics NPU", units) return _hardware("synaptics", "synaptics", "Synaptics NPU", units)
def detect_lighter_ane() -> DetectionHardware | None:
"""Find a Mac's Neural Engine by the provider library lighter's device places."""
library = os.environ.get(
"LIGHTER_ANE_EP", f"{LIB_ROOT}/lighter/liblighter_ane_ep.so"
)
if not os.path.exists(library):
return None
# runs through onnx, whose session picks lighter's provider when it is present
units = [HardwareUnit(device="onnx", label="Neural Engine")]
return _hardware("onnx:lighter", "onnx", "Apple Neural Engine", units)
def detect_cpu() -> DetectionHardware: def detect_cpu() -> DetectionHardware:
"""The CPU, which is always available.""" """The CPU, which is always available."""
units = [HardwareUnit(device="cpu", label="CPU")] units = [HardwareUnit(device="cpu", label="CPU")]
@@ -352,7 +338,6 @@ PROBES = (
detect_rockchip, detect_rockchip,
detect_axengine, detect_axengine,
detect_synaptics, detect_synaptics,
detect_lighter_ane,
detect_cpu, detect_cpu,
) )
+1 -2
View File
@@ -1,7 +1,7 @@
import json import json
import logging import logging
import os import os
from typing import Any, ClassVar, Literal from typing import Any, Literal
import numpy as np import numpy as np
import zmq import zmq
@@ -21,7 +21,6 @@ class ZmqDetectorConfig(BaseDetectorConfig):
model_config = ConfigDict( model_config = ConfigDict(
title="ZMQ IPC", title="ZMQ IPC",
) )
device_spec_field: ClassVar[str] = "endpoint"
type: Literal[DETECTOR_KEY] type: Literal[DETECTOR_KEY]
endpoint: str = Field( endpoint: str = Field(
-9
View File
@@ -84,8 +84,6 @@ _user_agent_args = [
PRESETS_HW_ACCEL_DECODE = { PRESETS_HW_ACCEL_DECODE = {
"preset-rpi-64-h264": "-c:v:1 h264_v4l2m2m", "preset-rpi-64-h264": "-c:v:1 h264_v4l2m2m",
"preset-rpi-64-h265": "-c:v:1 hevc_v4l2m2m", "preset-rpi-64-h265": "-c:v:1 hevc_v4l2m2m",
"preset-apple-silicon-h264": "-c:v h264_v4l2m2m",
"preset-apple-silicon-h265": "-c:v hevc_v4l2m2m",
FFMPEG_HWACCEL_VAAPI: "-hwaccel_flags allow_profile_mismatch -hwaccel vaapi -hwaccel_device {3} -hwaccel_output_format vaapi", FFMPEG_HWACCEL_VAAPI: "-hwaccel_flags allow_profile_mismatch -hwaccel vaapi -hwaccel_device {3} -hwaccel_output_format vaapi",
"preset-intel-qsv-h264": f"-hwaccel qsv -qsv_device {{3}} -hwaccel_output_format qsv -c:v h264_qsv{' -bsf:v dump_extra' if LIBAVFORMAT_VERSION_MAJOR >= 61 else ''}", # https://trac.ffmpeg.org/ticket/9766#comment:17 "preset-intel-qsv-h264": f"-hwaccel qsv -qsv_device {{3}} -hwaccel_output_format qsv -c:v h264_qsv{' -bsf:v dump_extra' if LIBAVFORMAT_VERSION_MAJOR >= 61 else ''}", # https://trac.ffmpeg.org/ticket/9766#comment:17
"preset-intel-qsv-h265": f"-load_plugin hevc_hw -hwaccel qsv -qsv_device {{3}} -hwaccel_output_format qsv{' -bsf:v dump_extra' if LIBAVFORMAT_VERSION_MAJOR >= 61 else ''}", # https://trac.ffmpeg.org/ticket/9766#comment:17 "preset-intel-qsv-h265": f"-load_plugin hevc_hw -hwaccel qsv -qsv_device {{3}} -hwaccel_output_format qsv{' -bsf:v dump_extra' if LIBAVFORMAT_VERSION_MAJOR >= 61 else ''}", # https://trac.ffmpeg.org/ticket/9766#comment:17
@@ -122,9 +120,6 @@ PRESETS_HW_ACCEL_DECODE["preset-rk-h265"] = PRESETS_HW_ACCEL_DECODE[
PRESETS_HW_ACCEL_SCALE = { PRESETS_HW_ACCEL_SCALE = {
"preset-rpi-64-h264": "-r {0} -vf fps={0},scale={1}:{2}", "preset-rpi-64-h264": "-r {0} -vf fps={0},scale={1}:{2}",
"preset-rpi-64-h265": "-r {0} -vf fps={0},scale={1}:{2}", "preset-rpi-64-h265": "-r {0} -vf fps={0},scale={1}:{2}",
# ffmpeg's v4l2m2m decoders cannot scale, so frames are scaled on the CPU
"preset-apple-silicon-h264": "-r {0} -vf fps={0},scale={1}:{2}",
"preset-apple-silicon-h265": "-r {0} -vf fps={0},scale={1}:{2}",
FFMPEG_HWACCEL_VAAPI: "-r {0} -vf fps={0},scale_vaapi=w={1}:h={2},hwdownload,format=nv12", FFMPEG_HWACCEL_VAAPI: "-r {0} -vf fps={0},scale_vaapi=w={1}:h={2},hwdownload,format=nv12",
"preset-intel-qsv-h264": "-r {0} -vf vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,fps={0},format=yuv420p", "preset-intel-qsv-h264": "-r {0} -vf vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,fps={0},format=yuv420p",
"preset-intel-qsv-h265": "-r {0} -vf vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,fps={0},format=yuv420p", "preset-intel-qsv-h265": "-r {0} -vf vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,fps={0},format=yuv420p",
@@ -155,8 +150,6 @@ PRESETS_HW_ACCEL_SCALE["preset-rk-h265"] = PRESETS_HW_ACCEL_SCALE[FFMPEG_HWACCEL
PRESETS_HW_ACCEL_ENCODE_BIRDSEYE = { PRESETS_HW_ACCEL_ENCODE_BIRDSEYE = {
"preset-rpi-64-h264": "{0} -hide_banner {1} -c:v h264_v4l2m2m {2}", "preset-rpi-64-h264": "{0} -hide_banner {1} -c:v h264_v4l2m2m {2}",
"preset-rpi-64-h265": "{0} -hide_banner {1} -c:v hevc_v4l2m2m {2}", "preset-rpi-64-h265": "{0} -hide_banner {1} -c:v hevc_v4l2m2m {2}",
"preset-apple-silicon-h264": "{0} -hide_banner {1} -c:v h264_v4l2m2m {2}",
"preset-apple-silicon-h265": "{0} -hide_banner {1} -c:v h264_v4l2m2m {2}",
# -vaapi_device is required in addition to -hwaccel_device: this is the only # -vaapi_device is required in addition to -hwaccel_device: this is the only
# birdseye preset that uses hwupload, and ffmpeg 8 initializes filters before # birdseye preset that uses hwupload, and ffmpeg 8 initializes filters before
# the decoder creates a device, so hwupload cannot see an -hwaccel_device one. # the decoder creates a device, so hwupload cannot see an -hwaccel_device one.
@@ -191,8 +184,6 @@ PRESETS_HW_ACCEL_ENCODE_BIRDSEYE["preset-rk-h264"] = PRESETS_HW_ACCEL_ENCODE_BIR
PRESETS_HW_ACCEL_ENCODE_TIMELAPSE = { PRESETS_HW_ACCEL_ENCODE_TIMELAPSE = {
"preset-rpi-64-h264": "{0} -hide_banner {1} -c:v h264_v4l2m2m -pix_fmt yuv420p {2}", "preset-rpi-64-h264": "{0} -hide_banner {1} -c:v h264_v4l2m2m -pix_fmt yuv420p {2}",
"preset-rpi-64-h265": "{0} -hide_banner {1} -c:v hevc_v4l2m2m -pix_fmt yuv420p {2}", "preset-rpi-64-h265": "{0} -hide_banner {1} -c:v hevc_v4l2m2m -pix_fmt yuv420p {2}",
"preset-apple-silicon-h264": "{0} -hide_banner {1} -c:v h264_v4l2m2m -pix_fmt yuv420p {2}",
"preset-apple-silicon-h265": "{0} -hide_banner {1} -c:v hevc_v4l2m2m -pix_fmt yuv420p {2}",
FFMPEG_HWACCEL_VAAPI: "{0} -hide_banner -hwaccel vaapi -hwaccel_output_format vaapi -hwaccel_device {3} {1} -c:v h264_vaapi {2}", FFMPEG_HWACCEL_VAAPI: "{0} -hide_banner -hwaccel vaapi -hwaccel_output_format vaapi -hwaccel_device {3} {1} -c:v h264_vaapi {2}",
"preset-intel-qsv-h264": "{0} -hide_banner {1} -c:v h264_qsv -profile:v high -level:v 4.1 -async_depth:v 1 {2}", "preset-intel-qsv-h264": "{0} -hide_banner {1} -c:v h264_qsv -profile:v high -level:v 4.1 -async_depth:v 1 {2}",
"preset-intel-qsv-h265": "{0} -hide_banner {1} -c:v hevc_qsv -profile:v main -level:v 4.1 -async_depth:v 1 {2}", "preset-intel-qsv-h265": "{0} -hide_banner {1} -c:v hevc_qsv -profile:v main -level:v 4.1 -async_depth:v 1 {2}",
+3 -8
View File
@@ -195,20 +195,15 @@ class Notice(Model):
first_seen = DateTimeField() first_seen = DateTimeField()
last_seen = DateTimeField() last_seen = DateTimeField()
count = IntegerField(default=1) count = IntegerField(default=1)
# hidden until the next occurrence dismissed_at = DateTimeField(null=True)
acknowledged_at = DateTimeField(null=True)
# hidden for good
muted_at = DateTimeField(null=True)
class NoticeStats(Model): class NoticeStats(Model):
kind = CharField(null=False, primary_key=True, max_length=50) kind = CharField(null=False, primary_key=True, max_length=50)
occurrences = IntegerField(default=0) occurrences = IntegerField(default=0)
acknowledgements = IntegerField(default=0) dismissals = IntegerField(default=0)
mutes = IntegerField(default=0)
first_seen = DateTimeField() first_seen = DateTimeField()
last_seen = DateTimeField() last_seen = DateTimeField()
# watermarks for a future analytics reporter; unused until then # watermarks for a future analytics reporter; unused until then
reported_occurrences = IntegerField(default=0) reported_occurrences = IntegerField(default=0)
reported_acknowledgements = IntegerField(default=0) reported_dismissals = IntegerField(default=0)
reported_mutes = IntegerField(default=0)
+54 -108
View File
@@ -5,7 +5,7 @@ import threading
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from typing import Any, cast from typing import Any
from frigate.const import REPLAY_CAMERA_PREFIX from frigate.const import REPLAY_CAMERA_PREFIX
from frigate.models import Notice, NoticeStats from frigate.models import Notice, NoticeStats
@@ -77,8 +77,9 @@ class NoticeRegistry:
) -> None: ) -> None:
"""Insert a notice or count another occurrence of it. """Insert a notice or count another occurrence of it.
Another occurrence shows an acknowledged notice again. A muted notice A dismissed notice stays dismissed when it is raised again, unless its
stays hidden. kind sets reopen_at_count. A kind that should come back after a
dismissal gives each episode its own scope.
""" """
definition = NOTICE_KINDS.get(kind) definition = NOTICE_KINDS.get(kind)
@@ -111,6 +112,7 @@ class NoticeRegistry:
first_seen=now, first_seen=now,
last_seen=now, last_seen=now,
count=1, count=1,
dismissed_at=None,
) )
self._bump_occurrences(kind, 1, now) self._bump_occurrences(kind, 1, now)
@@ -173,7 +175,7 @@ class NoticeRegistry:
self._notify() self._notify()
def resolve_camera(self, camera: str) -> None: def resolve_camera(self, camera: str) -> None:
"""Drop the notices and check mutes of a camera being deleted.""" """Drop the notices and check dismissals of a camera being deleted."""
camera_kinds = [ camera_kinds = [
key key
for key, definition in NOTICE_KINDS.items() for key, definition in NOTICE_KINDS.items()
@@ -191,7 +193,7 @@ class NoticeRegistry:
) )
# a stream id names its camera first; a config id ends with camera.<name> # a stream id names its camera first; a config id ends with camera.<name>
for check in self.muted_checks(): for check in self.dismissed_checks():
check_id = check["id"] check_id = check["id"]
if check_id.startswith(f"stream:{camera}:") or ( if check_id.startswith(f"stream:{camera}:") or (
@@ -203,50 +205,26 @@ class NoticeRegistry:
if deleted: if deleted:
self._notify() self._notify()
def acknowledge(self, row_id: str) -> bool: def purge_dismissed(self) -> int:
"""Hide a notice until it happens again. """Delete every dismissed row so each can show again. Returns how many."""
Returns False for an unknown id or a kind that never repeats, such as
a check row or the update notice.
"""
with self._lock: with self._lock:
existing = Notice.get_or_none(Notice.id == row_id) return int(
Notice.delete().where(Notice.dismissed_at.is_null(False)).execute()
)
if existing is None: def dismiss(self, row_id: str) -> bool:
return False
definition = NOTICE_KINDS.get(existing.kind)
if definition is None or not definition.counts_repeats:
return False
if existing.acknowledged_at is not None or existing.muted_at is not None:
return True
Notice.update(acknowledged_at=datetime.now().timestamp()).where(
Notice.id == row_id
).execute()
NoticeStats.update(acknowledgements=NoticeStats.acknowledgements + 1).where(
NoticeStats.kind == existing.kind
).execute()
self._notify()
return True
def mute(self, row_id: str) -> bool:
"""Hide a notice or check row for good. Returns False for an unknown id.""" """Hide a notice or check row for good. Returns False for an unknown id."""
now = datetime.now().timestamp()
with self._lock: with self._lock:
existing = Notice.get_or_none(Notice.id == row_id) existing = Notice.get_or_none(Notice.id == row_id)
if existing is None: if existing is None:
# a check row gets a notice row only once it is muted # a check row gets a notice row only once it is dismissed
kind, _, scope = row_id.partition(":") kind, _, scope = row_id.partition(":")
if kind not in CHECK_KINDS or not scope: if kind not in CHECK_KINDS or not scope:
return False return False
now = datetime.now().timestamp()
Notice.create( Notice.create(
id=row_id, id=row_id,
kind=kind, kind=kind,
@@ -255,78 +233,40 @@ class NoticeRegistry:
first_seen=now, first_seen=now,
last_seen=now, last_seen=now,
count=1, count=1,
muted_at=now, dismissed_at=now,
) )
return True return True
if existing.muted_at is not None: if existing.dismissed_at is not None:
return True return True
if existing.kind not in NOTICE_KINDS: if existing.kind not in NOTICE_KINDS:
return False return False
Notice.update(acknowledged_at=None, muted_at=now).where( Notice.update(dismissed_at=datetime.now().timestamp()).where(
Notice.id == row_id Notice.id == row_id
).execute() ).execute()
NoticeStats.update(mutes=NoticeStats.mutes + 1).where( NoticeStats.update(dismissals=NoticeStats.dismissals + 1).where(
NoticeStats.kind == existing.kind NoticeStats.kind == existing.kind
).execute() ).execute()
self._notify() self._notify()
return True return True
def unhide(self, row_id: str) -> bool: def dismissed_checks(self) -> list[dict[str, Any]]:
"""Show an acknowledged or muted row again. Returns False for an unknown id.""" """Dismissed config and stream check rows, newest first."""
with self._lock:
existing = Notice.get_or_none(Notice.id == row_id)
if existing is None:
return False
if existing.kind in CHECK_KINDS:
Notice.delete_by_id(row_id)
return True
Notice.update(acknowledged_at=None, muted_at=None).where(
Notice.id == row_id
).execute()
self._notify()
return True
def unhide_all(self) -> None:
"""Show every acknowledged and muted row again."""
with self._lock:
for check in self.muted_checks():
Notice.delete_by_id(check["id"])
shown = (
Notice.update(acknowledged_at=None, muted_at=None)
.where(
Notice.acknowledged_at.is_null(False)
| Notice.muted_at.is_null(False)
)
.execute()
)
if shown:
self._notify()
def muted_checks(self) -> list[dict[str, Any]]:
"""Muted config and stream check rows, newest first."""
rows = ( rows = (
Notice.select() Notice.select()
.where(Notice.kind.in_(list(CHECK_KINDS))) .where(Notice.kind.in_(list(CHECK_KINDS)))
.order_by(Notice.muted_at.desc()) .order_by(Notice.dismissed_at.desc())
) )
return [{"id": row.id, "muted_at": row.muted_at} for row in rows] return [{"id": row.id, "dismissed_at": row.dismissed_at} for row in rows]
def active(self, include_hidden: bool = False) -> list[dict[str, Any]]: def active(self, include_dismissed: bool = False) -> list[dict[str, Any]]:
"""Notices most severe first, then most recent first. """Notices most severe first, then most recent first.
Args: Args:
include_hidden: Also return acknowledged and muted notices, for the include_dismissed: Also return dismissed notices, for the history view
hidden list
""" """
rows = [] rows = []
@@ -336,9 +276,7 @@ class NoticeRegistry:
if definition is None: if definition is None:
continue continue
hidden = row.acknowledged_at is not None or row.muted_at is not None if row.dismissed_at is not None and not include_dismissed:
if hidden and not include_hidden:
continue continue
rows.append( rows.append(
@@ -353,9 +291,7 @@ class NoticeRegistry:
"first_seen": row.first_seen, "first_seen": row.first_seen,
"last_seen": row.last_seen, "last_seen": row.last_seen,
"count": row.count, "count": row.count,
"acknowledgeable": definition.counts_repeats, "dismissed_at": row.dismissed_at,
"acknowledged_at": row.acknowledged_at,
"muted_at": row.muted_at,
} }
) )
@@ -373,18 +309,29 @@ class NoticeRegistry:
{ {
"kind": row.kind, "kind": row.kind,
"occurrences": row.occurrences, "occurrences": row.occurrences,
"acknowledgements": row.acknowledgements, "dismissals": row.dismissals,
"mutes": row.mutes,
"first_seen": row.first_seen, "first_seen": row.first_seen,
"last_seen": row.last_seen, "last_seen": row.last_seen,
"reported_occurrences": row.reported_occurrences, "reported_occurrences": row.reported_occurrences,
"reported_acknowledgements": row.reported_acknowledgements, "reported_dismissals": row.reported_dismissals,
"reported_mutes": row.reported_mutes,
} }
for row in NoticeStats.select() for row in NoticeStats.select()
if row.kind in NOTICE_KINDS if row.kind in NOTICE_KINDS
] ]
def mark_reported(self, snapshot: list[dict[str, Any]]) -> None:
"""Move the analytics watermarks to the counts a sent report was built from.
Using the snapshot rather than the current counts sends anything raised
while the report was in flight with the next one.
"""
with self._lock:
for row in snapshot:
NoticeStats.update(
reported_occurrences=row["occurrences"],
reported_dismissals=row["dismissals"],
).where(NoticeStats.kind == row["kind"]).execute()
def _write_repeats( def _write_repeats(
self, self,
row: Notice, row: Notice,
@@ -393,18 +340,18 @@ class NoticeRegistry:
last_seen: float, last_seen: float,
params: dict[str, Any], params: dict[str, Any],
) -> None: ) -> None:
# called with the lock held; held repeats from before an acknowledgement # called with the lock held
# still count but leave the notice hidden fields: dict[str, Any] = {
still_acknowledged = row.acknowledged_at is not None and ( "count": row.count + count,
last_seen <= cast(float, row.acknowledged_at) "last_seen": last_seen,
) "params": params,
}
reopen_at = NOTICE_KINDS[kind].reopen_at_count
Notice.update( if reopen_at is not None and row.count < reopen_at <= row.count + count:
count=row.count + count, fields["dismissed_at"] = None
last_seen=last_seen,
params=params, Notice.update(**fields).where(Notice.id == row.id).execute()
acknowledged_at=row.acknowledged_at if still_acknowledged else None,
).where(Notice.id == row.id).execute()
self._bump_occurrences(kind, count, last_seen) self._bump_occurrences(kind, count, last_seen)
def _prune(self, kind: str, keep: int) -> None: def _prune(self, kind: str, keep: int) -> None:
@@ -428,8 +375,7 @@ class NoticeRegistry:
NoticeStats.create( NoticeStats.create(
kind=kind, kind=kind,
occurrences=count, occurrences=count,
acknowledgements=0, dismissals=0,
mutes=0,
first_seen=now, first_seen=now,
last_seen=now, last_seen=now,
) )
+16 -10
View File
@@ -28,9 +28,9 @@ class NoticeKind:
category: camera, detector, model, or system; a camera scope is a category: camera, detector, model, or system; a camera scope is a
camera name, and the UI shows it camera name, and the UI shows it
link: app route or absolute URL for the row, filled in from params link: app route or absolute URL for the row, filled in from params
counts_repeats: whether raising an existing notice counts another counts_repeats: whether raising an existing notice counts another occurrence
occurrence, which also shows an acknowledged notice again
batch_repeats: whether repeats wait in memory for the next flush batch_repeats: whether repeats wait in memory for the next flush
reopen_at_count: count at which a dismissed notice shows again
keep_latest: rows of this kind to keep; a new row drops the oldest keep_latest: rows of this kind to keep; a new row drops the oldest
reportable: whether a future analytics reporter may send this kind's counts reportable: whether a future analytics reporter may send this kind's counts
""" """
@@ -41,6 +41,7 @@ class NoticeKind:
link: str | None = None link: str | None = None
counts_repeats: bool = True counts_repeats: bool = True
batch_repeats: bool = False batch_repeats: bool = False
reopen_at_count: int | None = None
keep_latest: int | None = None keep_latest: int | None = None
reportable: bool = True reportable: bool = True
@@ -66,12 +67,6 @@ _KINDS = (
"camera", "camera",
link="/system#cameras", link="/system#cameras",
), ),
NoticeKind(
"ffmpeg_high_cpu", NoticeSeverity.warning, "camera", link="/system#cameras"
),
NoticeKind(
"detect_high_cpu", NoticeSeverity.warning, "camera", link="/system#cameras"
),
NoticeKind("shm_too_low", NoticeSeverity.warning, "system", link="/system#storage"), NoticeKind("shm_too_low", NoticeSeverity.warning, "system", link="/system#storage"),
# one row per user per burst; the login log lines carry the address # one row per user per burst; the login log lines carry the address
NoticeKind( NoticeKind(
@@ -80,9 +75,10 @@ _KINDS = (
"system", "system",
link="/logs", link="/logs",
batch_repeats=True, batch_repeats=True,
reopen_at_count=5,
keep_latest=100, keep_latest=100,
), ),
# one row per release, so muting it lasts until the next release # one row per release, so a dismissal lasts until the next release
NoticeKind( NoticeKind(
"update_available", "update_available",
NoticeSeverity.info, NoticeSeverity.info,
@@ -90,13 +86,23 @@ _KINDS = (
link="https://github.com/blakeblackshear/frigate/releases/tag/v{version}", link="https://github.com/blakeblackshear/frigate/releases/tag/v{version}",
counts_repeats=False, counts_repeats=False,
keep_latest=1, keep_latest=1,
reportable=False,
),
# raised while analytics is off; the row keeps a dismissal across restarts
NoticeKind(
"analytics_prompt",
NoticeSeverity.info,
"system",
link="/settings?page=systemTelemetry",
counts_repeats=False,
reportable=False,
), ),
) )
NOTICE_KINDS: dict[str, NoticeKind] = {kind.key: kind for kind in _KINDS} NOTICE_KINDS: dict[str, NoticeKind] = {kind.key: kind for kind in _KINDS}
# the Health tab builds config and stream check rows in the browser, so a notice # the Health tab builds config and stream check rows in the browser, so a notice
# row of these kinds only records a mute; its other fields are placeholders # row of these kinds only records a dismissal; its other fields are placeholders
CHECK_KINDS = frozenset({"config", "stream"}) CHECK_KINDS = frozenset({"config", "stream"})
+15 -61
View File
@@ -29,59 +29,38 @@ VERSION_REFRESH_S = 24 * 60 * 60
# a camera skipping at least this percent of its frames is falling behind # a camera skipping at least this percent of its frames is falling behind
SKIPPED_DETECTIONS_PCT = 5 SKIPPED_DETECTIONS_PCT = 5
# lifetime CPU averages at or above these are high; they match # for at least this long before it becomes a notice
# CameraFfmpegThreshold.error and CameraDetectThreshold.error in the UI SKIPPED_DETECTIONS_HOLD_S = 60
FFMPEG_HIGH_CPU_PCT = 20
DETECT_HIGH_CPU_PCT = 40
# a camera stays over a threshold this long before it becomes a notice
EPISODE_HOLD_S = 60
# detectors warm up after a start; the status bar waits this long too # detectors warm up after a start; the status bar waits this long too
STARTUP_GRACE_S = 120 STARTUP_GRACE_S = 120
def cpu_average(cpu_usages: dict[str, Any], pid: int | None) -> float | None: class SkippedDetectionsTracker:
"""A process's CPU use averaged over its lifetime, or None if unknown.""" """Finds cameras whose skipped share stays high long enough for a notice."""
usage = cpu_usages.get(str(pid)) if pid else None
try: def __init__(self) -> None:
return float(usage["cpu_average"]) if usage else None
except (KeyError, TypeError, ValueError):
return None
class EpisodeTracker:
"""Finds cameras whose value stays over a threshold long enough for a notice."""
def __init__(self, threshold: float) -> None:
self._threshold = threshold
self._since: dict[str, float] = {} self._since: dict[str, float] = {}
self._raised: set[str] = set() self._raised: set[str] = set()
def update(self, values: dict[str, float | None], now: float) -> list[str]: def update(self, cameras: dict[str, dict[str, Any]], now: float) -> list[str]:
"""Return the cameras whose episode qualified on this sample. """Return the cameras whose episode qualified on this sample."""
Args:
values: Each camera's current value, or None when it has none
now: Sample time in seconds
"""
qualified: list[str] = [] qualified: list[str] = []
# a removed camera that comes back starts a new episode # a removed camera that comes back starts a new episode
for camera in self._since.keys() - values.keys(): for camera in self._since.keys() - cameras.keys():
self._since.pop(camera) self._since.pop(camera)
self._raised.discard(camera) self._raised.discard(camera)
for camera, value in values.items(): for camera, camera_stats in cameras.items():
if value is None or value < self._threshold: if camera_stats["skipped_pct"] < SKIPPED_DETECTIONS_PCT:
self._since.pop(camera, None) self._since.pop(camera, None)
self._raised.discard(camera) self._raised.discard(camera)
continue continue
since = self._since.setdefault(camera, now) since = self._since.setdefault(camera, now)
if camera not in self._raised and now - since >= EPISODE_HOLD_S: if camera not in self._raised and now - since >= SKIPPED_DETECTIONS_HOLD_S:
self._raised.add(camera) self._raised.add(camera)
qualified.append(camera) qualified.append(camera)
@@ -101,9 +80,7 @@ class StatsEmitter(threading.Thread):
self.stop_event = stop_event self.stop_event = stop_event
self.hardware_stats = HardwareStats(config) self.hardware_stats = HardwareStats(config)
self.stats_history: list[dict[str, Any]] = [] self.stats_history: list[dict[str, Any]] = []
self.skipped_detections = EpisodeTracker(SKIPPED_DETECTIONS_PCT) self.skipped_detections = SkippedDetectionsTracker()
self.ffmpeg_cpu = EpisodeTracker(FFMPEG_HIGH_CPU_PCT)
self.detect_cpu = EpisodeTracker(DETECT_HIGH_CPU_PCT)
# the shm notice's params as last sent, so only a change is written # the shm notice's params as last sent, so only a change is written
self._shm_checked = False self._shm_checked = False
@@ -250,38 +227,15 @@ class StatsEmitter(threading.Thread):
def _update_notices(self, stats: dict[str, Any], now: float) -> None: def _update_notices(self, stats: dict[str, Any], now: float) -> None:
"""Update notices based on current stats or time.""" """Update notices based on current stats or time."""
cameras = stats["cameras"]
# absent when CPU collection timed out or failed on this tick
cpu_usages = stats.get("cpu_usages", {})
if stats["service"]["uptime"] >= STARTUP_GRACE_S:
# skipped detections # skipped detections
skipped = { if stats["service"]["uptime"] >= STARTUP_GRACE_S:
camera: camera_stats["skipped_pct"] for camera in self.skipped_detections.update(stats["cameras"], now):
for camera, camera_stats in cameras.items()
}
for camera in self.skipped_detections.update(skipped, now):
raise_notice( raise_notice(
"skipped_detections", "skipped_detections",
scope=camera, scope=camera,
params={"pct": skipped[camera]}, params={"pct": stats["cameras"][camera]["skipped_pct"]},
) )
# high ffmpeg and detect CPU
for tracker, kind, pid_key in (
(self.ffmpeg_cpu, "ffmpeg_high_cpu", "ffmpeg_pid"),
(self.detect_cpu, "detect_high_cpu", "pid"),
):
averages = {
camera: cpu_average(cpu_usages, camera_stats.get(pid_key))
for camera, camera_stats in cameras.items()
}
for camera in tracker.update(averages, now):
raise_notice(kind, scope=camera, params={"cpu": averages[camera]})
# shm too small for the cameras # shm too small for the cameras
self._update_shm_notice(stats["service"]["storage"]["/dev/shm"]) self._update_shm_notice(stats["service"]["storage"]["/dev/shm"])
+36
View File
@@ -0,0 +1,36 @@
"""Shared fixtures for the analytics tests."""
from typing import Any
from frigate.analytics.context import ReportContext
from frigate.config import FrigateConfig
FRONT_CAMERA: dict[str, Any] = {
"ffmpeg": {"inputs": [{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}]},
"detect": {"height": 720, "width": 1280, "fps": 5},
}
def make_config(config: dict[str, Any] | None = None) -> FrigateConfig:
"""A parsed config; top-level keys in config replace the defaults here.
hwaccel_args is set so parsing never probes the test host's GPU.
"""
base: dict[str, Any] = {
"mqtt": {"host": "mqtt"},
"ffmpeg": {"hwaccel_args": []},
"cameras": {"front": FRONT_CAMERA},
}
return FrigateConfig(**{**base, **(config or {})})
def make_context(
config: FrigateConfig | None = None,
stats: dict[str, Any] | None = None,
notice_stats: list[dict[str, Any]] | None = None,
) -> ReportContext:
return ReportContext(
config=config or make_config(),
stats=stats or {},
notice_stats=notice_stats or [],
)
@@ -0,0 +1,49 @@
"""Tests for the analytics preview API."""
from unittest.mock import Mock, patch
from frigate.analytics import report
from frigate.analytics.collectors import hardware
from frigate.models import Notice, NoticeStats, User
from frigate.notices.registry import NoticeRegistry
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
class TestHttpAnalytics(BaseTestHttp):
def setUp(self):
super().setUp([Notice, NoticeStats, User])
stats = Mock()
stats.get_latest_stats.return_value = self.test_stats
self.app = self.create_app(stats=stats, notice_registry=NoticeRegistry())
for target, name, value in (
(report, "load_state", None),
(hardware.hardware_prober, "probe", []),
(hardware, "hwaccel_options", ("", [])),
):
patcher = patch.object(target, name, return_value=value)
patcher.start()
self.addCleanup(patcher.stop)
def test_preview_returns_the_report_without_sending_it(self):
with (
patch("frigate.analytics.transport.requests.post") as post,
AuthTestClient(self.app) as client,
):
response = client.get("/analytics/preview")
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertEqual(body["install_id"], report.PREVIEW_INSTALL_ID)
self.assertEqual(body["schema_version"], 1)
self.assertEqual(body["cameras"]["total"], 1)
post.assert_not_called()
def test_preview_is_admin_only(self):
with AuthTestClient(self.app) as client:
response = client.get(
"/analytics/preview",
headers={"remote-user": "viewer", "remote-role": "viewer"},
)
self.assertEqual(response.status_code, 403)
+46 -62
View File
@@ -53,108 +53,92 @@ class TestHttpNotices(BaseTestHttp):
self.assertEqual(response.json()[0]["kind"], "detector_stuck") self.assertEqual(response.json()[0]["kind"], "detector_stuck")
self.assertEqual(response.json()[0]["occurrences"], 1) self.assertEqual(response.json()[0]["occurrences"], 1)
def test_acknowledge_hides_and_the_hidden_list_keeps_it(self): def test_dismiss_hides_and_history_keeps_it(self):
self.registry.raise_notice( self.registry.raise_notice(
"detector_stuck", scope="ov", params={"detector": "ov"} "detector_stuck", scope="ov", params={"detector": "ov"}
) )
with self.client() as client: with self.client() as client:
response = client.post("/notices/detector_stuck:ov/acknowledge") response = client.post("/notices/detector_stuck:ov/dismiss")
listed = client.get("/notices").json() listed = client.get("/notices").json()
hidden = client.get("/notices", params={"include_hidden": True}).json() history = client.get("/notices", params={"include_dismissed": True}).json()
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(listed, []) self.assertEqual(listed, [])
self.assertEqual([n["id"] for n in hidden], ["detector_stuck:ov"]) self.assertEqual([n["id"] for n in history], ["detector_stuck:ov"])
self.assertIsNotNone(hidden[0]["acknowledged_at"]) self.assertIsNotNone(history[0]["dismissed_at"])
def test_acknowledging_a_check_is_404(self): def test_dismiss_an_id_with_a_slash(self):
with self.client() as client:
response = client.post(f"/notices/{CONFIG_CHECK}/acknowledge")
self.assertEqual(response.status_code, 404)
def test_mute_an_id_with_a_slash(self):
self.registry.raise_notice( self.registry.raise_notice(
"model_download_failed", scope="yolo/model.onnx", params={} "model_download_failed", scope="yolo/model.onnx", params={}
) )
with self.client() as client: with self.client() as client:
response = client.post( response = client.post(
"/notices/model_download_failed:yolo/model.onnx/mute" "/notices/model_download_failed:yolo/model.onnx/dismiss"
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(self.registry.active(), []) self.assertEqual(self.registry.active(), [])
def test_unknown_ids_are_404(self): def test_dismiss_unknown_is_404(self):
with self.client() as client: with self.client() as client:
responses = [ response = client.post("/notices/nope/dismiss")
client.post("/notices/nope/acknowledge"),
client.post("/notices/nope/mute"),
client.delete("/notices/nope/hidden"),
]
self.assertEqual([r.status_code for r in responses], [404, 404, 404]) self.assertEqual(response.status_code, 404)
def test_requires_admin(self): def test_requires_admin(self):
viewer = {"remote-user": "viewer", "remote-role": "viewer"}
with TestClient(self.app) as client: with TestClient(self.app) as client:
responses = [ response = client.get(
client.get("/notices", headers=viewer), "/notices", headers={"remote-user": "viewer", "remote-role": "viewer"}
client.get("/notices/muted_checks", headers=viewer), )
client.post("/notices/detector_stuck:ov/acknowledge", headers=viewer),
client.post("/notices/detector_stuck:ov/mute", headers=viewer),
client.delete("/notices/detector_stuck:ov/hidden", headers=viewer),
client.delete("/notices/hidden", headers=viewer),
]
self.assertEqual([r.status_code for r in responses], [403] * 6) self.assertEqual(response.status_code, 403)
def test_muted_checks_are_listed_once(self): def test_dismissed_checks_are_listed_once(self):
with self.client() as client: with self.client() as client:
first = client.post(f"/notices/{CONFIG_CHECK}/mute") first = client.post(f"/notices/{CONFIG_CHECK}/dismiss")
again = client.post(f"/notices/{CONFIG_CHECK}/mute") again = client.post(f"/notices/{CONFIG_CHECK}/dismiss")
listed = client.get("/notices/muted_checks").json() listed = client.get("/notices/dismissed_checks").json()
hidden = client.get("/notices", params={"include_hidden": True}).json() history = client.get("/notices", params={"include_dismissed": True}).json()
self.assertEqual(first.status_code, 200) self.assertEqual(first.status_code, 200)
self.assertEqual(again.status_code, 200) self.assertEqual(again.status_code, 200)
self.assertEqual([check["id"] for check in listed], [CONFIG_CHECK]) self.assertEqual([check["id"] for check in listed], [CONFIG_CHECK])
self.assertIsNotNone(listed[0]["muted_at"]) self.assertIsNotNone(listed[0]["dismissed_at"])
# the Health tab builds the row itself, so it never comes back as a notice # the Health tab builds the row itself, so it never comes back as a notice
self.assertEqual(hidden, []) self.assertEqual(history, [])
def test_unhide_one_row(self): def test_dismissed_checks_require_admin(self):
with TestClient(self.app) as client:
response = client.get(
"/notices/dismissed_checks",
headers={"remote-user": "viewer", "remote-role": "viewer"},
)
self.assertEqual(response.status_code, 403)
def test_purge_dismissed_clears_notices_and_checks(self):
self.registry.raise_notice( self.registry.raise_notice(
"detector_stuck", scope="ov", params={"detector": "ov"} "detector_stuck", scope="ov", params={"detector": "ov"}
) )
self.registry.mute("detector_stuck:ov") self.registry.dismiss("detector_stuck:ov")
self.registry.mute(CONFIG_CHECK) self.registry.dismiss(CONFIG_CHECK)
with self.client() as client: with self.client() as client:
notice = client.delete("/notices/detector_stuck:ov/hidden") response = client.delete("/notices/dismissed")
check = client.delete(f"/notices/{CONFIG_CHECK}/hidden") history = client.get("/notices", params={"include_dismissed": True}).json()
listed = client.get("/notices").json() checks = client.get("/notices/dismissed_checks").json()
checks = client.get("/notices/muted_checks").json()
self.assertEqual([notice.status_code, check.status_code], [200, 200])
self.assertEqual([n["id"] for n in listed], ["detector_stuck:ov"])
self.assertEqual(checks, [])
def test_unhide_all_shows_notices_and_drops_check_mutes(self):
self.registry.raise_notice(
"detector_stuck", scope="ov", params={"detector": "ov"}
)
self.registry.acknowledge("detector_stuck:ov")
self.registry.mute(CONFIG_CHECK)
with self.client() as client:
response = client.delete("/notices/hidden")
listed = client.get("/notices").json()
checks = client.get("/notices/muted_checks").json()
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual([n["id"] for n in listed], ["detector_stuck:ov"]) self.assertEqual(history, [])
self.assertEqual(checks, []) self.assertEqual(checks, [])
def test_purge_dismissed_requires_admin(self):
with TestClient(self.app) as client:
response = client.delete(
"/notices/dismissed",
headers={"remote-user": "viewer", "remote-role": "viewer"},
)
self.assertEqual(response.status_code, 403)
@@ -0,0 +1,112 @@
"""Tests for the cameras collector."""
import unittest
from frigate.analytics.collectors import cameras
from frigate.analytics.schema import HeightBucket, HwaccelKey, RetainBucket
from frigate.test.analytics_helpers import make_config, make_context
MASK = {"coordinates": "0,0,1,0,1,1"}
CONFIG = {
"record": {
"enabled": True,
"continuous": {"days": 3},
"alerts": {"retain": {"days": 14}},
"detections": {"retain": {"days": 45}},
},
"cameras": {
"front": {
"ffmpeg": {
"hwaccel_args": "preset-vaapi",
"inputs": [
{
"path": "rtsp://127.0.0.1:8554/front_sub",
"roles": ["detect"],
"input_args": "preset-rtsp-restream",
},
{"path": "rtsp://127.0.0.1:8554/front", "roles": ["record"]},
],
},
"detect": {"enabled": True, "height": 720, "width": 1280, "fps": 5},
"zones": {"porch": MASK, "yard": MASK},
"motion": {"mask": {"tree": MASK}},
"objects": {
"mask": {"sign": MASK},
"filters": {"person": {"mask": {"bush": MASK}}},
},
"onvif": {"host": "10.0.0.9"},
},
"garage": {
"enabled": False,
"type": "lpr",
"ffmpeg": {
"hwaccel_args": ["-hwaccel", "vaapi"],
"inputs": [
{"path": "rtsp://10.0.0.5/stream", "roles": ["detect", "record"]}
],
},
"detect": {"height": 2160, "width": 3840, "fps": 15},
"record": {"enabled": False},
},
"_replay_front": {
"ffmpeg": {
"inputs": [{"path": "rtsp://10.0.0.1/replay", "roles": ["detect"]}]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
},
},
}
STATS = {
"cameras": {
"front": {"connection_quality": "excellent"},
"garage": {"connection_quality": "offline"},
"_replay_front": {"connection_quality": "fair"},
}
}
class TestCamerasCollector(unittest.TestCase):
def test_aggregates_cameras_and_skips_replays(self):
section = cameras.collect(make_context(make_config(CONFIG), STATS))
self.assertEqual((section.total, section.enabled), (2, 1))
self.assertEqual(section.types, {"generic": 1, "lpr": 1})
self.assertEqual(section.detect_height, {"720": 1, "ge_2160": 1})
self.assertEqual(section.detect_fps, {"le_5": 1, "gt_10": 1})
self.assertEqual(section.hwaccel, {"vaapi": 1, "custom": 1})
self.assertEqual(section.input_preset, {"rtsp-restream": 1, "rtsp-generic": 1})
self.assertEqual(section.go2rtc_restream, 1)
self.assertEqual(section.separate_detect_stream, 1)
self.assertEqual((section.detect, section.record, section.onvif), (1, 1, 1))
self.assertEqual((section.zones, section.cameras_with_zones), (2, 1))
self.assertEqual((section.motion_masks, section.object_masks), (1, 2))
self.assertEqual(section.connection_quality, {"excellent": 1})
self.assertEqual(section.retain_days.continuous, {"1_7": 1})
self.assertEqual(section.retain_days.motion, {"0": 1})
self.assertEqual(section.retain_days.alerts, {"8_30": 1})
self.assertEqual(section.retain_days.detections, {"gt_30": 1})
def test_buckets(self):
self.assertEqual(cameras.height_bucket(360), HeightBucket.le_360)
self.assertEqual(cameras.height_bucket(361), HeightBucket.h480)
self.assertEqual(cameras.height_bucket(1080), HeightBucket.h1080)
self.assertEqual(cameras.height_bucket(1440), HeightBucket.h1440)
self.assertEqual(cameras.height_bucket(2160), HeightBucket.ge_2160)
self.assertEqual(cameras.retain_bucket(0.5), RetainBucket.d1_7)
self.assertEqual(cameras.retain_bucket(0), RetainBucket.zero)
def test_preset_key(self):
self.assertEqual(cameras.preset_key([], HwaccelKey), "none")
self.assertEqual(cameras.preset_key("", HwaccelKey), "none")
self.assertEqual(cameras.preset_key("preset-bogus", HwaccelKey), "custom")
self.assertEqual(cameras.preset_key("-hwaccel vaapi", HwaccelKey), "custom")
self.assertEqual(cameras.preset_key("preset-nvidia", HwaccelKey), "nvidia")
def test_is_restream(self):
self.assertTrue(cameras.is_restream("rtsp://127.0.0.1:8554/front"))
self.assertTrue(cameras.is_restream("rtsp://localhost:8554/front"))
self.assertFalse(cameras.is_restream("rtsp://10.0.0.5:8554/front"))
self.assertFalse(cameras.is_restream("rtsp://[bad"))
self.assertFalse(cameras.is_restream("rtsp://127.0.0.1:notaport/x"))
@@ -0,0 +1,61 @@
"""Tests for the detection collector."""
import os
import tempfile
import unittest
from unittest.mock import patch
from frigate.analytics.collectors import detection
from frigate.analytics.schema import ModelSource
from frigate.detectors.detector_config import SceneEnum
from frigate.test.analytics_helpers import make_config, make_context
class TestDetectionCollector(unittest.TestCase):
def test_reports_each_model_with_its_mean_inference_speed(self):
config = make_config({"models": [{"devices": ["cpu", "cpu"]}]})
stats = {
"detectors": {
"cpu": {"inference_speed": 10.0},
"cpu#2": {"inference_speed": 20.0},
},
"detection_fps": 12.346,
"skipped_fps": 0,
}
section = detection.collect(make_context(config, stats))
model = section.models[SceneEnum.all]
self.assertEqual(model.detector, "cpu")
self.assertEqual(model.devices, 2)
self.assertEqual(model.model_type, "ssd")
self.assertEqual(model.input, "320x320")
self.assertEqual(model.source, ModelSource.default)
self.assertEqual(model.inference_ms, 15.0)
self.assertEqual(section.detection_fps, 12.35)
self.assertEqual(section.skipped_fps, 0.0)
def test_inference_is_null_before_the_first_stats(self):
section = detection.collect(make_context())
self.assertIsNone(section.models[SceneEnum.all].inference_ms)
def test_model_source(self):
with tempfile.TemporaryDirectory() as cache:
plus_model = os.path.join(cache, "abc123")
open(f"{plus_model}.json", "w").close()
with patch.object(detection, "MODEL_CACHE_DIR", cache):
self.assertEqual(detection.model_source(plus_model), ModelSource.plus)
self.assertEqual(
detection.model_source(os.path.join(cache, "no_info")),
ModelSource.custom,
)
self.assertEqual(detection.model_source(None), ModelSource.default)
self.assertEqual(
detection.model_source("/cpu_model.tflite"), ModelSource.default
)
self.assertEqual(
detection.model_source("/config/yolo.onnx"), ModelSource.custom
)
@@ -0,0 +1,128 @@
"""Tests for the features collector."""
import logging
import os
import unittest
from peewee_migrate import Router
from playhouse.sqlite_ext import SqliteExtDatabase
from playhouse.sqliteq import SqliteQueueDatabase
from frigate.analytics.collectors import features
from frigate.analytics.schema import EnrichmentDevice, SemanticSearchModel
from frigate.models import User
from frigate.test.analytics_helpers import FRONT_CAMERA, make_config, make_context
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
CONFIG = {
"mqtt": {"host": "mqtt", "enabled": True},
"genai": {
"local": {
"provider": "ollama",
"model": "llava",
"base_url": "http://ollama:11434",
"roles": ["descriptions", "embeddings"],
}
},
"semantic_search": {"enabled": True, "model": "local"},
"face_recognition": {"enabled": True, "model_size": "large"},
"birdseye": {"enabled": True, "modes": ["motion", "alerts"], "restream": True},
"proxy": {"header_map": {"user": "x-forwarded-user"}},
"camera_groups": {"outside": {"cameras": ["front"], "icon": "LuCar", "order": 0}},
"cameras": {
"front": {
**FRONT_CAMERA,
"semantic_search": {
"triggers": {
"cat": {
"type": "description",
"data": "a cat",
"threshold": 0.8,
"actions": ["notification"],
}
}
},
}
},
}
class TestFeaturesCollector(unittest.TestCase):
def setUp(self):
migrate_db = SqliteExtDatabase(TEST_DB)
del logging.getLogger("peewee_migrate").handlers[:]
Router(migrate_db).run()
migrate_db.close()
self.db = SqliteQueueDatabase(TEST_DB)
self.db.bind([User])
def tearDown(self):
# close() leaves the queue's writer thread running against the deleted file
self.db.stop()
if not self.db.is_closed():
self.db.close()
for file in TEST_DB_CLEANUPS:
try:
os.remove(file)
except OSError:
pass
def test_collects_enrichments_genai_integrations_and_users(self):
for username, role in (("a", "admin"), ("v", "viewer"), ("o", "operator")):
User.create(
username=username, role=role, password_hash="x", notification_tokens=[]
)
stats = {
"embeddings": {
"devices": {
"face_recognition": "OpenVINO GPU.0",
"semantic_search": "CUDA",
}
}
}
section = features.collect(make_context(make_config(CONFIG), stats))
self.assertEqual(section.face_recognition.device, EnrichmentDevice.openvino_gpu)
self.assertEqual(section.face_recognition.model_size, "large")
self.assertIsNone(section.lpr.device)
self.assertEqual(section.semantic_search.model, SemanticSearchModel.genai)
self.assertEqual(section.semantic_search.device, EnrichmentDevice.cuda)
self.assertEqual(section.semantic_search.triggers, 1)
self.assertEqual(section.genai.providers, {"ollama": 1})
self.assertEqual(section.genai.roles, {"descriptions": 1, "embeddings": 1})
self.assertEqual(section.birdseye.modes, ["motion", "alerts"])
self.assertTrue(section.mqtt)
self.assertTrue(section.proxy_auth)
self.assertEqual(section.users, {"admin": 1, "viewer": 1, "custom": 1})
self.assertEqual((section.camera_groups, section.profiles), (1, 0))
self.assertFalse(section.plus_api_key)
class TestFeatureMappings(unittest.TestCase):
def test_enrichment_device_labels(self):
cases = {
"CPU": EnrichmentDevice.cpu,
"MIGraphX": EnrichmentDevice.migraphx,
"OpenVINO NPU": EnrichmentDevice.openvino_npu,
"OpenVINO GPU.0,CPU": EnrichmentDevice.openvino_gpu,
"OpenVINO": EnrichmentDevice.other,
"CoreML": EnrichmentDevice.other,
}
for label, device in cases.items():
with self.subTest(label=label):
self.assertEqual(features.enrichment_device(label), device)
self.assertIsNone(features.enrichment_device(None))
def test_models_named_after_a_provider_are_genai(self):
self.assertEqual(features.semantic_model("jinav2"), SemanticSearchModel.jinav2)
self.assertEqual(
features.semantic_model("my-ollama"), SemanticSearchModel.genai
)
self.assertIsNone(features.semantic_model(None))
self.assertEqual(features.transcription_model("openai"), "genai")
@@ -0,0 +1,65 @@
"""Tests for the health collector."""
import unittest
from frigate.analytics.collectors import health
from frigate.test.analytics_helpers import make_context
class TestHealthCollector(unittest.TestCase):
def test_collects_uptime_cpu_timings_and_notice_deltas(self):
stats = {
"service": {"uptime": 7300, "retention_unmet": True},
"cpu_usages": {"frigate.full_system": {"cpu": "23.6", "mem": "40"}},
"embeddings": {
"face_recognition_speed": 41.234,
"text_embedding_speed": 0,
"devices": {},
},
}
notice_stats = [
{
"kind": "detector_stuck",
"occurrences": 5,
"dismissals": 1,
"reported_occurrences": 3,
"reported_dismissals": 1,
},
{
"kind": "shm_too_low",
"occurrences": 2,
"dismissals": 0,
"reported_occurrences": 2,
"reported_dismissals": 0,
},
{
"kind": "update_available",
"occurrences": 4,
"dismissals": 0,
"reported_occurrences": 0,
"reported_dismissals": 0,
},
]
section = health.collect(make_context(stats=stats, notice_stats=notice_stats))
self.assertEqual(section.uptime_hours, 2)
self.assertEqual(section.cpu_percent, 24)
self.assertEqual(section.enrichment_ms, {"face": 41.23})
self.assertTrue(section.retention_unmet)
self.assertEqual(
{
kind: (c.occurrences, c.dismissals)
for kind, c in section.notices.items()
},
{"detector_stuck": (2, 0)},
)
def test_missing_stats_give_zeros(self):
section = health.collect(make_context())
self.assertEqual(
(section.uptime_hours, section.cpu_percent, section.enrichment_ms),
(0, 0, {}),
)
self.assertEqual(section.notices, {})
@@ -0,0 +1,188 @@
"""Tests for the install and hardware collectors."""
import os
import tempfile
import unittest
from collections import Counter
from types import SimpleNamespace
from unittest.mock import patch
from frigate.analytics.collectors import hardware, install
from frigate.analytics.collectors.common import closed, histogram, rate
from frigate.analytics.schema import (
Arch,
DecodeFamily,
GpuVendor,
HardwareKey,
ImageVariant,
InstallType,
)
from frigate.const import RECORD_DIR
from frigate.test.analytics_helpers import make_context
class TestCommon(unittest.TestCase):
def test_closed_falls_back_for_unknown_values(self):
self.assertEqual(closed(Arch, "x86_64", Arch.other), Arch.x86_64)
self.assertEqual(closed(Arch, "sparc", Arch.other), Arch.other)
self.assertEqual(closed(Arch, None, Arch.other), Arch.other)
def test_rate_rounds_and_rejects_bad_numbers(self):
self.assertEqual(rate("12.346"), 12.35)
self.assertEqual(rate(-3), 0.0)
self.assertEqual(rate(float("nan")), 0.0)
self.assertEqual(rate(float("inf")), 0.0)
self.assertEqual(rate(None), 0.0)
def test_histogram_drops_empty_buckets(self):
self.assertEqual(
histogram(Counter({Arch.x86_64: 2, Arch.other: 0})), {"x86_64": 2}
)
class TestInstallCollector(unittest.TestCase):
def test_collects_version_variant_and_platform(self):
with (
patch.dict(os.environ, {"FRIGATE_IMAGE_VARIANT": "tensorrt-jp6"}),
patch.object(install, "install_type", return_value=InstallType.docker),
patch("platform.machine", return_value="aarch64"),
patch("platform.release", return_value="6.8.0-45-generic"),
patch("os.geteuid", return_value=1000),
):
section = install.collect(make_context())
self.assertEqual(section.image_variant, ImageVariant.tensorrt_jp6)
self.assertEqual(section.install_type, InstallType.docker)
self.assertEqual(section.arch, Arch.aarch64)
self.assertEqual(section.kernel, "6.8")
self.assertFalse(section.run_as_root)
def test_image_variant_is_dev_when_unset_and_other_when_unknown(self):
self.assertEqual(install.image_variant(None), ImageVariant.dev)
self.assertEqual(install.image_variant(""), ImageVariant.dev)
self.assertEqual(install.image_variant("h8l"), ImageVariant.other)
def test_install_type_checks_the_add_on_first(self):
with patch("os.path.isfile", return_value=True):
self.assertEqual(install.install_type(), InstallType.ha_addon)
with (
patch("os.path.isfile", return_value=False),
patch.dict(os.environ, {"KUBERNETES_SERVICE_HOST": "10.0.0.1"}),
):
self.assertEqual(install.install_type(), InstallType.kubernetes)
env = {k: v for k, v in os.environ.items() if k != "KUBERNETES_SERVICE_HOST"}
with (
patch("os.path.isfile", return_value=False),
patch.dict(os.environ, env, clear=True),
patch("os.path.exists", side_effect=lambda path: path == "/.dockerenv"),
):
self.assertEqual(install.install_type(), InstallType.docker)
def test_kernel_and_arch_fall_back(self):
self.assertEqual(install.kernel("weird"), "unknown")
self.assertEqual(install.arch("armv7l"), Arch.other)
self.assertEqual(install.arch("amd64"), Arch.x86_64)
class TestHardwareCollector(unittest.TestCase):
def setUp(self):
self.dir = tempfile.TemporaryDirectory()
self.addCleanup(self.dir.cleanup)
def write(self, name: str, content: str) -> str:
path = os.path.join(self.dir.name, name)
with open(path, "w") as f:
f.write(content)
return path
def test_cpu_model_prefers_the_board_model(self):
board = self.write("model", "Raspberry Pi 5 Model B Rev 1.0\x00")
cpuinfo = self.write("cpuinfo", "model name\t: Cortex-A76\n")
with (
patch.object(hardware, "DEVICE_TREE_MODEL", board),
patch.object(hardware, "CPUINFO", cpuinfo),
):
self.assertEqual(hardware.cpu_model(), "Raspberry Pi 5 Model B Rev 1.0")
def test_cpu_model_reads_cpuinfo_and_truncates(self):
cpuinfo = self.write("cpuinfo", "processor\t: 0\nmodel name\t: " + "x" * 80)
with (
patch.object(hardware, "DEVICE_TREE_MODEL", self.dir.name + "/none"),
patch.object(hardware, "CPUINFO", cpuinfo),
):
self.assertEqual(hardware.cpu_model(), "x" * 64)
def test_collects_gpus_decode_detection_and_storage(self):
stats = {
"gpu_usages": {
"NVIDIA GeForce RTX 3060": {"vendor": "nvidia"},
"mystery": {},
},
"service": {
"storage": {
RECORD_DIR: {
"total": 2048000.0,
"used": 512000.0,
"mount_type": "nfs4",
}
}
},
}
probed = [
SimpleNamespace(key="edgetpu:usb", count=2),
SimpleNamespace(key="new:thing", count=1),
SimpleNamespace(key="cpu", count=1),
]
families = [
SimpleNamespace(key="vaapi"),
SimpleNamespace(key="intel-qsv"),
SimpleNamespace(key="future"),
]
with (
patch.object(hardware.hardware_prober, "probe", return_value=probed),
patch.object(hardware, "hwaccel_options", return_value=("vaapi", families)),
patch.object(hardware, "cpu_model", return_value="Intel(R) N100"),
patch("os.cpu_count", return_value=4),
patch(
"psutil.virtual_memory", return_value=SimpleNamespace(total=16 * 2**30)
),
):
section = hardware.collect(make_context(stats=stats))
self.assertEqual(section.cpu_model, "Intel(R) N100")
self.assertEqual(section.cpu_cores, 4)
self.assertEqual(section.memory_gb, 16)
self.assertEqual(
[(gpu.vendor, gpu.name) for gpu in section.gpus],
[
(GpuVendor.nvidia, "NVIDIA GeForce RTX 3060"),
(GpuVendor.other, "mystery"),
],
)
self.assertEqual(
section.decode_families,
[DecodeFamily.vaapi, DecodeFamily.intel_qsv, DecodeFamily.other],
)
self.assertEqual(
section.detection_hardware,
{HardwareKey.edgetpu_usb: 2, HardwareKey.other: 1, HardwareKey.cpu: 1},
)
self.assertEqual(section.storage.record_fs, "nfs4")
self.assertEqual(section.storage.record_total_gb, 2000)
self.assertEqual(section.storage.record_used_pct, 25)
def test_storage_without_stats_is_unknown_and_empty(self):
section = hardware.storage({})
self.assertEqual(
(section.record_fs, section.record_total_gb, section.record_used_pct),
("unknown", 0, 0),
)
+107
View File
@@ -0,0 +1,107 @@
"""Tests for building a report from the collectors."""
import json
import logging
import os
import unittest
from unittest.mock import Mock, patch
from peewee_migrate import Router
from playhouse.sqlite_ext import SqliteExtDatabase
from playhouse.sqliteq import SqliteQueueDatabase
from frigate.analytics import report
from frigate.analytics.collectors import hardware
from frigate.analytics.schema import AnalyticsReport, InstallSection
from frigate.models import User
from frigate.test.analytics_helpers import make_context
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
INSTALL = InstallSection(
version="0.19.0",
image_variant="dev",
install_type="docker",
arch="x86_64",
kernel="6.8",
run_as_root=False,
)
class TestBuildReport(unittest.TestCase):
def test_a_failing_collector_nulls_only_its_section(self):
collectors = {
"install": lambda ctx: INSTALL,
"hardware": Mock(side_effect=RuntimeError("boom")),
"detection": lambda ctx: None,
"cameras": lambda ctx: None,
"features": lambda ctx: None,
"health": lambda ctx: None,
}
with patch.dict(report.COLLECTORS, collectors):
built = report.build_report(make_context(), "a" * 32, sent_at=1790000000)
self.assertEqual(built.install, INSTALL)
self.assertIsNone(built.hardware)
self.assertEqual(built.schema_version, 1)
self.assertEqual(built.sent_at, 1790000000)
self.assertRegex(built.report_id, r"^[0-9a-f-]{36}$")
class TestRealCollectors(unittest.TestCase):
def setUp(self):
migrate_db = SqliteExtDatabase(TEST_DB)
del logging.getLogger("peewee_migrate").handlers[:]
Router(migrate_db).run()
migrate_db.close()
self.db = SqliteQueueDatabase(TEST_DB)
self.db.bind([User])
def tearDown(self):
# close() leaves the queue's writer thread running against the deleted file
self.db.stop()
if not self.db.is_closed():
self.db.close()
for file in TEST_DB_CLEANUPS:
try:
os.remove(file)
except OSError:
pass
def test_every_section_builds_and_round_trips_through_json(self):
with (
patch.object(hardware.hardware_prober, "probe", return_value=[]),
patch.object(hardware, "hwaccel_options", return_value=("", [])),
):
built = report.build_report(make_context(), "a" * 32)
body = built.model_dump_json()
parsed = json.loads(body)
for section in (
"install",
"hardware",
"detection",
"cameras",
"features",
"health",
):
with self.subTest(section=section):
self.assertIsNotNone(parsed[section])
self.assertEqual(AnalyticsReport.model_validate_json(body), built)
def test_preview_uses_a_placeholder_id_before_the_first_report(self):
stats_emitter = Mock()
stats_emitter.get_latest_stats.return_value = {}
with (
patch.object(report, "load_state", return_value=None),
patch.object(hardware.hardware_prober, "probe", return_value=[]),
patch.object(hardware, "hwaccel_options", return_value=("", [])),
):
built = report.preview_report(make_context().config, stats_emitter, None)
self.assertEqual(built.install_id, report.PREVIEW_INSTALL_ID)
+227
View File
@@ -0,0 +1,227 @@
"""Tests for the analytics reporter's schedule and consent handling."""
import os
import tempfile
import threading
import unittest
from unittest.mock import Mock, patch
from frigate.analytics import reporter as reporter_module
from frigate.analytics.reporter import (
FIRST_DELAY_S,
INTERVAL_S,
JITTER_S,
PROMPT_KIND,
WAKE_S,
AnalyticsReporter,
)
from frigate.analytics.state import AnalyticsState, load_state, save_state
from frigate.analytics.transport import SendOutcome
from frigate.config.holder import ConfigHolder
from frigate.test.analytics_helpers import make_config
SNAPSHOT = [
{
"kind": "detector_stuck",
"occurrences": 3,
"dismissals": 0,
"reported_occurrences": 1,
"reported_dismissals": 0,
}
]
class TestAnalyticsReporter(unittest.TestCase):
def setUp(self):
self.dir = tempfile.TemporaryDirectory()
self.addCleanup(self.dir.cleanup)
self.path = os.path.join(self.dir.name, ".analytics.json")
self.now = 1_790_000_000.0
self.send = Mock(return_value=SendOutcome.accepted)
self.registry = Mock()
self.registry.stats.return_value = SNAPSHOT
self.stats = Mock()
self.stats.get_latest_stats.return_value = {}
self.holder = ConfigHolder(make_config({"telemetry": {"analytics": True}}))
for name in ("raise_notice", "resolve_notice"):
patcher = patch.object(reporter_module, name)
setattr(self, name, patcher.start())
self.addCleanup(patcher.stop)
self.built = Mock()
self.built.model_dump_json.return_value = '{"report": 1}'
patcher = patch.object(reporter_module, "build_report", return_value=self.built)
self.build_report = patcher.start()
self.addCleanup(patcher.stop)
def reporter(self, path: str | None = None) -> AnalyticsReporter:
# uniform(a, b) returns a: the shortest startup delay and interval
rng = Mock()
rng.uniform.side_effect = lambda low, high: low
return AnalyticsReporter(
self.holder,
self.stats,
self.registry,
threading.Event(),
state_path=path or self.path,
url="https://example.test/report",
send=self.send,
clock=lambda: self.now,
rng=rng,
)
def advance(self, seconds: float) -> None:
self.now += seconds
def test_waits_for_the_startup_delay_then_sends(self):
reporter = self.reporter()
reporter.tick()
self.send.assert_not_called()
self.advance(FIRST_DELAY_S[0])
reporter.tick()
self.send.assert_called_once_with(
"https://example.test/report", '{"report": 1}'
)
self.registry.mark_reported.assert_called_once_with(SNAPSHOT)
self.assertEqual(load_state(self.path).last_attempt_at, self.now)
def test_sends_once_per_interval(self):
reporter = self.reporter()
self.advance(FIRST_DELAY_S[0])
reporter.tick()
self.advance(WAKE_S)
reporter.tick()
self.assertEqual(self.send.call_count, 1)
self.advance(INTERVAL_S - JITTER_S)
reporter.tick()
self.assertEqual(self.send.call_count, 2)
def test_a_restart_inside_the_interval_does_not_send(self):
save_state(AnalyticsState("a" * 32, self.now), self.path)
reporter = self.reporter()
self.advance(FIRST_DELAY_S[0])
reporter.tick()
self.send.assert_not_called()
def test_a_failed_send_keeps_watermarks_and_waits_a_full_interval(self):
self.send.return_value = SendOutcome.failed
reporter = self.reporter()
self.advance(FIRST_DELAY_S[0])
reporter.tick()
self.advance(WAKE_S)
reporter.tick()
self.assertEqual(self.send.call_count, 1)
self.registry.mark_reported.assert_not_called()
def test_an_accepted_report_without_health_keeps_the_watermarks(self):
self.built.health = None
reporter = self.reporter()
self.advance(FIRST_DELAY_S[0])
reporter.tick()
self.send.assert_called_once()
self.registry.mark_reported.assert_not_called()
def test_consent_withdrawn_while_the_report_builds_stops_the_send(self):
reporter = self.reporter()
self.advance(FIRST_DELAY_S[0])
def withdraw(*args, **kwargs):
self.holder.set(make_config())
return self.built
self.build_report.side_effect = withdraw
reporter.tick()
self.send.assert_not_called()
self.assertFalse(os.path.exists(self.path))
def test_turning_sharing_off_and_on_between_wakes_starts_a_fresh_identity(self):
save_state(AnalyticsState("a" * 32, 0.0), self.path)
reporter = self.reporter()
reporter.tick()
self.holder.set(make_config())
self.holder.set(make_config({"telemetry": {"analytics": True}}))
self.advance(FIRST_DELAY_S[0])
reporter.tick()
self.raise_notice.assert_called_once_with(PROMPT_KIND)
self.assertNotEqual(load_state(self.path).install_id, "a" * 32)
def test_opting_out_raises_the_prompt_once_and_deletes_the_state(self):
self.holder.set(make_config())
save_state(AnalyticsState("a" * 32, 0.0), self.path)
reporter = self.reporter()
reporter.tick()
reporter.tick()
self.raise_notice.assert_called_once_with(PROMPT_KIND)
self.assertFalse(os.path.exists(self.path))
self.send.assert_not_called()
def test_opting_in_resolves_the_prompt(self):
self.holder.set(make_config())
reporter = self.reporter()
reporter.tick()
self.holder.set(make_config({"telemetry": {"analytics": True}}))
reporter.tick()
self.resolve_notice.assert_called_once_with(PROMPT_KIND)
def test_safe_mode_touches_nothing(self):
self.holder.set(make_config({"safe_mode": True}))
save_state(AnalyticsState("a" * 32, 0.0), self.path)
reporter = self.reporter()
self.advance(FIRST_DELAY_S[0])
reporter.tick()
self.raise_notice.assert_not_called()
self.assertTrue(os.path.exists(self.path))
self.send.assert_not_called()
def test_an_unwritable_config_dir_skips_sending_and_warns_once(self):
reporter = self.reporter(os.path.join(self.dir.name, "missing", "x.json"))
self.advance(FIRST_DELAY_S[0])
with self.assertLogs("frigate.analytics.reporter", "WARNING") as logs:
reporter.tick()
self.advance(WAKE_S)
reporter.tick()
self.send.assert_not_called()
self.assertEqual(len(logs.output), 1)
def test_a_last_attempt_in_the_future_is_ignored(self):
save_state(AnalyticsState("a" * 32, self.now + 10 * INTERVAL_S), self.path)
reporter = self.reporter()
self.advance(FIRST_DELAY_S[0])
reporter.tick()
self.send.assert_called_once()
def test_the_loop_survives_a_failing_tick(self):
reporter = self.reporter()
reporter.stop_event.set()
with (
patch.object(reporter, "tick", side_effect=RuntimeError("boom")),
self.assertLogs("frigate.analytics.reporter", "ERROR"),
):
reporter.run()
+137
View File
@@ -0,0 +1,137 @@
"""Tests for the analytics report schema."""
import inspect
import re
import unittest
from enum import Enum
from typing import Any, get_args, get_origin
from pydantic import BaseModel
import frigate.detectors.hardware as detection_hardware
import frigate.util.hwaccel as hwaccel
from frigate.analytics.schema import (
AnalyticsReport,
DecodeFamily,
HardwareKey,
HwaccelKey,
InputPresetKey,
NoticeKindKey,
)
from frigate.ffmpeg_presets import PRESETS_HW_ACCEL_DECODE, PRESETS_INPUT
from frigate.notices.types import NOTICE_KINDS
from frigate.util.hwaccel import HwaccelFamily
# the only str fields: generated ids and strings the hardware reports
ALLOWED_STRINGS = {
("AnalyticsReport", "install_id"),
("AnalyticsReport", "report_id"),
("InstallSection", "version"),
("InstallSection", "kernel"),
("HardwareSection", "cpu_model"),
("GpuInfo", "name"),
("StorageInfo", "record_fs"),
("DetectionModel", "input"),
}
def nested_models(annotation: Any) -> list[type[BaseModel]]:
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
return [annotation]
return [model for arg in get_args(annotation) for model in nested_models(arg)]
def all_models(
model: type[BaseModel], seen: set[type[BaseModel]] | None = None
) -> list[type[BaseModel]]:
seen = set() if seen is None else seen
if model in seen:
return []
seen.add(model)
found = [model]
for field in model.model_fields.values():
for nested in nested_models(field.annotation):
found += all_models(nested, seen)
return found
def dict_key_types(annotation: Any) -> list[Any]:
keys = [get_args(annotation)[0]] if get_origin(annotation) is dict else []
return keys + [key for arg in get_args(annotation) for key in dict_key_types(arg)]
class TestSchemaDiscipline(unittest.TestCase):
def test_every_field_has_a_description_and_public_flag(self):
for model in all_models(AnalyticsReport):
for name, field in model.model_fields.items():
with self.subTest(field=f"{model.__name__}.{name}"):
self.assertTrue(field.description)
self.assertIsInstance(field.json_schema_extra, dict)
self.assertIsInstance(field.json_schema_extra.get("x-public"), bool)
def test_strings_are_limited_to_ids_and_hardware_names(self):
for model in all_models(AnalyticsReport):
properties = model.model_json_schema()["properties"]
for name, field in model.model_fields.items():
if field.annotation is not str:
continue
with self.subTest(field=f"{model.__name__}.{name}"):
self.assertIn((model.__name__, name), ALLOWED_STRINGS)
self.assertTrue(
"maxLength" in properties[name] or "pattern" in properties[name]
)
def test_map_keys_are_enums(self):
for model in all_models(AnalyticsReport):
for name, field in model.model_fields.items():
for key_type in dict_key_types(field.annotation):
with self.subTest(field=f"{model.__name__}.{name}"):
self.assertTrue(issubclass(key_type, Enum))
class TestEnumsTrackTheirSources(unittest.TestCase):
def test_hardware_keys_cover_every_probe(self):
probed = set(
re.findall(
r'_hardware\(\s*"([^"]+)"', inspect.getsource(detection_hardware)
)
)
self.assertTrue(probed)
self.assertLessEqual(probed, {key.value for key in HardwareKey})
def test_decode_families_cover_every_hwaccel_family(self):
families = {
value.key
for value in vars(hwaccel).values()
if isinstance(value, HwaccelFamily)
}
self.assertTrue(families)
self.assertLessEqual(families, {family.value for family in DecodeFamily})
def test_preset_keys_mirror_the_presets(self):
for enum, presets in (
(HwaccelKey, PRESETS_HW_ACCEL_DECODE),
(InputPresetKey, PRESETS_INPUT),
):
with self.subTest(enum=enum.__name__):
self.assertEqual(
{key.value for key in enum},
{name.removeprefix("preset-") for name in presets}
| {"custom", "none"},
)
def test_notice_keys_are_the_reportable_kinds(self):
self.assertEqual(
{key.value for key in NoticeKindKey},
{key for key, kind in NOTICE_KINDS.items() if kind.reportable},
)
self.assertNotIn("analytics_prompt", {key.value for key in NoticeKindKey})
+64
View File
@@ -0,0 +1,64 @@
"""Tests for the analytics state file."""
import os
import tempfile
import unittest
from frigate.analytics.state import (
AnalyticsState,
delete_state,
load_state,
new_state,
save_state,
)
class TestAnalyticsState(unittest.TestCase):
def setUp(self):
self.dir = tempfile.TemporaryDirectory()
self.addCleanup(self.dir.cleanup)
self.path = os.path.join(self.dir.name, ".analytics.json")
def write(self, content: str) -> None:
with open(self.path, "w") as f:
f.write(content)
def test_missing_file_loads_as_none(self):
self.assertIsNone(load_state(self.path))
def test_save_then_load_round_trips(self):
state = AnalyticsState(install_id="a" * 32, last_attempt_at=1790000000.5)
self.assertTrue(save_state(state, self.path))
self.assertEqual(load_state(self.path), state)
def test_unusable_files_load_as_none(self):
for content in (
"{not json",
'["a"]',
'{"install_id": "short", "last_attempt_at": 1}',
'{"install_id": "' + "a" * 32 + '"}',
'{"install_id": "' + "a" * 32 + '", "last_attempt_at": true}',
):
with self.subTest(content=content):
self.write(content)
self.assertIsNone(load_state(self.path))
def test_save_reports_failure_instead_of_raising(self):
missing = os.path.join(self.dir.name, "missing", ".analytics.json")
self.assertFalse(save_state(new_state(), missing))
def test_new_state_has_a_hex_id_and_no_attempt(self):
state = new_state()
self.assertRegex(state.install_id, r"^[0-9a-f]{32}$")
self.assertEqual(state.last_attempt_at, 0.0)
def test_delete_removes_the_file_and_tolerates_a_missing_one(self):
save_state(new_state(), self.path)
delete_state(self.path)
delete_state(self.path)
self.assertFalse(os.path.exists(self.path))
+60
View File
@@ -0,0 +1,60 @@
"""Tests for sending a report."""
import unittest
from unittest.mock import Mock, patch
import requests
from frigate.analytics.transport import TIMEOUT_S, SendOutcome, send_report
from frigate.version import VERSION
URL = "https://example.test/report"
def post(status: int = 204, text: str = "", side_effect: Exception | None = None):
return patch(
"frigate.analytics.transport.requests.post",
return_value=Mock(status_code=status, text=text),
side_effect=side_effect,
)
class TestSendReport(unittest.TestCase):
def test_posts_the_body_as_json_with_a_user_agent(self):
with post() as mock_post:
outcome = send_report(URL, '{"a":1}')
self.assertIs(outcome, SendOutcome.accepted)
args, kwargs = mock_post.call_args
self.assertEqual(args[0], URL)
self.assertEqual(kwargs["data"], b'{"a":1}')
self.assertEqual(kwargs["headers"]["Content-Type"], "application/json")
self.assertEqual(kwargs["headers"]["User-Agent"], f"Frigate/{VERSION}")
self.assertFalse(kwargs["allow_redirects"])
self.assertEqual(kwargs["timeout"], TIMEOUT_S)
def test_maps_statuses_to_outcomes(self):
cases = (
(200, SendOutcome.accepted),
(400, SendOutcome.rejected),
(429, SendOutcome.rate_limited),
(302, SendOutcome.failed),
(500, SendOutcome.failed),
)
for status, outcome in cases:
with self.subTest(status=status), post(status):
self.assertIs(send_report(URL, "{}"), outcome)
def test_logs_the_rejection_reason(self):
with (
post(400, "install_id: bad"),
self.assertLogs("frigate.analytics.transport", "WARNING") as logs,
):
send_report(URL, "{}")
self.assertIn("install_id: bad", logs.output[0])
def test_a_network_error_fails_without_raising(self):
with post(side_effect=requests.ConnectionError("down")):
self.assertIs(send_report(URL, "{}"), SendOutcome.failed)
-25
View File
@@ -36,31 +36,6 @@ class TestEventsPerSecond(unittest.TestCase):
clock[0] += 100.0 clock[0] += 100.0
self.assertEqual(eps.eps(), 0.0) self.assertEqual(eps.eps(), 0.0)
def test_burst_after_start_is_not_divided_by_a_tiny_window(self) -> None:
eps = EventsPerSecond(last_n_seconds=10)
clock = [1000.0]
with patch("frigate.util.builtin.time.monotonic", side_effect=lambda: clock[0]):
eps.start()
# eleven buffered frames arrive within 100 ms of starting
for _ in range(11):
clock[0] += 0.01
eps.update()
# 11 events over less than a second is at most 11 per second
self.assertLessEqual(eps.eps(), 11.0)
def test_subsecond_window_keeps_its_rate(self) -> None:
eps = EventsPerSecond(last_n_seconds=0.5)
clock = [1000.0]
with patch("frigate.util.builtin.time.monotonic", side_effect=lambda: clock[0]):
eps.start()
# twenty events per second for two seconds
for _ in range(40):
clock[0] += 0.05
eps.update()
# read between events, so none sits exactly on the window edge
clock[0] += 0.01
self.assertAlmostEqual(eps.eps(), 20.0)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+5
View File
@@ -17,6 +17,11 @@ from frigate.util.builtin import deep_merge
class TestConfig(unittest.TestCase): class TestConfig(unittest.TestCase):
def test_analytics_is_off_by_default(self):
frigate_config = FrigateConfig(**self.minimal)
self.assertFalse(frigate_config.telemetry.analytics)
def setUp(self): def setUp(self):
self.minimal = { self.minimal = {
"mqtt": {"host": "mqtt"}, "mqtt": {"host": "mqtt"},
+25
View File
@@ -82,5 +82,30 @@ class TestSwapRuntimeConfig(unittest.TestCase):
app.genai_manager.update_config.assert_called_once_with(config) app.genai_manager.update_config.assert_called_once_with(config)
class TestConfigHolder(unittest.TestCase):
def test_set_passes_the_new_config_to_subscribers(self) -> None:
holder = ConfigHolder(MagicMock(name="boot_config"))
listener = MagicMock()
holder.subscribe(listener)
config = MagicMock(name="new_config")
holder.set(config)
listener.assert_called_once_with(config)
def test_a_failing_subscriber_does_not_block_the_swap(self) -> None:
holder = ConfigHolder(MagicMock(name="boot_config"))
after = MagicMock()
holder.subscribe(MagicMock(side_effect=RuntimeError("boom")))
holder.subscribe(after)
config = MagicMock(name="new_config")
with self.assertLogs("frigate.config.holder", "ERROR"):
holder.set(config)
self.assertIs(holder.config, config)
after.assert_called_once_with(config)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
-5
View File
@@ -59,10 +59,6 @@ class TestBuildDetectorConfig(unittest.TestCase):
def test_detectors_that_name_the_field_something_else(self): def test_detectors_that_name_the_field_something_else(self):
self.assertEqual(self._build("cpu:4").num_threads, 4) self.assertEqual(self._build("cpu:4").num_threads, 4)
self.assertEqual(self._build("rknn:2").num_cores, 2) self.assertEqual(self._build("rknn:2").num_cores, 2)
self.assertEqual(
self._build("zmq:tcp://host.docker.internal:5555").endpoint,
"tcp://host.docker.internal:5555",
)
def test_device_is_coerced_to_the_detector_field_type(self): def test_device_is_coerced_to_the_detector_field_type(self):
self.assertEqual(self._build("tensorrt:1").device, 1) self.assertEqual(self._build("tensorrt:1").device, 1)
@@ -70,7 +66,6 @@ class TestBuildDetectorConfig(unittest.TestCase):
def test_omitted_device_falls_back_to_the_detector_default(self): def test_omitted_device_falls_back_to_the_detector_default(self):
self.assertEqual(self._build("cpu").num_threads, 3) self.assertEqual(self._build("cpu").num_threads, 3)
self.assertEqual(self._build("rknn").num_cores, 0) self.assertEqual(self._build("rknn").num_cores, 0)
self.assertEqual(self._build("zmq").endpoint, "ipc:///tmp/cache/zmq_detector")
self.assertEqual(self._build("openvino").device, "AUTO") self.assertEqual(self._build("openvino").device, "AUTO")
self.assertIsNone(self._build("edgetpu").device) self.assertIsNone(self._build("edgetpu").device)
+1 -24
View File
@@ -25,7 +25,7 @@ class HardwareProbeTestCase(unittest.TestCase):
self.root = tempfile.TemporaryDirectory() self.root = tempfile.TemporaryDirectory()
self.addCleanup(self.root.cleanup) self.addCleanup(self.root.cleanup)
for name in ("SYS_ROOT", "DEV_ROOT", "PROC_ROOT", "ETC_ROOT", "LIB_ROOT"): for name in ("SYS_ROOT", "DEV_ROOT", "PROC_ROOT", "ETC_ROOT"):
sub = os.path.join(self.root.name, name.split("_")[0].lower()) sub = os.path.join(self.root.name, name.split("_")[0].lower())
os.makedirs(sub, exist_ok=True) os.makedirs(sub, exist_ok=True)
patcher = patch.object(hardware, name, sub) patcher = patch.object(hardware, name, sub)
@@ -167,29 +167,6 @@ class TestNvidia(HardwareProbeTestCase):
class TestAccelerators(HardwareProbeTestCase): class TestAccelerators(HardwareProbeTestCase):
def test_the_neural_engine_is_found_by_lighters_provider_library(self):
write(os.path.join(self.lib_root, "lighter", "liblighter_ane_ep.so"))
with patch.dict(os.environ, clear=False) as env:
env.pop("LIGHTER_ANE_EP", None)
ane = self.probe()["onnx:lighter"]
self.assertEqual(ane.detector, "onnx")
self.assertEqual(ane.units[0].device, "onnx")
self.assertEqual(ane.units[0].label, "Neural Engine")
def test_the_neural_engine_is_found_where_lighter_ane_ep_points(self):
library = os.path.join(self.root.name, "elsewhere", "liblighter_ane_ep.so")
write(library)
with patch.dict(os.environ, {"LIGHTER_ANE_EP": library}):
self.assertIn("onnx:lighter", self.probe())
def test_no_neural_engine_is_reported_without_the_library(self):
with patch.dict(os.environ, clear=False) as env:
env.pop("LIGHTER_ANE_EP", None)
self.assertNotIn("onnx:lighter", self.probe())
def test_hailo_is_found_by_its_device_node(self): def test_hailo_is_found_by_its_device_node(self):
write(os.path.join(self.dev_root, "hailo0")) write(os.path.join(self.dev_root, "hailo0"))
-10
View File
@@ -51,16 +51,6 @@ class TestFfmpegPresets(unittest.TestCase):
" ".join(frigate_config.cameras["back"].ffmpeg_cmds[0]["cmd"]) " ".join(frigate_config.cameras["back"].ffmpeg_cmds[0]["cmd"])
) )
def test_ffmpeg_hwaccel_apple_preset_decodes_every_stream(self):
self.default_ffmpeg["cameras"]["back"]["ffmpeg"]["hwaccel_args"] = (
"preset-apple-silicon-h265"
)
frigate_config = FrigateConfig(**self.default_ffmpeg)
cmd = " ".join(frigate_config.cameras["back"].ffmpeg_cmds[0]["cmd"])
assert "-c:v hevc_v4l2m2m" in cmd
assert "-c:v:1" not in cmd
assert "scale=1920:1080" in cmd
def test_ffmpeg_hwaccel_not_preset(self): def test_ffmpeg_hwaccel_not_preset(self):
self.default_ffmpeg["cameras"]["back"]["ffmpeg"]["hwaccel_args"] = ( self.default_ffmpeg["cameras"]["back"]["ffmpeg"]["hwaccel_args"] = (
"-other-hwaccel args" "-other-hwaccel args"
@@ -34,12 +34,6 @@ class HwaccelRecommendationTestCase(unittest.TestCase):
patcher.start() patcher.start()
self.addCleanup(patcher.stop) self.addCleanup(patcher.stop)
self.sys_root = os.path.join(self.root.name, "sys")
os.makedirs(self.sys_root)
patcher = patch.object(hwaccel, "SYS_ROOT", self.sys_root)
patcher.start()
self.addCleanup(patcher.stop)
drm = patch.object(hwaccel, "enumerate_drm_devices", return_value={}) drm = patch.object(hwaccel, "enumerate_drm_devices", return_value={})
self.drm = drm.start() self.drm = drm.start()
self.addCleanup(drm.stop) self.addCleanup(drm.stop)
@@ -72,12 +66,6 @@ class HwaccelRecommendationTestCase(unittest.TestCase):
with open(os.path.join(self.proc_root, "cpuinfo"), "w") as f: with open(os.path.join(self.proc_root, "cpuinfo"), "w") as f:
f.write(f"processor\t: 0\nmodel name\t: {model_name}\n") f.write(f"processor\t: 0\nmodel name\t: {model_name}\n")
def write_video_device(self, vendor: str) -> None:
device = os.path.join(self.sys_root, "class", "video4linux", "video0", "device")
os.makedirs(device)
with open(os.path.join(device, "vendor"), "w") as f:
f.write(f"{vendor}\n")
def write_device_tree(self) -> None: def write_device_tree(self) -> None:
os.makedirs(os.path.join(self.proc_root, "device-tree"), exist_ok=True) os.makedirs(os.path.join(self.proc_root, "device-tree"), exist_ok=True)
with open(os.path.join(self.proc_root, "device-tree", "compatible"), "w") as f: with open(os.path.join(self.proc_root, "device-tree", "compatible"), "w") as f:
@@ -203,22 +191,6 @@ class TestAvailableFamilies(HwaccelRecommendationTestCase):
self.assertIn(recommended, [family.key for family in families]) self.assertIn(recommended, [family.key for family in families])
class TestLighter(HwaccelRecommendationTestCase):
def test_lighters_media_engine_is_recommended(self):
self.write_video_device(hwaccel.LIGHTER_VIRTIO_VENDOR)
self.assertEqual(self.recommend(codecs={"h264"}), "apple-silicon")
self.assertEqual(
self.presets()["apple-silicon"],
{"h264": "preset-apple-silicon-h264", "h265": "preset-apple-silicon-h265"},
)
def test_another_virtio_media_device_is_not_lighters(self):
self.write_video_device("0x554d4551")
self.assertEqual(self.available(), [])
class TestCodecCoverage(HwaccelRecommendationTestCase): class TestCodecCoverage(HwaccelRecommendationTestCase):
def test_a_family_carries_a_preset_per_codec(self): def test_a_family_carries_a_preset_per_codec(self):
self.assertEqual( self.assertEqual(
+85 -119
View File
@@ -16,7 +16,9 @@ from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
# kinds that switch on the lifecycle knobs, so the tests do not depend on the # kinds that switch on the lifecycle knobs, so the tests do not depend on the
# values the real catalog picks # values the real catalog picks
BATCHED = NoticeKind("batched", NoticeSeverity.warning, "system", batch_repeats=True) BATCHED = NoticeKind(
"batched", NoticeSeverity.warning, "system", batch_repeats=True, reopen_at_count=3
)
PRUNED = NoticeKind("pruned", NoticeSeverity.info, "system", keep_latest=2) PRUNED = NoticeKind("pruned", NoticeSeverity.info, "system", keep_latest=2)
TEST_KINDS = {kind.key: kind for kind in (BATCHED, PRUNED)} TEST_KINDS = {kind.key: kind for kind in (BATCHED, PRUNED)}
@@ -46,6 +48,16 @@ class TestNoticeKinds(unittest.TestCase):
self.assertIsNone(kind.link_for({"version": "0.19.1"})) self.assertIsNone(kind.link_for({"version": "0.19.1"}))
def test_analytics_prompt_links_to_telemetry_settings(self):
kind = NOTICE_KINDS["analytics_prompt"]
self.assertEqual(kind.link_for({}), "/settings?page=systemTelemetry")
self.assertFalse(kind.counts_repeats)
self.assertFalse(kind.reportable)
def test_update_available_is_not_reported(self):
self.assertFalse(NOTICE_KINDS["update_available"].reportable)
class RegistryTestCase(unittest.TestCase): class RegistryTestCase(unittest.TestCase):
def setUp(self): def setUp(self):
@@ -75,11 +87,6 @@ class RegistryTestCase(unittest.TestCase):
mock_datetime.now.return_value.timestamp.return_value = timestamp mock_datetime.now.return_value.timestamp.return_value = timestamp
self.registry.raise_notice(kind, **kwargs) self.registry.raise_notice(kind, **kwargs)
def _acknowledge_at(self, timestamp: float, row_id: str) -> None:
with patch("frigate.notices.registry.datetime") as mock_datetime:
mock_datetime.now.return_value.timestamp.return_value = timestamp
self.registry.acknowledge(row_id)
class TestNoticeRegistry(RegistryTestCase): class TestNoticeRegistry(RegistryTestCase):
def test_raise_inserts_and_counts_one_occurrence(self): def test_raise_inserts_and_counts_one_occurrence(self):
@@ -125,98 +132,34 @@ class TestNoticeRegistry(RegistryTestCase):
self.assertEqual(self.registry.stats()[0]["occurrences"], 1) self.assertEqual(self.registry.stats()[0]["occurrences"], 1)
self.listener.assert_not_called() self.listener.assert_not_called()
def test_acknowledged_notice_returns_on_a_re_raise(self): def test_dismissal_survives_a_re_raise(self):
self.registry.raise_notice("detector_stuck", scope="ov", params={})
self.assertTrue(self.registry.acknowledge("detector_stuck:ov"))
self.assertEqual(self.registry.active(), [])
self.registry.raise_notice("detector_stuck", scope="ov", params={})
notice = self.registry.active()[0]
self.assertEqual(notice["count"], 2)
self.assertIsNone(notice["acknowledged_at"])
def test_muted_notice_stays_hidden_on_a_re_raise(self):
self.registry.raise_notice("skipped_detections", scope="front_door", params={}) self.registry.raise_notice("skipped_detections", scope="front_door", params={})
self.assertTrue(self.registry.mute("skipped_detections:front_door")) self.assertTrue(self.registry.dismiss("skipped_detections:front_door"))
self.registry.raise_notice("skipped_detections", scope="front_door", params={}) self.registry.raise_notice("skipped_detections", scope="front_door", params={})
self.assertEqual(self.registry.active(), []) self.assertEqual(self.registry.active(), [])
hidden = self.registry.active(include_hidden=True) history = self.registry.active(include_dismissed=True)
self.assertEqual(hidden[0]["count"], 2) self.assertEqual(history[0]["count"], 2)
self.assertIsNotNone(hidden[0]["muted_at"]) self.assertIsNotNone(history[0]["dismissed_at"])
def test_muting_an_acknowledged_notice_replaces_the_acknowledgement(self): def test_dismiss_counts_once(self):
self.registry.raise_notice("detector_stuck", scope="ov", params={}) self.registry.raise_notice("detector_stuck", scope="ov", params={})
self.registry.acknowledge("detector_stuck:ov")
self.assertTrue(self.registry.mute("detector_stuck:ov")) self.assertTrue(self.registry.dismiss("detector_stuck:ov"))
self.assertTrue(self.registry.dismiss("detector_stuck:ov"))
notice = self.registry.active(include_hidden=True)[0] self.assertEqual(self.registry.stats()[0]["dismissals"], 1)
self.assertIsNone(notice["acknowledged_at"])
self.assertIsNotNone(notice["muted_at"])
def test_acknowledging_a_muted_notice_keeps_it_muted(self): def test_dismiss_unknown_id(self):
self.assertFalse(self.registry.dismiss("nope"))
def test_dismissed_hidden_unless_requested(self):
self.registry.raise_notice("detector_stuck", scope="ov", params={}) self.registry.raise_notice("detector_stuck", scope="ov", params={})
self.registry.mute("detector_stuck:ov") self.registry.dismiss("detector_stuck:ov")
self.assertTrue(self.registry.acknowledge("detector_stuck:ov"))
notice = self.registry.active(include_hidden=True)[0]
self.assertIsNone(notice["acknowledged_at"])
self.assertIsNotNone(notice["muted_at"])
def test_acknowledge_and_mute_each_count_once(self):
self.registry.raise_notice("detector_stuck", scope="ov", params={})
self.registry.raise_notice("shm_too_low", params={})
self.assertTrue(self.registry.acknowledge("detector_stuck:ov"))
self.assertTrue(self.registry.acknowledge("detector_stuck:ov"))
self.assertTrue(self.registry.mute("shm_too_low"))
self.assertTrue(self.registry.mute("shm_too_low"))
stats = {s["kind"]: s for s in self.registry.stats()}
self.assertEqual(stats["detector_stuck"]["acknowledgements"], 1)
self.assertEqual(stats["detector_stuck"]["mutes"], 0)
self.assertEqual(stats["shm_too_low"]["mutes"], 1)
def test_unknown_ids_are_rejected(self):
self.assertFalse(self.registry.acknowledge("nope"))
self.assertFalse(self.registry.mute("nope"))
self.assertFalse(self.registry.unhide("nope"))
def test_kinds_that_never_repeat_cannot_be_acknowledged(self):
self.registry.raise_notice(
"update_available", scope="0.19.1", params={"version": "0.19.1"}
)
self.assertFalse(self.registry.acknowledge("update_available:0.19.1"))
self.assertFalse(
self.registry.acknowledge("config:detect:fps-greater-than-five:global")
)
self.assertEqual(self.registry.active()[0]["acknowledgeable"], False)
def test_hidden_notices_listed_only_when_requested(self):
self.registry.raise_notice("detector_stuck", scope="ov", params={})
self.registry.acknowledge("detector_stuck:ov")
self.assertEqual(self.registry.active(), []) self.assertEqual(self.registry.active(), [])
self.assertEqual(len(self.registry.active(include_hidden=True)), 1) self.assertEqual(len(self.registry.active(include_dismissed=True)), 1)
def test_unhide_shows_a_notice_and_drops_a_check_mute(self):
self.registry.raise_notice("detector_stuck", scope="ov", params={})
self.registry.mute("detector_stuck:ov")
self.registry.mute("config:detect:fps-greater-than-five:global")
self.assertTrue(self.registry.unhide("detector_stuck:ov"))
self.assertTrue(
self.registry.unhide("config:detect:fps-greater-than-five:global")
)
notice = self.registry.active()[0]
self.assertIsNone(notice["muted_at"])
self.assertEqual(self.registry.muted_checks(), [])
def test_resolve_deletes_and_keeps_stats(self): def test_resolve_deletes_and_keeps_stats(self):
self.registry.raise_notice( self.registry.raise_notice(
@@ -227,7 +170,7 @@ class TestNoticeRegistry(RegistryTestCase):
self.registry.resolve("model_download_failed", "yolo/model.onnx") self.registry.resolve("model_download_failed", "yolo/model.onnx")
self.registry.resolve("model_download_failed", "yolo/model.onnx") self.registry.resolve("model_download_failed", "yolo/model.onnx")
self.assertEqual(self.registry.active(include_hidden=True), []) self.assertEqual(self.registry.active(include_dismissed=True), [])
self.assertEqual(self.registry.stats()[0]["occurrences"], 1) self.assertEqual(self.registry.stats()[0]["occurrences"], 1)
self.listener.assert_called_once() self.listener.assert_called_once()
@@ -304,47 +247,63 @@ class TestNoticeRegistry(RegistryTestCase):
ids, ["model_download_failed:front_door", "skipped_detections:garage"] ids, ["model_download_failed:front_door", "skipped_detections:garage"]
) )
def test_resolve_camera_drops_that_cameras_check_mutes(self): def test_resolve_camera_drops_that_cameras_check_dismissals(self):
for check_id in ( for check_id in (
"stream:front_door:0:probe", "stream:front_door:0:probe",
"config:detect:fps-greater-than-five:camera.front_door", "config:detect:fps-greater-than-five:camera.front_door",
"config:detect:fps-greater-than-five:global", "config:detect:fps-greater-than-five:global",
"stream:garage:0:probe", "stream:garage:0:probe",
): ):
self.assertTrue(self.registry.mute(check_id)) self.assertTrue(self.registry.dismiss(check_id))
self.registry.resolve_camera("front_door") self.registry.resolve_camera("front_door")
ids = sorted(check["id"] for check in self.registry.muted_checks()) ids = sorted(check["id"] for check in self.registry.dismissed_checks())
self.assertEqual( self.assertEqual(
ids, ids,
["config:detect:fps-greater-than-five:global", "stream:garage:0:probe"], ["config:detect:fps-greater-than-five:global", "stream:garage:0:probe"],
) )
def test_camera_named_global_keeps_global_check_mutes(self): def test_camera_named_global_keeps_global_check_dismissals(self):
self.registry.mute("config:detect:fps-greater-than-five:global") self.registry.dismiss("config:detect:fps-greater-than-five:global")
self.registry.mute("config:detect:fps-greater-than-five:camera.global") self.registry.dismiss("config:detect:fps-greater-than-five:camera.global")
self.registry.resolve_camera("global") self.registry.resolve_camera("global")
ids = [check["id"] for check in self.registry.muted_checks()] ids = [check["id"] for check in self.registry.dismissed_checks()]
self.assertEqual(ids, ["config:detect:fps-greater-than-five:global"]) self.assertEqual(ids, ["config:detect:fps-greater-than-five:global"])
def test_unhide_all_shows_every_notice_and_keeps_counts(self): def test_purge_dismissed_keeps_active_notices_and_counts(self):
self.registry.raise_notice("detector_stuck", scope="ov", params={}) self.registry.raise_notice("detector_stuck", scope="ov", params={})
self.registry.raise_notice("skipped_detections", scope="garage", params={}) self.registry.raise_notice("skipped_detections", scope="garage", params={})
self.registry.acknowledge("detector_stuck:ov") self.registry.dismiss("detector_stuck:ov")
self.registry.mute("skipped_detections:garage") self.registry.dismiss("config:detect:fps-greater-than-five:camera.garage")
self.registry.mute("config:detect:fps-greater-than-five:camera.garage")
self.registry.unhide_all() self.assertEqual(self.registry.purge_dismissed(), 2)
ids = sorted(n["id"] for n in self.registry.active()) ids = [n["id"] for n in self.registry.active(include_dismissed=True)]
self.assertEqual(ids, ["detector_stuck:ov", "skipped_detections:garage"]) self.assertEqual(ids, ["skipped_detections:garage"])
self.assertEqual(self.registry.muted_checks(), []) self.assertEqual(self.registry.dismissed_checks(), [])
stats = {s["kind"]: s for s in self.registry.stats()} dismissals = {s["kind"]: s["dismissals"] for s in self.registry.stats()}
self.assertEqual(stats["detector_stuck"]["acknowledgements"], 1) self.assertEqual(dismissals["detector_stuck"], 1)
self.assertEqual(stats["skipped_detections"]["mutes"], 1)
class TestMarkReported(RegistryTestCase):
def test_moves_watermarks_to_the_snapshot_not_the_current_count(self):
self.registry.raise_notice(
"detector_stuck", scope="ov", params={"detector": "ov"}
)
snapshot = self.registry.stats()
self.registry.raise_notice(
"detector_stuck", scope="ov", params={"detector": "ov"}
)
self.registry.mark_reported(snapshot)
row = self.registry.stats()[0]
self.assertEqual(row["occurrences"], 2)
self.assertEqual(row["reported_occurrences"], 1)
self.assertEqual(row["reported_dismissals"], 0)
class TestApply(RegistryTestCase): class TestApply(RegistryTestCase):
@@ -418,7 +377,7 @@ class TestLifecycleKnobs(RegistryTestCase):
self.registry.flush() self.registry.flush()
self.assertEqual(self.registry.active(include_hidden=True), []) self.assertEqual(self.registry.active(include_dismissed=True), [])
self.listener.assert_not_called() self.listener.assert_not_called()
def test_a_new_row_starts_without_stale_repeats(self): def test_a_new_row_starts_without_stale_repeats(self):
@@ -431,26 +390,32 @@ class TestLifecycleKnobs(RegistryTestCase):
self.assertEqual(self.registry.active()[0]["count"], 1) self.assertEqual(self.registry.active()[0]["count"], 1)
def test_an_acknowledged_notice_returns_when_a_later_repeat_flushes(self): def test_a_dismissed_notice_reopens_at_the_count(self):
self._raise_at(1000.0, "batched") self.registry.raise_notice("batched")
self._acknowledge_at(1010.0, "batched") self.registry.dismiss("batched")
self._raise_at(1020.0, "batched") self.registry.raise_notice("batched")
self.registry.flush()
self.assertEqual(self.registry.active(), []) self.assertEqual(self.registry.active(), [])
self.registry.raise_notice("batched")
self.registry.flush() self.registry.flush()
self.assertEqual(self.registry.active()[0]["count"], 2) notice = self.registry.active()[0]
self.assertEqual(notice["count"], BATCHED.reopen_at_count)
self.assertIsNone(notice["dismissed_at"])
def test_repeats_held_from_before_an_acknowledgement_stay_hidden(self): def test_a_notice_dismissed_past_the_count_stays_dismissed(self):
self._raise_at(1000.0, "batched") for _ in range(3):
self._raise_at(1005.0, "batched") self.registry.raise_notice("batched")
self._acknowledge_at(1010.0, "batched") self.registry.flush()
self.registry.dismiss("batched")
self.registry.raise_notice("batched")
self.registry.flush() self.registry.flush()
self.assertEqual(self.registry.active(), []) self.assertEqual(self.registry.active(), [])
self.assertEqual(self.registry.active(include_hidden=True)[0]["count"], 2) self.assertEqual(self.registry.active(include_dismissed=True)[0]["count"], 4)
def test_keep_latest_drops_the_oldest_rows(self): def test_keep_latest_drops_the_oldest_rows(self):
for index, scope in enumerate(("a", "b", "c")): for index, scope in enumerate(("a", "b", "c")):
@@ -473,13 +438,14 @@ class TestCatalogLifecycles(RegistryTestCase):
ids = [n["id"] for n in self.registry.active()] ids = [n["id"] for n in self.registry.active()]
self.assertEqual(ids, ["update_available:0.19.1"]) self.assertEqual(ids, ["update_available:0.19.1"])
def test_an_acknowledged_failed_login_burst_returns_on_the_next_attempt(self): def test_a_dismissed_failed_login_burst_reopens_at_five_attempts(self):
self.registry.raise_notice("failed_login", scope="1000") self.registry.raise_notice("failed_login", scope="1000")
self.registry.acknowledge("failed_login:1000") self.registry.dismiss("failed_login:1000")
for _ in range(4):
self.registry.raise_notice("failed_login", scope="1000") self.registry.raise_notice("failed_login", scope="1000")
self.registry.flush() self.registry.flush()
burst = self.registry.active()[0] burst = self.registry.active()[0]
self.assertEqual(burst["count"], 2) self.assertEqual(burst["count"], 5)
self.assertIsNone(burst["acknowledged_at"]) self.assertIsNone(burst["dismissed_at"])
-147
View File
@@ -1,7 +1,5 @@
"""Tests for the device each model runner reports after loading.""" """Tests for the device each model runner reports after loading."""
import os
import tempfile
import threading import threading
import unittest import unittest
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@@ -40,7 +38,6 @@ class TestRunnerDeviceName(unittest.TestCase):
self._onnx(["OpenVINOExecutionProvider"]).device_name, "OpenVINO" self._onnx(["OpenVINOExecutionProvider"]).device_name, "OpenVINO"
) )
self.assertEqual(self._onnx(["CPUExecutionProvider"]).device_name, "CPU") self.assertEqual(self._onnx(["CPUExecutionProvider"]).device_name, "CPU")
self.assertEqual(self._onnx(["LighterANE"]).device_name, "Neural Engine")
self.assertEqual(self._onnx(["ROCMExecutionProvider"]).device_name, "ROCM") self.assertEqual(self._onnx(["ROCMExecutionProvider"]).device_name, "ROCM")
self.assertEqual(self._onnx([]).device_name, "CPU") self.assertEqual(self._onnx([]).device_name, "CPU")
@@ -132,147 +129,3 @@ class TestLoadedDeviceSnapshot(unittest.TestCase):
finally: finally:
stop.set() stop.set()
thread.join(timeout=5) thread.join(timeout=5)
class TestLighterANE(unittest.TestCase):
"""lighter's plugin provider, picked up by the ONNX session setup."""
def setUp(self):
loaded_devices.clear()
self.root = tempfile.TemporaryDirectory()
self.addCleanup(self.root.cleanup)
self.library = os.path.join(self.root.name, "liblighter_ane_ep.so")
env = patch.dict(os.environ, {"LIGHTER_ANE_EP": self.library})
env.start()
self.addCleanup(env.stop)
def _device(self) -> MagicMock:
device = MagicMock()
device.ep_name = "LighterANE"
return device
def test_no_devices_without_the_library(self):
with patch.object(detection_runners.ort, "get_ep_devices") as get_ep_devices:
self.assertEqual(detection_runners.get_lighter_ane_devices(), [])
get_ep_devices.assert_not_called()
def test_the_provider_is_registered_once(self):
open(self.library, "w").close()
device = self._device()
other = MagicMock()
other.ep_name = "CPUExecutionProvider"
with (
patch.object(
detection_runners.ort,
"get_ep_devices",
side_effect=[[other], [other, device], [other, device]],
),
patch.object(
detection_runners.ort, "register_execution_provider_library"
) as register,
):
self.assertEqual(detection_runners.get_lighter_ane_devices(), [device])
self.assertEqual(detection_runners.get_lighter_ane_devices(), [device])
register.assert_called_once_with("LighterANE", self.library)
def test_a_provider_that_will_not_load_is_skipped(self):
open(self.library, "w").close()
with (
patch.object(detection_runners.ort, "get_ep_devices", return_value=[]),
patch.object(
detection_runners.ort,
"register_execution_provider_library",
side_effect=RuntimeError("not a provider"),
),
):
self.assertEqual(detection_runners.get_lighter_ane_devices(), [])
def test_the_session_runs_on_the_neural_engine(self):
device = self._device()
session = MagicMock()
session.get_providers.return_value = ["LighterANE", "CPUExecutionProvider"]
options = MagicMock()
with (
patch.object(detection_runners, "is_rknn_compatible", return_value=False),
patch.object(
detection_runners, "get_lighter_ane_devices", return_value=[device]
),
patch.object(
detection_runners, "get_ort_session_options", return_value=options
),
patch.object(
detection_runners.ort, "InferenceSession", return_value=session
) as inference_session,
):
runner = get_optimized_runner("/models/yolo.onnx", "AUTO", "yolo-generic")
options.add_provider_for_devices.assert_called_once_with([device], {})
inference_session.assert_called_once_with(
"/models/yolo.onnx", sess_options=options
)
self.assertIsInstance(runner, ONNXModelRunner)
self.assertEqual(
loaded_devices["/models/yolo.onnx"], ("yolo-generic", "Neural Engine")
)
def test_a_model_the_neural_engine_cannot_load_uses_the_default_providers(self):
session = MagicMock()
session.get_providers.return_value = ["CPUExecutionProvider"]
with (
patch.object(detection_runners, "is_rknn_compatible", return_value=False),
patch.object(
detection_runners, "get_lighter_ane_devices", return_value=[MagicMock()]
),
patch.object(
detection_runners,
"get_ort_providers",
return_value=(["CPUExecutionProvider"], [{}]),
),
patch.object(
detection_runners, "is_openvino_gpu_npu_available", return_value=False
),
patch.object(
detection_runners.ort,
"InferenceSession",
side_effect=[RuntimeError("unsupported"), session],
) as inference_session,
patch.object(
detection_runners, "get_ort_session_options", return_value=MagicMock()
),
self.assertLogs(detection_runners.logger, level="WARNING"),
):
runner = get_optimized_runner("/models/jina.onnx", "AUTO", "jina-v2")
self.assertEqual(inference_session.call_count, 2)
self.assertIsInstance(runner, ONNXModelRunner)
self.assertEqual(loaded_devices["/models/jina.onnx"], ("jina-v2", "CPU"))
def test_a_cpu_model_stays_on_the_cpu(self):
session = MagicMock()
session.get_providers.return_value = ["CPUExecutionProvider"]
with (
patch.object(detection_runners, "is_rknn_compatible", return_value=False),
patch.object(
detection_runners, "get_lighter_ane_devices", return_value=[MagicMock()]
) as ane,
patch.object(
detection_runners,
"get_ort_providers",
return_value=(["CPUExecutionProvider"], [{}]),
),
patch.object(
detection_runners.ort, "InferenceSession", return_value=session
),
patch.object(
detection_runners, "get_ort_session_options", return_value=None
),
):
get_optimized_runner("/models/arcface.onnx", "CPU", "arcface")
ane.assert_not_called()
+18 -86
View File
@@ -1,14 +1,10 @@
"""Tests for the per-camera episode notices: skipped detections and high CPU.""" """Tests for the skipped detection rate and the notice it can raise."""
import unittest import unittest
from unittest.mock import call, patch from unittest.mock import patch
from frigate.stats import emitter from frigate.stats import emitter
from frigate.stats.emitter import ( from frigate.stats.emitter import SkippedDetectionsTracker
SKIPPED_DETECTIONS_PCT,
EpisodeTracker,
cpu_average,
)
from frigate.stats.util import skipped_percent from frigate.stats.util import skipped_percent
@@ -23,18 +19,18 @@ class TestSkippedPercent(unittest.TestCase):
self.assertEqual(skipped_percent(2.0, 5.0, False), 0.0) self.assertEqual(skipped_percent(2.0, 5.0, False), 0.0)
class TestEpisodeTracker(unittest.TestCase): class TestSkippedDetectionsTracker(unittest.TestCase):
def _cameras(self, pct: float) -> dict: def _cameras(self, pct: float) -> dict:
return {"front_door": pct} return {"front_door": {"skipped_pct": pct}}
def test_below_threshold_never_qualifies(self): def test_below_threshold_never_qualifies(self):
tracker = EpisodeTracker(SKIPPED_DETECTIONS_PCT) tracker = SkippedDetectionsTracker()
for tick in range(10): for tick in range(10):
self.assertEqual(tracker.update(self._cameras(4.9), tick * 15.0), []) self.assertEqual(tracker.update(self._cameras(4.9), tick * 15.0), [])
def test_qualifies_once_after_the_hold(self): def test_qualifies_once_after_the_hold(self):
tracker = EpisodeTracker(SKIPPED_DETECTIONS_PCT) tracker = SkippedDetectionsTracker()
results = [ results = [
tracker.update(self._cameras(10.0), tick * 15.0) for tick in range(8) tracker.update(self._cameras(10.0), tick * 15.0) for tick in range(8)
@@ -46,7 +42,7 @@ class TestEpisodeTracker(unittest.TestCase):
self.assertEqual(results[5:], [[], [], []]) self.assertEqual(results[5:], [[], [], []])
def test_a_dip_restarts_the_hold(self): def test_a_dip_restarts_the_hold(self):
tracker = EpisodeTracker(SKIPPED_DETECTIONS_PCT) tracker = SkippedDetectionsTracker()
for now, pct in ((0.0, 10.0), (15.0, 10.0), (30.0, 10.0), (45.0, 2.0)): for now, pct in ((0.0, 10.0), (15.0, 10.0), (30.0, 10.0), (45.0, 2.0)):
self.assertEqual(tracker.update(self._cameras(pct), now), []) self.assertEqual(tracker.update(self._cameras(pct), now), [])
@@ -55,7 +51,7 @@ class TestEpisodeTracker(unittest.TestCase):
self.assertEqual(tracker.update(self._cameras(10.0), 120.0), ["front_door"]) self.assertEqual(tracker.update(self._cameras(10.0), 120.0), ["front_door"])
def test_recovery_then_relapse_is_a_new_episode(self): def test_recovery_then_relapse_is_a_new_episode(self):
tracker = EpisodeTracker(SKIPPED_DETECTIONS_PCT) tracker = SkippedDetectionsTracker()
tracker.update(self._cameras(10.0), 0.0) tracker.update(self._cameras(10.0), 0.0)
self.assertEqual(tracker.update(self._cameras(10.0), 60.0), ["front_door"]) self.assertEqual(tracker.update(self._cameras(10.0), 60.0), ["front_door"])
@@ -65,15 +61,17 @@ class TestEpisodeTracker(unittest.TestCase):
self.assertEqual(tracker.update(self._cameras(10.0), 150.0), ["front_door"]) self.assertEqual(tracker.update(self._cameras(10.0), 150.0), ["front_door"])
def test_cameras_are_tracked_separately(self): def test_cameras_are_tracked_separately(self):
tracker = EpisodeTracker(SKIPPED_DETECTIONS_PCT) tracker = SkippedDetectionsTracker()
tracker.update({"a": 10.0, "b": 0.0}, 0.0) tracker.update({"a": {"skipped_pct": 10.0}, "b": {"skipped_pct": 0.0}}, 0.0)
qualified = tracker.update({"a": 10.0, "b": 10.0}, 60.0) qualified = tracker.update(
{"a": {"skipped_pct": 10.0}, "b": {"skipped_pct": 10.0}}, 60.0
)
self.assertEqual(qualified, ["a"]) self.assertEqual(qualified, ["a"])
def test_a_removed_camera_starts_a_new_episode(self): def test_a_removed_camera_starts_a_new_episode(self):
tracker = EpisodeTracker(SKIPPED_DETECTIONS_PCT) tracker = SkippedDetectionsTracker()
tracker.update(self._cameras(10.0), 0.0) tracker.update(self._cameras(10.0), 0.0)
tracker.update({}, 15.0) tracker.update({}, 15.0)
tracker.update(self._cameras(10.0), 30.0) tracker.update(self._cameras(10.0), 30.0)
@@ -81,25 +79,6 @@ class TestEpisodeTracker(unittest.TestCase):
self.assertEqual(tracker.update(self._cameras(10.0), 60.0), []) self.assertEqual(tracker.update(self._cameras(10.0), 60.0), [])
self.assertEqual(tracker.update(self._cameras(10.0), 90.0), ["front_door"]) self.assertEqual(tracker.update(self._cameras(10.0), 90.0), ["front_door"])
def test_a_missing_value_ends_the_episode(self):
tracker = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
tracker.update(self._cameras(10.0), 0.0)
tracker.update({"front_door": None}, 30.0)
self.assertEqual(tracker.update(self._cameras(10.0), 60.0), [])
class TestCpuAverage(unittest.TestCase):
def test_reads_the_lifetime_average(self):
usages = {"42": {"cpu": "80.0", "cpu_average": "25.0"}}
self.assertEqual(cpu_average(usages, 42), 25.0)
def test_unknown_process_has_no_average(self):
self.assertIsNone(cpu_average({}, 42))
self.assertIsNone(cpu_average({"42": {"cpu_average": "25.0"}}, None))
self.assertIsNone(cpu_average({"42": {"cpu_average": ""}}, 42))
class TestEmitterNotices(unittest.TestCase): class TestEmitterNotices(unittest.TestCase):
def setUp(self): def setUp(self):
@@ -115,25 +94,15 @@ class TestEmitterNotices(unittest.TestCase):
def _emitter(self) -> emitter.StatsEmitter: def _emitter(self) -> emitter.StatsEmitter:
stats_emitter = emitter.StatsEmitter.__new__(emitter.StatsEmitter) stats_emitter = emitter.StatsEmitter.__new__(emitter.StatsEmitter)
stats_emitter.skipped_detections = EpisodeTracker(SKIPPED_DETECTIONS_PCT) stats_emitter.skipped_detections = SkippedDetectionsTracker()
stats_emitter.ffmpeg_cpu = EpisodeTracker(emitter.FFMPEG_HIGH_CPU_PCT)
stats_emitter.detect_cpu = EpisodeTracker(emitter.DETECT_HIGH_CPU_PCT)
stats_emitter._shm_checked = False stats_emitter._shm_checked = False
stats_emitter._shm_params = None stats_emitter._shm_params = None
return stats_emitter return stats_emitter
def _stats( def _stats(self, uptime: int, pct: float) -> dict:
self, uptime: int, pct: float, ffmpeg_cpu: str = "5.0", detect_cpu: str = "5.0"
) -> dict:
return { return {
"service": {"uptime": uptime, "storage": {"/dev/shm": {}}}, "service": {"uptime": uptime, "storage": {"/dev/shm": {}}},
"cameras": { "cameras": {"front_door": {"skipped_pct": pct}},
"front_door": {"skipped_pct": pct, "ffmpeg_pid": 10, "pid": 11}
},
"cpu_usages": {
"10": {"cpu_average": ffmpeg_cpu},
"11": {"cpu_average": detect_cpu},
},
} }
def test_qualified_camera_raises_a_notice(self): def test_qualified_camera_raises_a_notice(self):
@@ -146,43 +115,6 @@ class TestEmitterNotices(unittest.TestCase):
"skipped_detections", scope="front_door", params={"pct": 12.5} "skipped_detections", scope="front_door", params={"pct": 12.5}
) )
def test_high_cpu_raises_a_notice_per_process(self):
stats_emitter = self._emitter()
for uptime, now in ((300, 0.0), (360, 60.0)):
stats_emitter._update_notices(
self._stats(uptime, 0.0, ffmpeg_cpu="25.0", detect_cpu="45.0"), now
)
self.assertEqual(
self.raise_notice.call_args_list,
[
call("ffmpeg_high_cpu", scope="front_door", params={"cpu": 25.0}),
call("detect_high_cpu", scope="front_door", params={"cpu": 45.0}),
],
)
def test_cpu_below_each_threshold_raises_nothing(self):
stats_emitter = self._emitter()
for uptime, now in ((300, 0.0), (360, 60.0)):
stats_emitter._update_notices(
self._stats(uptime, 0.0, ffmpeg_cpu="19.0", detect_cpu="39.0"), now
)
self.raise_notice.assert_not_called()
def test_missing_cpu_stats_raise_nothing(self):
stats_emitter = self._emitter()
for uptime, now in ((300, 0.0), (360, 60.0)):
stats = self._stats(uptime, 0.0)
del stats["cpu_usages"]
stats_emitter._update_notices(stats, now)
self.raise_notice.assert_not_called()
self.assertEqual(self.flush_notices.call_count, 2)
def test_startup_window_is_ignored(self): def test_startup_window_is_ignored(self):
stats_emitter = self._emitter() stats_emitter = self._emitter()
+4 -7
View File
@@ -56,13 +56,10 @@ class EventsPerSecond:
self._start = now self._start = now
# compute the (approximate) events in the last n seconds # compute the (approximate) events in the last n seconds
self.expire_timestamps(now) self.expire_timestamps(now)
# rate over at least one second (or the whole window, if shorter), seconds = min(now - self._start, self._last_n_seconds)
# so a burst of events right after start() is not divided by a # avoid divide by zero
# tiny window if seconds == 0:
seconds = max( seconds = 1
min(now - self._start, self._last_n_seconds),
min(1.0, self._last_n_seconds),
)
return len(self._timestamps) / seconds return len(self._timestamps) / seconds
# remove aged out timestamps # remove aged out timestamps
+1 -33
View File
@@ -9,7 +9,6 @@ and resolve it per camera against that camera's detect stream.
""" """
import logging import logging
import os
import re import re
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -24,20 +23,14 @@ from frigate.util.services import enumerate_drm_devices
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# roots the /proc and /sys reads use, so tests can point them at a fixture tree # root the /proc reads use, so tests can point them at a fixture tree
PROC_ROOT = "/proc" PROC_ROOT = "/proc"
SYS_ROOT = "/sys"
ANY_CODEC = "any" ANY_CODEC = "any"
# a Raspberry Pi has no detection hardware of its own, so it gets a key here # a Raspberry Pi has no detection hardware of its own, so it gets a key here
RASPBERRY_PI = "raspberrypi" RASPBERRY_PI = "raspberrypi"
# lighter (https://github.com/fieldwork-ai/lighter) decodes on a Mac's media
# engine through virtio-media devices, which carry its virtio vendor id "LGHT"
LIGHTER_MEDIA = "lighter"
LIGHTER_VIRTIO_VENDOR = "0x4c474854"
# ffprobe names h265 streams hevc # ffprobe names h265 streams hevc
CODEC_ALIASES = {"hevc": "h265"} CODEC_ALIASES = {"hevc": "h265"}
@@ -59,7 +52,6 @@ DECODE_HARDWARE = (
"rknn", "rknn",
"openvino:GPU", "openvino:GPU",
"onnx:amd", "onnx:amd",
LIGHTER_MEDIA,
RASPBERRY_PI, RASPBERRY_PI,
) )
@@ -106,10 +98,6 @@ FAMILY_RPI = HwaccelFamily(
key="rpi", key="rpi",
presets={"h264": "preset-rpi-64-h264", "h265": "preset-rpi-64-h265"}, presets={"h264": "preset-rpi-64-h264", "h265": "preset-rpi-64-h265"},
) )
FAMILY_APPLE = HwaccelFamily(
key="apple-silicon",
presets={"h264": "preset-apple-silicon-h264", "h265": "preset-apple-silicon-h265"},
)
def _read(path: str) -> str | None: def _read(path: str) -> str | None:
@@ -151,20 +139,6 @@ def _is_raspberry_pi() -> bool:
return "raspberrypi" in compatible return "raspberrypi" in compatible
def _has_lighter_media() -> bool:
video = f"{SYS_ROOT}/class/video4linux"
try:
devices = os.listdir(video)
except OSError:
return False
return any(
_read(f"{video}/{device}/device/vendor") == LIGHTER_VIRTIO_VENDOR
for device in devices
)
def _intel_families(generation: int | None) -> list[HwaccelFamily]: def _intel_families(generation: int | None) -> list[HwaccelFamily]:
"""vaapi drives every Intel GPU, qsv only those from gen8 on.""" """vaapi drives every Intel GPU, qsv only those from gen8 on."""
if generation is not None and generation < INTEL_QSV_SUPPORTED_GEN: if generation is not None and generation < INTEL_QSV_SUPPORTED_GEN:
@@ -190,9 +164,6 @@ def _families(key: str, generation: int | None) -> list[HwaccelFamily]:
if key == "onnx:amd": if key == "onnx:amd":
return [FAMILY_VAAPI] return [FAMILY_VAAPI]
if key == LIGHTER_MEDIA:
return [FAMILY_APPLE]
if key == RASPBERRY_PI: if key == RASPBERRY_PI:
return [FAMILY_RPI] return [FAMILY_RPI]
@@ -222,9 +193,6 @@ def _decode_hardware(detector_key: str | None) -> list[str]:
""" """
present = {found.key for found in hardware_prober.probe()} present = {found.key for found in hardware_prober.probe()}
if _has_lighter_media():
present.add(LIGHTER_MEDIA)
if _is_raspberry_pi(): if _is_raspberry_pi():
present.add(RASPBERRY_PI) present.add(RASPBERRY_PI)
+59
View File
@@ -0,0 +1,59 @@
"""Generate the analytics report JSON Schema from the Pydantic models.
The committed docs/static/frigate-analytics-schema.json documents every field
for the ingest Worker's aggregation job and the docs page. It is exact for this
version only, so the Worker validates just the envelope against it. Never edit
it by hand, and never run a formatter over it.
Usage (from the repository root):
python3 generate_analytics_schema.py # write the schema
python3 generate_analytics_schema.py --check # CI guard: fail if stale
"""
import argparse
import json
import sys
from pathlib import Path
from frigate.analytics.schema import SCHEMA_VERSION, AnalyticsReport
OUTPUT = Path(__file__).parent / "docs" / "static" / "frigate-analytics-schema.json"
def render() -> str:
schema = AnalyticsReport.model_json_schema()
schema["$schema"] = "https://json-schema.org/draft/2020-12/schema"
schema["$id"] = "https://docs.frigate.video/frigate-analytics-schema.json"
schema["x-schema-version"] = SCHEMA_VERSION
return json.dumps(schema, indent=2, sort_keys=True) + "\n"
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument(
"--check", action="store_true", help="fail if the committed schema is stale"
)
args = parser.parse_args()
rendered = render()
if args.check:
current = OUTPUT.read_text() if OUTPUT.exists() else ""
if current != rendered:
print(
f"{OUTPUT} is out of date, run python3 generate_analytics_schema.py",
file=sys.stderr,
)
return 1
print(f"{OUTPUT} is up to date")
return 0
OUTPUT.write_text(rendered)
print(f"Wrote {OUTPUT}")
return 0
if __name__ == "__main__":
sys.exit(main())
+3 -1
View File
@@ -47,8 +47,8 @@ from fastapi.routing import APIRoute
from ruamel.yaml import YAML from ruamel.yaml import YAML
from ruamel.yaml.scalarstring import LiteralScalarString from ruamel.yaml.scalarstring import LiteralScalarString
from frigate.api import app as main_app
from frigate.api import ( from frigate.api import (
analytics,
auth, auth,
camera, camera,
chat, chat,
@@ -65,6 +65,7 @@ from frigate.api import (
record, record,
review, review,
) )
from frigate.api import app as main_app
from frigate.api.auth import require_admin_by_default from frigate.api.auth import require_admin_by_default
logging.basicConfig(level=logging.INFO, format="%(message)s") logging.basicConfig(level=logging.INFO, format="%(message)s")
@@ -145,6 +146,7 @@ def build_app() -> FastAPI:
""" """
app = FastAPI() app = FastAPI()
routers = [ routers = [
analytics.router,
auth.router, auth.router,
camera.router, camera.router,
chat.router, chat.router,
+3 -6
View File
@@ -18,21 +18,18 @@ def migrate(migrator, database, fake=False, **kwargs):
'"first_seen" DATETIME NOT NULL, ' '"first_seen" DATETIME NOT NULL, '
'"last_seen" DATETIME NOT NULL, ' '"last_seen" DATETIME NOT NULL, '
'"count" INTEGER NOT NULL, ' '"count" INTEGER NOT NULL, '
'"acknowledged_at" DATETIME, ' '"dismissed_at" DATETIME)'
'"muted_at" DATETIME)'
) )
migrator.sql('CREATE INDEX IF NOT EXISTS "notice_kind" ON "notice" ("kind")') migrator.sql('CREATE INDEX IF NOT EXISTS "notice_kind" ON "notice" ("kind")')
migrator.sql( migrator.sql(
'CREATE TABLE IF NOT EXISTS "noticestats" (' 'CREATE TABLE IF NOT EXISTS "noticestats" ('
'"kind" VARCHAR(50) NOT NULL PRIMARY KEY, ' '"kind" VARCHAR(50) NOT NULL PRIMARY KEY, '
'"occurrences" INTEGER NOT NULL, ' '"occurrences" INTEGER NOT NULL, '
'"acknowledgements" INTEGER NOT NULL, ' '"dismissals" INTEGER NOT NULL, '
'"mutes" INTEGER NOT NULL, '
'"first_seen" DATETIME NOT NULL, ' '"first_seen" DATETIME NOT NULL, '
'"last_seen" DATETIME NOT NULL, ' '"last_seen" DATETIME NOT NULL, '
'"reported_occurrences" INTEGER NOT NULL DEFAULT 0, ' '"reported_occurrences" INTEGER NOT NULL DEFAULT 0, '
'"reported_acknowledgements" INTEGER NOT NULL DEFAULT 0, ' '"reported_dismissals" INTEGER NOT NULL DEFAULT 0)'
'"reported_mutes" INTEGER NOT NULL DEFAULT 0)'
) )
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -49,7 +49,7 @@ export interface ApiMockOverrides {
}; };
users?: { username: string; role: string }[]; users?: { username: string; role: string }[];
notices?: unknown[]; notices?: unknown[];
mutedChecks?: unknown[]; dismissedChecks?: unknown[];
/** camera name to the ffprobe entries returned for `paths=camera:<name>` */ /** camera name to the ffprobe entries returned for `paths=camera:<name>` */
ffprobe?: Record<string, unknown[]>; ffprobe?: Record<string, unknown[]>;
} }
@@ -238,8 +238,8 @@ export class ApiMocker {
await this.page.route("**/api/notices", (route) => await this.page.route("**/api/notices", (route) =>
route.fulfill({ json: overrides?.notices ?? [] }), route.fulfill({ json: overrides?.notices ?? [] }),
); );
await this.page.route("**/api/notices/muted_checks", (route) => await this.page.route("**/api/notices/dismissed_checks", (route) =>
route.fulfill({ json: overrides?.mutedChecks ?? [] }), route.fulfill({ json: overrides?.dismissedChecks ?? [] }),
); );
// Users. GET lists them; POST/PUT (create, password) just succeed, so // Users. GET lists them; POST/PUT (create, password) just succeed, so
@@ -0,0 +1,54 @@
/**
* Telemetry settings: the analytics preview shows the report Frigate would
* send, fetched only when opened.
*/
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { test, expect } from "../../fixtures/frigate-test";
const __dirname = dirname(fileURLToPath(import.meta.url));
const CONFIG_SCHEMA = JSON.parse(
readFileSync(
resolve(__dirname, "../../fixtures/mock-data/config-schema.json"),
"utf-8",
),
);
const PREVIEW = {
schema_version: 1,
install_id: "0".repeat(32),
install: { version: "0.19.0-test" },
};
test.describe("telemetry settings: analytics preview @medium", () => {
test("shows the report on demand", async ({ frigateApp }) => {
let requests = 0;
const page = frigateApp.page;
await page.route("**/api/config/schema.json", (route) =>
route.fulfill({ json: CONFIG_SCHEMA }),
);
await page.route("**/api/config/raw_paths", (route) =>
route.fulfill({ json: { telemetry: {} } }),
);
await page.route("**/api/analytics/preview", (route) => {
requests += 1;
return route.fulfill({ json: PREVIEW });
});
await frigateApp.goto("/settings?page=systemTelemetry");
const button = page.getByRole("button", { name: "Preview the report" });
await expect(button).toBeVisible();
expect(requests).toBe(0);
await button.click();
await expect(page.getByTestId("analytics-preview")).toContainText(
'"schema_version": 1',
);
expect(requests).toBe(1);
});
});
+71 -208
View File
@@ -1,8 +1,7 @@
/** /**
* Health tab tests -- MEDIUM tier. * Health tab tests -- MEDIUM tier.
* *
* Default tab, notice list rendering, acknowledge and mute, empty state, * Default tab, notice list rendering, dismiss, empty state, update notice.
* update notice.
*/ */
import { test, expect } from "../fixtures/frigate-test"; import { test, expect } from "../fixtures/frigate-test";
@@ -24,9 +23,7 @@ const ERROR_NOTICE = {
first_seen: NOW - 600, first_seen: NOW - 600,
last_seen: NOW, last_seen: NOW,
count: 2, count: 2,
acknowledgeable: true, dismissed_at: null,
acknowledged_at: null,
muted_at: null,
}; };
const EVENT_NOTICE = { const EVENT_NOTICE = {
@@ -40,9 +37,7 @@ const EVENT_NOTICE = {
first_seen: NOW - 7200, first_seen: NOW - 7200,
last_seen: NOW - 60, last_seen: NOW - 60,
count: 3, count: 3,
acknowledgeable: true, dismissed_at: null,
acknowledged_at: null,
muted_at: null,
}; };
test.describe("System — Health tab @medium", () => { test.describe("System — Health tab @medium", () => {
@@ -68,34 +63,31 @@ test.describe("System — Health tab @medium", () => {
"Downloading model.onnx for yolo failed: timeout", "Downloading model.onnx for yolo failed: timeout",
); );
await expect(rows.nth(0)).toContainText("2 times"); await expect(rows.nth(0)).toContainText("2 times");
await expect(
rows.nth(0).getByRole("button", { name: "Dismiss" }),
).toBeVisible();
await expect(rows.nth(1)).toContainText("Detector ov was restarted"); await expect(rows.nth(1)).toContainText("Detector ov was restarted");
await expect(rows.nth(1)).toContainText("3 times"); await expect(rows.nth(1)).toContainText("3 times");
for (const index of [0, 1]) {
await expect( await expect(
rows.nth(index).getByRole("button", { name: "Acknowledge" }), rows.nth(1).getByRole("button", { name: "Dismiss" }),
).toBeVisible(); ).toBeVisible();
await expect(
rows.nth(index).getByRole("button", { name: "Mute", exact: true }),
).toBeVisible();
}
}); });
for (const action of ["acknowledge", "mute"] as const) { test("dismiss posts and removes the row", async ({ frigateApp }) => {
test(`${action} posts and removes the row`, async ({ frigateApp }) => {
await frigateApp.installDefaults({ await frigateApp.installDefaults({
stats: QUIET_STATS, stats: QUIET_STATS,
notices: [EVENT_NOTICE], notices: [EVENT_NOTICE],
}); });
// the list shrinks after the POST so the refetch shows the row gone // the list shrinks after the dismiss so the refetch shows the row gone
let hidden = false; let dismissed = false;
await frigateApp.page.route("**/api/notices", (route) => await frigateApp.page.route("**/api/notices", (route) =>
route.fulfill({ json: hidden ? [] : [EVENT_NOTICE] }), route.fulfill({ json: dismissed ? [] : [EVENT_NOTICE] }),
); );
await frigateApp.page.route( await frigateApp.page.route(
`**/api/notices/detector_stuck/${action}`, "**/api/notices/detector_stuck/dismiss",
(route) => { (route) => {
hidden = true; dismissed = true;
return route.fulfill({ json: { success: true } }); return route.fulfill({ json: { success: true } });
}, },
); );
@@ -103,15 +95,12 @@ test.describe("System — Health tab @medium", () => {
await frigateApp.goto("/system#health"); await frigateApp.goto("/system#health");
const request = frigateApp.page.waitForRequest( const request = frigateApp.page.waitForRequest(
(req) => (req) =>
req.url().includes(`/api/notices/detector_stuck/${action}`) && req.url().includes("/api/notices/detector_stuck/dismiss") &&
req.method() === "POST", req.method() === "POST",
); );
await frigateApp.page await frigateApp.page
.getByTestId("health-problem-notice:detector_stuck") .getByTestId("health-problem-notice:detector_stuck")
.getByRole("button", { .getByRole("button", { name: "Dismiss" })
name: action === "mute" ? "Mute" : "Acknowledge",
exact: true,
})
.click(); .click();
await request; await request;
@@ -122,7 +111,6 @@ test.describe("System — Health tab @medium", () => {
frigateApp.page.getByText("Your Frigate installation is healthy"), frigateApp.page.getByText("Your Frigate installation is healthy"),
).toBeVisible(); ).toBeVisible();
}); });
}
test("empty state with no notices", async ({ frigateApp }) => { test("empty state with no notices", async ({ frigateApp }) => {
await frigateApp.installDefaults({ stats: QUIET_STATS }); await frigateApp.installDefaults({ stats: QUIET_STATS });
@@ -152,9 +140,7 @@ test.describe("System — Health tab @medium", () => {
first_seen: NOW - 3600, first_seen: NOW - 3600,
last_seen: NOW, last_seen: NOW,
count: 1, count: 1,
acknowledgeable: false, dismissed_at: null,
acknowledged_at: null,
muted_at: null,
}, },
], ],
}); });
@@ -170,23 +156,10 @@ test.describe("System — Health tab @medium", () => {
"href", "href",
"https://github.com/blakeblackshear/frigate/releases/tag/v0.19.0", "https://github.com/blakeblackshear/frigate/releases/tag/v0.19.0",
); );
await expect( await expect(row.getByRole("button", { name: "Dismiss" })).toBeVisible();
row.getByRole("button", { name: "Mute", exact: true }),
).toBeVisible();
await expect(row.getByRole("button", { name: "Acknowledge" })).toHaveCount(
0,
);
}); });
const ACKNOWLEDGED_NOTICE = { test("the filter shows dismissed notices without a Dismiss button", async ({
...EVENT_NOTICE,
id: "detector_stuck:coral",
params: { detector: "coral" },
acknowledged_at: NOW - 60,
};
const MUTED_NOTICE = { ...ERROR_NOTICE, muted_at: NOW - 120 };
test("the filter shows hidden notices marked by how they were hidden", async ({
frigateApp, frigateApp,
}) => { }) => {
await frigateApp.installDefaults({ await frigateApp.installDefaults({
@@ -196,85 +169,33 @@ test.describe("System — Health tab @medium", () => {
await frigateApp.page.route( await frigateApp.page.route(
(url) => (url) =>
url.pathname.endsWith("/api/notices") && url.pathname.endsWith("/api/notices") &&
url.searchParams.get("include_hidden") === "true", url.searchParams.get("include_dismissed") === "true",
(route) => (route) =>
route.fulfill({ route.fulfill({
json: [EVENT_NOTICE, ACKNOWLEDGED_NOTICE, MUTED_NOTICE], json: [EVENT_NOTICE, { ...ERROR_NOTICE, dismissed_at: NOW - 120 }],
}), }),
); );
await frigateApp.goto("/system#health"); await frigateApp.goto("/system#health");
await frigateApp.page.getByRole("button", { name: "Filter" }).click(); await frigateApp.page.getByRole("button", { name: "Filter" }).click();
const showHidden = frigateApp.page.getByRole("switch", { const showDismissed = frigateApp.page.getByRole("switch", {
name: "Show hidden", name: "Show dismissed",
}); });
await expect(showHidden).toHaveAttribute("aria-checked", "false"); await expect(showDismissed).toHaveAttribute("aria-checked", "false");
await showHidden.click(); await showDismissed.click();
// mobile opens the filter as a modal drawer, which hides the rows' roles
await frigateApp.page.keyboard.press("Escape");
const acknowledged = frigateApp.page.getByTestId( const row = frigateApp.page.getByTestId(
"health-problem-notice:detector_stuck:coral",
);
await expect(acknowledged).toBeVisible({ timeout: 15_000 });
await expect(acknowledged).toContainText("Acknowledged");
await expect(
acknowledged.getByRole("button", { name: "Show again" }),
).toBeVisible();
await expect(
acknowledged.getByRole("button", { name: "Acknowledge" }),
).toHaveCount(0);
const muted = frigateApp.page.getByTestId(
"health-problem-notice:model_download_failed:yolo/model.onnx", "health-problem-notice:model_download_failed:yolo/model.onnx",
); );
await expect(muted).toContainText("Muted"); await expect(row).toBeVisible({ timeout: 15_000 });
await expect(muted.getByRole("button", { name: "Unmute" })).toBeVisible(); await expect(row).toContainText("Dismissed");
await expect( await expect(row.getByRole("button", { name: "Dismiss" })).toHaveCount(0);
muted.getByRole("button", { name: "Mute", exact: true }),
).toHaveCount(0);
await frigateApp.page.getByRole("button", { name: "Filter" }).click(); await showDismissed.click();
await showHidden.click(); await expect(row).toHaveCount(0);
await expect(muted).toHaveCount(0);
}); });
test("unmute deletes the row's hidden state", async ({ frigateApp }) => { test("clear dismissed deletes the dismissed rows after confirming", async ({
await frigateApp.installDefaults({ stats: QUIET_STATS, notices: [] });
await frigateApp.page.route(
(url) =>
url.pathname.endsWith("/api/notices") &&
url.searchParams.get("include_hidden") === "true",
(route) => route.fulfill({ json: [MUTED_NOTICE] }),
);
await frigateApp.page.route(
"**/api/notices/model_download_failed:yolo/model.onnx/hidden",
(route) => route.fulfill({ json: { success: true } }),
);
await frigateApp.goto("/system#health");
await frigateApp.page.getByRole("button", { name: "Filter" }).click();
await frigateApp.page.getByRole("switch", { name: "Show hidden" }).click();
await frigateApp.page.keyboard.press("Escape");
const request = frigateApp.page.waitForRequest(
(req) =>
req
.url()
.endsWith(
"/api/notices/model_download_failed:yolo/model.onnx/hidden",
) && req.method() === "DELETE",
);
await frigateApp.page
.getByTestId(
"health-problem-notice:model_download_failed:yolo/model.onnx",
)
.getByRole("button", { name: "Unmute" })
.click({ timeout: 15_000 });
await request;
});
test("show all again unhides every row after confirming", async ({
frigateApp, frigateApp,
}) => { }) => {
await frigateApp.installDefaults({ await frigateApp.installDefaults({
@@ -282,25 +203,29 @@ test.describe("System — Health tab @medium", () => {
notices: [EVENT_NOTICE], notices: [EVENT_NOTICE],
}); });
// the hidden list loses its muted row once the DELETE lands // the history loses its dismissed row once the DELETE lands
let cleared = false; let cleared = false;
await frigateApp.page.route( await frigateApp.page.route(
(url) => (url) =>
url.pathname.endsWith("/api/notices") && url.pathname.endsWith("/api/notices") &&
url.searchParams.get("include_hidden") === "true", url.searchParams.get("include_dismissed") === "true",
(route) => (route) =>
route.fulfill({ route.fulfill({
json: cleared ? [EVENT_NOTICE] : [EVENT_NOTICE, MUTED_NOTICE], json: cleared
? [EVENT_NOTICE]
: [EVENT_NOTICE, { ...ERROR_NOTICE, dismissed_at: NOW - 120 }],
}), }),
); );
await frigateApp.page.route("**/api/notices/hidden", (route) => { await frigateApp.page.route("**/api/notices/dismissed", (route) => {
cleared = true; cleared = true;
return route.fulfill({ json: { success: true } }); return route.fulfill({ json: { success: true } });
}); });
await frigateApp.goto("/system#health"); await frigateApp.goto("/system#health");
await frigateApp.page.getByRole("button", { name: "Filter" }).click(); await frigateApp.page.getByRole("button", { name: "Filter" }).click();
await frigateApp.page.getByRole("switch", { name: "Show hidden" }).click(); await frigateApp.page
.getByRole("switch", { name: "Show dismissed" })
.click();
const row = frigateApp.page.getByTestId( const row = frigateApp.page.getByTestId(
"health-problem-notice:model_download_failed:yolo/model.onnx", "health-problem-notice:model_download_failed:yolo/model.onnx",
); );
@@ -308,20 +233,23 @@ test.describe("System — Health tab @medium", () => {
await frigateApp.page.keyboard.press("Escape"); await frigateApp.page.keyboard.press("Escape");
await frigateApp.page await frigateApp.page
.getByRole("button", { name: "Show all again" }) .getByRole("button", { name: "Clear dismissed" })
.click(); .click();
const request = frigateApp.page.waitForRequest( const request = frigateApp.page.waitForRequest(
(req) => (req) =>
req.url().endsWith("/api/notices/hidden") && req.method() === "DELETE", req.url().endsWith("/api/notices/dismissed") &&
req.method() === "DELETE",
); );
await frigateApp.page await frigateApp.page
.getByRole("alertdialog") .getByRole("alertdialog")
.getByRole("button", { name: "Show all again" }) .getByRole("button", { name: "Clear dismissed" })
.click(); .click();
await request; await request;
await expect(row).toHaveCount(0); await expect(row).toHaveCount(0);
await expect(frigateApp.page.getByText("No hidden notices")).toBeVisible(); await expect(
frigateApp.page.getByText("No dismissed notices"),
).toBeVisible();
}); });
test("severity switches hide notices of that severity", async ({ test("severity switches hide notices of that severity", async ({
@@ -362,9 +290,7 @@ test.describe("System — Health tab @medium", () => {
first_seen: start, first_seen: start,
last_seen: start + 60, last_seen: start + 60,
count, count,
acknowledgeable: true, dismissed_at: null,
acknowledged_at: null,
muted_at: null,
}); });
await frigateApp.installDefaults({ await frigateApp.installDefaults({
stats: QUIET_STATS, stats: QUIET_STATS,
@@ -406,9 +332,7 @@ test.describe("System — Health tab @medium", () => {
first_seen: NOW - 600, first_seen: NOW - 600,
last_seen: NOW - 600, last_seen: NOW - 600,
count: 1, count: 1,
acknowledgeable: true, dismissed_at: null,
acknowledged_at: null,
muted_at: null,
}, },
], ],
}); });
@@ -439,9 +363,7 @@ test.describe("System — Health tab @medium", () => {
first_seen: NOW - 600, first_seen: NOW - 600,
last_seen: NOW - 600, last_seen: NOW - 600,
count: 1, count: 1,
acknowledgeable: true, dismissed_at: null,
acknowledged_at: null,
muted_at: null,
}, },
], ],
}); });
@@ -773,7 +695,7 @@ test.describe("System — Health notices sources @medium", () => {
// the status bar shows a problem, so stats have loaded // the status bar shows a problem, so stats have loaded
await expect( await expect(
frigateApp.page.getByText("Recordings are being deleted"), frigateApp.page.getByText("Front Door is offline"),
).toBeVisible({ timeout: 15_000 }); ).toBeVisible({ timeout: 15_000 });
await expect( await expect(
frigateApp.page.locator("[data-testid^='health-problem-']"), frigateApp.page.locator("[data-testid^='health-problem-']"),
@@ -1124,7 +1046,7 @@ test.describe("System — Health notices sources @medium", () => {
await expect(frigateApp.page).toHaveURL(/\/system#health/); await expect(frigateApp.page).toHaveURL(/\/system#health/);
}); });
test("status bar counts shown notices next to the health text", async ({ test("status bar counts undismissed notices next to the health text", async ({
frigateApp, frigateApp,
}) => { }) => {
test.skip(frigateApp.isMobile, "Status bar is desktop-only"); test.skip(frigateApp.isMobile, "Status bar is desktop-only");
@@ -1167,63 +1089,7 @@ test.describe("System — Health notices sources @medium", () => {
await expect(frigateApp.page.getByText("System is healthy")).toHaveCount(0); await expect(frigateApp.page.getByText("System is healthy")).toHaveCount(0);
}); });
// the slow detector warning is added before the offline error test("a config row can be dismissed", async ({ frigateApp }) => {
const TWO_PROBLEM_STATS = {
detectors: { cpu: { inference_speed: 60 } },
cameras: { front_door: { camera_fps: 0 } },
};
test("status bar collapses several problems behind the most severe", async ({
frigateApp,
}) => {
test.skip(frigateApp.isMobile, "Status bar is desktop-only");
await frigateApp.installDefaults({ stats: TWO_PROBLEM_STATS });
await frigateApp.goto("/");
const summary = frigateApp.page.getByRole("button", {
name: "Front Door is offline +1",
});
await expect(summary).toBeVisible({ timeout: 15_000 });
await expect(frigateApp.page.getByText("Cpu is slow")).toHaveCount(0);
await summary.click();
const list = frigateApp.page.getByTestId("status-message-list");
await expect(list.getByRole("link")).toHaveText([
"Front Door is offline",
"Cpu is slow (60 ms)",
]);
await list.getByRole("link", { name: "Cpu is slow (60 ms)" }).click();
await expect(frigateApp.page).toHaveURL(/\/system#general/);
await expect(list).toHaveCount(0);
});
test("mobile status drawer stacks every problem", async ({ frigateApp }) => {
test.skip(!frigateApp.isMobile, "Mobile-only");
await frigateApp.installDefaults({ stats: TWO_PROBLEM_STATS });
await frigateApp.goto("/");
await frigateApp.page
.getByTestId("status-alert-trigger")
.click({ timeout: 15_000 });
const items = frigateApp.page
.getByTestId("status-message-list")
.getByRole("link");
await expect(items).toHaveText([
"Front Door is offline",
"Cpu is slow (60 ms)",
]);
const [first, second] = await Promise.all([
items.nth(0).boundingBox(),
items.nth(1).boundingBox(),
]);
expect(second!.y).toBeGreaterThan(first!.y);
});
test("a config row can be muted but not acknowledged", async ({
frigateApp,
}) => {
const id = "config:detect:fps-greater-than-five:camera.garage"; const id = "config:detect:fps-greater-than-five:camera.garage";
await frigateApp.installDefaults({ await frigateApp.installDefaults({
config: { config: {
@@ -1234,33 +1100,32 @@ test.describe("System — Health notices sources @medium", () => {
stats: QUIET_STATS, stats: QUIET_STATS,
}); });
// the list gains the mute after the POST so the refetch hides the row // the list gains the dismissal after the POST so the refetch hides the row
let muted = false; let dismissed = false;
await frigateApp.page.route(`**/api/notices/${id}/mute`, (route) => { await frigateApp.page.route(`**/api/notices/${id}/dismiss`, (route) => {
muted = true; dismissed = true;
return route.fulfill({ json: { success: true } }); return route.fulfill({ json: { success: true } });
}); });
await frigateApp.page.route("**/api/notices/muted_checks", (route) => await frigateApp.page.route("**/api/notices/dismissed_checks", (route) =>
route.fulfill({ json: muted ? [{ id, muted_at: NOW }] : [] }), route.fulfill({ json: dismissed ? [{ id, dismissed_at: NOW }] : [] }),
); );
await frigateApp.goto("/system#health"); await frigateApp.goto("/system#health");
const row = frigateApp.page.getByTestId(`health-problem-${id}`); const row = frigateApp.page.getByTestId(`health-problem-${id}`);
await expect(row).toBeVisible({ timeout: 15_000 }); await expect(row).toBeVisible({ timeout: 15_000 });
await expect(row.getByRole("button", { name: "Acknowledge" })).toHaveCount(
0,
);
const request = frigateApp.page.waitForRequest( const request = frigateApp.page.waitForRequest(
(req) => (req) =>
req.url().includes(`/api/notices/${id}/mute`) && req.url().includes(`/api/notices/${id}/dismiss`) &&
req.method() === "POST", req.method() === "POST",
); );
await row.getByRole("button", { name: "Mute", exact: true }).click(); await row.getByRole("button", { name: "Dismiss" }).click();
await request; await request;
await expect(row).toHaveCount(0); await expect(row).toHaveCount(0);
}); });
test("muted config rows move to the hidden list", async ({ frigateApp }) => { test("dismissed config rows move to the dismissed list", async ({
frigateApp,
}) => {
const id = "config:detect:fps-greater-than-five:camera.garage"; const id = "config:detect:fps-greater-than-five:camera.garage";
await frigateApp.installDefaults({ await frigateApp.installDefaults({
config: { config: {
@@ -1269,12 +1134,12 @@ test.describe("System — Health notices sources @medium", () => {
}, },
}, },
stats: QUIET_STATS, stats: QUIET_STATS,
mutedChecks: [{ id, muted_at: NOW - 120 }], dismissedChecks: [{ id, dismissed_at: NOW - 120 }],
}); });
await frigateApp.page.route( await frigateApp.page.route(
(url) => (url) =>
url.pathname.endsWith("/api/notices") && url.pathname.endsWith("/api/notices") &&
url.searchParams.get("include_hidden") === "true", url.searchParams.get("include_dismissed") === "true",
(route) => route.fulfill({ json: [] }), (route) => route.fulfill({ json: [] }),
); );
await frigateApp.goto("/system#health"); await frigateApp.goto("/system#health");
@@ -1288,14 +1153,12 @@ test.describe("System — Health notices sources @medium", () => {
await expect(row).toHaveCount(0); await expect(row).toHaveCount(0);
await frigateApp.page.getByRole("button", { name: "Filter" }).click(); await frigateApp.page.getByRole("button", { name: "Filter" }).click();
await frigateApp.page.getByRole("switch", { name: "Show hidden" }).click(); await frigateApp.page
await frigateApp.page.keyboard.press("Escape"); .getByRole("switch", { name: "Show dismissed" })
.click();
await expect(row).toBeVisible(); await expect(row).toBeVisible();
await expect(row).toContainText("Muted"); await expect(row).toContainText("Dismissed");
await expect(row.getByRole("button", { name: "Unmute" })).toBeVisible(); await expect(row.getByRole("button", { name: "Dismiss" })).toHaveCount(0);
await expect(
row.getByRole("button", { name: "Mute", exact: true }),
).toHaveCount(0);
}); });
}); });
+4
View File
@@ -222,6 +222,10 @@
"telemetry": { "telemetry": {
"label": "Telemetry", "label": "Telemetry",
"description": "System telemetry and stats options including GPU and network bandwidth monitoring.", "description": "System telemetry and stats options including GPU and network bandwidth monitoring.",
"analytics": {
"label": "Share anonymous analytics",
"description": "Send one anonymous usage report a day to help the Frigate maintainers decide what to support. Nothing is sent until this is on."
},
"network_interfaces": { "network_interfaces": {
"label": "Network interfaces", "label": "Network interfaces",
"description": "List of network interface name prefixes to monitor for bandwidth statistics." "description": "List of network interface name prefixes to monitor for bandwidth statistics."
+5 -2
View File
@@ -1,4 +1,9 @@
{ {
"analyticsPreview": {
"show": "Preview the report",
"desc": "Frigate sends this report once a day while sharing is on. It never includes camera names, zones, labels, or addresses.",
"error": "Unable to build the preview"
},
"documentTitle": { "documentTitle": {
"default": "Settings - Frigate", "default": "Settings - Frigate",
"authentication": "Authentication Settings - Frigate", "authentication": "Authentication Settings - Frigate",
@@ -1567,8 +1572,6 @@
"presetLabels": { "presetLabels": {
"preset-rpi-64-h264": "Raspberry Pi (H.264)", "preset-rpi-64-h264": "Raspberry Pi (H.264)",
"preset-rpi-64-h265": "Raspberry Pi (H.265)", "preset-rpi-64-h265": "Raspberry Pi (H.265)",
"preset-apple-silicon-h264": "Apple Silicon (H.264)",
"preset-apple-silicon-h265": "Apple Silicon (H.265)",
"preset-vaapi": "VAAPI (Intel/AMD GPU)", "preset-vaapi": "VAAPI (Intel/AMD GPU)",
"preset-intel-qsv-h264": "Intel QuickSync (H.264)", "preset-intel-qsv-h264": "Intel QuickSync (H.264)",
"preset-intel-qsv-h265": "Intel QuickSync (H.265)", "preset-intel-qsv-h265": "Intel QuickSync (H.265)",
-1
View File
@@ -48,7 +48,6 @@
"rkmpp": "RKMPP (Rockchip)", "rkmpp": "RKMPP (Rockchip)",
"jetson": "NVIDIA Jetson", "jetson": "NVIDIA Jetson",
"rpi": "V4L2 (Raspberry Pi)", "rpi": "V4L2 (Raspberry Pi)",
"apple-silicon": "Media engine (Apple Silicon)",
"none": "None (software decoding)" "none": "None (software decoding)"
} }
}, },
+12 -19
View File
@@ -19,26 +19,20 @@
"notices": { "notices": {
"title": "Notices", "title": "Notices",
"empty": "Your Frigate installation is healthy", "empty": "Your Frigate installation is healthy",
"acknowledge": "Acknowledge", "dismiss": "Dismiss",
"acknowledgeHint": "Hide until this happens again",
"mute": "Mute",
"muteHint": "Never show this again",
"unmute": "Unmute",
"showAgain": "Show again",
"openSettings": "Open settings", "openSettings": "Open settings",
"openLink": "Open link", "openLink": "Open link",
"noMatches": "No notices match the filter", "noMatches": "No notices match the filter",
"hiddenTitle": "Hidden", "dismissedTitle": "Dismissed",
"noneHidden": "No hidden notices", "noneDismissed": "No dismissed notices",
"showAll": "Show all again", "clearDismissed": "Clear dismissed",
"showAllTitle": "Show all hidden notices?", "clearDismissedTitle": "Clear dismissed notices?",
"showAllDesc": "Every acknowledged and muted notice returns to the Notices list, including config and stream checks.", "clearDismissedDesc": "Every dismissed notice is deleted. Config and stream checks show again right away, and other notices return the next time they happen.",
"firstSeen_one": "First seen {{time}}", "firstSeen_one": "First seen {{time}}",
"firstSeen_other": "First seen {{time}} · {{count}} times", "firstSeen_other": "First seen {{time}} · {{count}} times",
"acknowledgedAt": "Acknowledged {{time}}", "dismissedAt": "Dismissed {{time}}",
"mutedAt": "Muted {{time}}",
"filter": { "filter": {
"showHidden": "Show hidden", "showDismissed": "Show dismissed",
"severity": "Severity", "severity": "Severity",
"error": "Error", "error": "Error",
"warning": "Warning", "warning": "Warning",
@@ -48,12 +42,11 @@
"detector_stuck": "Detector {{detector}} was restarted after it stopped responding", "detector_stuck": "Detector {{detector}} was restarted after it stopped responding",
"model_download_failed": "Downloading {{file}} for {{model}} failed: {{error}}", "model_download_failed": "Downloading {{file}} for {{model}} failed: {{error}}",
"skipped_detections": "Detection could not keep up and skipped {{pct}}% of frames for over a minute", "skipped_detections": "Detection could not keep up and skipped {{pct}}% of frames for over a minute",
"ffmpeg_high_cpu": "FFmpeg CPU usage is high ({{cpu}}% average)",
"detect_high_cpu": "Detection CPU usage is high ({{cpu}}% average)",
"shm_too_low": "/dev/shm allocation ({{total}} MB) should be increased to at least {{min}} MB", "shm_too_low": "/dev/shm allocation ({{total}} MB) should be increased to at least {{min}} MB",
"failed_login_one": "Failed login attempt for {{user}}", "failed_login_one": "Failed login attempt for {{user}}",
"failed_login_other": "Failed login attempts for {{user}}", "failed_login_other": "Failed login attempts for {{user}}",
"update_available": "Frigate {{version}} is available" "update_available": "Frigate {{version}} is available",
"analytics_prompt": "Consider sharing anonymous usage statistics to help the developers improve Frigate"
}, },
"streamPrefix": "Stream {{index}}: {{message}}", "streamPrefix": "Stream {{index}}: {{message}}",
"streamPrefixRestream": "Stream {{index}} (via go2rtc): {{message}}", "streamPrefixRestream": "Stream {{index}} (via go2rtc): {{message}}",
@@ -332,12 +325,12 @@
}, },
"lastRefreshed": "Last refreshed: ", "lastRefreshed": "Last refreshed: ",
"stats": { "stats": {
"ffmpegHighCpuUsage": "{{camera}} has high FFmpeg CPU usage ({{ffmpegAvg}}%)",
"cameraSkippedDetections": "{{camera}} is skipping detection on {{pct}}% of frames", "cameraSkippedDetections": "{{camera}} is skipping detection on {{pct}}% of frames",
"detectHighCpuUsage": "{{camera}} has high detect CPU usage ({{detectAvg}}%)",
"healthy": "System is healthy", "healthy": "System is healthy",
"systemNotices_one": "{{count}} system notice", "systemNotices_one": "{{count}} system notice",
"systemNotices_other": "{{count}} system notices", "systemNotices_other": "{{count}} system notices",
"moreMessages_one": "+{{count}}",
"moreMessages_other": "+{{count}}",
"reindexingEmbeddings": "Reindexing embeddings ({{processed}}% complete)", "reindexingEmbeddings": "Reindexing embeddings ({{processed}}% complete)",
"cameraIsOffline": "{{camera}} is offline", "cameraIsOffline": "{{camera}} is offline",
"detectIsSlow": "{{detect}} is slow ({{speed}} ms)", "detectIsSlow": "{{detect}} is slow ({{speed}} ms)",
-78
View File
@@ -1,78 +0,0 @@
import { StatusMessage } from "@/context/statusbar-context";
import { cn } from "@/lib/utils";
import { ProblemSeverity } from "@/types/stats";
import { IoIosWarning } from "react-icons/io";
import { Link } from "react-router-dom";
const SEVERITY_COLOR: Record<ProblemSeverity, string> = {
error: "text-danger",
warning: "text-orange-400",
info: "text-selected",
};
type StatusMessageItemProps = {
message: StatusMessage;
className?: string;
onNavigate?: () => void;
};
/** One status bar message, a link when it has one. */
export function StatusMessageItem({
message,
className,
onNavigate,
}: StatusMessageItemProps) {
const content = (
<div
className={cn(
"flex items-center gap-2 text-sm",
message.link && "cursor-pointer hover:underline",
className,
)}
>
<IoIosWarning
className={cn("size-5 shrink-0", SEVERITY_COLOR[message.severity])}
/>
{message.text}
</div>
);
if (!message.link) {
return content;
}
return (
<Link to={message.link} onClick={onNavigate}>
{content}
</Link>
);
}
type StatusMessageListProps = {
messages: StatusMessage[];
className?: string;
onNavigate?: () => void;
};
/** Status bar messages stacked one per line. */
export default function StatusMessageList({
messages,
className,
onNavigate,
}: StatusMessageListProps) {
return (
<div
className={cn("flex flex-col gap-2", className)}
data-testid="status-message-list"
>
{messages.map((message, index) => (
// ids are unique only within a message key
<StatusMessageItem
key={`${index}:${message.id}`}
message={message}
onNavigate={onNavigate}
/>
))}
</div>
);
}
+75 -55
View File
@@ -1,24 +1,20 @@
import { StatusMessage } from "@/context/statusbar-context"; import { useEmbeddingsReindexProgress } from "@/api/ws";
import { useAutoFrigateStats } from "@/hooks/use-stats";
import useStatusMessages from "@/hooks/use-status-messages";
import StatusMessageList, {
StatusMessageItem,
} from "@/components/StatusMessageList";
import { import {
Popover, StatusBarMessagesContext,
PopoverContent, StatusMessage,
PopoverTrigger, } from "@/context/statusbar-context";
} from "@/components/ui/popover"; import useStats, { useAutoFrigateStats } from "@/hooks/use-stats";
import StatusBarNotices from "@/components/health/StatusBarNotices"; import StatusBarNotices from "@/components/health/StatusBarNotices";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ProfilesApiResponse } from "@/types/profile"; import type { ProfilesApiResponse } from "@/types/profile";
import { getProfileColor } from "@/utils/profileColors"; import { getProfileColor } from "@/utils/profileColors";
import { useIsAdmin } from "@/hooks/use-is-admin"; import { useIsAdmin } from "@/hooks/use-is-admin";
import { useMemo, useState } from "react"; import { useContext, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import useSWR from "swr"; import useSWR from "swr";
import { FaCheck } from "react-icons/fa"; import { FaCheck } from "react-icons/fa";
import { IoIosWarning } from "react-icons/io";
import { MdCircle } from "react-icons/md"; import { MdCircle } from "react-icons/md";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
@@ -26,7 +22,9 @@ export default function Statusbar() {
const { t } = useTranslation(["views/system"]); const { t } = useTranslation(["views/system"]);
const isAdmin = useIsAdmin(); const isAdmin = useIsAdmin();
const messages = useStatusMessages(); const { messages, addMessage, clearMessages } = useContext(
StatusBarMessagesContext,
)!;
const stats = useAutoFrigateStats(); const stats = useAutoFrigateStats();
@@ -40,6 +38,21 @@ export default function Statusbar() {
return parseInt(systemCpu); return parseInt(systemCpu);
}, [stats]); }, [stats]);
const { potentialProblems } = useStats(stats);
useEffect(() => {
clearMessages("stats");
potentialProblems.forEach((problem) => {
addMessage(
"stats",
problem.text,
problem.color,
undefined,
problem.relevantLink,
);
});
}, [potentialProblems, addMessage, clearMessages]);
const { data: profilesData } = useSWR<ProfilesApiResponse>("profiles"); const { data: profilesData } = useSWR<ProfilesApiResponse>("profiles");
const activeProfile = useMemo(() => { const activeProfile = useMemo(() => {
@@ -55,6 +68,28 @@ export default function Statusbar() {
}; };
}, [profilesData]); }, [profilesData]);
const { payload: reindexState } = useEmbeddingsReindexProgress();
useEffect(() => {
if (reindexState) {
if (reindexState.status == "indexing") {
clearMessages("embeddings-reindex");
addMessage(
"embeddings-reindex",
t("stats.reindexingEmbeddings", {
processed: Math.floor(
(reindexState.processed_objects / reindexState.total_objects) *
100,
),
}),
);
}
if (reindexState.status === "completed") {
clearMessages("embeddings-reindex");
}
}
}, [reindexState, addMessage, clearMessages, t]);
return ( return (
<div className="absolute bottom-0 left-0 right-0 z-10 flex h-8 w-full items-center justify-between border-t border-secondary-highlight bg-background_alt px-4 dark:text-secondary-foreground"> <div className="absolute bottom-0 left-0 right-0 z-10 flex h-8 w-full items-center justify-between border-t border-secondary-highlight bg-background_alt px-4 dark:text-secondary-foreground">
<div className="flex h-full items-center gap-2"> <div className="flex h-full items-center gap-2">
@@ -152,57 +187,42 @@ export default function Statusbar() {
))} ))}
</div> </div>
<div className="no-scrollbar flex h-full max-w-[50%] items-center gap-2 overflow-x-auto"> <div className="no-scrollbar flex h-full max-w-[50%] items-center gap-2 overflow-x-auto">
{!isAdmin ? null : messages.length === 0 ? ( {!isAdmin ? null : Object.entries(messages).length === 0 ? (
<Link to="/system#health" className="flex items-center gap-2 text-sm"> <Link to="/system#health" className="flex items-center gap-2 text-sm">
<FaCheck className="size-3 text-green-500" /> <FaCheck className="size-3 text-green-500" />
{t("stats.healthy")} {t("stats.healthy")}
</Link> </Link>
) : messages.length === 1 ? (
<StatusMessageItem
message={messages[0]}
className="whitespace-nowrap"
/>
) : ( ) : (
<StatusMessagesPopover messages={messages} /> Object.entries(messages).map(([key, messageArray]) => (
<div key={key} className="flex h-full items-center gap-2">
{messageArray.map(({ text, color, link }: StatusMessage) => {
const message = (
<div
key={text}
className={`flex items-center gap-2 whitespace-nowrap text-sm ${link ? "cursor-pointer hover:underline" : ""}`}
>
<IoIosWarning
className={`size-5 ${color || "text-danger"}`}
/>
{text}
</div>
);
if (link) {
return (
<Link key={text} to={link}>
{message}
</Link>
);
} else {
return message;
}
})}
</div>
))
)} )}
{isAdmin && <StatusBarNotices />} {isAdmin && <StatusBarNotices />}
</div> </div>
</div> </div>
); );
} }
type StatusMessagesPopoverProps = {
messages: StatusMessage[];
};
/** The most severe message and a count of the rest, which open the full list. */
function StatusMessagesPopover({ messages }: StatusMessagesPopoverProps) {
const { t } = useTranslation(["views/system"]);
const [open, setOpen] = useState(false);
const [first, ...rest] = messages;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="flex items-center gap-2 text-sm hover:underline"
>
<StatusMessageItem
message={{ ...first, link: undefined }}
className="whitespace-nowrap"
/>
<span className="shrink-0 rounded-full bg-secondary px-1.5 text-xs text-secondary-foreground">
{t("stats.moreMessages", { count: rest.length })}
</span>
</button>
</PopoverTrigger>
<PopoverContent side="top" align="end" className="w-auto max-w-md">
<StatusMessageList
messages={messages}
onNavigate={() => setOpen(false)}
/>
</PopoverContent>
</Popover>
);
}
@@ -3,9 +3,17 @@ import type { SectionConfigOverrides } from "./types";
const telemetry: SectionConfigOverrides = { const telemetry: SectionConfigOverrides = {
base: { base: {
sectionDocs: "/configuration/advanced/reference", sectionDocs: "/configuration/advanced/reference",
fieldDocs: {
analytics: "/configuration/advanced/analytics",
},
restartRequired: ["version_check"], restartRequired: ["version_check"],
fieldOrder: ["network_interfaces", "stats", "version_check"], fieldOrder: ["analytics", "network_interfaces", "stats", "version_check"],
advancedFields: [], advancedFields: [],
uiSchema: {
analytics: {
"ui:after": { render: "AnalyticsPreview" },
},
},
}, },
}; };
@@ -0,0 +1,56 @@
import { useState } from "react";
import useSWR from "swr";
import { useTranslation } from "react-i18next";
import { LuChevronDown, LuChevronRight } from "react-icons/lu";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
export default function AnalyticsPreview() {
const { t } = useTranslation("views/settings");
const [open, setOpen] = useState(false);
// built on demand, since building runs every collector on the server
const { data, error } = useSWR<Record<string, unknown>>(
open ? "analytics/preview" : null,
{ revalidateOnFocus: false },
);
return (
<Collapsible open={open} onOpenChange={setOpen} className="mt-2">
<CollapsibleTrigger asChild>
<Button variant="outline" size="sm" className="gap-1">
{open ? (
<LuChevronDown className="size-4" />
) : (
<LuChevronRight className="size-4" />
)}
{t("analyticsPreview.show")}
</Button>
</CollapsibleTrigger>
<div className="mt-1 text-xs text-muted-foreground">
{t("analyticsPreview.desc")}
</div>
<CollapsibleContent className="mt-2">
{error ? (
<div className="text-sm text-danger">
{t("analyticsPreview.error")}
</div>
) : data ? (
<pre
data-testid="analytics-preview"
className="max-h-96 overflow-auto rounded-md bg-secondary p-3 text-xs"
>
{JSON.stringify(data, null, 2)}
</pre>
) : (
<ActivityIndicator className="size-5" />
)}
</CollapsibleContent>
</Collapsible>
);
}
@@ -1,4 +1,5 @@
import type { ComponentType } from "react"; import type { ComponentType } from "react";
import AnalyticsPreview from "./AnalyticsPreview";
import SemanticSearchReindex from "./SemanticSearchReindex.tsx"; import SemanticSearchReindex from "./SemanticSearchReindex.tsx";
import CameraReviewStatusToggles from "./CameraReviewStatusToggles"; import CameraReviewStatusToggles from "./CameraReviewStatusToggles";
import ProxyRoleMap from "./ProxyRoleMap"; import ProxyRoleMap from "./ProxyRoleMap";
@@ -56,6 +57,9 @@ export const sectionRenderers: SectionRenderers = {
birdseye: { birdseye: {
BirdseyeCameraReorder, BirdseyeCameraReorder,
}, },
telemetry: {
AnalyticsPreview,
},
}; };
export default sectionRenderers; export default sectionRenderers;
+6 -46
View File
@@ -2,11 +2,7 @@ import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FaTriangleExclamation } from "react-icons/fa6"; import { FaTriangleExclamation } from "react-icons/fa6";
import { import {
LuBell,
LuBellOff,
LuCheck,
LuExternalLink, LuExternalLink,
LuEye,
LuInfo, LuInfo,
LuSlidersHorizontal, LuSlidersHorizontal,
LuX, LuX,
@@ -55,13 +51,7 @@ export default function HealthProblemRow({ problem }: HealthProblemRowProps) {
problem.link || problem.link ||
problem.docLink || problem.docLink ||
problem.externalLink || problem.externalLink ||
problem.onAcknowledge || problem.onDismiss;
problem.onMute ||
problem.onUnhide;
const unhideLabel =
problem.hidden === "muted"
? t("health.notices.unmute")
: t("health.notices.showAgain");
return ( return (
<div <div
@@ -160,46 +150,16 @@ export default function HealthProblemRow({ problem }: HealthProblemRowProps) {
</Button> </Button>
</RowAction> </RowAction>
)} )}
{problem.onAcknowledge && ( {problem.onDismiss && (
<RowAction label={t("health.notices.acknowledgeHint")}> <RowAction label={t("health.notices.dismiss")}>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className={ICON_BUTTON_CLASS} className={ICON_BUTTON_CLASS}
aria-label={t("health.notices.acknowledge")} aria-label={t("health.notices.dismiss")}
onClick={problem.onAcknowledge} onClick={problem.onDismiss}
> >
<LuCheck className="size-3.5" /> <LuX className="size-3.5" />
</Button>
</RowAction>
)}
{problem.onMute && (
<RowAction label={t("health.notices.muteHint")}>
<Button
variant="ghost"
size="icon"
className={ICON_BUTTON_CLASS}
aria-label={t("health.notices.mute")}
onClick={problem.onMute}
>
<LuBellOff className="size-3.5" />
</Button>
</RowAction>
)}
{problem.onUnhide && (
<RowAction label={unhideLabel}>
<Button
variant="ghost"
size="icon"
className={ICON_BUTTON_CLASS}
aria-label={unhideLabel}
onClick={problem.onUnhide}
>
{problem.hidden === "muted" ? (
<LuBell className="size-3.5" />
) : (
<LuEye className="size-3.5" />
)}
</Button> </Button>
</RowAction> </RowAction>
)} )}
@@ -24,7 +24,7 @@ export default function NoticeFilterButton({
const { t } = useTranslation(["views/system", "components/filter"]); const { t } = useTranslation(["views/system", "components/filter"]);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const active = const active =
filter.showHidden || filter.showDismissed ||
filter.severities.length < DEFAULT_NOTICE_FILTER.severities.length; filter.severities.length < DEFAULT_NOTICE_FILTER.severities.length;
const severityLabels: Record<HealthSeverity, string> = { const severityLabels: Record<HealthSeverity, string> = {
@@ -59,10 +59,10 @@ export default function NoticeFilterButton({
const content = ( const content = (
<div className="space-y-3 p-4"> <div className="space-y-3 p-4">
<FilterSwitch <FilterSwitch
label={t("health.notices.filter.showHidden")} label={t("health.notices.filter.showDismissed")}
isChecked={filter.showHidden} isChecked={filter.showDismissed}
onCheckedChange={(showHidden) => onCheckedChange={(showDismissed) =>
onFilterChange({ ...filter, showHidden }) onFilterChange({ ...filter, showDismissed })
} }
/> />
<DropdownMenuSeparator /> <DropdownMenuSeparator />
+19 -17
View File
@@ -23,9 +23,9 @@ type NoticesPaneProps = {
export default function NoticesPane({ filter }: NoticesPaneProps) { export default function NoticesPane({ filter }: NoticesPaneProps) {
const { t } = useTranslation(["views/system", "views/settings", "common"]); const { t } = useTranslation(["views/system", "views/settings", "common"]);
const { problems, hidden, loading, unhideAll } = useHealthProblems( const { problems, dismissed, loading, clearDismissed } = useHealthProblems(
t, t,
filter.showHidden, filter.showDismissed,
); );
const [confirmClear, setConfirmClear] = useState(false); const [confirmClear, setConfirmClear] = useState(false);
@@ -37,10 +37,12 @@ export default function NoticesPane({ filter }: NoticesPaneProps) {
[problems, filter.severities], [problems, filter.severities],
); );
const shownHidden = useMemo( const shownDismissed = useMemo(
() => () =>
hidden?.filter((problem) => filter.severities.includes(problem.severity)), dismissed?.filter((problem) =>
[hidden, filter.severities], filter.severities.includes(problem.severity),
),
[dismissed, filter.severities],
); );
return ( return (
@@ -68,32 +70,32 @@ export default function NoticesPane({ filter }: NoticesPaneProps) {
</div> </div>
)} )}
</div> </div>
{filter.showHidden && ( {filter.showDismissed && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{t("health.notices.hiddenTitle")} {t("health.notices.dismissedTitle")}
</div> </div>
{hidden && hidden.length > 0 && ( {dismissed && dismissed.length > 0 && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => setConfirmClear(true)} onClick={() => setConfirmClear(true)}
> >
{t("health.notices.showAll")} {t("health.notices.clearDismissed")}
</Button> </Button>
)} )}
</div> </div>
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl"> <div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
{shownHidden === undefined ? ( {shownDismissed === undefined ? (
<Skeleton className="h-10 w-full" /> <Skeleton className="h-10 w-full" />
) : shownHidden.length === 0 ? ( ) : shownDismissed.length === 0 ? (
<div className="px-1 py-2 text-sm text-muted-foreground"> <div className="px-1 py-2 text-sm text-muted-foreground">
{t("health.notices.noneHidden")} {t("health.notices.noneDismissed")}
</div> </div>
) : ( ) : (
<div className="flex flex-col"> <div className="flex flex-col">
{shownHidden.map((problem) => ( {shownDismissed.map((problem) => (
<HealthProblemRow key={problem.id} problem={problem} /> <HealthProblemRow key={problem.id} problem={problem} />
))} ))}
</div> </div>
@@ -105,10 +107,10 @@ export default function NoticesPane({ filter }: NoticesPaneProps) {
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle> <AlertDialogTitle>
{t("health.notices.showAllTitle")} {t("health.notices.clearDismissedTitle")}
</AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
{t("health.notices.showAllDesc")} {t("health.notices.clearDismissedDesc")}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
@@ -117,9 +119,9 @@ export default function NoticesPane({ filter }: NoticesPaneProps) {
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
className={buttonVariants({ variant: "destructive" })} className={buttonVariants({ variant: "destructive" })}
onClick={unhideAll} onClick={clearDismissed}
> >
{t("health.notices.showAll")} {t("health.notices.clearDismissed")}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useHealthProblems } from "@/hooks/use-health-problems"; import { useHealthProblems } from "@/hooks/use-health-problems";
/** The count of shown Notices rows, shown before the status bar's health text. */ /** The count of undismissed Notices rows, shown before the status bar's health text. */
export default function StatusBarNotices() { export default function StatusBarNotices() {
const { t } = useTranslation(["views/system"]); const { t } = useTranslation(["views/system"]);
const { problems, loading } = useHealthProblems(t); const { problems, loading } = useHealthProblems(t);

Some files were not shown because too many files have changed in this diff Show More