mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-02 21:06:52 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afc335491d | ||
|
|
3175872a15 | ||
|
|
991af1370f | ||
|
|
35bec8499b | ||
|
|
45012d1c02 | ||
|
|
d014b62701 | ||
|
|
eef55045b6 | ||
|
|
2961449d4d | ||
|
|
39f76e4a2c | ||
|
|
b6cf95a844 | ||
|
|
0453f945c7 | ||
|
|
141410f8e9 | ||
|
|
1c3eebeebb | ||
|
|
505b976d19 | ||
|
|
5e2c59d27d | ||
|
|
d97dd29561 | ||
|
|
b2fc066e6c | ||
|
|
f0d7903965 | ||
|
|
036786cb30 | ||
|
|
9876052cb4 | ||
|
|
510e415a25 | ||
|
|
ab20815584 | ||
|
|
890512b054 | ||
|
|
a1fd978cab | ||
|
|
5de5cee3c6 | ||
|
|
e99eb77ca1 | ||
|
|
bb1e556ba9 | ||
|
|
4f0c1b8ee7 | ||
|
|
a2085e27f6 | ||
|
|
194e9bef69 | ||
|
|
9d109bfd12 | ||
|
|
dcda458a82 | ||
|
|
6c6683034e | ||
|
|
3ce3217db2 | ||
|
|
8a0c848914 | ||
|
|
fa4002cbe2 | ||
|
|
a94b532655 | ||
|
|
fec73c887e | ||
|
|
da135da0fb | ||
|
|
f5e398036e | ||
|
|
2dd700aa5a | ||
|
|
378fbec416 | ||
|
|
91a93167d2 | ||
|
|
dfe6428111 | ||
|
|
5e37b2c5c2 | ||
|
|
36607133e5 | ||
|
|
622fc97671 | ||
|
|
5d807587ab | ||
|
|
31bbf910c7 | ||
|
|
f0d7c1d7d4 | ||
|
|
a83219af56 | ||
|
|
4a2fb2f09c | ||
|
|
68893b28fc | ||
|
|
fbb904302c | ||
|
|
e425ab5f90 | ||
|
|
f486f7d57e | ||
|
|
c0adab1228 |
@@ -55,6 +55,7 @@ Dahua
|
||||
datasheet
|
||||
debconf
|
||||
deci
|
||||
deepstack
|
||||
defragment
|
||||
devcontainer
|
||||
DEVICEMAP
|
||||
|
||||
@@ -26,8 +26,8 @@ body:
|
||||
id: version
|
||||
attributes:
|
||||
label: Beta Version
|
||||
description: Visible on the System Metrics page in the Web UI. Please include the full version including the build identifier (eg. 0.19.0-beta1, 0.19.0-8b72c7a, etc.)
|
||||
placeholder: "0.19.0-beta1"
|
||||
description: Visible on the System Metrics page in the Web UI. Please include the full version including the build identifier (eg. 0.18.0-beta1, 0.18.0-8b72c7a, etc.)
|
||||
placeholder: "0.18.0-beta1"
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
@@ -6,9 +6,7 @@ body:
|
||||
value: |
|
||||
Use this form to submit a reproducible bug in Frigate or Frigate's UI.
|
||||
|
||||
If you are running on Proxmox, please see the [Proxmox FAQ](https://github.com/blakeblackshear/frigate/discussions/23916) and reproduce the issue on a standard Docker install first (bare metal, or a VM running plain Debian/Ubuntu) before submitting here.
|
||||
|
||||
**⚠️ If you are running a beta version (0.19.0-beta or similar), please use the [Beta Support template](https://github.com/blakeblackshear/frigate/discussions/new?category=beta-support) instead.**
|
||||
**⚠️ If you are running a beta version (0.18.0-beta or similar), please use the [Beta Support template](https://github.com/blakeblackshear/frigate/discussions/new?category=beta-support) instead.**
|
||||
|
||||
Before submitting your bug report, please ask the AI with the "Ask AI" button on the [official documentation site][ai] about your issue, [search the discussions][discussions], look at recent open and closed [pull requests][prs], read the [official Frigate documentation][docs], and read the [Frigate FAQ][faq] pinned at the Discussion page to see if your bug has already been fixed by the developers or reported by the community.
|
||||
|
||||
|
||||
+15
-17
@@ -23,7 +23,7 @@ jobs:
|
||||
name: AMD64 Build
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up QEMU and Buildx
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
- amd64_build
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up QEMU and Buildx
|
||||
@@ -144,11 +144,9 @@ jobs:
|
||||
done'
|
||||
- name: Assert device access grants
|
||||
run: |
|
||||
# a fake accelerator node created after boot, then the oneshot re-run.
|
||||
# /command is on PATH only for s6-supervised services, and the
|
||||
# with-contenv shebang resolves its execline helpers through PATH
|
||||
# a fake accelerator node created after boot, then the oneshot re-run
|
||||
docker exec frigate mknod /dev/apex_9 c 120 99
|
||||
docker exec frigate sh -c 'export PATH=/command:$PATH; exec /etc/s6-overlay/s6-rc.d/init-devices/run'
|
||||
docker exec frigate /etc/s6-overlay/s6-rc.d/init-devices/run
|
||||
acl=$(docker exec frigate getfacl -p /dev/apex_9)
|
||||
echo "$acl"
|
||||
echo "$acl" | grep -q "user:frigate:rw-"
|
||||
@@ -156,7 +154,7 @@ jobs:
|
||||
# the usb tree gets recursive grants plus a default ACL that
|
||||
# newly created nodes inherit (the Coral re-enumeration path)
|
||||
docker exec frigate sh -c 'mkdir -p /dev/bus/usb/001 && mknod /dev/bus/usb/001/002 c 189 1'
|
||||
docker exec frigate sh -c 'export PATH=/command:$PATH; exec /etc/s6-overlay/s6-rc.d/init-devices/run'
|
||||
docker exec frigate /etc/s6-overlay/s6-rc.d/init-devices/run
|
||||
docker exec frigate getfacl -p /dev/bus/usb/001 | grep -q "user:frigate:rwx"
|
||||
docker exec frigate sh -c 'mknod /dev/bus/usb/001/099 c 189 98 && chmod 664 /dev/bus/usb/001/099'
|
||||
inherited=$(docker exec frigate getfacl -p /dev/bus/usb/001/099)
|
||||
@@ -173,7 +171,7 @@ jobs:
|
||||
# hardware that is absent must stay silent: the literal table entries
|
||||
# are not globs, so nullglob does not drop them and only an existence
|
||||
# check keeps them from warning on every boot
|
||||
out=$(docker exec frigate sh -c 'export PATH=/command:$PATH; exec /etc/s6-overlay/s6-rc.d/init-devices/run')
|
||||
out=$(docker exec frigate /etc/s6-overlay/s6-rc.d/init-devices/run)
|
||||
echo "$out"
|
||||
if echo "$out" | grep -q "WARN"; then
|
||||
echo "grant warned about device nodes that do not exist"; exit 1
|
||||
@@ -303,7 +301,7 @@ jobs:
|
||||
# /run must allow exec: S6_READ_ONLY_ROOT has s6 copy its service
|
||||
# scripts there and run them, and --tmpfs defaults to noexec
|
||||
docker run -d --name frigate-ro --shm-size 256m \
|
||||
--read-only --tmpfs /tmp:rw,size=1g --tmpfs /run:exec,nosuid,nodev,mode=0755,uid=1000,gid=1000 \
|
||||
--read-only --tmpfs /tmp:rw,size=1g --tmpfs /run:exec,nosuid,nodev,mode=0755 \
|
||||
--user 1000:1000 \
|
||||
--security-opt no-new-privileges:true \
|
||||
-v /tmp/frigate-config-ro:/config \
|
||||
@@ -397,7 +395,7 @@ jobs:
|
||||
# setfacl under a read-only rootfs, which nothing else covers:
|
||||
# init-devices exits early under --user, so that path is never reached
|
||||
docker exec frigate-rod mknod /dev/apex_9 c 120 99
|
||||
docker exec frigate-rod sh -c 'export PATH=/command:$PATH; exec /etc/s6-overlay/s6-rc.d/init-devices/run'
|
||||
docker exec frigate-rod /etc/s6-overlay/s6-rc.d/init-devices/run
|
||||
docker exec frigate-rod getfacl -p /dev/apex_9 | grep -q "user:frigate:rw-"
|
||||
docker rm -f frigate-rod
|
||||
- name: "Assert switching that install to user: still starts"
|
||||
@@ -405,7 +403,7 @@ jobs:
|
||||
# the config dir above now holds a go2rtc-owned go2rtc_homekit.yml,
|
||||
# which user: keeps readable but not writable (no supplementary groups)
|
||||
docker run -d --name frigate-rod-user --shm-size 256m \
|
||||
--read-only --tmpfs /tmp:rw,size=1g --tmpfs /run:exec,nosuid,nodev,mode=0755,uid=1000,gid=1000 \
|
||||
--read-only --tmpfs /tmp:rw,size=1g --tmpfs /run:exec,nosuid,nodev,mode=0755 \
|
||||
--user 1000:1000 \
|
||||
-v /tmp/frigate-config-rod:/config \
|
||||
-v /tmp/frigate-media-rod:/media/frigate \
|
||||
@@ -426,7 +424,7 @@ jobs:
|
||||
name: ARM Build
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up QEMU and Buildx
|
||||
@@ -461,7 +459,7 @@ jobs:
|
||||
name: Jetson Jetpack 6
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up QEMU and Buildx
|
||||
@@ -492,7 +490,7 @@ jobs:
|
||||
- amd64_build
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up QEMU and Buildx
|
||||
@@ -533,7 +531,7 @@ jobs:
|
||||
- arm64_build
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up QEMU and Buildx
|
||||
@@ -558,7 +556,7 @@ jobs:
|
||||
- arm64_build
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up QEMU and Buildx
|
||||
@@ -590,7 +588,7 @@ jobs:
|
||||
with:
|
||||
string: ${{ github.repository }}
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -16,10 +16,10 @@ jobs:
|
||||
name: Web - Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20.x
|
||||
- run: npm install
|
||||
@@ -35,10 +35,10 @@ jobs:
|
||||
name: Web - Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20.x
|
||||
- run: npm install
|
||||
@@ -54,10 +54,10 @@ jobs:
|
||||
name: Web - E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20.x
|
||||
- run: npm install
|
||||
@@ -86,11 +86,11 @@ jobs:
|
||||
name: Python Checks
|
||||
steps:
|
||||
- name: Check out the repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v7.0.0
|
||||
uses: actions/setup-python@v5.4.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
- name: Install requirements
|
||||
@@ -109,10 +109,10 @@ jobs:
|
||||
name: Python Tests
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20.x
|
||||
- name: Install devcontainer cli
|
||||
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- id: lowercaseRepo
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
with:
|
||||
string: ${{ github.repository }}
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
aiofiles == 25.1.*
|
||||
click == 8.5.*
|
||||
aiofiles == 24.1.*
|
||||
click == 8.1.*
|
||||
# FastAPI
|
||||
aiohttp == 3.12.*
|
||||
starlette == 0.47.*
|
||||
starlette-context == 0.4.*
|
||||
fastapi[standard-no-fastapi-cloud-cli] == 0.116.*
|
||||
uvicorn == 0.46.*
|
||||
uvicorn == 0.35.*
|
||||
slowapi == 0.1.*
|
||||
joserfc == 1.6.*
|
||||
cryptography == 46.0.*
|
||||
joserfc == 1.2.*
|
||||
cryptography == 44.0.*
|
||||
pathvalidate == 3.3.*
|
||||
markupsafe == 3.0.*
|
||||
python-multipart == 0.0.31
|
||||
python-multipart == 0.0.26
|
||||
# Classification Model Training
|
||||
tensorflow == 2.19.* ; platform_machine == 'aarch64'
|
||||
tensorflow-cpu == 2.21.* ; platform_machine == 'x86_64'
|
||||
tensorflow-cpu == 2.19.* ; platform_machine == 'x86_64'
|
||||
# General
|
||||
mypy == 1.6.1
|
||||
onvif-zeep-async == 4.0.*
|
||||
@@ -26,10 +26,10 @@ psutil == 7.1.*
|
||||
pydantic == 2.10.*
|
||||
git+https://github.com/fbcotter/py3nvml#egg=py3nvml
|
||||
pytz == 2025.*
|
||||
pyzmq == 27.1.*
|
||||
pyzmq == 26.2.*
|
||||
ruamel.yaml == 0.18.*
|
||||
tzlocal == 5.2
|
||||
requests == 2.33.*
|
||||
requests == 2.32.*
|
||||
types-requests == 2.32.*
|
||||
norfair == 2.3.*
|
||||
setproctitle == 1.3.*
|
||||
@@ -51,10 +51,10 @@ google-genai == 1.58.*
|
||||
ollama == 0.6.*
|
||||
openai == 1.65.*
|
||||
# push notifications
|
||||
py-vapid == 1.9.4
|
||||
py-vapid == 1.9.*
|
||||
pywebpush == 2.0.*
|
||||
# alpr
|
||||
pyclipper == 1.4.*
|
||||
pyclipper == 1.3.*
|
||||
shapely == 2.0.*
|
||||
rapidfuzz==3.12.*
|
||||
# HailoRT
|
||||
@@ -69,7 +69,7 @@ tflite_runtime @ https://github.com/frigate-nvr/TFlite-builds/releases/download/
|
||||
tflite_runtime @ https://github.com/feranick/TFlite-builds/releases/download/v2.17.1/tflite_runtime-2.17.1-cp311-cp311-linux_aarch64.whl; platform_machine == 'aarch64'
|
||||
# audio transcription
|
||||
sherpa-onnx==1.12.*
|
||||
faster-whisper==1.2.*
|
||||
faster-whisper==1.1.*
|
||||
librosa==0.11.*
|
||||
soundfile==0.13.*
|
||||
# Memory profiling
|
||||
|
||||
@@ -166,9 +166,7 @@ http {
|
||||
include auth_request.conf;
|
||||
types {
|
||||
video/mp4 mp4;
|
||||
image/jpeg jpg jpeg;
|
||||
image/png png;
|
||||
image/webp webp;
|
||||
image/jpeg jpg;
|
||||
}
|
||||
|
||||
expires 7d;
|
||||
|
||||
@@ -824,6 +824,24 @@ cpu:
|
||||
models:
|
||||
- devices:
|
||||
- cpu:3
|
||||
deepstack:
|
||||
title: DeepStack / CodeProject.AI
|
||||
models:
|
||||
- key: yolo
|
||||
label: YOLO
|
||||
recommended: true
|
||||
download: This detector runs object detection over the network against a CodeProject.AI or DeepStack server, so no model is downloaded into Frigate itself. Visit the [CodeProject.AI official website](https://www.codeproject.com/Articles/5322557/CodeProject-AI-Server-AI-the-easy-way) to download and install the AI server on your preferred device (e.g. Raspberry Pi, Nvidia Jetson, or other compatible hardware) before configuring the detector.
|
||||
ui: |-
|
||||
Navigate to **Settings > System > Detection models** and add a model. The CodeProject.AI server is not reported by the hardware probe, so set `devices` to `deepstack:http://<your_codeproject_ai_server_ip>:<port>/v1/vision/detection` in YAML.
|
||||
|
||||
| Field | Value |
|
||||
| ------------- | ---------------------------------------------------------------------- |
|
||||
| **API URL** | `http://<your_codeproject_ai_server_ip>:<port>/v1/vision/detection` |
|
||||
| **API Timeout** | `0.1` (seconds) |
|
||||
yaml: |-
|
||||
models:
|
||||
- devices:
|
||||
- deepstack:http://<your_codeproject_ai_server_ip>:<port>/v1/vision/detection
|
||||
memryx:
|
||||
title: MemryX
|
||||
models:
|
||||
@@ -1015,7 +1033,7 @@ synaptics:
|
||||
- key: ssd
|
||||
label: SSD MobileNet
|
||||
recommended: true
|
||||
download: A synap model is provided in the container at `/synaptics/mobilenet.synap` and is used by this detector type by default. The model comes from the [Synap-release Github](https://github.com/synaptics-astra/synap-release/tree/v1.5.0/models/dolphin/object_detection/coco/model/mobilenet224_full80).
|
||||
download: A synap model is provided in the container at `/mobilenet.synap` and is used by this detector type by default. The model comes from the [Synap-release Github](https://github.com/synaptics-astra/synap-release/tree/v1.5.0/models/dolphin/object_detection/coco/model/mobilenet224_full80).
|
||||
ui: |-
|
||||
Navigate to **Settings > System > Detection models** and select **Synaptics NPU** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||
|
||||
|
||||
@@ -499,7 +499,7 @@ cameras:
|
||||
|
||||
## Synaptics
|
||||
|
||||
Hardware accelerated video de-/encoding is supported on Synaptics SL-series SoC.
|
||||
Hardware accelerated video de-/encoding is supported on Synpatics SL-series SoC.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
|
||||
@@ -313,16 +313,8 @@ To remove root from the container entirely, add Docker's `user:`:
|
||||
|
||||
```yaml
|
||||
user: "1000:1000" # NOT compatible with PUID/PGID, see the run modes table
|
||||
tmpfs:
|
||||
- /tmp:size=256m
|
||||
- /tmp/cache:size=1000000000
|
||||
- /run:exec,nosuid,nodev,mode=0755,uid=1000,gid=1000,size=16m # uid must match user:
|
||||
```
|
||||
|
||||
`/run` has to be owned by that uid as well. s6 writes its runtime state there before anything else starts, and with no root in the container a root-owned `/run` stops it during init with `cannot create /run/test of writability`. Keep `uid` and `gid` in the tmpfs options matching `user:`, and don't carry that pair back into the default mode, where a root-owned `/run` is what keeps the unprivileged services out of s6's runtime state.
|
||||
|
||||
This only bites once root is genuinely gone. s6's init helper is setuid, so `user:` on its own still lets init regain root and correct `/run` itself. The `no-new-privileges:true` above is what blocks that, which is also what makes the `/run` ownership mandatory. Dropping it would hide the problem by handing init root again.
|
||||
|
||||
Two things change, and the first one will break a working install if you skip it. The startup device grants can't run, because there is no root left to run them, so every device you pass stops working until you grant that uid access yourself with `group_add:` or a udev rule; see [Manual setup](#manual-setup). Expect this to surface as a driver error rather than a permission error, like `No VA display found` from VAAPI. And every service then runs as that one uid, so go2rtc no longer gets its own restricted user. `/config` and `/media/frigate` have to be owned by that uid already, since Frigate never adjusts ownership in this mode. Switching an existing install over also leaves `/config/go2rtc_homekit.yml` owned by the go2rtc user, which this mode can't write; `chown` it to your uid or HomeKit pairing changes stop persisting. Frigate warns and starts either way.
|
||||
|
||||
This mode can also take `cap_drop: [ALL]`, which the default mode cannot: starting as root needs `CAP_CHOWN` for the ownership sweep, `CAP_SETUID` and `CAP_SETGID` to drop to the runtime user, and `CAP_FOWNER` for the device grants.
|
||||
|
||||
@@ -362,7 +362,7 @@ Intel NPUs cannot be used under Home Assistant OS, which does not include the NP
|
||||
|
||||
:::warning
|
||||
|
||||
The Apple Silicon detector client is being reworked. Its extra options no longer have a place in the config, so only the endpoint carried in the device string is honored right now, and `request_timeout_ms` and `linger_ms` are ignored. Anything else is dropped when your config is migrated.
|
||||
The network-based detectors (Deepstack and the Apple Silicon client) are being reworked. Their extra options no longer have a place in the config, so only the endpoint carried in the device string is honored right now: Deepstack ignores `api_key` and `api_timeout`, and the Apple Silicon client ignores `request_timeout_ms` and `linger_ms`. Anything else is dropped when your config is migrated.
|
||||
|
||||
:::
|
||||
|
||||
@@ -540,6 +540,30 @@ A TensorFlow Lite model is provided in the container at `/cpu_model.tflite` and
|
||||
|
||||
When using CPU detectors, you can add one CPU detector per camera. Adding more detectors than the number of cameras should not improve performance.
|
||||
|
||||
## Deepstack / CodeProject.AI Server Detector
|
||||
|
||||
:::warning
|
||||
|
||||
The network-based detectors (Deepstack and the Apple Silicon client) are being reworked. Their extra options no longer have a place in the config, so only the endpoint carried in the device string is honored right now: Deepstack ignores `api_key` and `api_timeout`, and the Apple Silicon client ignores `request_timeout_ms` and `linger_ms`. Anything else is dropped when your config is migrated.
|
||||
|
||||
:::
|
||||
|
||||
The Deepstack / CodeProject.AI Server detector for Frigate allows you to integrate Deepstack and CodeProject.AI object detection capabilities into Frigate. CodeProject.AI and DeepStack are open-source AI platforms that can be run on various devices such as the Raspberry Pi, Nvidia Jetson, and other compatible hardware. It is important to note that the integration is performed over the network, so the inference times may not be as fast as native Frigate detectors, but it still provides an efficient and reliable solution for object detection and tracking.
|
||||
|
||||
### Setup {#setup-deepstack}
|
||||
|
||||
To get started with CodeProject.AI, visit their [official website](https://www.codeproject.com/Articles/5322557/CodeProject-AI-Server-AI-the-easy-way) to follow the instructions to download and install the AI server on your preferred device. Detailed setup instructions for CodeProject.AI are outside the scope of the Frigate documentation.
|
||||
|
||||
To integrate CodeProject.AI into Frigate, configure the detector as follows:
|
||||
|
||||
### Configuration {#configuration-deepstack}
|
||||
|
||||
<ModelConfigDropdown detectorTitle="DeepStack" models={objectDetectorsModels.deepstack.models} />
|
||||
|
||||
Replace `<your_codeproject_ai_server_ip>` and `<port>` with the IP address and port of your CodeProject.AI server.
|
||||
|
||||
To verify that the integration is working correctly, start Frigate and observe the logs for any error messages related to CodeProject.AI. Additionally, you can check the Frigate web interface to see if the objects detected by CodeProject.AI are being displayed and tracked properly.
|
||||
|
||||
# Community Supported Detectors
|
||||
|
||||
## MemryX MX3
|
||||
|
||||
@@ -450,7 +450,7 @@ For advanced use cases, the [custom export HTTP API](../integrations/api/export-
|
||||
POST /export/custom/{camera_name}/start/{start_time}/end/{end_time}
|
||||
```
|
||||
|
||||
The request body accepts `ffmpeg_input_args` and `ffmpeg_output_args` to control encoding, frame rate, filters, and other FFmpeg options. If neither is provided, Frigate defaults to time-lapse output settings (25x speed, 30 FPS) with audio removed (`-an`). When providing your own `ffmpeg_input_args`, include `-an` if you want audio stripped from the export.
|
||||
The request body accepts `ffmpeg_input_args` and `ffmpeg_output_args` to control encoding, frame rate, filters, and other FFmpeg options. If neither is provided, Frigate defaults to time-lapse output settings (25x speed, 30 FPS).
|
||||
|
||||
The following example exports a time-lapse at 60x speed with 25 FPS:
|
||||
|
||||
|
||||
@@ -197,7 +197,7 @@ For cameras that support two-way talk, go2rtc will automatically establish an au
|
||||
To prevent this, you must configure two separate stream instances:
|
||||
|
||||
1. One stream instance with `#backchannel=0` for Frigate's viewing, recording, and detection (prevents go2rtc from establishing the blocking backchannel)
|
||||
2. A second stream instance with no `#` parameters at all for two-way talk functionality (can be used by Frigate's WebRTC viewer or other applications)
|
||||
2. A second stream instance without `#backchannel=0` for two-way talk functionality (can be used by Frigate's WebRTC viewer or other applications)
|
||||
|
||||
Configuration example:
|
||||
|
||||
@@ -215,8 +215,6 @@ In this configuration:
|
||||
- `front_door` stream is used by Frigate for viewing, recording, and detection. The `#backchannel=0` parameter prevents go2rtc from establishing the audio output backchannel, so it won't block two-way talk access.
|
||||
- `front_door_twoway` stream is used for two-way talk functionality. This stream can be used by Frigate's WebRTC viewer when two-way talk is enabled, or by other applications (like Home Assistant Advanced Camera Card) that need access to the camera's audio output channel.
|
||||
|
||||
Any `#` parameter on a bare `rtsp://` source disables the backchannel unless the URL explicitly contains `#backchannel=1`. A two-way talk stream with something like `#video=h264` on it silently loses two-way audio, and Frigate will report that two-way talk is unavailable for that stream.
|
||||
|
||||
## Security: Restricted Stream Sources
|
||||
|
||||
For security reasons, the `echo:`, `expr:`, and `exec:` stream sources are disabled by default in go2rtc. These sources allow arbitrary command execution and can pose security risks if misconfigured.
|
||||
|
||||
@@ -142,6 +142,10 @@ When [notifications](/configuration/notifications) are enabled and users have re
|
||||
|
||||
If an [MQTT broker](/integrations/mqtt) is configured, Frigate maintains a connection to the broker's host and port. This is typically a local network connection, but will require internet if you use a cloud-hosted MQTT broker.
|
||||
|
||||
### DeepStack / CodeProject.AI
|
||||
|
||||
When using the [DeepStack detector plugin](/configuration/object_detectors), Frigate sends images to the configured API endpoint for inference. This is typically local but depends on where the service is hosted.
|
||||
|
||||
## WebRTC (STUN)
|
||||
|
||||
For [WebRTC live streaming](/configuration/live), Frigate uses STUN for NAT traversal:
|
||||
|
||||
@@ -39,20 +39,6 @@ To do this efficiently the following setup is required:
|
||||
|
||||
When this is done correctly, the GPU will do the decoding and scaling which will result in a small increase in CPU usage but with better results.
|
||||
|
||||
### How can I rotate my camera's video feed?
|
||||
|
||||
Rotation is best done in the camera's firmware settings (usually called rotate, flip, or corridor mode) so the video arrives already rotated and no extra processing is needed. Check there first.
|
||||
|
||||
If your camera does not support rotation, go2rtc's ffmpeg module can rotate the stream with the `#rotate` parameter (`90`, `180`, `270`, or `-90`), but this is not recommended: rotation requires transcoding (re-encoding) the video, which significantly increases CPU usage, especially for high resolution streams.
|
||||
|
||||
```yaml
|
||||
go2rtc:
|
||||
streams:
|
||||
my_camera: "ffmpeg:rtsp://user:password@192.168.1.10:554/stream#video=h264#hardware#rotate=90"
|
||||
```
|
||||
|
||||
Point the camera's inputs at the restream as described in the [restream docs](/configuration/restream.md), and swap `detect -> width` and `detect -> height` to match the rotated resolution.
|
||||
|
||||
### My mjpeg stream or snapshots look green and crazy
|
||||
|
||||
This almost always means that the width/height defined for your camera are not correct. Double check the resolution with VLC or another player. Also make sure you don't have the width and height values backwards.
|
||||
@@ -184,6 +170,6 @@ Filters and masks only hide the incorrect result - they don't teach Frigate what
|
||||
|
||||
### Where do I see problems Frigate has detected?
|
||||
|
||||
Open System > Health. The Notices list 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.
|
||||
Open System > Health. The Notices list shows problems the backend has noticed on its own, such as a camera whose ffmpeg keeps crashing, a detector that had to be restarted, a model download that failed, or recordings being deleted before their retention period. Entries that describe a one-time event can be dismissed; entries that describe an ongoing condition clear themselves once it is fixed.
|
||||
|
||||
The Hardware section below the notices shows whether the detection hardware, hardware acceleration, and enrichment devices in your config were found and are being used, so a GPU that silently fell back to the CPU shows up as a warning. Run stream checks to probe every camera's streams for the same problems the camera wizard reports.
|
||||
|
||||
@@ -78,9 +78,7 @@ go2rtc:
|
||||
|
||||
:::warning
|
||||
|
||||
The transcoding modifiers (`#video=`, `#audio=`, `#hardware`, …) **only take effect on a source that is prefixed with `ffmpeg:`**. Adding them to a bare `rtsp://…#audio=opus` source does nothing: go2rtc ignores them. Likewise, when a source references another stream by name (e.g. `ffmpeg:back#audio=aac`), the name must match the stream key **exactly** (it is case sensitive), or the transcode is silently never produced. This is the single most common configuration mistake. In the Frigate UI, the **Use compatibility mode (ffmpeg)** toggle adds the `ffmpeg:` prefix for you.
|
||||
|
||||
A bare `rtsp://` source reads a different set of modifiers: `#backchannel=`, `#media=`, `#timeout=`, and `#transport=`. These do nothing on an `ffmpeg:` source. Adding **any** modifier to a bare `rtsp://` source also disables the camera's backchannel unless the URL explicitly contains `#backchannel=1`, so a stream dedicated to two-way talk should carry no modifiers at all.
|
||||
The `#`-modifiers (`#video=`, `#audio=`, `#hardware`, `#backchannel=0`, …) **only take effect on a source that is prefixed with `ffmpeg:`**. Adding them to a bare `rtsp://…#audio=opus` source does nothing: go2rtc ignores them. Likewise, when a source references another stream by name (e.g. `ffmpeg:back#audio=aac`), the name must match the stream key **exactly** (it is case sensitive), or the transcode is silently never produced. This is the single most common configuration mistake. In the Frigate UI, the **Use compatibility mode (ffmpeg)** toggle adds the `ffmpeg:` prefix for you.
|
||||
|
||||
:::
|
||||
|
||||
@@ -155,7 +153,7 @@ WebRTC is only attempted when MSE fails or when using a camera's two-way talk fe
|
||||
|
||||
- **Codec mismatch**: WebRTC cannot carry H.265 or AAC. The stream backing the WebRTC view must provide Opus (or PCMA/PCMU) audio and H.264 video. Add an `ffmpeg:back#audio=opus` source as shown above.
|
||||
- **Port `8555` not reachable, or no candidates set**: WebRTC needs port `8555` (both TCP and UDP) open and a reachable candidate advertised. On Docker installs running on a custom/overlay network, go2rtc may advertise unreachable container IPs as ICE candidates; setting `webrtc.filters.candidates: []` and supplying only your host's LAN IP resolves this. See [WebRTC extra configuration](/configuration/live#webrtc-extra-configuration).
|
||||
- **Two-way talk** additionally requires a secure context (HTTPS or the authenticated port `8971`, because browsers block microphone access on plain HTTP). The camera's RTSP backchannel must also be handled correctly: go2rtc seizes the backchannel by default, which blocks two-way audio for other consumers and can inject static. Disable it on the primary stream with `#backchannel=0` and use a separate dedicated stream for talk, carrying no `#` modifiers of any kind, as documented in [preventing go2rtc from blocking two-way audio](/configuration/restream#two-way-talk-restream).
|
||||
- **Two-way talk** additionally requires a secure context (HTTPS or the authenticated port `8971`, because browsers block microphone access on plain HTTP). The camera's RTSP backchannel must also be handled correctly: go2rtc seizes the backchannel by default, which blocks two-way audio for other consumers and can inject static. Disable it on the primary stream with `#backchannel=0` and use a separate dedicated stream for talk, as documented in [preventing go2rtc from blocking two-way audio](/configuration/restream#two-way-talk-restream).
|
||||
|
||||
## High CPU usage
|
||||
|
||||
|
||||
Generated
+377
-554
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -28,7 +28,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"docusaurus-plugin-openapi-docs": "^4.5.1",
|
||||
"docusaurus-theme-openapi-docs": "^4.5.1",
|
||||
"js-yaml": "^4.3.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
"marked": "^16.4.2",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"raw-loader": "^4.0.2",
|
||||
|
||||
Vendored
+4
-44
@@ -4171,13 +4171,13 @@ paths:
|
||||
description: |-
|
||||
**Access:** Admin role required.
|
||||
|
||||
Get notices, most severe first.
|
||||
Get the active notices, most severe first.
|
||||
|
||||
Args:
|
||||
include_dismissed: Also return dismissed notices, for the history view
|
||||
include_dismissed: Also return event notices the user has dismissed
|
||||
|
||||
Returns:
|
||||
The notices
|
||||
The active notices
|
||||
operationId: get_notices_notices_get
|
||||
parameters:
|
||||
- name: include_dismissed
|
||||
@@ -4221,44 +4221,6 @@ paths:
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/notices/dismissed_checks:
|
||||
get:
|
||||
tags:
|
||||
- Notices
|
||||
summary: Get Dismissed Checks
|
||||
description: |-
|
||||
**Access:** Admin role required.
|
||||
|
||||
Get the dismissed config and stream check rows, newest first.
|
||||
operationId: get_dismissed_checks_notices_dismissed_checks_get
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/notices/dismissed:
|
||||
delete:
|
||||
tags:
|
||||
- Notices
|
||||
summary: Purge Dismissed
|
||||
description: |-
|
||||
**Access:** Admin role required.
|
||||
|
||||
Delete every dismissed notice and check row so each can show again.
|
||||
operationId: purge_dismissed_notices_dismissed_delete
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/notices/{notice_id}/dismiss:
|
||||
post:
|
||||
tags:
|
||||
@@ -4267,9 +4229,7 @@ paths:
|
||||
description: |-
|
||||
**Access:** Admin role required.
|
||||
|
||||
Hide a notice or a config or stream check row.
|
||||
|
||||
It stays hidden if the same problem happens again.
|
||||
Hide an event notice until it is raised again.
|
||||
operationId: dismiss_notice_notices__notice_id__dismiss_post
|
||||
parameters:
|
||||
- name: notice_id
|
||||
|
||||
@@ -8,7 +8,6 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -35,7 +34,6 @@ from frigate.api.media_auth import (
|
||||
from frigate.config import AuthConfig, ProxyConfig
|
||||
from frigate.const import CONFIG_DIR, JWT_SECRET_ENV_VAR, PASSWORD_HASH_ALGORITHM
|
||||
from frigate.models import User
|
||||
from frigate.notices import raise_notice
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -255,58 +253,6 @@ class RateLimiter:
|
||||
|
||||
rateLimiter = RateLimiter()
|
||||
|
||||
# a failed login this long after the user's previous one opens a new burst
|
||||
FAILED_LOGIN_BURST_GAP_S = 300
|
||||
|
||||
# the username comes from the request, so it is cut before it reaches a notice
|
||||
MAX_NOTICE_USERNAME = 64
|
||||
|
||||
# unknown usernames are unbounded, so past this many open bursts a new one only
|
||||
# reaches the log
|
||||
MAX_OPEN_BURSTS = 100
|
||||
|
||||
|
||||
class FailedLoginTracker:
|
||||
"""Groups each user's failed logins into bursts, one notice per burst."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# user -> (burst start, last attempt), stalest attempt first
|
||||
self._bursts: dict[str, tuple[int, float]] = {}
|
||||
|
||||
def record(self, user: str, now: float, *, known: bool = False) -> None:
|
||||
"""Count a failed login toward the user's open burst, or open a new one.
|
||||
|
||||
Once MAX_OPEN_BURSTS are open, only a known user opens another.
|
||||
"""
|
||||
user = user[:MAX_NOTICE_USERNAME]
|
||||
|
||||
with self._lock:
|
||||
# bursts that went quiet are over
|
||||
while self._bursts:
|
||||
stalest = next(iter(self._bursts))
|
||||
|
||||
if now - self._bursts[stalest][1] < FAILED_LOGIN_BURST_GAP_S:
|
||||
break
|
||||
|
||||
del self._bursts[stalest]
|
||||
|
||||
if (
|
||||
not known
|
||||
and user not in self._bursts
|
||||
and len(self._bursts) >= MAX_OPEN_BURSTS
|
||||
):
|
||||
return
|
||||
|
||||
start, _ = self._bursts.pop(user, (int(now), now))
|
||||
self._bursts[user] = (start, now)
|
||||
|
||||
raise_notice("failed_login", scope=f"{user}:{start}", params={"user": user})
|
||||
|
||||
|
||||
failed_logins = FailedLoginTracker()
|
||||
|
||||
|
||||
def get_remote_addr(request: Request):
|
||||
# fall back to the direct TCP peer when no proxy chain is present
|
||||
@@ -934,7 +880,6 @@ def login(request: Request, body: AppPostLoginBody):
|
||||
db_user: User = User.get_by_id(user)
|
||||
except DoesNotExist:
|
||||
logger.warning(f"Login failed for unknown user '{user}' from {remote_addr}")
|
||||
failed_logins.record(user, time.time())
|
||||
return JSONResponse(content={"message": "Login failed"}, status_code=401)
|
||||
|
||||
password_hash = db_user.password_hash
|
||||
@@ -966,7 +911,6 @@ def login(request: Request, body: AppPostLoginBody):
|
||||
logger.warning(
|
||||
f"Login failed for user '{user}' (invalid password) from {remote_addr}"
|
||||
)
|
||||
failed_logins.record(user, time.time(), known=True)
|
||||
return JSONResponse(content={"message": "Login failed"}, status_code=401)
|
||||
|
||||
|
||||
|
||||
@@ -71,7 +71,6 @@ from frigate.jobs.export import (
|
||||
from frigate.models import Export, ExportCase, Previews, Recordings
|
||||
from frigate.record.export import (
|
||||
DEFAULT_TIME_LAPSE_FFMPEG_ARGS,
|
||||
DEFAULT_TIME_LAPSE_FFMPEG_INPUT_ARGS,
|
||||
ChaptersEnum,
|
||||
ExportStreamEnum,
|
||||
PlaybackSourceEnum,
|
||||
@@ -1088,7 +1087,7 @@ def export_recording_custom(
|
||||
|
||||
# Set default values if not provided (timelapse defaults)
|
||||
if ffmpeg_input_args is None:
|
||||
ffmpeg_input_args = DEFAULT_TIME_LAPSE_FFMPEG_INPUT_ARGS
|
||||
ffmpeg_input_args = ""
|
||||
|
||||
if ffmpeg_output_args is None:
|
||||
ffmpeg_output_args = DEFAULT_TIME_LAPSE_FFMPEG_ARGS
|
||||
|
||||
+17
-27
@@ -7,6 +7,7 @@ from fastapi.responses import JSONResponse
|
||||
|
||||
from frigate.api.auth import require_role
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.notices.registry import DismissResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -15,13 +16,13 @@ router = APIRouter(tags=[Tags.notices])
|
||||
|
||||
@router.get("/notices", dependencies=[Depends(require_role(["admin"]))])
|
||||
def get_notices(request: Request, include_dismissed: bool = False) -> JSONResponse:
|
||||
"""Get notices, most severe first.
|
||||
"""Get the active notices, most severe first.
|
||||
|
||||
Args:
|
||||
include_dismissed: Also return dismissed notices, for the history view
|
||||
include_dismissed: Also return event notices the user has dismissed
|
||||
|
||||
Returns:
|
||||
The notices
|
||||
The active notices
|
||||
"""
|
||||
return JSONResponse(
|
||||
content=request.app.notice_registry.active(include_dismissed=include_dismissed)
|
||||
@@ -34,37 +35,26 @@ def get_notice_stats(request: Request) -> JSONResponse:
|
||||
return JSONResponse(content=request.app.notice_registry.stats())
|
||||
|
||||
|
||||
@router.get(
|
||||
"/notices/dismissed_checks", dependencies=[Depends(require_role(["admin"]))]
|
||||
)
|
||||
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/dismissed", dependencies=[Depends(require_role(["admin"]))])
|
||||
def purge_dismissed(request: Request) -> JSONResponse:
|
||||
"""Delete every dismissed notice and check row so each can show again."""
|
||||
request.app.notice_registry.purge_dismissed()
|
||||
return JSONResponse(
|
||||
content={"success": True, "message": "Dismissed notices cleared"}
|
||||
)
|
||||
|
||||
|
||||
# model notice ids contain a slash, so the id is a path parameter
|
||||
@router.post(
|
||||
"/notices/{notice_id:path}/dismiss",
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
"/notices/{notice_id}/dismiss", dependencies=[Depends(require_role(["admin"]))]
|
||||
)
|
||||
def dismiss_notice(request: Request, notice_id: str) -> JSONResponse:
|
||||
"""Hide a notice or a config or stream check row.
|
||||
"""Hide an event notice until it is raised again."""
|
||||
result = request.app.notice_registry.dismiss(notice_id)
|
||||
|
||||
It stays hidden if the same problem happens again.
|
||||
"""
|
||||
if not request.app.notice_registry.dismiss(notice_id):
|
||||
if result == DismissResult.not_found:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Notice not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
if result == DismissResult.not_dismissable:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "This notice clears itself when the problem is fixed",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
return JSONResponse(content={"success": True, "message": "Notice dismissed"})
|
||||
|
||||
+11
-9
@@ -73,7 +73,6 @@ from frigate.models import (
|
||||
Trigger,
|
||||
User,
|
||||
)
|
||||
from frigate.notices import install_registry
|
||||
from frigate.notices.registry import NoticeRegistry
|
||||
from frigate.object_detection.base import ObjectDetectProcess
|
||||
from frigate.object_detection.util import detection_frame_size
|
||||
@@ -311,6 +310,10 @@ class FrigateApp:
|
||||
]
|
||||
self.db.bind(models)
|
||||
|
||||
self.notice_registry = NoticeRegistry()
|
||||
# producers confirm state notices again at startup if they still hold
|
||||
self.notice_registry.mark_state_notices_unconfirmed()
|
||||
|
||||
def check_db_data_migrations(self) -> None:
|
||||
# check if vacuum needs to be run
|
||||
if not os.path.exists(f"{CONFIG_DIR}/.exports"):
|
||||
@@ -322,10 +325,6 @@ class FrigateApp:
|
||||
|
||||
migrate_exports(self.config.ffmpeg, list(self.config.cameras.keys()))
|
||||
|
||||
def install_notice_registry(self) -> None:
|
||||
self.notice_registry = NoticeRegistry()
|
||||
install_registry(self.notice_registry)
|
||||
|
||||
def init_embeddings_client(self) -> None:
|
||||
# Create a client for other processes to use
|
||||
self.embeddings = EmbeddingsContext(self.db)
|
||||
@@ -511,7 +510,9 @@ class FrigateApp:
|
||||
self.record_cleanup.start()
|
||||
|
||||
def start_storage_maintainer(self) -> None:
|
||||
self.storage_maintainer = StorageMaintainer(self.config, self.stop_event)
|
||||
self.storage_maintainer = StorageMaintainer(
|
||||
self.config, self.stop_event, self.notice_registry
|
||||
)
|
||||
self.storage_maintainer.start()
|
||||
|
||||
def start_stats_emitter(self) -> None:
|
||||
@@ -523,14 +524,16 @@ class FrigateApp:
|
||||
self.embeddings_metrics,
|
||||
self.detectors,
|
||||
self.processes,
|
||||
self.storage_maintainer,
|
||||
),
|
||||
self.stop_event,
|
||||
notice_registry=self.notice_registry,
|
||||
)
|
||||
self.stats_emitter.start()
|
||||
|
||||
def start_watchdog(self) -> None:
|
||||
self.frigate_watchdog = FrigateWatchdog(self.detectors, self.stop_event)
|
||||
self.frigate_watchdog = FrigateWatchdog(
|
||||
self.detectors, self.stop_event, self.notice_registry
|
||||
)
|
||||
|
||||
# (attribute on self, key in self.processes, factory)
|
||||
specs: list[tuple[str, str, Callable[[], FrigateProcess]]] = [
|
||||
@@ -641,7 +644,6 @@ class FrigateApp:
|
||||
self.init_embeddings_manager()
|
||||
self.bind_database()
|
||||
self.check_db_data_migrations()
|
||||
self.install_notice_registry()
|
||||
|
||||
# Clean up any stale replay camera artifacts (filesystem + DB)
|
||||
cleanup_replay_cameras()
|
||||
|
||||
@@ -357,7 +357,20 @@ class Dispatcher:
|
||||
return
|
||||
|
||||
try:
|
||||
self.notice_registry.apply(payload)
|
||||
action = payload.get("action")
|
||||
kind = payload.get("kind")
|
||||
scope = payload.get("scope")
|
||||
|
||||
if not isinstance(kind, str):
|
||||
logger.warning("Ignoring notice update without a kind")
|
||||
elif action == "raise":
|
||||
self.notice_registry.raise_notice(
|
||||
kind, scope=scope, params=payload.get("params") or {}
|
||||
)
|
||||
elif action == "resolve":
|
||||
self.notice_registry.resolve(kind, scope)
|
||||
else:
|
||||
logger.warning("Ignoring notice update with action %s", action)
|
||||
except Exception:
|
||||
# a raise here would kill the REP thread for every process
|
||||
logger.exception("Failed to apply notice update")
|
||||
|
||||
@@ -16,7 +16,6 @@ from frigate.config.classification import CustomClassificationConfig
|
||||
from frigate.const import CLIPS_DIR, MODEL_CACHE_DIR
|
||||
from frigate.log import suppress_stderr_during
|
||||
from frigate.util.builtin import EventsPerSecond, InferenceSpeed, load_labels
|
||||
from frigate.util.file import trim_oldest_files
|
||||
from frigate.util.image import calculate_region
|
||||
from frigate.util.object import box_overlaps
|
||||
|
||||
@@ -730,4 +729,16 @@ def write_classification_attempt(
|
||||
file = os.path.join(folder, f"{event_id}-{timestamp}-{label}-{score}.webp")
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
cv2.imwrite(file, frame)
|
||||
trim_oldest_files(folder, max_files)
|
||||
|
||||
# delete oldest face image if maximum is reached
|
||||
try:
|
||||
files = sorted(
|
||||
filter(lambda f: f.endswith(".webp"), os.listdir(folder)),
|
||||
key=lambda f: os.path.getctime(os.path.join(folder, f)),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
if len(files) > max_files:
|
||||
os.unlink(os.path.join(folder, files[-1]))
|
||||
except (FileNotFoundError, OSError):
|
||||
pass
|
||||
|
||||
@@ -6,6 +6,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
@@ -27,7 +28,6 @@ from frigate.data_processing.common.face.recognizer import (
|
||||
)
|
||||
from frigate.types import TrackedObjectUpdateTypesEnum
|
||||
from frigate.util.builtin import EventsPerSecond, InferenceSpeed
|
||||
from frigate.util.file import trim_oldest_files
|
||||
from frigate.util.image import area
|
||||
from frigate.util.path import safe_join, sanitize_path_component
|
||||
|
||||
@@ -489,4 +489,13 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
)
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
cv2.imwrite(file, frame)
|
||||
trim_oldest_files(folder, self.config.face_recognition.save_attempts)
|
||||
|
||||
files = sorted(
|
||||
filter(lambda f: f.endswith(".webp"), os.listdir(folder)),
|
||||
key=lambda f: os.path.getctime(os.path.join(folder, f)),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
# delete oldest face image if maximum is reached
|
||||
if len(files) > self.config.face_recognition.save_attempts:
|
||||
Path(os.path.join(folder, files[-1])).unlink(missing_ok=True)
|
||||
|
||||
@@ -247,20 +247,15 @@ class CudaGraphRunner(BaseModelRunner):
|
||||
EnrichmentModelTypeEnum.yolov9_license_plate.value,
|
||||
]
|
||||
|
||||
# ORT performs two regular runs before it starts capturing, but on some
|
||||
# driver / cuDNN combinations the arena still has to extend on the run that
|
||||
# captures, and cudaMalloc is not allowed during capture. Running with
|
||||
# capture disabled first keeps those allocations outside of the capture.
|
||||
GRAPH_FREE_WARMUP_RUNS = 2
|
||||
|
||||
def __init__(self, session: ort.InferenceSession, cuda_device_id: int):
|
||||
self._session = session
|
||||
self._cuda_device_id = cuda_device_id
|
||||
self._prepared = False
|
||||
self._captured = False
|
||||
self._io_binding: ort.IOBinding | None = None
|
||||
self._input_name: str | None = None
|
||||
self._output_names: list[str] | None = None
|
||||
self._input_ortvalue: ort.OrtValue | None = None
|
||||
self._output_ortvalues: ort.OrtValue | None = None
|
||||
|
||||
def get_input_names(self) -> list[str]:
|
||||
"""Get input names for the model."""
|
||||
@@ -270,41 +265,35 @@ class CudaGraphRunner(BaseModelRunner):
|
||||
"""Get the input width of the model."""
|
||||
return self._session.get_inputs()[0].shape[3]
|
||||
|
||||
def _prepare(self, input_name: str, tensor_input: np.ndarray) -> None:
|
||||
"""Bind CUDA buffers and warm the session up with capture disabled."""
|
||||
self._io_binding = self._session.io_binding()
|
||||
self._input_name = input_name
|
||||
self._output_names = [o.name for o in self._session.get_outputs()]
|
||||
|
||||
self._input_ortvalue = ort.OrtValue.ortvalue_from_numpy(
|
||||
tensor_input, "cuda", self._cuda_device_id
|
||||
)
|
||||
self._io_binding.bind_ortvalue_input(self._input_name, self._input_ortvalue)
|
||||
|
||||
for name in self._output_names:
|
||||
# Bind outputs to CUDA and allow ORT to allocate appropriately
|
||||
self._io_binding.bind_output(name, "cuda", self._cuda_device_id)
|
||||
|
||||
# gpu_graph_id -1 disables capture and replay for the run
|
||||
warmup_options = ort.RunOptions()
|
||||
warmup_options.add_run_config_entry("gpu_graph_id", "-1")
|
||||
|
||||
for _ in range(self.GRAPH_FREE_WARMUP_RUNS):
|
||||
self._session.run_with_iobinding(self._io_binding, warmup_options)
|
||||
|
||||
self._prepared = True
|
||||
|
||||
def run(self, input: dict[str, Any]):
|
||||
# Extract the single tensor input (assuming one input)
|
||||
input_name = list(input.keys())[0]
|
||||
tensor_input = np.ascontiguousarray(input[input_name])
|
||||
tensor_input = input[input_name]
|
||||
tensor_input = np.ascontiguousarray(tensor_input)
|
||||
|
||||
if not self._prepared:
|
||||
self._prepare(input_name, tensor_input)
|
||||
else:
|
||||
# Replay using updated input
|
||||
self._input_ortvalue.update_inplace(tensor_input)
|
||||
if not self._captured:
|
||||
# Prepare IOBinding with CUDA buffers and let ORT allocate outputs on device
|
||||
self._io_binding = self._session.io_binding()
|
||||
self._input_name = input_name
|
||||
self._output_names = [o.name for o in self._session.get_outputs()]
|
||||
|
||||
self._input_ortvalue = ort.OrtValue.ortvalue_from_numpy(
|
||||
tensor_input, "cuda", self._cuda_device_id
|
||||
)
|
||||
self._io_binding.bind_ortvalue_input(self._input_name, self._input_ortvalue)
|
||||
|
||||
for name in self._output_names:
|
||||
# Bind outputs to CUDA and allow ORT to allocate appropriately
|
||||
self._io_binding.bind_output(name, "cuda", self._cuda_device_id)
|
||||
|
||||
# First IOBinding run to allocate, execute, and capture CUDA Graph
|
||||
ro = ort.RunOptions()
|
||||
self._session.run_with_iobinding(self._io_binding, ro)
|
||||
self._captured = True
|
||||
return self._io_binding.copy_outputs_to_cpu()
|
||||
|
||||
# Replay using updated input, copy results to CPU
|
||||
self._input_ortvalue.update_inplace(tensor_input)
|
||||
ro = ort.RunOptions()
|
||||
self._session.run_with_iobinding(self._io_binding, ro)
|
||||
return self._io_binding.copy_outputs_to_cpu()
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import io
|
||||
import logging
|
||||
from typing import Literal
|
||||
|
||||
import numpy as np
|
||||
import requests
|
||||
from PIL import Image
|
||||
from pydantic import ConfigDict, Field
|
||||
|
||||
from frigate.detectors.detection_api import DetectionApi
|
||||
from frigate.detectors.detector_config import BaseDetectorConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DETECTOR_KEY = "deepstack"
|
||||
|
||||
|
||||
class DeepstackDetectorConfig(BaseDetectorConfig):
|
||||
"""DeepStack/CodeProject.AI detector that sends images to a remote DeepStack HTTP API for inference. Not recommended."""
|
||||
|
||||
model_config = ConfigDict(
|
||||
title="DeepStack",
|
||||
)
|
||||
|
||||
type: Literal[DETECTOR_KEY]
|
||||
api_url: str = Field(
|
||||
default="http://localhost:80/v1/vision/detection",
|
||||
title="DeepStack API URL",
|
||||
description="The URL of the DeepStack API.",
|
||||
)
|
||||
api_timeout: float = Field(
|
||||
default=0.1,
|
||||
title="DeepStack API timeout (in seconds)",
|
||||
description="Maximum time allowed for a DeepStack API request.",
|
||||
)
|
||||
api_key: str = Field(
|
||||
default="",
|
||||
title="DeepStack API key (if required)",
|
||||
description="Optional API key for authenticated DeepStack services.",
|
||||
)
|
||||
|
||||
|
||||
class DeepStack(DetectionApi):
|
||||
type_key = DETECTOR_KEY
|
||||
|
||||
def __init__(self, detector_config: DeepstackDetectorConfig):
|
||||
self.api_url = detector_config.api_url
|
||||
self.api_timeout = detector_config.api_timeout
|
||||
self.api_key = detector_config.api_key
|
||||
self.labels = detector_config.model.merged_labelmap
|
||||
self.session = requests.Session()
|
||||
|
||||
def get_label_index(self, label_value):
|
||||
if label_value.lower() == "truck":
|
||||
label_value = "car"
|
||||
for index, value in self.labels.items():
|
||||
if value == label_value.lower():
|
||||
return index
|
||||
return -1
|
||||
|
||||
def detect_raw(self, tensor_input):
|
||||
image_data = np.squeeze(tensor_input).astype(np.uint8)
|
||||
image = Image.fromarray(image_data)
|
||||
self.w, self.h = image.size
|
||||
with io.BytesIO() as output:
|
||||
image.save(output, format="JPEG")
|
||||
image_bytes = output.getvalue()
|
||||
data = {"api_key": self.api_key}
|
||||
|
||||
try:
|
||||
response = self.session.post(
|
||||
self.api_url,
|
||||
data=data,
|
||||
files={"image": image_bytes},
|
||||
timeout=self.api_timeout,
|
||||
)
|
||||
except requests.exceptions.RequestException as ex:
|
||||
logger.error("Error calling deepstack API: %s", ex)
|
||||
return np.zeros((20, 6), np.float32)
|
||||
|
||||
response_json = response.json()
|
||||
detections = np.zeros((20, 6), np.float32)
|
||||
if response_json.get("predictions") is None:
|
||||
logger.debug(f"Error in parsing response json: {response_json}")
|
||||
return detections
|
||||
|
||||
for i, detection in enumerate(response_json.get("predictions")):
|
||||
logger.debug(f"Response: {detection}")
|
||||
if detection["confidence"] < 0.4:
|
||||
logger.debug("Break due to confidence < 0.4")
|
||||
break
|
||||
label = self.get_label_index(detection["label"])
|
||||
if label < 0:
|
||||
logger.debug("Break due to unknown label")
|
||||
break
|
||||
detections[i] = [
|
||||
label,
|
||||
float(detection["confidence"]),
|
||||
detection["y_min"] / self.h,
|
||||
detection["x_min"] / self.w,
|
||||
detection["y_max"] / self.h,
|
||||
detection["x_max"] / self.w,
|
||||
]
|
||||
|
||||
return detections
|
||||
@@ -1,84 +0,0 @@
|
||||
"""Raise and resolve notices from any Frigate process or thread.
|
||||
|
||||
A notice records something worth seeing later, such as an event or a setup
|
||||
problem only the backend can detect. A condition that comes and goes belongs in
|
||||
stats and the status bar, and a config problem belongs in a settings message
|
||||
flagged for health.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import multiprocessing as mp
|
||||
import threading
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.const import UPDATE_NOTICE
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frigate.notices.registry import NoticeRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_registry: "NoticeRegistry | None" = None
|
||||
|
||||
# zmq sockets are not thread safe, so every thread in a process shares one
|
||||
# requestor behind a lock
|
||||
_requestor: InterProcessRequestor | None = None
|
||||
_requestor_lock = threading.Lock()
|
||||
|
||||
|
||||
def install_registry(registry: "NoticeRegistry | None") -> None:
|
||||
"""Send this process's notices straight to the registry.
|
||||
|
||||
Only the main process installs one. Children start from the forkserver,
|
||||
so they never inherit it and send over IPC instead.
|
||||
"""
|
||||
global _registry
|
||||
_registry = registry
|
||||
|
||||
|
||||
def raise_notice(
|
||||
kind: str, *, scope: str | None = None, params: dict[str, Any] | None = None
|
||||
) -> None:
|
||||
"""Record a notice, or count another occurrence of it. Never raises."""
|
||||
_send({"action": "raise", "kind": kind, "scope": scope, "params": params or {}})
|
||||
|
||||
|
||||
def resolve_notice(kind: str, scope: str | None = None) -> None:
|
||||
"""Delete a notice once its problem is fixed. Never raises."""
|
||||
_send({"action": "resolve", "kind": kind, "scope": scope})
|
||||
|
||||
|
||||
def resolve_kind(kind: str) -> None:
|
||||
"""Delete every notice of a kind. Never raises."""
|
||||
_send({"action": "resolve_kind", "kind": kind})
|
||||
|
||||
|
||||
def flush_notices() -> None:
|
||||
"""Write repeats the registry held back. Only the main process has any."""
|
||||
if _registry is None:
|
||||
return
|
||||
|
||||
try:
|
||||
_registry.flush()
|
||||
except Exception:
|
||||
logger.exception("Failed to flush notices")
|
||||
|
||||
|
||||
def _send(update: dict[str, Any]) -> None:
|
||||
global _requestor
|
||||
|
||||
try:
|
||||
if _registry is not None:
|
||||
_registry.apply(update)
|
||||
elif mp.parent_process() is not None:
|
||||
with _requestor_lock:
|
||||
if _requestor is None:
|
||||
_requestor = InterProcessRequestor()
|
||||
|
||||
_requestor.send_data(UPDATE_NOTICE, update)
|
||||
else:
|
||||
# the main process before startup installs the registry, or a test
|
||||
logger.debug("No notice registry for %s", update["kind"])
|
||||
except Exception:
|
||||
logger.exception("Failed to update notice %s", update["kind"])
|
||||
|
||||
+78
-187
@@ -1,44 +1,49 @@
|
||||
"""Registry of notices and per-kind occurrence counts."""
|
||||
"""Registry of active notices and per-kind occurrence counts."""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from frigate.const import REPLAY_CAMERA_PREFIX
|
||||
from frigate.models import Notice, NoticeStats
|
||||
from frigate.notices.types import CHECK_KINDS, NOTICE_KINDS, SEVERITY_ORDER, notice_id
|
||||
from frigate.notices.types import (
|
||||
NOTICE_KINDS,
|
||||
SEVERITY_ORDER,
|
||||
NoticeMode,
|
||||
notice_id,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _HeldRepeats:
|
||||
kind: str
|
||||
count: int
|
||||
last_seen: float
|
||||
params: dict[str, Any]
|
||||
class DismissResult(str, Enum):
|
||||
ok = "ok"
|
||||
not_found = "not_found"
|
||||
not_dismissable = "not_dismissable"
|
||||
|
||||
|
||||
# last_seen value of a state notice that has not been raised since startup
|
||||
UNCONFIRMED = 0
|
||||
|
||||
|
||||
class NoticeRegistry:
|
||||
"""Owns the notice tables. Lives in the main process only.
|
||||
|
||||
Producers call the functions in frigate.notices, which reach this object
|
||||
directly in the main process and through the dispatcher everywhere else.
|
||||
It holds no rules for particular kinds; those live in NOTICE_KINDS.
|
||||
Producers in the main process call it directly. Other processes send an
|
||||
update_notice request that the dispatcher forwards here. The FastAPI app
|
||||
runs inside the main process too, so the API's dismiss shares this exact
|
||||
object, its lock, and its listeners.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._listeners: list[Callable[[], None]] = []
|
||||
|
||||
# row id -> repeats of a batch_repeats kind that flush() writes
|
||||
self._held: dict[str, _HeldRepeats] = {}
|
||||
|
||||
def subscribe(self, listener: Callable[[], None]) -> None:
|
||||
"""Call listener after every change to the notices."""
|
||||
"""Call listener after every change to the active list."""
|
||||
self._listeners.append(listener)
|
||||
|
||||
def _notify(self) -> None:
|
||||
@@ -48,26 +53,6 @@ class NoticeRegistry:
|
||||
except Exception:
|
||||
logger.exception("Notice listener failed")
|
||||
|
||||
def apply(self, update: dict[str, Any]) -> None:
|
||||
"""Apply one update message built by frigate.notices."""
|
||||
kind = update.get("kind")
|
||||
|
||||
if not isinstance(kind, str):
|
||||
logger.warning("Ignoring notice update without a kind")
|
||||
return
|
||||
|
||||
match update.get("action"):
|
||||
case "raise":
|
||||
self.raise_notice(
|
||||
kind, scope=update.get("scope"), params=update.get("params")
|
||||
)
|
||||
case "resolve":
|
||||
self.resolve(kind, update.get("scope"))
|
||||
case "resolve_kind":
|
||||
self.resolve_kind(kind)
|
||||
case action:
|
||||
logger.warning("Ignoring notice update with action %s", action)
|
||||
|
||||
def raise_notice(
|
||||
self,
|
||||
kind: str,
|
||||
@@ -75,12 +60,7 @@ class NoticeRegistry:
|
||||
scope: str | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Insert a notice or count another occurrence of it.
|
||||
|
||||
A dismissed notice stays dismissed when it is raised again, unless its
|
||||
kind sets reopen_at_count. A kind that should come back after a
|
||||
dismissal gives each episode its own scope.
|
||||
"""
|
||||
"""Insert or update a notice. See the counting rule in the spec."""
|
||||
definition = NOTICE_KINDS.get(kind)
|
||||
|
||||
if definition is None:
|
||||
@@ -102,8 +82,6 @@ class NoticeRegistry:
|
||||
existing = Notice.get_or_none(Notice.id == row_id)
|
||||
|
||||
if existing is None:
|
||||
# repeats held for a row that has since been deleted are stale
|
||||
self._held.pop(row_id, None)
|
||||
Notice.create(
|
||||
id=row_id,
|
||||
kind=kind,
|
||||
@@ -114,48 +92,28 @@ class NoticeRegistry:
|
||||
count=1,
|
||||
dismissed_at=None,
|
||||
)
|
||||
self._bump_occurrences(kind, 1, now)
|
||||
|
||||
if definition.keep_latest is not None:
|
||||
self._prune(kind, definition.keep_latest)
|
||||
elif not definition.counts_repeats:
|
||||
return
|
||||
elif definition.batch_repeats:
|
||||
held = self._held.setdefault(row_id, _HeldRepeats(kind, 0, now, params))
|
||||
held.count += 1
|
||||
held.last_seen = now
|
||||
held.params = params
|
||||
return
|
||||
self._bump_occurrences(kind, now)
|
||||
elif definition.mode == NoticeMode.event:
|
||||
Notice.update(
|
||||
count=Notice.count + 1,
|
||||
last_seen=now,
|
||||
params=params,
|
||||
dismissed_at=None,
|
||||
).where(Notice.id == row_id).execute()
|
||||
self._bump_occurrences(kind, now)
|
||||
else:
|
||||
self._write_repeats(existing, kind, 1, now, params)
|
||||
# one episode counts once; a repeat with nothing new is free
|
||||
confirmed = existing.last_seen != UNCONFIRMED
|
||||
|
||||
if confirmed and existing.params == params:
|
||||
return
|
||||
|
||||
Notice.update(last_seen=now, params=params).where(
|
||||
Notice.id == row_id
|
||||
).execute()
|
||||
|
||||
self._notify()
|
||||
|
||||
def flush(self) -> None:
|
||||
"""Write the repeats that batch_repeats kinds held back."""
|
||||
with self._lock:
|
||||
held, self._held = self._held, {}
|
||||
written = False
|
||||
|
||||
for row_id, repeats in held.items():
|
||||
row = Notice.get_or_none(Notice.id == row_id)
|
||||
|
||||
# resolved while its repeats waited
|
||||
if row is None:
|
||||
continue
|
||||
|
||||
self._write_repeats(
|
||||
row,
|
||||
repeats.kind,
|
||||
repeats.count,
|
||||
repeats.last_seen,
|
||||
repeats.params,
|
||||
)
|
||||
written = True
|
||||
|
||||
if written:
|
||||
self._notify()
|
||||
|
||||
def resolve(self, kind: str, scope: str | None = None) -> None:
|
||||
"""Delete a notice if present. Safe to call when it is absent."""
|
||||
with self._lock:
|
||||
@@ -166,16 +124,8 @@ class NoticeRegistry:
|
||||
if deleted:
|
||||
self._notify()
|
||||
|
||||
def resolve_kind(self, kind: str) -> None:
|
||||
"""Delete every notice of a kind."""
|
||||
with self._lock:
|
||||
deleted = Notice.delete().where(Notice.kind == kind).execute()
|
||||
|
||||
if deleted:
|
||||
self._notify()
|
||||
|
||||
def resolve_camera(self, camera: str) -> None:
|
||||
"""Drop the notices and check dismissals of a camera being deleted."""
|
||||
"""Drop every camera-category notice scoped to a camera being deleted."""
|
||||
camera_kinds = [
|
||||
key
|
||||
for key, definition in NOTICE_KINDS.items()
|
||||
@@ -192,82 +142,33 @@ class NoticeRegistry:
|
||||
.execute()
|
||||
)
|
||||
|
||||
# a stream id names its camera first; a config id ends with camera.<name>
|
||||
for check in self.dismissed_checks():
|
||||
check_id = check["id"]
|
||||
|
||||
if check_id.startswith(f"stream:{camera}:") or (
|
||||
check_id.startswith("config:")
|
||||
and check_id.endswith(f":camera.{camera}")
|
||||
):
|
||||
Notice.delete_by_id(check_id)
|
||||
|
||||
if deleted:
|
||||
self._notify()
|
||||
|
||||
def purge_dismissed(self) -> int:
|
||||
"""Delete every dismissed row so each can show again. Returns how many."""
|
||||
with self._lock:
|
||||
return int(
|
||||
Notice.delete().where(Notice.dismissed_at.is_null(False)).execute()
|
||||
)
|
||||
|
||||
def dismiss(self, row_id: str) -> bool:
|
||||
"""Hide a notice or check row for good. Returns False for an unknown id."""
|
||||
def dismiss(self, row_id: str) -> DismissResult:
|
||||
"""Hide an event notice until it is raised again."""
|
||||
with self._lock:
|
||||
existing = Notice.get_or_none(Notice.id == row_id)
|
||||
|
||||
if existing is None:
|
||||
# a check row gets a notice row only once it is dismissed
|
||||
kind, _, scope = row_id.partition(":")
|
||||
return DismissResult.not_found
|
||||
|
||||
if kind not in CHECK_KINDS or not scope:
|
||||
return False
|
||||
definition = NOTICE_KINDS.get(existing.kind)
|
||||
|
||||
now = datetime.now().timestamp()
|
||||
Notice.create(
|
||||
id=row_id,
|
||||
kind=kind,
|
||||
scope=scope,
|
||||
params={},
|
||||
first_seen=now,
|
||||
last_seen=now,
|
||||
count=1,
|
||||
dismissed_at=now,
|
||||
)
|
||||
return True
|
||||
if definition is None or definition.mode == NoticeMode.state:
|
||||
return DismissResult.not_dismissable
|
||||
|
||||
if existing.dismissed_at is not None:
|
||||
return True
|
||||
|
||||
if existing.kind not in NOTICE_KINDS:
|
||||
return False
|
||||
|
||||
Notice.update(dismissed_at=datetime.now().timestamp()).where(
|
||||
Notice.id == row_id
|
||||
).execute()
|
||||
now = datetime.now().timestamp()
|
||||
Notice.update(dismissed_at=now).where(Notice.id == row_id).execute()
|
||||
NoticeStats.update(dismissals=NoticeStats.dismissals + 1).where(
|
||||
NoticeStats.kind == existing.kind
|
||||
).execute()
|
||||
|
||||
self._notify()
|
||||
return True
|
||||
|
||||
def dismissed_checks(self) -> list[dict[str, Any]]:
|
||||
"""Dismissed config and stream check rows, newest first."""
|
||||
rows = (
|
||||
Notice.select()
|
||||
.where(Notice.kind.in_(list(CHECK_KINDS))) # type: ignore[call-arg, arg-type, misc]
|
||||
.order_by(Notice.dismissed_at.desc())
|
||||
)
|
||||
return [{"id": row.id, "dismissed_at": row.dismissed_at} for row in rows]
|
||||
return DismissResult.ok
|
||||
|
||||
def active(self, include_dismissed: bool = False) -> list[dict[str, Any]]:
|
||||
"""Notices most severe first, then most recent first.
|
||||
|
||||
Args:
|
||||
include_dismissed: Also return dismissed notices, for the history view
|
||||
"""
|
||||
"""The active notices, most severe first, then most recent first."""
|
||||
rows = []
|
||||
|
||||
for row in Notice.select():
|
||||
@@ -279,15 +180,18 @@ class NoticeRegistry:
|
||||
if row.dismissed_at is not None and not include_dismissed:
|
||||
continue
|
||||
|
||||
if definition.mode == NoticeMode.state and row.last_seen == UNCONFIRMED:
|
||||
continue
|
||||
|
||||
rows.append(
|
||||
{
|
||||
"id": row.id,
|
||||
"kind": row.kind,
|
||||
"mode": definition.mode.value,
|
||||
"severity": definition.severity.value,
|
||||
"category": definition.category,
|
||||
"scope": row.scope,
|
||||
"params": row.params,
|
||||
"link": definition.link_for(row.params),
|
||||
"first_seen": row.first_seen,
|
||||
"last_seen": row.last_seen,
|
||||
"count": row.count,
|
||||
@@ -304,7 +208,7 @@ class NoticeRegistry:
|
||||
return rows
|
||||
|
||||
def stats(self) -> list[dict[str, Any]]:
|
||||
"""Lifetime counts per kind, for later analytics."""
|
||||
"""Lifetime counts per kind, for the UI meta line and later analytics."""
|
||||
return [
|
||||
{
|
||||
"kind": row.kind,
|
||||
@@ -319,54 +223,41 @@ class NoticeRegistry:
|
||||
if row.kind in NOTICE_KINDS
|
||||
]
|
||||
|
||||
def _write_repeats(
|
||||
self,
|
||||
row: Notice,
|
||||
kind: str,
|
||||
count: int,
|
||||
last_seen: float,
|
||||
params: dict[str, Any],
|
||||
) -> None:
|
||||
# called with the lock held
|
||||
fields: dict[str, Any] = {
|
||||
"count": row.count + count,
|
||||
"last_seen": last_seen,
|
||||
"params": params,
|
||||
}
|
||||
reopen_at = NOTICE_KINDS[kind].reopen_at_count
|
||||
def mark_state_notices_unconfirmed(self) -> None:
|
||||
"""Hide state notices until their producer raises them again.
|
||||
|
||||
if reopen_at is not None and row.count < reopen_at <= row.count + count:
|
||||
fields["dismissed_at"] = None
|
||||
Rows already unconfirmed from the previous run are deleted. Hiding
|
||||
rather than deleting keeps one episode that spans a restart at one
|
||||
occurrence.
|
||||
"""
|
||||
state_kinds = [
|
||||
key
|
||||
for key, definition in NOTICE_KINDS.items()
|
||||
if definition.mode == NoticeMode.state
|
||||
]
|
||||
|
||||
Notice.update(**fields).where(Notice.id == row.id).execute()
|
||||
self._bump_occurrences(kind, count, last_seen)
|
||||
with self._lock:
|
||||
Notice.delete().where(
|
||||
Notice.kind.in_(state_kinds), # type: ignore[call-arg, arg-type, misc]
|
||||
Notice.last_seen == UNCONFIRMED,
|
||||
).execute()
|
||||
Notice.update(last_seen=UNCONFIRMED).where(
|
||||
Notice.kind.in_(state_kinds) # type: ignore[call-arg, arg-type, misc]
|
||||
).execute()
|
||||
|
||||
def _prune(self, kind: str, keep: int) -> None:
|
||||
# called with the lock held
|
||||
newest = (
|
||||
Notice.select(Notice.id)
|
||||
.where(Notice.kind == kind)
|
||||
.order_by(Notice.first_seen.desc())
|
||||
.limit(keep)
|
||||
)
|
||||
Notice.delete().where(
|
||||
Notice.kind == kind,
|
||||
Notice.id.not_in(newest), # type: ignore[call-arg, misc]
|
||||
).execute()
|
||||
|
||||
def _bump_occurrences(self, kind: str, count: int, now: float) -> None:
|
||||
def _bump_occurrences(self, kind: str, now: float) -> None:
|
||||
# called with the lock held
|
||||
stats = NoticeStats.get_or_none(NoticeStats.kind == kind)
|
||||
|
||||
if stats is None:
|
||||
NoticeStats.create(
|
||||
kind=kind,
|
||||
occurrences=count,
|
||||
occurrences=1,
|
||||
dismissals=0,
|
||||
first_seen=now,
|
||||
last_seen=now,
|
||||
)
|
||||
else:
|
||||
NoticeStats.update(
|
||||
occurrences=NoticeStats.occurrences + count, last_seen=now
|
||||
occurrences=NoticeStats.occurrences + 1, last_seen=now
|
||||
).where(NoticeStats.kind == kind).execute()
|
||||
|
||||
+17
-55
@@ -1,8 +1,14 @@
|
||||
"""Notice kinds and the definitions that fix their severity and category."""
|
||||
"""Notice kinds and the definitions that fix their mode, severity, and category."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
|
||||
class NoticeMode(str, Enum):
|
||||
"""Whether a producer resolves the notice itself or the user dismisses it."""
|
||||
|
||||
state = "state"
|
||||
event = "event"
|
||||
|
||||
|
||||
class NoticeSeverity(str, Enum):
|
||||
@@ -24,77 +30,33 @@ class NoticeKind:
|
||||
|
||||
Attributes:
|
||||
key: Stable machine-readable name, also the translation key suffix
|
||||
mode: state notices are resolved by their producer, event notices by the user
|
||||
severity: error, warning, or info
|
||||
category: camera, detector, model, or system; a camera scope is a
|
||||
camera name, and the UI shows it
|
||||
link: app route or absolute URL for the row, filled in from params
|
||||
counts_repeats: whether raising an existing notice counts another occurrence
|
||||
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
|
||||
category: what the scope names: camera, detector, and model kinds carry a scope
|
||||
reportable: whether a future analytics reporter may send this kind's counts
|
||||
"""
|
||||
|
||||
key: str
|
||||
mode: NoticeMode
|
||||
severity: NoticeSeverity
|
||||
category: str
|
||||
link: str | None = None
|
||||
counts_repeats: bool = True
|
||||
batch_repeats: bool = False
|
||||
reopen_at_count: int | None = None
|
||||
keep_latest: int | None = None
|
||||
reportable: bool = True
|
||||
|
||||
def link_for(self, params: dict[str, Any]) -> str | None:
|
||||
"""The link with params filled in, or None if a param is missing."""
|
||||
if self.link is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return self.link.format(**params)
|
||||
except (KeyError, IndexError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
SCOPED_CATEGORIES = frozenset({"camera", "detector", "model"})
|
||||
|
||||
_KINDS = (
|
||||
NoticeKind("ffmpeg_crash_loop", NoticeMode.state, NoticeSeverity.error, "camera"),
|
||||
NoticeKind("detector_stuck", NoticeMode.event, NoticeSeverity.warning, "detector"),
|
||||
NoticeKind(
|
||||
"detector_stuck", NoticeSeverity.warning, "detector", link="/system#general"
|
||||
),
|
||||
NoticeKind("model_download_failed", NoticeSeverity.error, "model"),
|
||||
NoticeKind(
|
||||
"skipped_detections",
|
||||
NoticeSeverity.warning,
|
||||
"camera",
|
||||
link="/system#cameras",
|
||||
),
|
||||
NoticeKind("shm_too_low", NoticeSeverity.warning, "system", link="/system#storage"),
|
||||
# one row per user per burst; the login log lines carry the address
|
||||
NoticeKind(
|
||||
"failed_login",
|
||||
NoticeSeverity.warning,
|
||||
"system",
|
||||
link="/logs",
|
||||
batch_repeats=True,
|
||||
reopen_at_count=5,
|
||||
keep_latest=100,
|
||||
),
|
||||
# one row per release, so a dismissal lasts until the next release
|
||||
NoticeKind(
|
||||
"update_available",
|
||||
NoticeSeverity.info,
|
||||
"system",
|
||||
link="https://github.com/blakeblackshear/frigate/releases/tag/v{version}",
|
||||
counts_repeats=False,
|
||||
keep_latest=1,
|
||||
"model_download_failed", NoticeMode.event, NoticeSeverity.error, "model"
|
||||
),
|
||||
NoticeKind("retention_unmet", NoticeMode.state, NoticeSeverity.error, "storage"),
|
||||
NoticeKind("update_available", NoticeMode.state, NoticeSeverity.info, "system"),
|
||||
)
|
||||
|
||||
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
|
||||
# row of these kinds only records a dismissal; its other fields are placeholders
|
||||
CHECK_KINDS = frozenset({"config", "stream"})
|
||||
|
||||
|
||||
def notice_id(kind: str, scope: str | None) -> str:
|
||||
"""The row id for a kind and optional scope."""
|
||||
|
||||
@@ -50,9 +50,8 @@ from frigate.util.time import is_current_hour
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
DEFAULT_TIME_LAPSE_FFMPEG_INPUT_ARGS = "-an"
|
||||
DEFAULT_TIME_LAPSE_FFMPEG_ARGS = "-vf setpts=0.04*PTS -r 30"
|
||||
TIMELAPSE_DATA_INPUT_ARGS = "-skip_frame nokey"
|
||||
TIMELAPSE_DATA_INPUT_ARGS = "-an -skip_frame nokey"
|
||||
|
||||
# nginx-vod repackages each stream into fMP4 with a timescale derived from
|
||||
# that stream's frame rate, and the concat demuxer rescales every input to
|
||||
@@ -1194,7 +1193,7 @@ class RecordingExporter(threading.Thread):
|
||||
parse_preset_hardware_acceleration_encode(
|
||||
self.config.ffmpeg.ffmpeg_path,
|
||||
hwaccel_args,
|
||||
f"{self.ffmpeg_input_args} {ffmpeg_input}".strip(),
|
||||
f"{self.ffmpeg_input_args} -an {ffmpeg_input}".strip(),
|
||||
f"{self.ffmpeg_output_args} -movflags +faststart".strip(),
|
||||
EncodeTypeEnum.timelapse,
|
||||
)
|
||||
|
||||
+10
-91
@@ -11,7 +11,7 @@ from typing import Any
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.const import FREQUENCY_STATS_POINTS
|
||||
from frigate.notices import flush_notices, raise_notice, resolve_kind, resolve_notice
|
||||
from frigate.notices.registry import NoticeRegistry
|
||||
from frigate.stats.hardware import HardwareStats
|
||||
from frigate.stats.prometheus import update_metrics
|
||||
from frigate.stats.util import get_latest_version, is_newer_version, stats_snapshot
|
||||
@@ -26,46 +26,6 @@ MAX_STATS_POINTS = 80
|
||||
# how often to ask GitHub for the latest release
|
||||
VERSION_REFRESH_S = 24 * 60 * 60
|
||||
|
||||
# a camera skipping at least this percent of its frames is falling behind
|
||||
SKIPPED_DETECTIONS_PCT = 5
|
||||
|
||||
# for at least this long before it becomes a notice
|
||||
SKIPPED_DETECTIONS_HOLD_S = 60
|
||||
|
||||
# detectors warm up after a start; the status bar waits this long too
|
||||
STARTUP_GRACE_S = 120
|
||||
|
||||
|
||||
class SkippedDetectionsTracker:
|
||||
"""Finds cameras whose skipped share stays high long enough for a notice."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._since: dict[str, float] = {}
|
||||
self._raised: set[str] = set()
|
||||
|
||||
def update(self, cameras: dict[str, dict[str, Any]], now: float) -> list[str]:
|
||||
"""Return the cameras whose episode qualified on this sample."""
|
||||
qualified: list[str] = []
|
||||
|
||||
# a removed camera that comes back starts a new episode
|
||||
for camera in self._since.keys() - cameras.keys():
|
||||
self._since.pop(camera)
|
||||
self._raised.discard(camera)
|
||||
|
||||
for camera, camera_stats in cameras.items():
|
||||
if camera_stats["skipped_pct"] < SKIPPED_DETECTIONS_PCT:
|
||||
self._since.pop(camera, None)
|
||||
self._raised.discard(camera)
|
||||
continue
|
||||
|
||||
since = self._since.setdefault(camera, now)
|
||||
|
||||
if camera not in self._raised and now - since >= SKIPPED_DETECTIONS_HOLD_S:
|
||||
self._raised.add(camera)
|
||||
qualified.append(camera)
|
||||
|
||||
return qualified
|
||||
|
||||
|
||||
class StatsEmitter(threading.Thread):
|
||||
def __init__(
|
||||
@@ -73,18 +33,15 @@ class StatsEmitter(threading.Thread):
|
||||
config: FrigateConfig,
|
||||
stats_tracking: StatsTrackingTypes,
|
||||
stop_event: MpEvent,
|
||||
notice_registry: NoticeRegistry | None = None,
|
||||
):
|
||||
super().__init__(name="frigate_stats_emitter")
|
||||
self.config = config
|
||||
self.stats_tracking = stats_tracking
|
||||
self.stop_event = stop_event
|
||||
self.notice_registry = notice_registry
|
||||
self.hardware_stats = HardwareStats(config)
|
||||
self.stats_history: list[dict[str, Any]] = []
|
||||
self.skipped_detections = SkippedDetectionsTracker()
|
||||
|
||||
# the shm notice's params as last sent, so only a change is written
|
||||
self._shm_checked = False
|
||||
self._shm_params: dict[str, Any] | None = None
|
||||
|
||||
# create communication for stats
|
||||
self.requestor = InterProcessRequestor()
|
||||
@@ -177,6 +134,9 @@ class StatsEmitter(threading.Thread):
|
||||
|
||||
def _check_update_notice(self) -> None:
|
||||
"""Raise or resolve the update notice from the tracked latest version."""
|
||||
if self.notice_registry is None:
|
||||
return
|
||||
|
||||
latest = self.stats_tracking["latest_frigate_version"]
|
||||
|
||||
# a failed lookup says nothing about whether an update exists
|
||||
@@ -184,9 +144,11 @@ class StatsEmitter(threading.Thread):
|
||||
return
|
||||
|
||||
if is_newer_version(VERSION, latest):
|
||||
raise_notice("update_available", scope=latest, params={"version": latest})
|
||||
self.notice_registry.raise_notice(
|
||||
"update_available", params={"version": latest}
|
||||
)
|
||||
else:
|
||||
resolve_kind("update_available")
|
||||
self.notice_registry.resolve("update_available")
|
||||
|
||||
def _refresh_latest_version(self) -> None:
|
||||
"""Refresh the latest release on a daemon thread so the request never stalls stats."""
|
||||
@@ -204,46 +166,6 @@ class StatsEmitter(threading.Thread):
|
||||
target=refresh, name="frigate_version_check", daemon=True
|
||||
).start()
|
||||
|
||||
def _update_shm_notice(self, shm: dict[str, Any]) -> None:
|
||||
"""Raise the shm notice while /dev/shm is smaller than the cameras need."""
|
||||
params = (
|
||||
{"total": shm["total"], "min": shm["min_shm"]}
|
||||
if shm and shm["total"] < shm["min_shm"]
|
||||
else None
|
||||
)
|
||||
|
||||
# the first tick always sends, so a row the last run left is raised
|
||||
# again or cleared
|
||||
if self._shm_checked and params == self._shm_params:
|
||||
return
|
||||
|
||||
self._shm_checked = True
|
||||
self._shm_params = params
|
||||
|
||||
if params is None:
|
||||
resolve_notice("shm_too_low")
|
||||
else:
|
||||
raise_notice("shm_too_low", params=params)
|
||||
|
||||
def _update_notices(self, stats: dict[str, Any], now: float) -> None:
|
||||
"""Update notices based on current stats or time."""
|
||||
# skipped detections
|
||||
if stats["service"]["uptime"] >= STARTUP_GRACE_S:
|
||||
for camera in self.skipped_detections.update(stats["cameras"], now):
|
||||
raise_notice(
|
||||
"skipped_detections",
|
||||
scope=camera,
|
||||
params={"pct": stats["cameras"][camera]["skipped_pct"]},
|
||||
)
|
||||
|
||||
# shm too small for the cameras
|
||||
self._update_shm_notice(stats["service"]["storage"]["/dev/shm"])
|
||||
|
||||
# repeats of batched kinds, such as failed logins
|
||||
flush_notices()
|
||||
|
||||
# add any additional notice types here
|
||||
|
||||
def run(self) -> None:
|
||||
time.sleep(10)
|
||||
self._check_update_notice()
|
||||
@@ -264,14 +186,11 @@ class StatsEmitter(threading.Thread):
|
||||
)
|
||||
self.stats_history.append(stats)
|
||||
self.stats_history = self.stats_history[-MAX_STATS_POINTS:]
|
||||
self._update_notices(stats, time.time())
|
||||
|
||||
if counter == 0:
|
||||
self.requestor.send_data("stats", json.dumps(stats))
|
||||
|
||||
logger.debug("Finished stats collection")
|
||||
|
||||
# write the repeats held back since the last tick
|
||||
flush_notices()
|
||||
self.hardware_stats.stop()
|
||||
logger.info("Exiting stats emitter...")
|
||||
|
||||
+1
-28
@@ -6,7 +6,7 @@ import shutil
|
||||
import time
|
||||
from json import JSONDecodeError
|
||||
from multiprocessing.managers import DictProxy
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
from requests.exceptions import RequestException
|
||||
@@ -24,9 +24,6 @@ from frigate.util.services import (
|
||||
)
|
||||
from frigate.version import VERSION
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frigate.storage import StorageMaintainer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -91,7 +88,6 @@ def stats_init(
|
||||
embeddings_metrics: DataProcessorMetrics,
|
||||
detectors: dict[str, ObjectDetectProcess],
|
||||
processes: dict[str, int],
|
||||
storage_maintainer: "StorageMaintainer | None" = None,
|
||||
) -> StatsTrackingTypes:
|
||||
stats_tracking: StatsTrackingTypes = {
|
||||
"camera_metrics": camera_metrics,
|
||||
@@ -101,7 +97,6 @@ def stats_init(
|
||||
"latest_frigate_version": get_latest_version(config),
|
||||
"last_updated": int(time.time()),
|
||||
"processes": processes,
|
||||
"storage_maintainer": storage_maintainer,
|
||||
}
|
||||
return stats_tracking
|
||||
|
||||
@@ -221,18 +216,6 @@ def embeddings_stats(
|
||||
return stats
|
||||
|
||||
|
||||
def skipped_percent(skipped_fps: float, camera_fps: float, enabled: bool) -> float:
|
||||
"""Percent of a camera's frames dropped before detection.
|
||||
|
||||
camera_fps counts the dropped frames too. A disabled camera keeps its last
|
||||
readings, so it reports zero.
|
||||
"""
|
||||
if not enabled or camera_fps <= 0:
|
||||
return 0.0
|
||||
|
||||
return round(skipped_fps / camera_fps * 100, 1)
|
||||
|
||||
|
||||
def stats_snapshot(
|
||||
config: FrigateConfig,
|
||||
stats_tracking: StatsTrackingTypes,
|
||||
@@ -290,11 +273,6 @@ def stats_snapshot(
|
||||
"camera_fps": round(camera_stats.camera_fps.value, 2),
|
||||
"process_fps": round(camera_stats.process_fps.value, 2),
|
||||
"skipped_fps": round(camera_stats.skipped_fps.value, 2),
|
||||
"skipped_pct": skipped_percent(
|
||||
camera_stats.skipped_fps.value,
|
||||
current_fps,
|
||||
config.cameras[name].enabled,
|
||||
),
|
||||
"detection_fps": round(camera_stats.detection_fps.value, 2),
|
||||
"detection_enabled": config.cameras[name].detect.enabled,
|
||||
"pid": pid,
|
||||
@@ -323,17 +301,12 @@ def stats_snapshot(
|
||||
if bandwidth_stats:
|
||||
stats["bandwidth_usages"] = bandwidth_stats
|
||||
|
||||
storage_maintainer = stats_tracking.get("storage_maintainer")
|
||||
|
||||
stats["service"] = {
|
||||
"uptime": (int(time.time()) - stats_tracking["started"]),
|
||||
"version": VERSION,
|
||||
"latest_version": stats_tracking["latest_frigate_version"],
|
||||
"storage": {},
|
||||
"last_updated": int(time.time()),
|
||||
"retention_unmet": bool(
|
||||
storage_maintainer and storage_maintainer.retention_unmet
|
||||
),
|
||||
}
|
||||
|
||||
for path in [RECORD_DIR, CLIPS_DIR, CACHE_DIR]:
|
||||
|
||||
+21
-8
@@ -20,6 +20,7 @@ from frigate.const import (
|
||||
STREAM_TYPE_SUB,
|
||||
)
|
||||
from frigate.models import Event, Recordings
|
||||
from frigate.notices.registry import NoticeRegistry
|
||||
from frigate.util.builtin import clear_and_unlink
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -34,14 +35,17 @@ BANDWIDTH_SAMPLE_TARGET = 50
|
||||
class StorageMaintainer(threading.Thread):
|
||||
"""Maintain frigates recording storage."""
|
||||
|
||||
def __init__(self, config: FrigateConfig, stop_event: MpEvent) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
config: FrigateConfig,
|
||||
stop_event: MpEvent,
|
||||
notice_registry: NoticeRegistry | None = None,
|
||||
) -> None:
|
||||
super().__init__(name="storage_maintainer")
|
||||
self.config = config
|
||||
self.stop_event = stop_event
|
||||
self.notice_registry = notice_registry
|
||||
self.camera_storage_stats: dict[str, dict] = {}
|
||||
|
||||
# read by stats; true while maintenance has to delete retained recordings
|
||||
self.retention_unmet = False
|
||||
self.config_subscriber = CameraConfigUpdateSubscriber(
|
||||
self.config,
|
||||
self.config.cameras,
|
||||
@@ -346,10 +350,19 @@ class StorageMaintainer(threading.Thread):
|
||||
# this file was not found so we must assume no space was cleaned up
|
||||
pass
|
||||
|
||||
self.retention_unmet = True
|
||||
if self.notice_registry is not None:
|
||||
self.notice_registry.raise_notice(
|
||||
"retention_unmet",
|
||||
params={
|
||||
"needed_mb": round(float(hourly_bandwidth), 1),
|
||||
"cleared_mb": round(float(deleted_segments_size), 1),
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.info(f"Cleaned up {deleted_segments_size:.2f} MB of recordings")
|
||||
self.retention_unmet = False
|
||||
|
||||
if self.notice_registry is not None:
|
||||
self.notice_registry.resolve("retention_unmet")
|
||||
|
||||
logger.debug(f"Expiring {len(deleted_recordings)} recordings")
|
||||
# delete up to 100,000 at a time
|
||||
@@ -435,5 +448,5 @@ class StorageMaintainer(threading.Thread):
|
||||
"Less than 1 hour of recording space left, running storage maintenance..."
|
||||
)
|
||||
self.reduce_storage_consumption()
|
||||
else:
|
||||
self.retention_unmet = False
|
||||
elif self.notice_registry is not None:
|
||||
self.notice_registry.resolve("retention_unmet")
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
"""Tests for authentication endpoints."""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import ANY, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
from frigate.api.auth import (
|
||||
FAILED_LOGIN_BURST_GAP_S,
|
||||
MAX_NOTICE_USERNAME,
|
||||
MAX_OPEN_BURSTS,
|
||||
FailedLoginTracker,
|
||||
hash_password,
|
||||
)
|
||||
from frigate.api.auth import hash_password
|
||||
from frigate.const import JWT_SECRET_ENV_VAR
|
||||
from frigate.models import User
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
@@ -50,116 +43,3 @@ class TestHttpAuth(BaseTestHttp):
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert any("Login failed" in m and "admin" in m for m in logs.output)
|
||||
|
||||
|
||||
@patch.dict(os.environ, {JWT_SECRET_ENV_VAR: "test-secret"})
|
||||
class TestFailedLoginNotices(BaseTestHttp):
|
||||
def setUp(self):
|
||||
super().setUp([User])
|
||||
self.app = super().create_app()
|
||||
User.insert(
|
||||
username="admin",
|
||||
password_hash=hash_password("correct-horse-battery", iterations=1000),
|
||||
role="admin",
|
||||
notification_tokens=[],
|
||||
).execute()
|
||||
tracker_patch = patch("frigate.api.auth.failed_logins")
|
||||
self.failed_logins = tracker_patch.start()
|
||||
self.addCleanup(tracker_patch.stop)
|
||||
|
||||
def tearDown(self):
|
||||
User.delete().execute()
|
||||
super().tearDown()
|
||||
|
||||
def _login(self, user: str, password: str) -> int:
|
||||
with AuthTestClient(self.app) as client:
|
||||
response = client.post("/login", json={"user": user, "password": password})
|
||||
|
||||
return response.status_code
|
||||
|
||||
def test_unknown_user_is_recorded(self):
|
||||
self.assertEqual(self._login("ghost", "irrelevant"), 401)
|
||||
self.failed_logins.record.assert_called_once_with("ghost", ANY)
|
||||
|
||||
def test_bad_password_is_recorded_as_known(self):
|
||||
self.assertEqual(self._login("admin", "wrong"), 401)
|
||||
self.failed_logins.record.assert_called_once_with("admin", ANY, known=True)
|
||||
|
||||
def test_success_is_not_recorded(self):
|
||||
self.assertEqual(self._login("admin", "correct-horse-battery"), 200)
|
||||
self.failed_logins.record.assert_not_called()
|
||||
|
||||
|
||||
class TestFailedLoginTracker(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tracker = FailedLoginTracker()
|
||||
raise_patch = patch("frigate.api.auth.raise_notice")
|
||||
self.raise_notice = raise_patch.start()
|
||||
self.addCleanup(raise_patch.stop)
|
||||
|
||||
def _scopes(self) -> list[str]:
|
||||
return [call.kwargs["scope"] for call in self.raise_notice.call_args_list]
|
||||
|
||||
def _fill(self, now: float) -> None:
|
||||
for index in range(MAX_OPEN_BURSTS):
|
||||
self.tracker.record(f"ghost{index}", now)
|
||||
|
||||
def test_attempts_inside_the_gap_share_a_burst(self):
|
||||
for now in (1000.0, 1010.0, 1299.0):
|
||||
self.tracker.record("admin", now)
|
||||
|
||||
self.raise_notice.assert_called_with(
|
||||
"failed_login", scope="admin:1000", params={"user": "admin"}
|
||||
)
|
||||
self.assertEqual(self._scopes(), ["admin:1000"] * 3)
|
||||
|
||||
def test_each_user_gets_their_own_burst(self):
|
||||
self.tracker.record("admin", 1000.0)
|
||||
self.tracker.record("ghost", 1010.0)
|
||||
self.tracker.record("admin", 1020.0)
|
||||
|
||||
self.assertEqual(self._scopes(), ["admin:1000", "ghost:1010", "admin:1000"])
|
||||
|
||||
def test_a_quiet_gap_opens_a_new_burst(self):
|
||||
self.tracker.record("admin", 1000.0)
|
||||
self.tracker.record("admin", 1000.0 + FAILED_LOGIN_BURST_GAP_S)
|
||||
|
||||
self.assertEqual(self._scopes(), ["admin:1000", "admin:1300"])
|
||||
|
||||
def test_the_gap_runs_from_the_latest_attempt(self):
|
||||
for now in (1000.0, 1200.0, 1450.0):
|
||||
self.tracker.record("admin", now)
|
||||
|
||||
self.assertEqual(self._scopes(), ["admin:1000"] * 3)
|
||||
|
||||
def test_quiet_users_are_forgotten(self):
|
||||
self.tracker.record("ghost", 1000.0)
|
||||
self.tracker.record("admin", 1000.0 + FAILED_LOGIN_BURST_GAP_S)
|
||||
|
||||
self.assertEqual(list(self.tracker._bursts), ["admin"])
|
||||
|
||||
def test_a_long_username_is_cut(self):
|
||||
self.tracker.record("x" * 500, 1000.0)
|
||||
|
||||
user = self.raise_notice.call_args.kwargs["params"]["user"]
|
||||
self.assertEqual(len(user), MAX_NOTICE_USERNAME)
|
||||
|
||||
def test_unknown_users_past_the_cap_get_no_notice(self):
|
||||
self._fill(1000.0)
|
||||
self.tracker.record("one-too-many", 1001.0)
|
||||
self.tracker.record("ghost0", 1002.0)
|
||||
|
||||
self.assertEqual(self.raise_notice.call_count, MAX_OPEN_BURSTS + 1)
|
||||
self.assertEqual(self._scopes()[-1], "ghost0:1000")
|
||||
|
||||
def test_known_users_skip_the_cap(self):
|
||||
self._fill(1000.0)
|
||||
self.tracker.record("admin", 1001.0, known=True)
|
||||
|
||||
self.assertEqual(self._scopes()[-1], "admin:1001")
|
||||
|
||||
def test_quiet_bursts_free_their_slots(self):
|
||||
self._fill(1000.0)
|
||||
self.tracker.record("late", 1000.0 + FAILED_LOGIN_BURST_GAP_S)
|
||||
|
||||
self.assertEqual(self._scopes()[-1], "late:1300")
|
||||
|
||||
@@ -6,8 +6,6 @@ from frigate.models import Notice, NoticeStats
|
||||
from frigate.notices.registry import NoticeRegistry
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
|
||||
CONFIG_CHECK = "config:detect:fps-greater-than-five:camera.garage"
|
||||
|
||||
|
||||
class TestHttpNotices(BaseTestHttp):
|
||||
def setUp(self):
|
||||
@@ -19,32 +17,20 @@ class TestHttpNotices(BaseTestHttp):
|
||||
return AuthTestClient(self.app)
|
||||
|
||||
def test_get_notices_orders_by_severity(self):
|
||||
self.registry.raise_notice(
|
||||
"detector_stuck", scope="ov", params={"detector": "ov"}
|
||||
)
|
||||
self.registry.raise_notice(
|
||||
"model_download_failed",
|
||||
scope="yolo/model.onnx",
|
||||
params={"file": "model.onnx", "error": "timeout"},
|
||||
)
|
||||
self.registry.raise_notice("detector_stuck", params={"detector": "ov"})
|
||||
self.registry.raise_notice("retention_unmet", params={"needed_mb": 1})
|
||||
|
||||
with self.client() as client:
|
||||
response = client.get("/notices")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(
|
||||
[n["id"] for n in response.json()],
|
||||
["model_download_failed:yolo/model.onnx", "detector_stuck:ov"],
|
||||
[n["id"] for n in response.json()], ["retention_unmet", "detector_stuck"]
|
||||
)
|
||||
self.assertEqual(response.json()[0]["severity"], "error")
|
||||
self.assertEqual(
|
||||
[n["link"] for n in response.json()], [None, "/system#general"]
|
||||
)
|
||||
|
||||
def test_get_stats(self):
|
||||
self.registry.raise_notice(
|
||||
"detector_stuck", scope="ov", params={"detector": "ov"}
|
||||
)
|
||||
self.registry.raise_notice("detector_stuck", params={"detector": "ov"})
|
||||
|
||||
with self.client() as client:
|
||||
response = client.get("/notices/stats")
|
||||
@@ -53,33 +39,15 @@ class TestHttpNotices(BaseTestHttp):
|
||||
self.assertEqual(response.json()[0]["kind"], "detector_stuck")
|
||||
self.assertEqual(response.json()[0]["occurrences"], 1)
|
||||
|
||||
def test_dismiss_hides_and_history_keeps_it(self):
|
||||
self.registry.raise_notice(
|
||||
"detector_stuck", scope="ov", params={"detector": "ov"}
|
||||
)
|
||||
def test_dismiss_event(self):
|
||||
self.registry.raise_notice("detector_stuck", params={"detector": "ov"})
|
||||
|
||||
with self.client() as client:
|
||||
response = client.post("/notices/detector_stuck:ov/dismiss")
|
||||
response = client.post("/notices/detector_stuck/dismiss")
|
||||
listed = client.get("/notices").json()
|
||||
history = client.get("/notices", params={"include_dismissed": True}).json()
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(listed, [])
|
||||
self.assertEqual([n["id"] for n in history], ["detector_stuck:ov"])
|
||||
self.assertIsNotNone(history[0]["dismissed_at"])
|
||||
|
||||
def test_dismiss_an_id_with_a_slash(self):
|
||||
self.registry.raise_notice(
|
||||
"model_download_failed", scope="yolo/model.onnx", params={}
|
||||
)
|
||||
|
||||
with self.client() as client:
|
||||
response = client.post(
|
||||
"/notices/model_download_failed:yolo/model.onnx/dismiss"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(self.registry.active(), [])
|
||||
|
||||
def test_dismiss_unknown_is_404(self):
|
||||
with self.client() as client:
|
||||
@@ -87,6 +55,14 @@ class TestHttpNotices(BaseTestHttp):
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_dismiss_state_is_400(self):
|
||||
self.registry.raise_notice("retention_unmet", params={})
|
||||
|
||||
with self.client() as client:
|
||||
response = client.post("/notices/retention_unmet/dismiss")
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_requires_admin(self):
|
||||
with TestClient(self.app) as client:
|
||||
response = client.get(
|
||||
@@ -94,51 +70,3 @@ class TestHttpNotices(BaseTestHttp):
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_dismissed_checks_are_listed_once(self):
|
||||
with self.client() as client:
|
||||
first = client.post(f"/notices/{CONFIG_CHECK}/dismiss")
|
||||
again = client.post(f"/notices/{CONFIG_CHECK}/dismiss")
|
||||
listed = client.get("/notices/dismissed_checks").json()
|
||||
history = client.get("/notices", params={"include_dismissed": True}).json()
|
||||
|
||||
self.assertEqual(first.status_code, 200)
|
||||
self.assertEqual(again.status_code, 200)
|
||||
self.assertEqual([check["id"] for check in listed], [CONFIG_CHECK])
|
||||
self.assertIsNotNone(listed[0]["dismissed_at"])
|
||||
# the Health tab builds the row itself, so it never comes back as a notice
|
||||
self.assertEqual(history, [])
|
||||
|
||||
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(
|
||||
"detector_stuck", scope="ov", params={"detector": "ov"}
|
||||
)
|
||||
self.registry.dismiss("detector_stuck:ov")
|
||||
self.registry.dismiss(CONFIG_CHECK)
|
||||
|
||||
with self.client() as client:
|
||||
response = client.delete("/notices/dismissed")
|
||||
history = client.get("/notices", params={"include_dismissed": True}).json()
|
||||
checks = client.get("/notices/dismissed_checks").json()
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(history, [])
|
||||
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)
|
||||
|
||||
@@ -6,7 +6,11 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.const import STREAM_TYPE_MAIN, STREAM_TYPE_SUB
|
||||
from frigate.video.ffmpeg import CameraWatchdog
|
||||
from frigate.video.ffmpeg import (
|
||||
CRASH_LOOP_RECOVERY_S,
|
||||
CRASH_LOOP_RESTARTS,
|
||||
CameraWatchdog,
|
||||
)
|
||||
|
||||
|
||||
def build_watchdog(
|
||||
@@ -224,3 +228,75 @@ class TestCameraWatchdogStreamHealth(unittest.TestCase):
|
||||
|
||||
assert watchdog.record_stale_threshold[STREAM_TYPE_MAIN] == 120
|
||||
assert watchdog.record_stale_threshold[STREAM_TYPE_SUB] == 150
|
||||
|
||||
|
||||
class TestCameraWatchdogCrashLoop(unittest.TestCase):
|
||||
"""The crash loop notice follows restarts in the trailing hour."""
|
||||
|
||||
def _build_watchdog(self) -> CameraWatchdog:
|
||||
return build_watchdog()
|
||||
|
||||
def _notice_payloads(self, watchdog: CameraWatchdog) -> list[dict]:
|
||||
return [
|
||||
call.args[1]
|
||||
for call in watchdog.requestor.send_data.call_args_list
|
||||
if call.args[0] == "update_notice"
|
||||
]
|
||||
|
||||
def test_below_threshold_raises_nothing(self):
|
||||
watchdog = self._build_watchdog()
|
||||
now = datetime.now().timestamp()
|
||||
watchdog.reconnect_timestamps.extend([now] * (CRASH_LOOP_RESTARTS - 1))
|
||||
watchdog.capture_thread = MagicMock()
|
||||
watchdog.capture_thread.is_alive.return_value = False
|
||||
|
||||
watchdog._update_crash_loop_notice(now)
|
||||
|
||||
self.assertEqual(self._notice_payloads(watchdog), [])
|
||||
|
||||
def test_threshold_raises_once_per_restart_count(self):
|
||||
watchdog = self._build_watchdog()
|
||||
now = datetime.now().timestamp()
|
||||
watchdog.reconnect_timestamps.extend([now] * CRASH_LOOP_RESTARTS)
|
||||
watchdog.capture_thread = MagicMock()
|
||||
watchdog.capture_thread.is_alive.return_value = False
|
||||
|
||||
watchdog._update_crash_loop_notice(now)
|
||||
watchdog._update_crash_loop_notice(now)
|
||||
|
||||
payloads = self._notice_payloads(watchdog)
|
||||
self.assertEqual(len(payloads), 1)
|
||||
self.assertEqual(payloads[0]["action"], "raise")
|
||||
self.assertEqual(payloads[0]["kind"], "ffmpeg_crash_loop")
|
||||
self.assertEqual(payloads[0]["scope"], "front_door")
|
||||
self.assertEqual(payloads[0]["params"], {"restarts": CRASH_LOOP_RESTARTS})
|
||||
|
||||
def test_recovery_window_resolves(self):
|
||||
watchdog = self._build_watchdog()
|
||||
now = datetime.now().timestamp()
|
||||
# the last restart is at `now`, so recovery is measured from there
|
||||
watchdog.reconnect_timestamps.extend([now] * CRASH_LOOP_RESTARTS)
|
||||
watchdog.capture_thread = MagicMock()
|
||||
watchdog.capture_thread.is_alive.return_value = False
|
||||
watchdog._update_crash_loop_notice(now)
|
||||
|
||||
watchdog.capture_thread.is_alive.return_value = True
|
||||
watchdog._update_crash_loop_notice(now + CRASH_LOOP_RECOVERY_S - 1)
|
||||
self.assertEqual(len(self._notice_payloads(watchdog)), 1)
|
||||
|
||||
watchdog._update_crash_loop_notice(now + CRASH_LOOP_RECOVERY_S + 1)
|
||||
|
||||
payloads = self._notice_payloads(watchdog)
|
||||
self.assertEqual(len(payloads), 2)
|
||||
self.assertEqual(payloads[1]["action"], "resolve")
|
||||
self.assertFalse(watchdog.crash_loop_raised)
|
||||
|
||||
def test_disable_resolves(self):
|
||||
watchdog = self._build_watchdog()
|
||||
watchdog.crash_loop_raised = True
|
||||
|
||||
watchdog._resolve_crash_loop()
|
||||
|
||||
payloads = self._notice_payloads(watchdog)
|
||||
self.assertEqual(payloads[-1]["action"], "resolve")
|
||||
self.assertFalse(watchdog.crash_loop_raised)
|
||||
|
||||
@@ -128,10 +128,10 @@ class TestMigrateModels(unittest.TestCase):
|
||||
migrated = migrate_models(
|
||||
{
|
||||
"detectors": {
|
||||
"remote": {
|
||||
"type": "zmq",
|
||||
"endpoint": "tcp://host:5555",
|
||||
"request_timeout_ms": 200,
|
||||
"ds": {
|
||||
"type": "deepstack",
|
||||
"api_url": "http://host:5000/v1/vision/detection",
|
||||
"api_key": "secret",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,9 +139,9 @@ class TestMigrateModels(unittest.TestCase):
|
||||
|
||||
self.assertEqual(
|
||||
migrated["models"][0]["devices"],
|
||||
["zmq:tcp://host:5555"],
|
||||
["deepstack:http://host:5000/v1/vision/detection"],
|
||||
)
|
||||
self.assertTrue(any("request_timeout_ms" in message for message in logs.output))
|
||||
self.assertTrue(any("api_key" in message for message in logs.output))
|
||||
|
||||
def test_mixed_detector_types_are_logged(self):
|
||||
with self.assertLogs("frigate.util.config", level=logging.ERROR) as logs:
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
"""Tests for ONNX Runtime session option selection."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
import onnxruntime as ort
|
||||
|
||||
from frigate.detectors.detection_runners import (
|
||||
CudaGraphRunner,
|
||||
get_ort_session_options,
|
||||
)
|
||||
from frigate.detectors.detection_runners import get_ort_session_options
|
||||
from frigate.detectors.detector_config import ModelTypeEnum
|
||||
from frigate.embeddings.types import EnrichmentModelTypeEnum
|
||||
|
||||
@@ -44,45 +39,3 @@ class TestGetOrtSessionOptions(unittest.TestCase):
|
||||
]:
|
||||
with self.subTest(model_type=model_type):
|
||||
self.assertIsNone(get_ort_session_options(model_type))
|
||||
|
||||
|
||||
class TestCudaGraphRunner(unittest.TestCase):
|
||||
"""CUDA graph capture fails if the arena has to allocate during capture, so
|
||||
the session is warmed up with capture disabled before the first real run."""
|
||||
|
||||
def setUp(self):
|
||||
self.session = MagicMock()
|
||||
self.session.get_outputs.return_value = [MagicMock(name="output")]
|
||||
self.io_binding = self.session.io_binding.return_value
|
||||
self.input = {"images": np.zeros((1, 3, 320, 320), np.float32)}
|
||||
|
||||
def _annotations(self) -> list[str | None]:
|
||||
"""Graph annotation id passed with each run, None when unset."""
|
||||
annotations = []
|
||||
|
||||
for call in self.session.run_with_iobinding.call_args_list:
|
||||
try:
|
||||
annotations.append(call.args[1].get_run_config_entry("gpu_graph_id"))
|
||||
except RuntimeError:
|
||||
annotations.append(None)
|
||||
|
||||
return annotations
|
||||
|
||||
def test_first_run_warms_up_with_capture_disabled(self):
|
||||
with patch.object(ort.OrtValue, "ortvalue_from_numpy"):
|
||||
CudaGraphRunner(self.session, 0).run(self.input)
|
||||
|
||||
self.assertEqual(
|
||||
self._annotations(),
|
||||
["-1"] * CudaGraphRunner.GRAPH_FREE_WARMUP_RUNS + [None],
|
||||
)
|
||||
|
||||
def test_later_runs_allow_capture(self):
|
||||
with patch.object(ort.OrtValue, "ortvalue_from_numpy"):
|
||||
runner = CudaGraphRunner(self.session, 0)
|
||||
runner.run(self.input)
|
||||
self.session.run_with_iobinding.reset_mock()
|
||||
runner.run(self.input)
|
||||
|
||||
self.assertEqual(self._annotations(), [None])
|
||||
runner._input_ortvalue.update_inplace.assert_called_once()
|
||||
|
||||
@@ -518,7 +518,7 @@ class TestNoticeWiring(unittest.TestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.registry = MagicMock()
|
||||
self.registry.active.return_value = [{"id": "detector_stuck:ov"}]
|
||||
self.registry.active.return_value = [{"id": "retention_unmet"}]
|
||||
config = MagicMock()
|
||||
config.cameras = {}
|
||||
|
||||
@@ -540,28 +540,37 @@ class TestNoticeWiring(unittest.TestCase):
|
||||
self.dispatcher._publish_notices
|
||||
)
|
||||
|
||||
def test_update_request_reaches_registry(self) -> None:
|
||||
update = {
|
||||
"action": "raise",
|
||||
"kind": "model_download_failed",
|
||||
"scope": "yolo/model.onnx",
|
||||
"params": {"file": "model.onnx", "error": "timeout"},
|
||||
}
|
||||
def test_raise_request_reaches_registry(self) -> None:
|
||||
self.dispatcher._receive(
|
||||
"update_notice",
|
||||
{
|
||||
"action": "raise",
|
||||
"kind": "ffmpeg_crash_loop",
|
||||
"scope": "front_door",
|
||||
"params": {"restarts": 5},
|
||||
},
|
||||
)
|
||||
|
||||
self.dispatcher._receive("update_notice", update)
|
||||
self.registry.raise_notice.assert_called_once_with(
|
||||
"ffmpeg_crash_loop", scope="front_door", params={"restarts": 5}
|
||||
)
|
||||
|
||||
self.registry.apply.assert_called_once_with(update)
|
||||
def test_resolve_request_reaches_registry(self) -> None:
|
||||
self.dispatcher._receive(
|
||||
"update_notice",
|
||||
{"action": "resolve", "kind": "retention_unmet", "scope": None},
|
||||
)
|
||||
|
||||
self.registry.resolve.assert_called_once_with("retention_unmet", None)
|
||||
|
||||
def test_malformed_request_does_not_raise(self) -> None:
|
||||
self.registry.apply.side_effect = RuntimeError("boom")
|
||||
self.registry.raise_notice.side_effect = RuntimeError("boom")
|
||||
|
||||
self.dispatcher._receive("update_notice", "not a dict")
|
||||
self.dispatcher._receive(
|
||||
"update_notice", {"action": "raise", "kind": "x", "params": {}}
|
||||
)
|
||||
|
||||
self.registry.apply.assert_called_once()
|
||||
|
||||
def test_publish_local_skips_mqtt(self) -> None:
|
||||
mqtt = MagicMock(spec=MqttClient)
|
||||
other = MagicMock()
|
||||
@@ -578,7 +587,7 @@ class TestNoticeWiring(unittest.TestCase):
|
||||
self.dispatcher._publish_notices()
|
||||
|
||||
self.dispatcher.publish_local.assert_called_once_with(
|
||||
"notices", '[{"id": "detector_stuck:ov"}]'
|
||||
"notices", '[{"id": "retention_unmet"}]'
|
||||
)
|
||||
|
||||
def test_snapshot_includes_notices(self) -> None:
|
||||
@@ -590,4 +599,4 @@ class TestNoticeWiring(unittest.TestCase):
|
||||
|
||||
self.dispatcher.publish_runtime_snapshot(publisher)
|
||||
|
||||
publisher.assert_any_call("notices", '[{"id": "detector_stuck:ov"}]', False)
|
||||
publisher.assert_any_call("notices", '[{"id": "retention_unmet"}]', False)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from unittest import TestCase
|
||||
from unittest.mock import patch
|
||||
@@ -87,52 +86,3 @@ class TestFileUtils(TestCase):
|
||||
pass
|
||||
|
||||
assert file_util.get_event_thumbnail_bytes(event) is None
|
||||
|
||||
|
||||
class TestTrimOldestFiles(TestCase):
|
||||
def _fill(self, folder: str, names: list[str]) -> None:
|
||||
# a short gap keeps the ctime order deterministic
|
||||
for name in names:
|
||||
with open(os.path.join(folder, name), "wb"):
|
||||
pass
|
||||
|
||||
time.sleep(0.01)
|
||||
|
||||
def test_trims_folder_already_over_limit(self):
|
||||
"""Verify one call trims a folder that is far over the limit."""
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
self._fill(folder, [f"{i:03d}.webp" for i in range(10)])
|
||||
|
||||
file_util.trim_oldest_files(folder, 4)
|
||||
|
||||
assert sorted(os.listdir(folder)) == [
|
||||
"006.webp",
|
||||
"007.webp",
|
||||
"008.webp",
|
||||
"009.webp",
|
||||
]
|
||||
|
||||
def test_counts_every_listed_image_extension(self):
|
||||
"""Verify the trim counts the same images the train listing shows."""
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
self._fill(
|
||||
folder, ["notes.txt", "a.jpg", "b.jpeg", "c.png", "d.webp", "e.webp"]
|
||||
)
|
||||
|
||||
file_util.trim_oldest_files(folder, 2)
|
||||
|
||||
assert sorted(os.listdir(folder)) == ["d.webp", "e.webp", "notes.txt"]
|
||||
|
||||
def test_file_removed_during_scan_does_not_skip_trim(self):
|
||||
"""Verify a file deleted between listing and stat still trims the rest."""
|
||||
real_listdir = os.listdir
|
||||
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
self._fill(folder, [f"{i:03d}.webp" for i in range(10)])
|
||||
|
||||
with patch(
|
||||
"os.listdir", side_effect=lambda p: real_listdir(p) + ["gone.webp"]
|
||||
):
|
||||
file_util.trim_oldest_files(folder, 4)
|
||||
|
||||
assert len(os.listdir(folder)) == 4
|
||||
|
||||
@@ -13,12 +13,13 @@ class TestModelDownloadNotice(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.download_path = tempfile.mkdtemp()
|
||||
downloader.last_download_error.clear()
|
||||
raise_patch = patch("frigate.util.downloader.raise_notice")
|
||||
resolve_patch = patch("frigate.util.downloader.resolve_notice")
|
||||
self.raise_notice = raise_patch.start()
|
||||
self.resolve_notice = resolve_patch.start()
|
||||
self.addCleanup(raise_patch.stop)
|
||||
self.addCleanup(resolve_patch.stop)
|
||||
|
||||
def _notice_calls(self, requestor: MagicMock) -> list[dict]:
|
||||
return [
|
||||
call.args[1]
|
||||
for call in requestor.send_data.call_args_list
|
||||
if call.args[0] == "update_notice"
|
||||
]
|
||||
|
||||
def _downloader(self, download_func) -> ModelDownloader:
|
||||
with patch("frigate.util.downloader.InterProcessRequestor"):
|
||||
@@ -35,16 +36,15 @@ class TestModelDownloadNotice(unittest.TestCase):
|
||||
with self.assertRaises(RuntimeError):
|
||||
model_downloader._download_models()
|
||||
|
||||
self.raise_notice.assert_called_once_with(
|
||||
"model_download_failed",
|
||||
scope="facedet/facedet.onnx",
|
||||
params={
|
||||
"file": "facedet.onnx",
|
||||
"model": "facedet",
|
||||
"error": "HTTP 503 from upstream",
|
||||
},
|
||||
notices = self._notice_calls(model_downloader.requestor)
|
||||
self.assertEqual(len(notices), 1)
|
||||
self.assertEqual(notices[0]["action"], "raise")
|
||||
self.assertEqual(notices[0]["kind"], "model_download_failed")
|
||||
self.assertEqual(notices[0]["scope"], "facedet/facedet.onnx")
|
||||
self.assertEqual(
|
||||
notices[0]["params"],
|
||||
{"file": "facedet.onnx", "error": "HTTP 503 from upstream"},
|
||||
)
|
||||
self.resolve_notice.assert_not_called()
|
||||
|
||||
def test_swallowing_download_that_leaves_no_file_reports(self):
|
||||
target = os.path.join(self.download_path, "facedet.onnx")
|
||||
@@ -62,11 +62,10 @@ class TestModelDownloadNotice(unittest.TestCase):
|
||||
model_downloader = self._downloader(swallowing)
|
||||
model_downloader._download_models()
|
||||
|
||||
self.raise_notice.assert_called_once()
|
||||
self.assertEqual(
|
||||
self.raise_notice.call_args.kwargs["params"],
|
||||
{"file": "facedet.onnx", "model": "facedet", "error": "dns"},
|
||||
)
|
||||
notices = self._notice_calls(model_downloader.requestor)
|
||||
self.assertEqual(len(notices), 1)
|
||||
self.assertEqual(notices[0]["action"], "raise")
|
||||
self.assertEqual(notices[0]["params"], {"file": "facedet.onnx", "error": "dns"})
|
||||
self.assertFalse(os.path.exists(target))
|
||||
|
||||
def test_success_resolves(self):
|
||||
@@ -77,10 +76,10 @@ class TestModelDownloadNotice(unittest.TestCase):
|
||||
model_downloader = self._downloader(succeeding)
|
||||
model_downloader._download_models()
|
||||
|
||||
self.raise_notice.assert_not_called()
|
||||
self.resolve_notice.assert_called_once_with(
|
||||
"model_download_failed", "facedet/facedet.onnx"
|
||||
)
|
||||
notices = self._notice_calls(model_downloader.requestor)
|
||||
self.assertEqual(len(notices), 1)
|
||||
self.assertEqual(notices[0]["action"], "resolve")
|
||||
self.assertEqual(notices[0]["scope"], "facedet/facedet.onnx")
|
||||
|
||||
def test_a_sibling_downloader_only_resolves_its_own_files(self):
|
||||
"""PaddleOCR, facedet and jina each spread one model_name over several
|
||||
@@ -97,8 +96,9 @@ class TestModelDownloadNotice(unittest.TestCase):
|
||||
|
||||
sibling._download_models()
|
||||
|
||||
self.resolve_notice.assert_called_once_with(
|
||||
"model_download_failed", "paddleocr-onnx/det.onnx"
|
||||
self.assertEqual(
|
||||
[n["scope"] for n in self._notice_calls(sibling.requestor)],
|
||||
["paddleocr-onnx/det.onnx"],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
"""Tests for raising and resolving notices through frigate.notices."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from frigate import notices
|
||||
|
||||
|
||||
class TestNoticeFacade(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
notices.install_registry(None)
|
||||
|
||||
def test_the_installed_registry_applies_each_update(self):
|
||||
registry = MagicMock()
|
||||
notices.install_registry(registry)
|
||||
|
||||
notices.raise_notice("detector_stuck", scope="ov", params={"detector": "ov"})
|
||||
notices.resolve_notice("detector_stuck", "ov")
|
||||
notices.resolve_kind("update_available")
|
||||
|
||||
self.assertEqual(
|
||||
[call.args[0] for call in registry.apply.call_args_list],
|
||||
[
|
||||
{
|
||||
"action": "raise",
|
||||
"kind": "detector_stuck",
|
||||
"scope": "ov",
|
||||
"params": {"detector": "ov"},
|
||||
},
|
||||
{"action": "resolve", "kind": "detector_stuck", "scope": "ov"},
|
||||
{"action": "resolve_kind", "kind": "update_available"},
|
||||
],
|
||||
)
|
||||
|
||||
def test_a_child_process_sends_over_ipc(self):
|
||||
with (
|
||||
patch.object(notices.mp, "parent_process", return_value=MagicMock()),
|
||||
patch.object(notices, "InterProcessRequestor") as requestor,
|
||||
patch.object(notices, "_requestor", None),
|
||||
):
|
||||
notices.raise_notice("detector_stuck", scope="ov")
|
||||
notices.resolve_notice("detector_stuck", "ov")
|
||||
|
||||
# one requestor serves every call in the process
|
||||
requestor.assert_called_once_with()
|
||||
send_data = requestor.return_value.send_data
|
||||
self.assertEqual(send_data.call_count, 2)
|
||||
self.assertEqual(
|
||||
send_data.call_args_list[0].args,
|
||||
(
|
||||
"update_notice",
|
||||
{
|
||||
"action": "raise",
|
||||
"kind": "detector_stuck",
|
||||
"scope": "ov",
|
||||
"params": {},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
def test_the_main_process_without_a_registry_drops_updates(self):
|
||||
with (
|
||||
patch.object(notices.mp, "parent_process", return_value=None),
|
||||
patch.object(notices, "InterProcessRequestor") as requestor,
|
||||
):
|
||||
notices.raise_notice("detector_stuck", scope="ov")
|
||||
|
||||
requestor.assert_not_called()
|
||||
|
||||
def test_a_failing_registry_is_logged_not_raised(self):
|
||||
registry = MagicMock()
|
||||
registry.apply.side_effect = RuntimeError("db")
|
||||
notices.install_registry(registry)
|
||||
|
||||
with self.assertLogs("frigate.notices", level="ERROR"):
|
||||
notices.raise_notice("detector_stuck", scope="ov")
|
||||
|
||||
def test_flush_reaches_only_an_installed_registry(self):
|
||||
notices.flush_notices()
|
||||
|
||||
registry = MagicMock()
|
||||
notices.install_registry(registry)
|
||||
notices.flush_notices()
|
||||
|
||||
registry.flush.assert_called_once_with()
|
||||
@@ -3,24 +3,21 @@
|
||||
import logging
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from peewee_migrate import Router
|
||||
from playhouse.sqlite_ext import SqliteExtDatabase
|
||||
from playhouse.sqliteq import SqliteQueueDatabase
|
||||
|
||||
from frigate.models import Notice, NoticeStats
|
||||
from frigate.notices.registry import NoticeRegistry
|
||||
from frigate.notices.types import NOTICE_KINDS, NoticeKind, NoticeSeverity, notice_id
|
||||
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
|
||||
# values the real catalog picks
|
||||
BATCHED = NoticeKind(
|
||||
"batched", NoticeSeverity.warning, "system", batch_repeats=True, reopen_at_count=3
|
||||
from frigate.notices.registry import DismissResult, NoticeRegistry
|
||||
from frigate.notices.types import (
|
||||
NOTICE_KINDS,
|
||||
NoticeMode,
|
||||
NoticeSeverity,
|
||||
notice_id,
|
||||
)
|
||||
PRUNED = NoticeKind("pruned", NoticeSeverity.info, "system", keep_latest=2)
|
||||
TEST_KINDS = {kind.key: kind for kind in (BATCHED, PRUNED)}
|
||||
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
|
||||
|
||||
|
||||
class TestNoticeKinds(unittest.TestCase):
|
||||
@@ -28,28 +25,29 @@ class TestNoticeKinds(unittest.TestCase):
|
||||
for key, definition in NOTICE_KINDS.items():
|
||||
self.assertEqual(key, definition.key)
|
||||
|
||||
def test_notice_id_with_and_without_scope(self):
|
||||
self.assertEqual(notice_id("failed_login", None), "failed_login")
|
||||
def test_first_producers_are_defined(self):
|
||||
self.assertEqual(NOTICE_KINDS["ffmpeg_crash_loop"].mode, NoticeMode.state)
|
||||
self.assertEqual(
|
||||
notice_id("skipped_detections", "front_door"),
|
||||
"skipped_detections:front_door",
|
||||
NOTICE_KINDS["ffmpeg_crash_loop"].severity, NoticeSeverity.error
|
||||
)
|
||||
self.assertEqual(NOTICE_KINDS["detector_stuck"].mode, NoticeMode.event)
|
||||
self.assertEqual(
|
||||
NOTICE_KINDS["detector_stuck"].severity, NoticeSeverity.warning
|
||||
)
|
||||
self.assertEqual(NOTICE_KINDS["model_download_failed"].mode, NoticeMode.event)
|
||||
self.assertEqual(NOTICE_KINDS["retention_unmet"].mode, NoticeMode.state)
|
||||
self.assertEqual(NOTICE_KINDS["update_available"].mode, NoticeMode.state)
|
||||
self.assertEqual(NOTICE_KINDS["update_available"].severity, NoticeSeverity.info)
|
||||
|
||||
def test_notice_id_with_and_without_scope(self):
|
||||
self.assertEqual(notice_id("retention_unmet", None), "retention_unmet")
|
||||
self.assertEqual(
|
||||
notice_id("ffmpeg_crash_loop", "front_door"),
|
||||
"ffmpeg_crash_loop:front_door",
|
||||
)
|
||||
|
||||
def test_link_is_filled_in_from_params(self):
|
||||
kind = NoticeKind(
|
||||
"k", NoticeSeverity.info, "system", link="https://x/v{version}"
|
||||
)
|
||||
|
||||
self.assertEqual(kind.link_for({"version": "0.19.1"}), "https://x/v0.19.1")
|
||||
self.assertIsNone(kind.link_for({}))
|
||||
|
||||
def test_a_kind_without_a_link(self):
|
||||
kind = NoticeKind("k", NoticeSeverity.info, "system")
|
||||
|
||||
self.assertIsNone(kind.link_for({"version": "0.19.1"}))
|
||||
|
||||
|
||||
class RegistryTestCase(unittest.TestCase):
|
||||
class TestNoticeRegistry(unittest.TestCase):
|
||||
def setUp(self):
|
||||
migrate_db = SqliteExtDatabase("test.db")
|
||||
del logging.getLogger("peewee_migrate").handlers[:]
|
||||
@@ -72,128 +70,132 @@ class RegistryTestCase(unittest.TestCase):
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _raise_at(self, timestamp: float, kind: str, **kwargs) -> None:
|
||||
with patch("frigate.notices.registry.datetime") as mock_datetime:
|
||||
mock_datetime.now.return_value.timestamp.return_value = timestamp
|
||||
self.registry.raise_notice(kind, **kwargs)
|
||||
|
||||
|
||||
class TestNoticeRegistry(RegistryTestCase):
|
||||
def test_raise_inserts_and_counts_one_occurrence(self):
|
||||
self.registry.raise_notice(
|
||||
"skipped_detections", scope="front_door", params={"pct": 12.5}
|
||||
"ffmpeg_crash_loop", scope="front_door", params={"restarts": 5}
|
||||
)
|
||||
|
||||
active = self.registry.active()
|
||||
self.assertEqual(len(active), 1)
|
||||
self.assertEqual(active[0]["id"], "skipped_detections:front_door")
|
||||
self.assertEqual(active[0]["severity"], "warning")
|
||||
self.assertEqual(active[0]["id"], "ffmpeg_crash_loop:front_door")
|
||||
self.assertEqual(active[0]["severity"], "error")
|
||||
self.assertEqual(active[0]["mode"], "state")
|
||||
self.assertEqual(active[0]["category"], "camera")
|
||||
self.assertEqual(active[0]["params"], {"pct": 12.5})
|
||||
self.assertEqual(active[0]["link"], "/system#cameras")
|
||||
self.assertEqual(active[0]["scope"], "front_door")
|
||||
self.assertEqual(active[0]["params"], {"restarts": 5})
|
||||
self.assertEqual(active[0]["count"], 1)
|
||||
self.assertEqual(self.registry.stats()[0]["occurrences"], 1)
|
||||
self.listener.assert_called_once()
|
||||
|
||||
def test_re_raise_counts_and_updates_params(self):
|
||||
def test_state_re_raise_with_new_params_updates_without_counting(self):
|
||||
self.registry.raise_notice(
|
||||
"skipped_detections", scope="front_door", params={"pct": 12.5}
|
||||
)
|
||||
self.registry.raise_notice(
|
||||
"skipped_detections", scope="front_door", params={"pct": 8.0}
|
||||
)
|
||||
|
||||
active = self.registry.active()
|
||||
self.assertEqual(active[0]["count"], 2)
|
||||
self.assertEqual(active[0]["params"], {"pct": 8.0})
|
||||
self.assertEqual(self.registry.stats()[0]["occurrences"], 2)
|
||||
|
||||
def test_repeat_of_a_non_counting_kind_is_a_no_op(self):
|
||||
self.registry.raise_notice(
|
||||
"update_available", scope="0.19.1", params={"version": "0.19.1"}
|
||||
"ffmpeg_crash_loop", scope="front_door", params={"restarts": 5}
|
||||
)
|
||||
self.listener.reset_mock()
|
||||
|
||||
self.registry.raise_notice(
|
||||
"update_available", scope="0.19.1", params={"version": "0.19.1"}
|
||||
"ffmpeg_crash_loop", scope="front_door", params={"restarts": 6}
|
||||
)
|
||||
|
||||
self.assertEqual(self.registry.active()[0]["count"], 1)
|
||||
active = self.registry.active()
|
||||
self.assertEqual(active[0]["count"], 1)
|
||||
self.assertEqual(active[0]["params"], {"restarts": 6})
|
||||
self.assertEqual(self.registry.stats()[0]["occurrences"], 1)
|
||||
self.listener.assert_called_once()
|
||||
|
||||
def test_state_re_raise_with_same_params_is_a_no_op(self):
|
||||
self.registry.raise_notice(
|
||||
"ffmpeg_crash_loop", scope="front_door", params={"restarts": 5}
|
||||
)
|
||||
before = self.registry.active()[0]["last_seen"]
|
||||
self.listener.reset_mock()
|
||||
|
||||
self.registry.raise_notice(
|
||||
"ffmpeg_crash_loop", scope="front_door", params={"restarts": 5}
|
||||
)
|
||||
|
||||
self.assertEqual(self.registry.active()[0]["last_seen"], before)
|
||||
self.listener.assert_not_called()
|
||||
|
||||
def test_dismissal_survives_a_re_raise(self):
|
||||
self.registry.raise_notice("skipped_detections", scope="front_door", params={})
|
||||
self.assertTrue(self.registry.dismiss("skipped_detections:front_door"))
|
||||
def test_event_re_raise_counts_and_undismisses(self):
|
||||
self.registry.raise_notice("detector_stuck", params={"detector": "ov"})
|
||||
self.assertEqual(self.registry.dismiss("detector_stuck"), DismissResult.ok)
|
||||
self.assertEqual(self.registry.active(), [])
|
||||
|
||||
self.registry.raise_notice("skipped_detections", scope="front_door", params={})
|
||||
self.registry.raise_notice("detector_stuck", params={"detector": "ov"})
|
||||
# no scope here on purpose: a global event id is still valid
|
||||
|
||||
active = self.registry.active()
|
||||
self.assertEqual(len(active), 1)
|
||||
self.assertEqual(active[0]["count"], 2)
|
||||
self.assertIsNone(active[0]["dismissed_at"])
|
||||
stats = self.registry.stats()[0]
|
||||
self.assertEqual(stats["occurrences"], 2)
|
||||
self.assertEqual(stats["dismissals"], 1)
|
||||
|
||||
def test_resolve_deletes_and_keeps_stats(self):
|
||||
self.registry.raise_notice("retention_unmet", params={"needed_mb": 1})
|
||||
self.listener.reset_mock()
|
||||
|
||||
self.registry.resolve("retention_unmet")
|
||||
self.registry.resolve("retention_unmet")
|
||||
|
||||
self.assertEqual(self.registry.active(), [])
|
||||
history = self.registry.active(include_dismissed=True)
|
||||
self.assertEqual(history[0]["count"], 2)
|
||||
self.assertIsNotNone(history[0]["dismissed_at"])
|
||||
self.assertEqual(self.registry.stats()[0]["occurrences"], 1)
|
||||
self.listener.assert_called_once()
|
||||
|
||||
def test_dismiss_counts_once(self):
|
||||
self.registry.raise_notice("detector_stuck", scope="ov", params={})
|
||||
def test_state_notice_cannot_be_dismissed(self):
|
||||
self.registry.raise_notice("retention_unmet", params={})
|
||||
|
||||
self.assertTrue(self.registry.dismiss("detector_stuck:ov"))
|
||||
self.assertTrue(self.registry.dismiss("detector_stuck:ov"))
|
||||
|
||||
self.assertEqual(self.registry.stats()[0]["dismissals"], 1)
|
||||
self.assertEqual(
|
||||
self.registry.dismiss("retention_unmet"), DismissResult.not_dismissable
|
||||
)
|
||||
self.assertEqual(len(self.registry.active()), 1)
|
||||
|
||||
def test_dismiss_unknown_id(self):
|
||||
self.assertFalse(self.registry.dismiss("nope"))
|
||||
self.assertEqual(self.registry.dismiss("nope"), DismissResult.not_found)
|
||||
|
||||
def test_dismissed_hidden_unless_requested(self):
|
||||
self.registry.raise_notice("detector_stuck", scope="ov", params={})
|
||||
self.registry.dismiss("detector_stuck:ov")
|
||||
self.registry.raise_notice("detector_stuck", params={"detector": "ov"})
|
||||
self.registry.dismiss("detector_stuck")
|
||||
|
||||
self.assertEqual(self.registry.active(), [])
|
||||
self.assertEqual(len(self.registry.active(include_dismissed=True)), 1)
|
||||
|
||||
def test_resolve_deletes_and_keeps_stats(self):
|
||||
def test_startup_hides_state_notices_until_confirmed(self):
|
||||
self.registry.raise_notice("retention_unmet", params={"needed_mb": 1})
|
||||
self.registry.raise_notice(
|
||||
"model_download_failed", scope="yolo/model.onnx", params={}
|
||||
"detector_stuck", scope="ov", params={"detector": "ov"}
|
||||
)
|
||||
self.listener.reset_mock()
|
||||
|
||||
self.registry.resolve("model_download_failed", "yolo/model.onnx")
|
||||
self.registry.resolve("model_download_failed", "yolo/model.onnx")
|
||||
|
||||
self.assertEqual(self.registry.active(include_dismissed=True), [])
|
||||
self.assertEqual(self.registry.stats()[0]["occurrences"], 1)
|
||||
self.listener.assert_called_once()
|
||||
|
||||
def test_resolve_kind_drops_every_scope_of_that_kind(self):
|
||||
self.registry.raise_notice("detector_stuck", params={})
|
||||
self.registry.raise_notice("detector_stuck", scope="ov", params={})
|
||||
self.registry.raise_notice("skipped_detections", scope="garage", params={})
|
||||
|
||||
self.registry.resolve_kind("detector_stuck")
|
||||
self.registry.mark_state_notices_unconfirmed()
|
||||
|
||||
ids = [n["id"] for n in self.registry.active()]
|
||||
self.assertEqual(ids, ["skipped_detections:garage"])
|
||||
self.assertEqual(ids, ["detector_stuck:ov"])
|
||||
|
||||
def test_active_fills_in_each_link(self):
|
||||
self.registry.raise_notice(
|
||||
"update_available", scope="0.19.1", params={"version": "0.19.1"}
|
||||
)
|
||||
self.registry.raise_notice(
|
||||
"model_download_failed", scope="yolo/model.onnx", params={}
|
||||
)
|
||||
# the producer confirms the same episode: visible again, no new occurrence
|
||||
self.registry.raise_notice("retention_unmet", params={"needed_mb": 1})
|
||||
|
||||
links = {n["kind"]: n["link"] for n in self.registry.active()}
|
||||
ids = [n["id"] for n in self.registry.active()]
|
||||
self.assertEqual(ids, ["retention_unmet", "detector_stuck:ov"])
|
||||
self.assertEqual(
|
||||
links,
|
||||
{
|
||||
"update_available": "https://github.com/blakeblackshear/frigate/releases/tag/v0.19.1",
|
||||
"model_download_failed": None,
|
||||
},
|
||||
[s for s in self.registry.stats() if s["kind"] == "retention_unmet"][0][
|
||||
"occurrences"
|
||||
],
|
||||
1,
|
||||
)
|
||||
|
||||
def test_unconfirmed_across_two_startups_is_deleted(self):
|
||||
self.registry.raise_notice("retention_unmet", params={})
|
||||
|
||||
self.registry.mark_state_notices_unconfirmed()
|
||||
self.registry.mark_state_notices_unconfirmed()
|
||||
|
||||
self.assertEqual(self.registry.active(include_dismissed=True), [])
|
||||
|
||||
def test_replay_camera_is_ignored(self):
|
||||
self.registry.raise_notice(
|
||||
"skipped_detections", scope="_replay_front_door", params={}
|
||||
"ffmpeg_crash_loop", scope="_replay_front_door", params={}
|
||||
)
|
||||
|
||||
self.assertEqual(self.registry.active(), [])
|
||||
@@ -206,26 +208,22 @@ class TestNoticeRegistry(RegistryTestCase):
|
||||
self.listener.assert_not_called()
|
||||
|
||||
def test_active_sorts_by_severity_then_recency(self):
|
||||
self.registry.raise_notice("detector_stuck", scope="ov", params={})
|
||||
self.registry.raise_notice(
|
||||
"model_download_failed", scope="yolo/model.onnx", params={}
|
||||
)
|
||||
self.registry.raise_notice("detector_stuck", params={"detector": "ov"})
|
||||
self.registry.raise_notice("retention_unmet", params={})
|
||||
|
||||
ids = [n["id"] for n in self.registry.active()]
|
||||
self.assertEqual(
|
||||
ids, ["model_download_failed:yolo/model.onnx", "detector_stuck:ov"]
|
||||
)
|
||||
self.assertEqual(ids, ["retention_unmet", "detector_stuck"])
|
||||
|
||||
def test_listener_exception_does_not_propagate(self):
|
||||
self.registry.subscribe(MagicMock(side_effect=RuntimeError("boom")))
|
||||
|
||||
self.registry.raise_notice("detector_stuck", scope="ov", params={})
|
||||
self.registry.raise_notice("retention_unmet", params={})
|
||||
|
||||
self.assertEqual(len(self.registry.active()), 1)
|
||||
|
||||
def test_resolve_camera_drops_only_that_camera(self):
|
||||
self.registry.raise_notice("skipped_detections", scope="front_door", params={})
|
||||
self.registry.raise_notice("skipped_detections", scope="garage", params={})
|
||||
self.registry.raise_notice("ffmpeg_crash_loop", scope="front_door", params={})
|
||||
self.registry.raise_notice("ffmpeg_crash_loop", scope="garage", params={})
|
||||
self.registry.raise_notice(
|
||||
"model_download_failed", scope="front_door", params={}
|
||||
)
|
||||
@@ -234,190 +232,5 @@ class TestNoticeRegistry(RegistryTestCase):
|
||||
|
||||
ids = sorted(n["id"] for n in self.registry.active())
|
||||
self.assertEqual(
|
||||
ids, ["model_download_failed:front_door", "skipped_detections:garage"]
|
||||
ids, ["ffmpeg_crash_loop:garage", "model_download_failed:front_door"]
|
||||
)
|
||||
|
||||
def test_resolve_camera_drops_that_cameras_check_dismissals(self):
|
||||
for check_id in (
|
||||
"stream:front_door:0:probe",
|
||||
"config:detect:fps-greater-than-five:camera.front_door",
|
||||
"config:detect:fps-greater-than-five:global",
|
||||
"stream:garage:0:probe",
|
||||
):
|
||||
self.assertTrue(self.registry.dismiss(check_id))
|
||||
|
||||
self.registry.resolve_camera("front_door")
|
||||
|
||||
ids = sorted(check["id"] for check in self.registry.dismissed_checks())
|
||||
self.assertEqual(
|
||||
ids,
|
||||
["config:detect:fps-greater-than-five:global", "stream:garage:0:probe"],
|
||||
)
|
||||
|
||||
def test_camera_named_global_keeps_global_check_dismissals(self):
|
||||
self.registry.dismiss("config:detect:fps-greater-than-five:global")
|
||||
self.registry.dismiss("config:detect:fps-greater-than-five:camera.global")
|
||||
|
||||
self.registry.resolve_camera("global")
|
||||
|
||||
ids = [check["id"] for check in self.registry.dismissed_checks()]
|
||||
self.assertEqual(ids, ["config:detect:fps-greater-than-five:global"])
|
||||
|
||||
def test_purge_dismissed_keeps_active_notices_and_counts(self):
|
||||
self.registry.raise_notice("detector_stuck", scope="ov", params={})
|
||||
self.registry.raise_notice("skipped_detections", scope="garage", params={})
|
||||
self.registry.dismiss("detector_stuck:ov")
|
||||
self.registry.dismiss("config:detect:fps-greater-than-five:camera.garage")
|
||||
|
||||
self.assertEqual(self.registry.purge_dismissed(), 2)
|
||||
|
||||
ids = [n["id"] for n in self.registry.active(include_dismissed=True)]
|
||||
self.assertEqual(ids, ["skipped_detections:garage"])
|
||||
self.assertEqual(self.registry.dismissed_checks(), [])
|
||||
dismissals = {s["kind"]: s["dismissals"] for s in self.registry.stats()}
|
||||
self.assertEqual(dismissals["detector_stuck"], 1)
|
||||
|
||||
|
||||
class TestApply(RegistryTestCase):
|
||||
def test_each_action_reaches_its_method(self):
|
||||
self.registry.apply(
|
||||
{
|
||||
"action": "raise",
|
||||
"kind": "detector_stuck",
|
||||
"scope": "ov",
|
||||
"params": {"detector": "ov"},
|
||||
}
|
||||
)
|
||||
self.assertEqual(
|
||||
[n["id"] for n in self.registry.active()], ["detector_stuck:ov"]
|
||||
)
|
||||
|
||||
self.registry.apply(
|
||||
{"action": "resolve", "kind": "detector_stuck", "scope": "ov"}
|
||||
)
|
||||
self.assertEqual(self.registry.active(), [])
|
||||
|
||||
for scope in ("a", "b"):
|
||||
self.registry.apply(
|
||||
{"action": "raise", "kind": "detector_stuck", "scope": scope}
|
||||
)
|
||||
self.registry.apply({"action": "resolve_kind", "kind": "detector_stuck"})
|
||||
self.assertEqual(self.registry.active(), [])
|
||||
|
||||
def test_malformed_updates_are_logged_and_ignored(self):
|
||||
with self.assertLogs("frigate.notices.registry", level="WARNING") as logs:
|
||||
self.registry.apply({"action": "raise"})
|
||||
self.registry.apply({"action": "explode", "kind": "detector_stuck"})
|
||||
|
||||
self.assertEqual(len(logs.output), 2)
|
||||
self.assertEqual(self.registry.active(), [])
|
||||
|
||||
|
||||
@patch.dict(NOTICE_KINDS, TEST_KINDS)
|
||||
class TestLifecycleKnobs(RegistryTestCase):
|
||||
def test_batched_repeats_wait_for_the_flush(self):
|
||||
self.registry.raise_notice("batched", params={"n": 1})
|
||||
self.listener.reset_mock()
|
||||
|
||||
for n in (2, 3, 4):
|
||||
self.registry.raise_notice("batched", params={"n": n})
|
||||
|
||||
self.assertEqual(self.registry.active()[0]["count"], 1)
|
||||
self.listener.assert_not_called()
|
||||
|
||||
self.registry.flush()
|
||||
|
||||
notice = self.registry.active()[0]
|
||||
self.assertEqual(notice["count"], 4)
|
||||
self.assertEqual(notice["params"], {"n": 4})
|
||||
self.assertEqual(self.registry.stats()[0]["occurrences"], 4)
|
||||
self.listener.assert_called_once()
|
||||
|
||||
def test_flush_with_nothing_held_is_a_no_op(self):
|
||||
self.registry.raise_notice("batched")
|
||||
self.listener.reset_mock()
|
||||
|
||||
self.registry.flush()
|
||||
|
||||
self.listener.assert_not_called()
|
||||
|
||||
def test_repeats_of_a_resolved_notice_are_dropped(self):
|
||||
self.registry.raise_notice("batched")
|
||||
self.registry.raise_notice("batched")
|
||||
self.registry.resolve("batched")
|
||||
self.listener.reset_mock()
|
||||
|
||||
self.registry.flush()
|
||||
|
||||
self.assertEqual(self.registry.active(include_dismissed=True), [])
|
||||
self.listener.assert_not_called()
|
||||
|
||||
def test_a_new_row_starts_without_stale_repeats(self):
|
||||
self.registry.raise_notice("batched")
|
||||
self.registry.raise_notice("batched")
|
||||
self.registry.resolve("batched")
|
||||
|
||||
self.registry.raise_notice("batched")
|
||||
self.registry.flush()
|
||||
|
||||
self.assertEqual(self.registry.active()[0]["count"], 1)
|
||||
|
||||
def test_a_dismissed_notice_reopens_at_the_count(self):
|
||||
self.registry.raise_notice("batched")
|
||||
self.registry.dismiss("batched")
|
||||
|
||||
self.registry.raise_notice("batched")
|
||||
self.registry.flush()
|
||||
self.assertEqual(self.registry.active(), [])
|
||||
|
||||
self.registry.raise_notice("batched")
|
||||
self.registry.flush()
|
||||
|
||||
notice = self.registry.active()[0]
|
||||
self.assertEqual(notice["count"], BATCHED.reopen_at_count)
|
||||
self.assertIsNone(notice["dismissed_at"])
|
||||
|
||||
def test_a_notice_dismissed_past_the_count_stays_dismissed(self):
|
||||
for _ in range(3):
|
||||
self.registry.raise_notice("batched")
|
||||
self.registry.flush()
|
||||
self.registry.dismiss("batched")
|
||||
|
||||
self.registry.raise_notice("batched")
|
||||
self.registry.flush()
|
||||
|
||||
self.assertEqual(self.registry.active(), [])
|
||||
self.assertEqual(self.registry.active(include_dismissed=True)[0]["count"], 4)
|
||||
|
||||
def test_keep_latest_drops_the_oldest_rows(self):
|
||||
for index, scope in enumerate(("a", "b", "c")):
|
||||
self._raise_at(1000.0 + index, "pruned", scope=scope)
|
||||
|
||||
ids = sorted(n["id"] for n in self.registry.active())
|
||||
self.assertEqual(ids, ["pruned:b", "pruned:c"])
|
||||
|
||||
|
||||
class TestCatalogLifecycles(RegistryTestCase):
|
||||
def test_a_new_release_replaces_the_old_update_notice(self):
|
||||
for index, version in enumerate(("0.19.0", "0.19.1")):
|
||||
self._raise_at(
|
||||
1000.0 + index,
|
||||
"update_available",
|
||||
scope=version,
|
||||
params={"version": version},
|
||||
)
|
||||
|
||||
ids = [n["id"] for n in self.registry.active()]
|
||||
self.assertEqual(ids, ["update_available:0.19.1"])
|
||||
|
||||
def test_a_dismissed_failed_login_burst_reopens_at_five_attempts(self):
|
||||
self.registry.raise_notice("failed_login", scope="1000")
|
||||
self.registry.dismiss("failed_login:1000")
|
||||
|
||||
for _ in range(4):
|
||||
self.registry.raise_notice("failed_login", scope="1000")
|
||||
self.registry.flush()
|
||||
|
||||
burst = self.registry.active()[0]
|
||||
self.assertEqual(burst["count"], 5)
|
||||
self.assertIsNone(burst["dismissed_at"])
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
"""Tests for the notice raised while /dev/shm is too small."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from frigate.stats import emitter
|
||||
|
||||
TOO_SMALL = {"total": 64.0, "min_shm": 180}
|
||||
BIG_ENOUGH = {"total": 512.0, "min_shm": 180}
|
||||
|
||||
|
||||
class TestShmNotice(unittest.TestCase):
|
||||
def setUp(self):
|
||||
raise_patch = patch.object(emitter, "raise_notice")
|
||||
resolve_patch = patch.object(emitter, "resolve_notice")
|
||||
self.raise_notice = raise_patch.start()
|
||||
self.resolve_notice = resolve_patch.start()
|
||||
self.addCleanup(raise_patch.stop)
|
||||
self.addCleanup(resolve_patch.stop)
|
||||
|
||||
self.emitter = emitter.StatsEmitter.__new__(emitter.StatsEmitter)
|
||||
self.emitter._shm_checked = False
|
||||
self.emitter._shm_params = None
|
||||
|
||||
def test_too_small_raises_once(self):
|
||||
for _ in range(3):
|
||||
self.emitter._update_shm_notice(TOO_SMALL)
|
||||
|
||||
self.raise_notice.assert_called_once_with(
|
||||
"shm_too_low", params={"total": 64.0, "min": 180}
|
||||
)
|
||||
self.resolve_notice.assert_not_called()
|
||||
|
||||
def test_first_tick_clears_a_row_the_last_run_left(self):
|
||||
self.emitter._update_shm_notice(BIG_ENOUGH)
|
||||
self.emitter._update_shm_notice(BIG_ENOUGH)
|
||||
|
||||
self.resolve_notice.assert_called_once_with("shm_too_low")
|
||||
self.raise_notice.assert_not_called()
|
||||
|
||||
def test_a_new_requirement_updates_the_notice(self):
|
||||
self.emitter._update_shm_notice(TOO_SMALL)
|
||||
self.emitter._update_shm_notice({"total": 64.0, "min_shm": 200})
|
||||
|
||||
self.assertEqual(self.raise_notice.call_count, 2)
|
||||
self.raise_notice.assert_called_with(
|
||||
"shm_too_low", params={"total": 64.0, "min": 200}
|
||||
)
|
||||
|
||||
def test_growing_shm_resolves_the_notice(self):
|
||||
self.emitter._update_shm_notice(TOO_SMALL)
|
||||
self.emitter._update_shm_notice(BIG_ENOUGH)
|
||||
|
||||
self.resolve_notice.assert_called_once_with("shm_too_low")
|
||||
|
||||
def test_missing_shm_is_not_a_problem(self):
|
||||
self.emitter._update_shm_notice({})
|
||||
|
||||
self.raise_notice.assert_not_called()
|
||||
self.resolve_notice.assert_called_once_with("shm_too_low")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,129 +0,0 @@
|
||||
"""Tests for the skipped detection rate and the notice it can raise."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from frigate.stats import emitter
|
||||
from frigate.stats.emitter import SkippedDetectionsTracker
|
||||
from frigate.stats.util import skipped_percent
|
||||
|
||||
|
||||
class TestSkippedPercent(unittest.TestCase):
|
||||
def test_share_of_all_frames(self):
|
||||
self.assertEqual(skipped_percent(0.5, 5.0, True), 10.0)
|
||||
|
||||
def test_offline_camera_reports_zero(self):
|
||||
self.assertEqual(skipped_percent(2.0, 0.0, True), 0.0)
|
||||
|
||||
def test_disabled_camera_reports_zero(self):
|
||||
self.assertEqual(skipped_percent(2.0, 5.0, False), 0.0)
|
||||
|
||||
|
||||
class TestSkippedDetectionsTracker(unittest.TestCase):
|
||||
def _cameras(self, pct: float) -> dict:
|
||||
return {"front_door": {"skipped_pct": pct}}
|
||||
|
||||
def test_below_threshold_never_qualifies(self):
|
||||
tracker = SkippedDetectionsTracker()
|
||||
|
||||
for tick in range(10):
|
||||
self.assertEqual(tracker.update(self._cameras(4.9), tick * 15.0), [])
|
||||
|
||||
def test_qualifies_once_after_the_hold(self):
|
||||
tracker = SkippedDetectionsTracker()
|
||||
|
||||
results = [
|
||||
tracker.update(self._cameras(10.0), tick * 15.0) for tick in range(8)
|
||||
]
|
||||
|
||||
# ticks at 0, 15, 30, and 45 s are inside the hold; 60 s qualifies
|
||||
self.assertEqual(results[:4], [[], [], [], []])
|
||||
self.assertEqual(results[4], ["front_door"])
|
||||
self.assertEqual(results[5:], [[], [], []])
|
||||
|
||||
def test_a_dip_restarts_the_hold(self):
|
||||
tracker = SkippedDetectionsTracker()
|
||||
|
||||
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(10.0), 60.0), [])
|
||||
self.assertEqual(tracker.update(self._cameras(10.0), 120.0), ["front_door"])
|
||||
|
||||
def test_recovery_then_relapse_is_a_new_episode(self):
|
||||
tracker = SkippedDetectionsTracker()
|
||||
tracker.update(self._cameras(10.0), 0.0)
|
||||
self.assertEqual(tracker.update(self._cameras(10.0), 60.0), ["front_door"])
|
||||
|
||||
tracker.update(self._cameras(0.0), 75.0)
|
||||
tracker.update(self._cameras(10.0), 90.0)
|
||||
|
||||
self.assertEqual(tracker.update(self._cameras(10.0), 150.0), ["front_door"])
|
||||
|
||||
def test_cameras_are_tracked_separately(self):
|
||||
tracker = SkippedDetectionsTracker()
|
||||
tracker.update({"a": {"skipped_pct": 10.0}, "b": {"skipped_pct": 0.0}}, 0.0)
|
||||
|
||||
qualified = tracker.update(
|
||||
{"a": {"skipped_pct": 10.0}, "b": {"skipped_pct": 10.0}}, 60.0
|
||||
)
|
||||
|
||||
self.assertEqual(qualified, ["a"])
|
||||
|
||||
def test_a_removed_camera_starts_a_new_episode(self):
|
||||
tracker = SkippedDetectionsTracker()
|
||||
tracker.update(self._cameras(10.0), 0.0)
|
||||
tracker.update({}, 15.0)
|
||||
tracker.update(self._cameras(10.0), 30.0)
|
||||
|
||||
self.assertEqual(tracker.update(self._cameras(10.0), 60.0), [])
|
||||
self.assertEqual(tracker.update(self._cameras(10.0), 90.0), ["front_door"])
|
||||
|
||||
|
||||
class TestEmitterNotices(unittest.TestCase):
|
||||
def setUp(self):
|
||||
raise_patch = patch.object(emitter, "raise_notice")
|
||||
resolve_patch = patch.object(emitter, "resolve_notice")
|
||||
flush_patch = patch.object(emitter, "flush_notices")
|
||||
self.raise_notice = raise_patch.start()
|
||||
resolve_patch.start()
|
||||
self.flush_notices = flush_patch.start()
|
||||
self.addCleanup(raise_patch.stop)
|
||||
self.addCleanup(resolve_patch.stop)
|
||||
self.addCleanup(flush_patch.stop)
|
||||
|
||||
def _emitter(self) -> emitter.StatsEmitter:
|
||||
stats_emitter = emitter.StatsEmitter.__new__(emitter.StatsEmitter)
|
||||
stats_emitter.skipped_detections = SkippedDetectionsTracker()
|
||||
stats_emitter._shm_checked = False
|
||||
stats_emitter._shm_params = None
|
||||
return stats_emitter
|
||||
|
||||
def _stats(self, uptime: int, pct: float) -> dict:
|
||||
return {
|
||||
"service": {"uptime": uptime, "storage": {"/dev/shm": {}}},
|
||||
"cameras": {"front_door": {"skipped_pct": pct}},
|
||||
}
|
||||
|
||||
def test_qualified_camera_raises_a_notice(self):
|
||||
stats_emitter = self._emitter()
|
||||
|
||||
stats_emitter._update_notices(self._stats(300, 12.5), 0.0)
|
||||
stats_emitter._update_notices(self._stats(360, 12.5), 60.0)
|
||||
|
||||
self.raise_notice.assert_called_once_with(
|
||||
"skipped_detections", scope="front_door", params={"pct": 12.5}
|
||||
)
|
||||
|
||||
def test_startup_window_is_ignored(self):
|
||||
stats_emitter = self._emitter()
|
||||
|
||||
stats_emitter._update_notices(self._stats(30, 50.0), 0.0)
|
||||
stats_emitter._update_notices(self._stats(90, 50.0), 60.0)
|
||||
|
||||
self.raise_notice.assert_not_called()
|
||||
|
||||
def test_every_tick_flushes_held_repeats(self):
|
||||
self._emitter()._update_notices(self._stats(30, 0.0), 0.0)
|
||||
|
||||
self.flush_notices.assert_called_once_with()
|
||||
@@ -34,46 +34,44 @@ class TestIsNewerVersion(unittest.TestCase):
|
||||
|
||||
|
||||
class TestUpdateNotice(unittest.TestCase):
|
||||
def setUp(self):
|
||||
raise_patch = patch.object(emitter, "raise_notice")
|
||||
resolve_patch = patch.object(emitter, "resolve_kind")
|
||||
self.raise_notice = raise_patch.start()
|
||||
self.resolve_kind = resolve_patch.start()
|
||||
self.addCleanup(raise_patch.stop)
|
||||
self.addCleanup(resolve_patch.stop)
|
||||
|
||||
def _emitter(self, latest: str) -> emitter.StatsEmitter:
|
||||
stats_emitter = emitter.StatsEmitter.__new__(emitter.StatsEmitter)
|
||||
stats_emitter.config = MagicMock()
|
||||
stats_emitter.stats_tracking = {"latest_frigate_version": latest}
|
||||
stats_emitter.notice_registry = MagicMock()
|
||||
return stats_emitter
|
||||
|
||||
def test_newer_release_raises(self):
|
||||
with patch.object(emitter, "VERSION", "0.18.1-abcdef"):
|
||||
self._emitter("0.19.0")._check_update_notice()
|
||||
stats_emitter = self._emitter("0.19.0")
|
||||
|
||||
self.raise_notice.assert_called_once_with(
|
||||
"update_available", scope="0.19.0", params={"version": "0.19.0"}
|
||||
with patch.object(emitter, "VERSION", "0.18.1-abcdef"):
|
||||
stats_emitter._check_update_notice()
|
||||
|
||||
stats_emitter.notice_registry.raise_notice.assert_called_once_with(
|
||||
"update_available", params={"version": "0.19.0"}
|
||||
)
|
||||
self.resolve_kind.assert_not_called()
|
||||
stats_emitter.notice_registry.resolve.assert_not_called()
|
||||
|
||||
def test_current_or_disabled_resolves(self):
|
||||
for latest in ("0.18.1", "disabled"):
|
||||
with self.subTest(latest=latest):
|
||||
self.resolve_kind.reset_mock()
|
||||
stats_emitter = self._emitter(latest)
|
||||
|
||||
with patch.object(emitter, "VERSION", "0.18.1-abcdef"):
|
||||
self._emitter(latest)._check_update_notice()
|
||||
with patch.object(emitter, "VERSION", "0.18.1-abcdef"):
|
||||
stats_emitter._check_update_notice()
|
||||
|
||||
self.raise_notice.assert_not_called()
|
||||
self.resolve_kind.assert_called_once_with("update_available")
|
||||
stats_emitter.notice_registry.raise_notice.assert_not_called()
|
||||
stats_emitter.notice_registry.resolve.assert_called_once_with(
|
||||
"update_available"
|
||||
)
|
||||
|
||||
def test_a_failed_lookup_leaves_the_notice_alone(self):
|
||||
with patch.object(emitter, "VERSION", "0.18.1-abcdef"):
|
||||
self._emitter("unknown")._check_update_notice()
|
||||
stats_emitter = self._emitter("unknown")
|
||||
|
||||
self.raise_notice.assert_not_called()
|
||||
self.resolve_kind.assert_not_called()
|
||||
with patch.object(emitter, "VERSION", "0.18.1-abcdef"):
|
||||
stats_emitter._check_update_notice()
|
||||
|
||||
stats_emitter.notice_registry.raise_notice.assert_not_called()
|
||||
stats_emitter.notice_registry.resolve.assert_not_called()
|
||||
|
||||
def test_refresh_updates_tracking_on_a_thread_then_checks(self):
|
||||
stats_emitter = self._emitter("0.18.0")
|
||||
@@ -91,7 +89,7 @@ class TestUpdateNotice(unittest.TestCase):
|
||||
self.assertEqual(
|
||||
stats_emitter.stats_tracking["latest_frigate_version"], "0.19.0"
|
||||
)
|
||||
self.raise_notice.assert_called_once()
|
||||
stats_emitter.notice_registry.raise_notice.assert_called_once()
|
||||
|
||||
def test_failed_refresh_keeps_the_last_known_version(self):
|
||||
stats_emitter = self._emitter("0.20.0")
|
||||
@@ -107,10 +105,10 @@ class TestUpdateNotice(unittest.TestCase):
|
||||
self.assertEqual(
|
||||
stats_emitter.stats_tracking["latest_frigate_version"], "0.20.0"
|
||||
)
|
||||
self.raise_notice.assert_called_once_with(
|
||||
"update_available", scope="0.20.0", params={"version": "0.20.0"}
|
||||
stats_emitter.notice_registry.raise_notice.assert_called_once_with(
|
||||
"update_available", params={"version": "0.20.0"}
|
||||
)
|
||||
self.resolve_kind.assert_not_called()
|
||||
stats_emitter.notice_registry.resolve.assert_not_called()
|
||||
|
||||
def test_disabled_version_check_still_clears_the_notice(self):
|
||||
stats_emitter = self._emitter("0.20.0")
|
||||
@@ -126,4 +124,6 @@ class TestUpdateNotice(unittest.TestCase):
|
||||
self.assertEqual(
|
||||
stats_emitter.stats_tracking["latest_frigate_version"], "disabled"
|
||||
)
|
||||
self.resolve_kind.assert_called_once_with("update_available")
|
||||
stats_emitter.notice_registry.resolve.assert_called_once_with(
|
||||
"update_available"
|
||||
)
|
||||
|
||||
@@ -556,9 +556,10 @@ class TestHttp(unittest.TestCase):
|
||||
assert Recordings.get(Recordings.id == rec_k2_id)
|
||||
assert Recordings.get(Recordings.id == rec_k3_id)
|
||||
|
||||
def test_unmet_retention_sets_the_live_flag(self):
|
||||
def test_unmet_retention_raises_notice(self):
|
||||
registry = MagicMock()
|
||||
config = FrigateConfig(**self.minimal_config)
|
||||
storage = StorageMaintainer(config, MagicMock())
|
||||
storage = StorageMaintainer(config, MagicMock(), registry)
|
||||
storage.camera_storage_stats = {
|
||||
"front_door": {"needs_refresh": False, "usage": 10, "bandwidth": 10}
|
||||
}
|
||||
@@ -566,17 +567,22 @@ class TestHttp(unittest.TestCase):
|
||||
with patch.object(storage, "expected_hourly_bandwidth", return_value=100.0):
|
||||
storage.reduce_storage_consumption()
|
||||
|
||||
self.assertTrue(storage.retention_unmet)
|
||||
registry.raise_notice.assert_called_once()
|
||||
kind = registry.raise_notice.call_args.args[0]
|
||||
params = registry.raise_notice.call_args.kwargs["params"]
|
||||
self.assertEqual(kind, "retention_unmet")
|
||||
self.assertEqual(params["needed_mb"], 100.0)
|
||||
self.assertEqual(params["cleared_mb"], 0.0)
|
||||
|
||||
def test_clean_run_clears_the_live_flag(self):
|
||||
def test_clean_run_resolves_notice(self):
|
||||
registry = MagicMock()
|
||||
config = FrigateConfig(**self.minimal_config)
|
||||
storage = StorageMaintainer(config, MagicMock())
|
||||
storage.retention_unmet = True
|
||||
storage = StorageMaintainer(config, MagicMock(), registry)
|
||||
|
||||
with patch.object(storage, "check_storage_needs_cleanup", return_value=False):
|
||||
storage._maintain_once()
|
||||
|
||||
self.assertFalse(storage.retention_unmet)
|
||||
registry.resolve.assert_called_once_with("retention_unmet")
|
||||
|
||||
|
||||
def _insert_mock_event(
|
||||
|
||||
@@ -14,27 +14,25 @@ class TestDetectorStuckNotice(unittest.TestCase):
|
||||
return detector
|
||||
|
||||
def test_stuck_restart_raises_notice(self):
|
||||
registry = MagicMock()
|
||||
detector = self._stuck_detector()
|
||||
watchdog = FrigateWatchdog({"ov": detector}, MagicMock())
|
||||
watchdog = FrigateWatchdog({"ov": detector}, MagicMock(), registry)
|
||||
|
||||
with (
|
||||
patch("frigate.watchdog.datetime") as mock_datetime,
|
||||
patch("frigate.watchdog.raise_notice") as raise_notice,
|
||||
):
|
||||
with patch("frigate.watchdog.datetime") as mock_datetime:
|
||||
mock_datetime.datetime.now.return_value.timestamp.return_value = 100.0
|
||||
watchdog._check_detectors()
|
||||
|
||||
detector.start_or_restart.assert_called_once()
|
||||
raise_notice.assert_called_once_with(
|
||||
registry.raise_notice.assert_called_once_with(
|
||||
"detector_stuck", scope="ov", params={"detector": "ov"}
|
||||
)
|
||||
|
||||
def test_healthy_detector_raises_nothing(self):
|
||||
registry = MagicMock()
|
||||
detector = self._stuck_detector()
|
||||
detector.detection_start.value = 0.0
|
||||
watchdog = FrigateWatchdog({"ov": detector}, MagicMock())
|
||||
watchdog = FrigateWatchdog({"ov": detector}, MagicMock(), registry)
|
||||
|
||||
with patch("frigate.watchdog.raise_notice") as raise_notice:
|
||||
watchdog._check_detectors()
|
||||
watchdog._check_detectors()
|
||||
|
||||
raise_notice.assert_not_called()
|
||||
registry.raise_notice.assert_not_called()
|
||||
|
||||
+1
-5
@@ -1,13 +1,10 @@
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, TypedDict
|
||||
from typing import TypedDict
|
||||
|
||||
from frigate.camera import CameraMetrics
|
||||
from frigate.data_processing.types import DataProcessorMetrics
|
||||
from frigate.object_detection.base import ObjectDetectProcess
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frigate.storage import StorageMaintainer
|
||||
|
||||
|
||||
class StatsTrackingTypes(TypedDict):
|
||||
camera_metrics: dict[str, CameraMetrics]
|
||||
@@ -17,7 +14,6 @@ class StatsTrackingTypes(TypedDict):
|
||||
latest_frigate_version: str
|
||||
last_updated: int
|
||||
processes: dict[str, int]
|
||||
storage_maintainer: "StorageMaintainer | None"
|
||||
|
||||
|
||||
class ModelStatusTypesEnum(str, Enum):
|
||||
|
||||
@@ -31,6 +31,7 @@ DEFAULT_CONFIG_FILE = os.path.join(CONFIG_DIR, "config.yml")
|
||||
DETECTOR_DEVICE_FIELDS = {
|
||||
"cpu": "num_threads",
|
||||
"rknn": "num_cores",
|
||||
"deepstack": "api_url",
|
||||
"degirum": "location",
|
||||
"zmq": "endpoint",
|
||||
}
|
||||
@@ -39,6 +40,7 @@ DETECTOR_DEVICE_FIELDS = {
|
||||
# detectors that use them are being reworked, so they are dropped rather than
|
||||
# carried over.
|
||||
DROPPED_DETECTOR_OPTIONS = {
|
||||
"deepstack": ["api_timeout", "api_key"],
|
||||
"degirum": ["zoo", "token"],
|
||||
"zmq": ["request_timeout_ms", "linger_ms"],
|
||||
}
|
||||
|
||||
@@ -7,8 +7,7 @@ from pathlib import Path
|
||||
import requests
|
||||
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.const import UPDATE_MODEL_STATE
|
||||
from frigate.notices import raise_notice, resolve_notice
|
||||
from frigate.const import UPDATE_MODEL_STATE, UPDATE_NOTICE
|
||||
from frigate.types import ModelStatusTypesEnum
|
||||
from frigate.util.file import FileLock
|
||||
|
||||
@@ -65,14 +64,26 @@ class ModelDownloader:
|
||||
return f"{self.model_name}/{file_name}"
|
||||
|
||||
def _report_failure(self, file_name: str, error: str) -> None:
|
||||
raise_notice(
|
||||
"model_download_failed",
|
||||
scope=self._notice_scope(file_name),
|
||||
params={"file": file_name, "model": self.model_name, "error": error},
|
||||
self.requestor.send_data(
|
||||
UPDATE_NOTICE,
|
||||
{
|
||||
"action": "raise",
|
||||
"kind": "model_download_failed",
|
||||
"scope": self._notice_scope(file_name),
|
||||
"params": {"file": file_name, "error": error},
|
||||
},
|
||||
)
|
||||
|
||||
def _resolve_failure(self, file_name: str) -> None:
|
||||
resolve_notice("model_download_failed", self._notice_scope(file_name))
|
||||
self.requestor.send_data(
|
||||
UPDATE_NOTICE,
|
||||
{
|
||||
"action": "resolve",
|
||||
"kind": "model_download_failed",
|
||||
"scope": self._notice_scope(file_name),
|
||||
"params": {},
|
||||
},
|
||||
)
|
||||
|
||||
def _send_state(self, file_name: str, state: ModelStatusTypesEnum) -> None:
|
||||
self.requestor.send_data(
|
||||
|
||||
@@ -269,41 +269,6 @@ def delete_event_thumbnail(event: Event) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
### Training Images
|
||||
|
||||
TRAINING_IMAGE_EXTENSIONS = (".webp", ".png", ".jpg", ".jpeg")
|
||||
|
||||
|
||||
def trim_oldest_files(folder: str, max_files: int) -> None:
|
||||
"""Delete the oldest training images until at most max_files remain."""
|
||||
try:
|
||||
names = os.listdir(folder)
|
||||
except OSError:
|
||||
return
|
||||
|
||||
files: list[tuple[float, str]] = []
|
||||
|
||||
for name in names:
|
||||
if not name.lower().endswith(TRAINING_IMAGE_EXTENSIONS):
|
||||
continue
|
||||
|
||||
path = os.path.join(folder, name)
|
||||
|
||||
# the UI can move or delete an image between listdir and stat
|
||||
try:
|
||||
files.append((os.path.getctime(path), path))
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
files.sort(reverse=True)
|
||||
|
||||
for _, path in files[max_files:]:
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError:
|
||||
logger.debug("Unable to delete training image %s", path)
|
||||
|
||||
|
||||
### File Locking
|
||||
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ from frigate.const import (
|
||||
STREAM_TYPE_MAIN,
|
||||
STREAM_TYPE_SUB,
|
||||
STREAM_TYPE_TO_ROLE,
|
||||
UPDATE_NOTICE,
|
||||
)
|
||||
from frigate.log import LogPipe
|
||||
from frigate.util.builtin import EventsPerSecond, get_record_segment_time
|
||||
@@ -43,6 +44,11 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
RECORD_GRACE_SECONDS = 90
|
||||
|
||||
# a camera whose ffmpeg restarts this many times in the trailing hour is in a
|
||||
# crash loop; it recovers once the capture thread stays up this long
|
||||
CRASH_LOOP_RESTARTS = 5
|
||||
CRASH_LOOP_RECOVERY_S = 600
|
||||
|
||||
|
||||
def capture_frames(
|
||||
ffmpeg_process: sp.Popen[Any],
|
||||
@@ -140,6 +146,8 @@ class CameraWatchdog(threading.Thread):
|
||||
self.stop_event = stop_event
|
||||
self.sleeptime = self.config.ffmpeg.retry_interval
|
||||
self.reconnect_timestamps = deque()
|
||||
self.crash_loop_raised = False
|
||||
self._crash_loop_sent_restarts = 0
|
||||
self.stalls = stalls
|
||||
self.reconnects = reconnects
|
||||
self.detection_frame = detection_frame
|
||||
@@ -189,6 +197,53 @@ class CameraWatchdog(threading.Thread):
|
||||
self._last_record_status: dict[str, str] = {}
|
||||
self._last_status_update_time: float = 0.0
|
||||
|
||||
def _update_crash_loop_notice(self, now: float) -> None:
|
||||
"""Raise the crash loop notice as restarts pile up, resolve once stable.
|
||||
|
||||
Called on every 1 second tick, so raises are sent only when the restart
|
||||
count changes; the registry treats repeats of a state notice as updates.
|
||||
"""
|
||||
restarts = len(self.reconnect_timestamps)
|
||||
alive = self.capture_thread is not None and self.capture_thread.is_alive()
|
||||
|
||||
if not alive and restarts >= CRASH_LOOP_RESTARTS:
|
||||
if self.crash_loop_raised and restarts == self._crash_loop_sent_restarts:
|
||||
return
|
||||
|
||||
self.requestor.send_data(
|
||||
UPDATE_NOTICE,
|
||||
{
|
||||
"action": "raise",
|
||||
"kind": "ffmpeg_crash_loop",
|
||||
"scope": self.config.name,
|
||||
"params": {"restarts": restarts},
|
||||
},
|
||||
)
|
||||
self.crash_loop_raised = True
|
||||
self._crash_loop_sent_restarts = restarts
|
||||
elif (
|
||||
self.crash_loop_raised
|
||||
and alive
|
||||
and (
|
||||
restarts == 0
|
||||
or now - self.reconnect_timestamps[-1] >= CRASH_LOOP_RECOVERY_S
|
||||
)
|
||||
):
|
||||
self._resolve_crash_loop()
|
||||
|
||||
def _resolve_crash_loop(self) -> None:
|
||||
self.requestor.send_data(
|
||||
UPDATE_NOTICE,
|
||||
{
|
||||
"action": "resolve",
|
||||
"kind": "ffmpeg_crash_loop",
|
||||
"scope": self.config.name,
|
||||
"params": {},
|
||||
},
|
||||
)
|
||||
self.crash_loop_raised = False
|
||||
self._crash_loop_sent_restarts = 0
|
||||
|
||||
def _send_detect_status(self, status: str, now: float) -> None:
|
||||
"""Send detect status only if changed or retry_interval has elapsed."""
|
||||
if (
|
||||
@@ -379,6 +434,9 @@ class CameraWatchdog(threading.Thread):
|
||||
# cameras without a sub stream never get a record_sub topic
|
||||
if self.config.record.sub.enabled:
|
||||
self._send_record_status(STREAM_TYPE_SUB, "disabled", now)
|
||||
|
||||
if self.crash_loop_raised:
|
||||
self._resolve_crash_loop()
|
||||
self.was_enabled = enabled
|
||||
continue
|
||||
|
||||
@@ -456,6 +514,7 @@ class CameraWatchdog(threading.Thread):
|
||||
self.logger.error(
|
||||
f"Ffmpeg process crashed unexpectedly for {self.config.name}."
|
||||
)
|
||||
self._update_crash_loop_notice(now)
|
||||
if can_restart:
|
||||
self.reset_capture_thread(terminate=False)
|
||||
last_restart_time = now
|
||||
@@ -485,6 +544,7 @@ class CameraWatchdog(threading.Thread):
|
||||
# process is running normally
|
||||
self._send_detect_status("online", now)
|
||||
self.fps_overflow_count = 0
|
||||
self._update_crash_loop_notice(now)
|
||||
|
||||
for p in self.ffmpeg_other_processes:
|
||||
poll = p["process"].poll()
|
||||
|
||||
+8
-2
@@ -7,7 +7,7 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
|
||||
from frigate.notices import raise_notice
|
||||
from frigate.notices.registry import NoticeRegistry
|
||||
from frigate.object_detection.base import ObjectDetectProcess
|
||||
from frigate.util.process import FrigateProcess
|
||||
from frigate.util.services import restart_frigate
|
||||
@@ -45,10 +45,12 @@ class FrigateWatchdog(threading.Thread):
|
||||
self,
|
||||
detectors: dict[str, ObjectDetectProcess],
|
||||
stop_event: MpEvent,
|
||||
notice_registry: NoticeRegistry | None = None,
|
||||
):
|
||||
super().__init__(name="frigate_watchdog")
|
||||
self.detectors = detectors
|
||||
self.stop_event = stop_event
|
||||
self.notice_registry = notice_registry
|
||||
self._monitored: list[MonitoredProcess] = []
|
||||
|
||||
def register(
|
||||
@@ -124,7 +126,11 @@ class FrigateWatchdog(threading.Thread):
|
||||
"Detection appears to be stuck. Restarting detection process..."
|
||||
)
|
||||
detector.start_or_restart()
|
||||
raise_notice("detector_stuck", scope=name, params={"detector": name})
|
||||
|
||||
if self.notice_registry is not None:
|
||||
self.notice_registry.raise_notice(
|
||||
"detector_stuck", scope=name, params={"detector": name}
|
||||
)
|
||||
elif (
|
||||
detector.detect_process is not None
|
||||
and not detector.detect_process.is_alive()
|
||||
|
||||
@@ -331,8 +331,7 @@ def build_access_map(
|
||||
for method in route.methods:
|
||||
if method in ("HEAD", "OPTIONS"):
|
||||
continue
|
||||
# the OpenAPI paths drop convertors such as :path, like path_format
|
||||
access_map[(route.path_format, method.lower())] = {
|
||||
access_map[(route.path, method.lower())] = {
|
||||
"level": level,
|
||||
"roles": roles,
|
||||
"flag": flag,
|
||||
|
||||
@@ -55,11 +55,8 @@ export class FrigateApp {
|
||||
});
|
||||
|
||||
await this.ws.install(this.page, statsFactory(overrides?.stats));
|
||||
await this.api.install(overrides);
|
||||
// media goes last so its per-event routes win over the broader
|
||||
// `**/api/events**` list route, which otherwise answers thumbnail and
|
||||
// snapshot requests with the events JSON
|
||||
await this.media.install();
|
||||
await this.api.install(overrides);
|
||||
}
|
||||
|
||||
/** Navigate to a page. Always call installDefaults() first. */
|
||||
|
||||
@@ -52,9 +52,6 @@ export function deepMerge<T extends Record<string, unknown>>(
|
||||
export const BASE_CONFIG = {
|
||||
...configSnapshot,
|
||||
version: "0.15.0-test",
|
||||
// injected by the /config endpoint rather than the Pydantic model, so it
|
||||
// is absent from the snapshot
|
||||
plus: { enabled: false },
|
||||
cameras: {
|
||||
...configSnapshot.cameras,
|
||||
front_door: {
|
||||
|
||||
@@ -16,7 +16,6 @@ function cameraStats(_name: string) {
|
||||
pid: 102,
|
||||
process_fps: 5.0,
|
||||
skipped_fps: 0,
|
||||
skipped_pct: 0,
|
||||
connection_quality: "excellent" as const,
|
||||
expected_fps: 5,
|
||||
reconnects_last_hour: 0,
|
||||
@@ -76,7 +75,6 @@ export const BASE_STATS = {
|
||||
uptime: 86400,
|
||||
latest_version: "0.15.0",
|
||||
version: "0.15.0-test",
|
||||
retention_unmet: false,
|
||||
},
|
||||
camera_fps: 15.0,
|
||||
process_fps: 15.0,
|
||||
|
||||
@@ -49,7 +49,7 @@ export interface ApiMockOverrides {
|
||||
};
|
||||
users?: { username: string; role: string }[];
|
||||
notices?: unknown[];
|
||||
dismissedChecks?: unknown[];
|
||||
noticeStats?: unknown[];
|
||||
/** camera name to the ffprobe entries returned for `paths=camera:<name>` */
|
||||
ffprobe?: Record<string, unknown[]>;
|
||||
}
|
||||
@@ -234,12 +234,13 @@ export class ApiMocker {
|
||||
return route.fulfill({ json: entries });
|
||||
});
|
||||
|
||||
// Notices
|
||||
// Notices. The stats route is registered after the list route so it wins
|
||||
// for /api/notices/stats; the list glob does not match a sub-path anyway.
|
||||
await this.page.route("**/api/notices", (route) =>
|
||||
route.fulfill({ json: overrides?.notices ?? [] }),
|
||||
);
|
||||
await this.page.route("**/api/notices/dismissed_checks", (route) =>
|
||||
route.fulfill({ json: overrides?.dismissedChecks ?? [] }),
|
||||
await this.page.route("**/api/notices/stats", (route) =>
|
||||
route.fulfill({ json: overrides?.noticeStats ?? [] }),
|
||||
);
|
||||
|
||||
// Users. GET lists them; POST/PUT (create, password) just succeed, so
|
||||
@@ -323,9 +324,8 @@ export class MediaMocker {
|
||||
}),
|
||||
);
|
||||
|
||||
// Event thumbnails. The explore grid and detail dialog request .webp,
|
||||
// everything else requests .jpg.
|
||||
await this.page.route("**/api/events/*/thumbnail.{jpg,webp}**", (route) =>
|
||||
// Event thumbnails
|
||||
await this.page.route("**/api/events/*/thumbnail.jpg**", (route) =>
|
||||
route.fulfill({
|
||||
contentType: "image/png",
|
||||
body: PLACEHOLDER_PNG,
|
||||
|
||||
@@ -1,284 +0,0 @@
|
||||
/**
|
||||
* Command menu tests -- HIGH tier.
|
||||
*
|
||||
* Ctrl+K and "/" open a search box over pages, cameras, camera groups,
|
||||
* settings sections and actions. Text fields keep their own keys, recent
|
||||
* commands survive a reload, and viewers never see admin entries.
|
||||
* The menu is desktop only.
|
||||
*/
|
||||
|
||||
import { test, expect } from "../fixtures/frigate-test";
|
||||
import type { FrigateApp } from "../fixtures/frigate-test";
|
||||
import { getMonacoVisibleText } from "../helpers/monaco";
|
||||
import {
|
||||
restrictedProfile,
|
||||
viewerProfile,
|
||||
} from "../fixtures/mock-data/profile";
|
||||
|
||||
const SAMPLE_CONFIG = "mqtt:\n host: mqtt\n";
|
||||
|
||||
function menu(app: FrigateApp) {
|
||||
return app.page.locator("[cmdk-root]");
|
||||
}
|
||||
|
||||
function commands(app: FrigateApp, text: string | RegExp) {
|
||||
return app.page.locator("[cmdk-item]", { hasText: text });
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the recent command ids straight out of idb-keyval's store, which is
|
||||
* where useUserPersistence writes under a key namespaced by username. The
|
||||
* write is fire and forget, so a test that reloads has to wait for it first.
|
||||
*/
|
||||
function storedRecents(app: FrigateApp) {
|
||||
return app.page.evaluate(
|
||||
() =>
|
||||
new Promise<string[]>((resolve) => {
|
||||
const request = indexedDB.open("keyval-store");
|
||||
request.onerror = () => resolve([]);
|
||||
request.onsuccess = () => {
|
||||
const read = request.result
|
||||
.transaction("keyval", "readonly")
|
||||
.objectStore("keyval")
|
||||
.get("command-menu-recent:admin");
|
||||
read.onerror = () => resolve([]);
|
||||
read.onsuccess = () => resolve(read.result ?? []);
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function openMenu(app: FrigateApp) {
|
||||
await app.page.keyboard.press("Control+k");
|
||||
await expect(menu(app)).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe("Command menu - opening @high", () => {
|
||||
test.skip(
|
||||
({ frigateApp }) => frigateApp.isMobile,
|
||||
"The command menu is desktop only",
|
||||
);
|
||||
|
||||
test("Ctrl+K opens the menu and closes it again", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/");
|
||||
await openMenu(frigateApp);
|
||||
|
||||
await expect(menu(frigateApp).locator("[cmdk-group-heading]")).toHaveText([
|
||||
"Pages",
|
||||
"Cameras",
|
||||
"Camera groups",
|
||||
"Settings",
|
||||
"Actions",
|
||||
]);
|
||||
|
||||
await frigateApp.page.keyboard.press("Control+k");
|
||||
await expect(menu(frigateApp)).toBeHidden();
|
||||
});
|
||||
|
||||
test("slash opens the menu without typing itself", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/review");
|
||||
await frigateApp.page.keyboard.press("/");
|
||||
await expect(menu(frigateApp)).toBeVisible();
|
||||
await expect(frigateApp.page.locator("[cmdk-input]")).toHaveValue("");
|
||||
});
|
||||
|
||||
test("the config editor keeps Ctrl+K and slash", async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG });
|
||||
await frigateApp.goto("/config");
|
||||
await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible(
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
|
||||
await frigateApp.page.locator(".monaco-editor").first().click();
|
||||
await frigateApp.page.keyboard.type("/comment");
|
||||
await expect
|
||||
.poll(() => getMonacoVisibleText(frigateApp.page), { timeout: 10_000 })
|
||||
.toContain("/comment");
|
||||
await expect(menu(frigateApp)).toHaveCount(0);
|
||||
|
||||
// Ctrl+K opens a chord in Monaco, so the menu leaves it alone. Escape
|
||||
// cancels the pending chord, which would otherwise eat the next key.
|
||||
await frigateApp.page.keyboard.press("Control+k");
|
||||
await expect(menu(frigateApp)).toHaveCount(0);
|
||||
await frigateApp.page.keyboard.press("Escape");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Command menu - navigating @high", () => {
|
||||
test.skip(
|
||||
({ frigateApp }) => frigateApp.isMobile,
|
||||
"The command menu is desktop only",
|
||||
);
|
||||
|
||||
test("a camera entry jumps to its live view", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/review");
|
||||
await openMenu(frigateApp);
|
||||
await frigateApp.page.keyboard.type("backy");
|
||||
|
||||
await expect(commands(frigateApp, "Garage")).toHaveCount(0);
|
||||
await commands(frigateApp, "Backyard").filter({ hasText: "Live" }).click();
|
||||
|
||||
await expect(frigateApp.page).toHaveURL(/\/#backyard$/);
|
||||
await expect(menu(frigateApp)).toBeHidden();
|
||||
});
|
||||
|
||||
test("a camera entry deep links into review", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/");
|
||||
await openMenu(frigateApp);
|
||||
await frigateApp.page.keyboard.type("front door");
|
||||
await commands(frigateApp, "Front Door")
|
||||
.filter({ hasText: "Review" })
|
||||
.click();
|
||||
|
||||
await expect(frigateApp.page).toHaveURL(/\/review\?cameras=front_door$/);
|
||||
});
|
||||
|
||||
test("a camera group opens the live grid filtered to it", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto("/review");
|
||||
await openMenu(frigateApp);
|
||||
await frigateApp.page.keyboard.type("outdoor");
|
||||
await commands(frigateApp, "outdoor").click();
|
||||
|
||||
await expect(frigateApp.page).toHaveURL(/\/\?group=outdoor$/);
|
||||
});
|
||||
|
||||
test("repeated settings names are told apart by their group", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto("/");
|
||||
await openMenu(frigateApp);
|
||||
await frigateApp.page.keyboard.type("object detection");
|
||||
|
||||
const matches = commands(frigateApp, "Object detection");
|
||||
await expect(matches).toHaveCount(2);
|
||||
await expect(
|
||||
matches.filter({ hasText: "Global configuration" }),
|
||||
).toHaveCount(1);
|
||||
await expect(
|
||||
matches.filter({ hasText: "Camera configuration" }),
|
||||
).toHaveCount(1);
|
||||
|
||||
await matches.filter({ hasText: "Camera configuration" }).click();
|
||||
await expect(frigateApp.page).toHaveURL(/\/settings\?page=cameraDetect$/);
|
||||
});
|
||||
|
||||
test("a command run once comes back under Recent", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/");
|
||||
await openMenu(frigateApp);
|
||||
await frigateApp.page.keyboard.type("export");
|
||||
await commands(frigateApp, "Export").first().click();
|
||||
await expect(frigateApp.page).toHaveURL(/\/export$/);
|
||||
|
||||
await expect
|
||||
.poll(() => storedRecents(frigateApp))
|
||||
.toEqual(["page-/export"]);
|
||||
|
||||
// Recents live in IndexedDB, so they outlive a full reload.
|
||||
await frigateApp.goto("/");
|
||||
await openMenu(frigateApp);
|
||||
await expect(
|
||||
menu(frigateApp).locator("[cmdk-group-heading]").first(),
|
||||
).toHaveText("Recent");
|
||||
await expect(
|
||||
frigateApp.page.locator('[cmdk-item][data-value^="recent-"]'),
|
||||
).toHaveText(["Export"]);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Command menu - actions @high", () => {
|
||||
test.skip(
|
||||
({ frigateApp }) => frigateApp.isMobile,
|
||||
"The command menu is desktop only",
|
||||
);
|
||||
|
||||
test("the theme action flips the root class", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/");
|
||||
const isDark = () =>
|
||||
frigateApp.page.evaluate(() =>
|
||||
document.documentElement.classList.contains("dark"),
|
||||
);
|
||||
const before = await isDark();
|
||||
|
||||
await openMenu(frigateApp);
|
||||
await frigateApp.page.keyboard.type("toggle light");
|
||||
await commands(frigateApp, "Toggle light and dark mode").click();
|
||||
|
||||
await expect.poll(isDark).toBe(!before);
|
||||
});
|
||||
|
||||
test("restart asks for confirmation first", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/");
|
||||
await openMenu(frigateApp);
|
||||
await frigateApp.page.keyboard.type("restart");
|
||||
await commands(frigateApp, "Restart Frigate").click();
|
||||
|
||||
const confirm = frigateApp.page.getByRole("alertdialog");
|
||||
await expect(confirm).toBeVisible();
|
||||
await confirm.getByRole("button", { name: /cancel/i }).click();
|
||||
await expect(confirm).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Command menu - permissions @high", () => {
|
||||
test.skip(
|
||||
({ frigateApp }) => frigateApp.isMobile,
|
||||
"The command menu is desktop only",
|
||||
);
|
||||
|
||||
test("viewers get cameras but no admin entries", async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults({ profile: viewerProfile() });
|
||||
await frigateApp.goto("/");
|
||||
await openMenu(frigateApp);
|
||||
|
||||
await expect(commands(frigateApp, "Front Door").first()).toBeVisible();
|
||||
await expect(commands(frigateApp, "UI settings")).toHaveCount(1);
|
||||
await expect(commands(frigateApp, "Restart Frigate")).toHaveCount(0);
|
||||
await expect(commands(frigateApp, "Configuration Editor")).toHaveCount(0);
|
||||
await expect(commands(frigateApp, "Motion tuner")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("a custom role only reaches groups holding its cameras", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
profile: restrictedProfile(["garage"], { role: "restricted" }),
|
||||
});
|
||||
await frigateApp.goto("/");
|
||||
await openMenu(frigateApp);
|
||||
|
||||
const input = frigateApp.page.locator("[cmdk-input]");
|
||||
const group = (name: string) =>
|
||||
frigateApp.page.locator(`[cmdk-item][data-value="group-${name}"]`);
|
||||
|
||||
await input.fill("garage");
|
||||
await expect(commands(frigateApp, "Garage").first()).toBeVisible();
|
||||
await expect(group("default")).toHaveCount(1);
|
||||
|
||||
// "outdoor" holds no camera this role may see
|
||||
await input.fill("outdoor");
|
||||
await expect(group("outdoor")).toHaveCount(0);
|
||||
|
||||
// nor may a hidden camera's name pull a group up through its search terms
|
||||
await input.fill("backyard");
|
||||
await expect(group("default")).toHaveCount(0);
|
||||
await expect(commands(frigateApp, "Backyard")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Command menu - mobile @high @mobile", () => {
|
||||
test.skip(
|
||||
({ frigateApp }) => !frigateApp.isMobile,
|
||||
"Desktop is covered above",
|
||||
);
|
||||
|
||||
test("the menu is not mounted on a phone", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/");
|
||||
await frigateApp.page.keyboard.press("Control+k");
|
||||
await expect(menu(frigateApp)).toHaveCount(0);
|
||||
|
||||
await frigateApp.page.keyboard.press("/");
|
||||
await expect(menu(frigateApp)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -1,214 +0,0 @@
|
||||
/**
|
||||
* App shell error boundaries.
|
||||
*
|
||||
* Both failures are forced through the mock layer rather than through test
|
||||
* hooks in the app. A non-array payload reaches a component that treats it as
|
||||
* a list and throws on the first render; aborting a page's asset request
|
||||
* reproduces what an open tab sees after Frigate is updated underneath it.
|
||||
*/
|
||||
|
||||
import { test, expect, type FrigateApp } from "../fixtures/frigate-test";
|
||||
import { grantClipboardPermissions, readClipboard } from "../helpers/clipboard";
|
||||
|
||||
/** Exports builds its list with `rawExports.filter(...)`. */
|
||||
async function breakExportsPage(app: FrigateApp) {
|
||||
await app.page.route("**/api/exports**", (route) =>
|
||||
route.fulfill({ json: { unexpected: true } }),
|
||||
);
|
||||
await app.goto("/export");
|
||||
await expect(app.page.getByTestId("error-panel")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* `useStats` runs `Object.entries(stats.detectors)` and only the status bar
|
||||
* and bottom bar call it, so null detectors throw in one chrome component.
|
||||
*/
|
||||
async function breakStatusbar(app: FrigateApp) {
|
||||
await app.installDefaults({ stats: { detectors: null } });
|
||||
await app.goto("/");
|
||||
}
|
||||
|
||||
/** GeneralSettings and the status bar both read profiles, so both throw. */
|
||||
async function breakAllDesktopChrome(app: FrigateApp) {
|
||||
await app.page.route("**/api/profiles**", (route) =>
|
||||
route.fulfill({
|
||||
json: { profiles: { broken: true }, active_profile: "default" },
|
||||
}),
|
||||
);
|
||||
await app.goto("/");
|
||||
}
|
||||
|
||||
test.describe("Error boundaries - page failure @high", () => {
|
||||
// React mirrors the caught error to console.error, and the panel shows the
|
||||
// TypeError message on purpose.
|
||||
test.use({
|
||||
expectedErrors: [
|
||||
/is not a function|An error occurred in the|The above error occurred/,
|
||||
],
|
||||
});
|
||||
|
||||
test("a thrown page renders a recovery panel, not a blank screen", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await breakExportsPage(frigateApp);
|
||||
|
||||
const panel = frigateApp.page.getByTestId("error-panel");
|
||||
await expect(panel.getByText("This page stopped working")).toBeVisible();
|
||||
await expect(panel.getByRole("button", { name: "Reload" })).toBeVisible();
|
||||
await expect(panel.getByTestId("error-panel-message")).toContainText(
|
||||
"is not a function",
|
||||
);
|
||||
|
||||
// Contained to the route: the panel sits in the page container and the
|
||||
// chrome boundary never trips, so navigation stays usable.
|
||||
await expect(
|
||||
frigateApp.page.locator("#pageRoot [data-testid='error-panel']"),
|
||||
).toBeVisible();
|
||||
await expect(frigateApp.page.getByTestId("error-strip")).toHaveCount(0);
|
||||
await expect(frigateApp.page.locator('a[href="/"]').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("Copy details puts a triageable report on the clipboard", async ({
|
||||
frigateApp,
|
||||
context,
|
||||
}) => {
|
||||
await grantClipboardPermissions(context);
|
||||
await breakExportsPage(frigateApp);
|
||||
|
||||
await frigateApp.page
|
||||
.getByTestId("error-panel")
|
||||
.getByRole("button", { name: "Copy details" })
|
||||
.click();
|
||||
|
||||
await expect
|
||||
.poll(() => readClipboard(frigateApp.page), { timeout: 5_000 })
|
||||
.toContain("Frigate UI crash report");
|
||||
|
||||
const report = await readClipboard(frigateApp.page);
|
||||
expect(report).toContain("Version: 0.15.0-test");
|
||||
expect(report).toMatch(/^Page: http/m);
|
||||
expect(report).toContain("is not a function");
|
||||
});
|
||||
|
||||
test("navigating away drops the panel", async ({ frigateApp }) => {
|
||||
await breakExportsPage(frigateApp);
|
||||
|
||||
await frigateApp.page.locator('a[href="/"]').first().click();
|
||||
await expect(frigateApp.page).toHaveURL(/\/$/);
|
||||
await expect(frigateApp.page.getByTestId("error-panel")).toHaveCount(0);
|
||||
await expect(
|
||||
frigateApp.page.locator("[data-camera='front_door']"),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("@mobile the panel leaves the bottom bar reachable", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(!frigateApp.isMobile, "Mobile-only assertion");
|
||||
await breakExportsPage(frigateApp);
|
||||
|
||||
await expect(
|
||||
frigateApp.page
|
||||
.getByTestId("error-panel")
|
||||
.getByRole("button", { name: "Reload" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
frigateApp.page.locator('a[href="/review"]').first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Error boundaries - chrome failure @high", () => {
|
||||
test.use({
|
||||
expectedErrors: [
|
||||
/is not a function|An error occurred in the|The above error occurred/,
|
||||
],
|
||||
});
|
||||
|
||||
test("a thrown status bar leaves the sidebar usable", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "The status bar is desktop chrome");
|
||||
await breakStatusbar(frigateApp);
|
||||
|
||||
const strip = frigateApp.page.getByTestId("error-strip");
|
||||
await expect(strip).toBeVisible({ timeout: 10_000 });
|
||||
await expect(strip).toHaveCount(1);
|
||||
await expect(strip.getByRole("button", { name: "Reload" })).toBeVisible();
|
||||
|
||||
// Each chrome component owns a boundary, so the sidebar outlives the
|
||||
// status bar and the user can still navigate out.
|
||||
await expect(frigateApp.page.locator("aside")).toBeVisible();
|
||||
await expect(
|
||||
frigateApp.page.locator('a[href="/review"]').first(),
|
||||
).toBeVisible();
|
||||
await expect(frigateApp.page.getByTestId("error-panel")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("chrome failures never reach the page content", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "Desktop chrome");
|
||||
await breakAllDesktopChrome(frigateApp);
|
||||
|
||||
await expect(
|
||||
frigateApp.page.getByTestId("error-strip").first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
await expect(frigateApp.page.getByTestId("error-panel")).toHaveCount(0);
|
||||
await expect(
|
||||
frigateApp.page.locator("[data-camera='front_door']"),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Error boundaries - clipboard refused @high", () => {
|
||||
test.use({
|
||||
expectedErrors: [
|
||||
/is not a function|An error occurred in the|The above error occurred/,
|
||||
],
|
||||
});
|
||||
|
||||
test("a refused clipboard write reports the failure", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
// copy-to-clipboard treats a false return from execCommand as a failure
|
||||
// and falls back to a prompt, which Playwright dismisses on its own.
|
||||
await frigateApp.page.addInitScript(() => {
|
||||
document.execCommand = () => false;
|
||||
});
|
||||
await breakExportsPage(frigateApp);
|
||||
|
||||
await frigateApp.page
|
||||
.getByTestId("error-panel")
|
||||
.getByRole("button", { name: "Copy details" })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
frigateApp.page.getByText("Could not copy details to clipboard"),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Error boundaries - stale assets @high", () => {
|
||||
test.use({
|
||||
expectedErrors: [
|
||||
/Failed to fetch dynamically imported module|Importing a module script failed|net::ERR_FAILED|An error occurred in the|The above error occurred/,
|
||||
],
|
||||
});
|
||||
|
||||
test("a missing page chunk asks for a reload", async ({ frigateApp }) => {
|
||||
await frigateApp.page.route(
|
||||
/\/assets\/Exports-[^/]+\.js(\?.*)?$/,
|
||||
(route) => route.abort("failed"),
|
||||
);
|
||||
await frigateApp.goto("/");
|
||||
await frigateApp.page.locator('a[href="/export"]').first().click();
|
||||
|
||||
const panel = frigateApp.page.getByTestId("error-panel");
|
||||
await expect(panel).toBeVisible({ timeout: 10_000 });
|
||||
await expect(panel.getByText("Reload required")).toBeVisible();
|
||||
await expect(panel.getByRole("button", { name: "Reload" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -263,71 +263,3 @@ test.describe("Explore — mobile @high @mobile", () => {
|
||||
await expect(searchInput).toBeFocused();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frigate+ submission — desktop only
|
||||
// The detail dialog's previous/next arrows only render on desktop.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Explore — Frigate+ submission (desktop) @high", () => {
|
||||
test.skip(
|
||||
({ frigateApp }) => frigateApp.isMobile,
|
||||
"Detail dialog navigation arrows are desktop-only",
|
||||
);
|
||||
|
||||
test("in-flight submission does not mark the next tracked object as submitted", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({ config: { plus: { enabled: true } } });
|
||||
const page = frigateApp.page;
|
||||
|
||||
// Hold the submission open so it is still in flight while the user moves
|
||||
// on to the next tracked object.
|
||||
let releaseSubmission: () => void = () => {};
|
||||
const submissionHeld = new Promise<void>((resolve) => {
|
||||
releaseSubmission = resolve;
|
||||
});
|
||||
let submissions = 0;
|
||||
await page.route("**/api/events/*/plus", async (route) => {
|
||||
submissions += 1;
|
||||
await submissionHeld;
|
||||
await route.fulfill({ json: { success: true } });
|
||||
});
|
||||
|
||||
await frigateApp.goto("/explore?labels=person");
|
||||
|
||||
const firstResult = page.locator("[data-start]").first();
|
||||
await expect(firstResult).toBeVisible({ timeout: 10_000 });
|
||||
await firstResult.click();
|
||||
|
||||
// The label being confirmed is rendered in a <code> tag inside the
|
||||
// "Is this object a <label>?" question.
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog.locator("code")).toHaveText("person");
|
||||
|
||||
await dialog.getByRole("button", { name: "Yes", exact: true }).click();
|
||||
await expect.poll(() => submissions, { timeout: 5_000 }).toBe(1);
|
||||
|
||||
await page.getByRole("button", { name: "Next tracked object" }).click();
|
||||
await expect(dialog.locator("code")).toHaveText("car");
|
||||
|
||||
const submissionLanded = page.waitForResponse(/\/api\/events\/.*\/plus/);
|
||||
releaseSubmission();
|
||||
await submissionLanded;
|
||||
// two frames is enough for React to flush the response handler's state
|
||||
// updates, so the assertions below can't pass by racing ahead of them
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
||||
),
|
||||
);
|
||||
|
||||
// The car was never submitted, so its question must be untouched.
|
||||
expect(submissions).toBe(1);
|
||||
await expect(dialog.getByText("Submitted")).toHaveCount(0);
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: "Yes", exact: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
/**
|
||||
* Debug Replay range selection from History -- HIGH tier.
|
||||
*
|
||||
* Covers the "Select from Timeline" flow that Debug Replay shares with
|
||||
* Export. The other half of the same report, a loading spinner latched
|
||||
* after Cancel, needs real media: the vod mock serves an empty playlist.
|
||||
*/
|
||||
|
||||
import { test, expect, type FrigateApp } from "../fixtures/frigate-test";
|
||||
|
||||
// the selection is seeded around the playback position, so land near the
|
||||
// live edge
|
||||
const playbackTime = Math.floor(Date.now() / 1000) - 300;
|
||||
|
||||
async function openRecordingView(frigateApp: FrigateApp) {
|
||||
// The recording view pulls these while the timeline renders; the preview
|
||||
// server 500s on them, which the error collector would flag.
|
||||
await frigateApp.page.route("**/api/*/recordings**", (route) =>
|
||||
route.fulfill({ json: [] }),
|
||||
);
|
||||
await frigateApp.page.route("**/api/recordings/unavailable**", (route) =>
|
||||
route.fulfill({ json: [] }),
|
||||
);
|
||||
// inert here; the 0.19 recording view fetches coverage and needs an
|
||||
// object, so this has to follow the broad recordings route to win
|
||||
await frigateApp.page.route("**/api/*/recordings/coverage**", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
spans: [
|
||||
{
|
||||
start_time: playbackTime - 3600,
|
||||
end_time: playbackTime + 600,
|
||||
streams: ["main"],
|
||||
},
|
||||
],
|
||||
codecs_compatible: true,
|
||||
streams: {
|
||||
main: {
|
||||
video_codec: "h264",
|
||||
audio_rate: null,
|
||||
audio_codec: null,
|
||||
has_audio: false,
|
||||
bitrate: 2_000_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await frigateApp.goto(`/review?timestamp=front_door_${playbackTime}`);
|
||||
}
|
||||
|
||||
// desktop reaches Debug Replay through the Actions menu, mobile through
|
||||
// the settings drawer; both render the same form
|
||||
async function openDebugReplayForm(frigateApp: FrigateApp) {
|
||||
if (frigateApp.isMobile) {
|
||||
await frigateApp.page
|
||||
.getByRole("button", { name: /filters/i })
|
||||
.first()
|
||||
.click({ timeout: 15_000 });
|
||||
await frigateApp.page
|
||||
.getByRole("button", { name: /^debug replay$/i })
|
||||
.click();
|
||||
} else {
|
||||
await frigateApp.page
|
||||
.getByRole("button", { name: /actions/i })
|
||||
.click({ timeout: 15_000 });
|
||||
await frigateApp.page
|
||||
.getByRole("menuitem", { name: /debug replay/i })
|
||||
.click();
|
||||
}
|
||||
|
||||
const form = frigateApp.page.getByRole("dialog");
|
||||
await expect(form).toBeVisible({ timeout: 5_000 });
|
||||
return form;
|
||||
}
|
||||
|
||||
async function selectRangeFromTimeline(frigateApp: FrigateApp) {
|
||||
const form = await openDebugReplayForm(frigateApp);
|
||||
await form.getByText("From Timeline").click();
|
||||
await form.getByRole("button", { name: "Select", exact: true }).click();
|
||||
await expect(form).toBeHidden({ timeout: 5_000 });
|
||||
|
||||
await expect(frigateApp.page.locator(".export-start")).toHaveText(
|
||||
/\d{1,2}:\d{2}/,
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
}
|
||||
|
||||
// moving the playhead between the two selections is what makes the second
|
||||
// range differ from the first
|
||||
async function reselectRange(frigateApp: FrigateApp) {
|
||||
await selectRangeFromTimeline(frigateApp);
|
||||
|
||||
await frigateApp.page
|
||||
.getByRole("button", { name: /^cancel$/i })
|
||||
.click({ timeout: 5_000 });
|
||||
await expect(frigateApp.page.locator(".export-start")).toHaveCount(0);
|
||||
|
||||
const segments = frigateApp.page.locator(".segment[data-segment-id]");
|
||||
const count = await segments.count();
|
||||
await segments.nth(Math.min(20, count - 1)).click({ force: true });
|
||||
|
||||
await selectRangeFromTimeline(frigateApp);
|
||||
}
|
||||
|
||||
// the loop flipped this label between the two ranges ~25 times a second
|
||||
async function countHandleLabelChanges(frigateApp: FrigateApp) {
|
||||
return frigateApp.page.evaluate(async () => {
|
||||
const handle = document.querySelector(".export-start");
|
||||
if (!handle) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
let changes = 0;
|
||||
let last = handle.textContent;
|
||||
const observer = new MutationObserver(() => {
|
||||
if (handle.textContent !== last) {
|
||||
changes += 1;
|
||||
last = handle.textContent;
|
||||
}
|
||||
});
|
||||
observer.observe(handle, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
characterData: true,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
observer.disconnect();
|
||||
return changes;
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Debug Replay from History @high", () => {
|
||||
test("a reselected range lands once and stays put", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
if (frigateApp.isMobile) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
const pageErrors: string[] = [];
|
||||
frigateApp.page.on("pageerror", (err) => pageErrors.push(err.message));
|
||||
|
||||
await frigateApp.installDefaults();
|
||||
await openRecordingView(frigateApp);
|
||||
await reselectRange(frigateApp);
|
||||
|
||||
expect(await countHandleLabelChanges(frigateApp)).toBe(0);
|
||||
expect(
|
||||
pageErrors.filter((message) => /Maximum update depth/i.test(message)),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("dragging a handle after a reselect moves it", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
if (frigateApp.isMobile) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
await frigateApp.installDefaults();
|
||||
await openRecordingView(frigateApp);
|
||||
await reselectRange(frigateApp);
|
||||
|
||||
const start = frigateApp.page.locator(".export-start");
|
||||
const before = (await start.textContent()) ?? "";
|
||||
const box = await start.boundingBox();
|
||||
if (!box) {
|
||||
throw new Error("export start handle has no bounding box");
|
||||
}
|
||||
|
||||
const x = box.x + box.width / 2;
|
||||
const y = box.y + box.height / 2;
|
||||
await frigateApp.page.mouse.move(x, y);
|
||||
await frigateApp.page.mouse.down();
|
||||
await frigateApp.page.mouse.move(x, y - 90, { steps: 12 });
|
||||
await frigateApp.page.mouse.up();
|
||||
|
||||
await expect(start).not.toHaveText(before, { timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Debug Replay from History — mobile @high @mobile", () => {
|
||||
test("a reselected range lands once and stays put", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
if (!frigateApp.isMobile) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
const pageErrors: string[] = [];
|
||||
frigateApp.page.on("pageerror", (err) => pageErrors.push(err.message));
|
||||
|
||||
await frigateApp.installDefaults();
|
||||
await openRecordingView(frigateApp);
|
||||
await reselectRange(frigateApp);
|
||||
|
||||
expect(await countHandleLabelChanges(frigateApp)).toBe(0);
|
||||
expect(
|
||||
pageErrors.filter((message) => /Maximum update depth/i.test(message)),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -5,35 +5,34 @@
|
||||
*/
|
||||
|
||||
import { test, expect } from "../fixtures/frigate-test";
|
||||
import { viewerProfile } from "../fixtures/mock-data/profile";
|
||||
|
||||
const NOW = Math.floor(Date.now() / 1000);
|
||||
|
||||
// the fixture detector runs at 75.5 ms, above the live warning threshold
|
||||
const QUIET_STATS = { detectors: { cpu: { inference_speed: 10 } } };
|
||||
|
||||
const ERROR_NOTICE = {
|
||||
id: "model_download_failed:yolo/model.onnx",
|
||||
kind: "model_download_failed",
|
||||
const STATE_NOTICE = {
|
||||
id: "ffmpeg_crash_loop:front_door",
|
||||
kind: "ffmpeg_crash_loop",
|
||||
mode: "state",
|
||||
severity: "error",
|
||||
category: "model",
|
||||
scope: "yolo/model.onnx",
|
||||
params: { file: "model.onnx", model: "yolo", error: "timeout" },
|
||||
link: null,
|
||||
category: "camera",
|
||||
scope: "front_door",
|
||||
params: { restarts: 6 },
|
||||
first_seen: NOW - 600,
|
||||
last_seen: NOW,
|
||||
count: 2,
|
||||
count: 1,
|
||||
dismissed_at: null,
|
||||
};
|
||||
|
||||
const EVENT_NOTICE = {
|
||||
id: "detector_stuck",
|
||||
kind: "detector_stuck",
|
||||
mode: "event",
|
||||
severity: "warning",
|
||||
category: "detector",
|
||||
scope: null,
|
||||
params: { detector: "ov" },
|
||||
link: "/system#general",
|
||||
first_seen: NOW - 7200,
|
||||
last_seen: NOW - 60,
|
||||
count: 3,
|
||||
@@ -46,7 +45,16 @@ test.describe("System — Health tab @medium", () => {
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
stats: QUIET_STATS,
|
||||
notices: [ERROR_NOTICE, EVENT_NOTICE],
|
||||
notices: [STATE_NOTICE, EVENT_NOTICE],
|
||||
noticeStats: [
|
||||
{
|
||||
kind: "ffmpeg_crash_loop",
|
||||
occurrences: 14,
|
||||
dismissals: 0,
|
||||
first_seen: NOW - 86400 * 20,
|
||||
last_seen: NOW,
|
||||
},
|
||||
],
|
||||
});
|
||||
await frigateApp.goto("/system");
|
||||
|
||||
@@ -59,13 +67,11 @@ test.describe("System — Health tab @medium", () => {
|
||||
const rows = frigateApp.page.locator("[data-testid^='health-problem-']");
|
||||
await expect(rows).toHaveCount(2);
|
||||
await expect(rows.nth(0)).toHaveAttribute("data-severity", "error");
|
||||
await expect(rows.nth(0)).toContainText(
|
||||
"Downloading model.onnx for yolo failed: timeout",
|
||||
);
|
||||
await expect(rows.nth(0)).toContainText("2 times");
|
||||
await expect(rows.nth(0)).toContainText("ffmpeg has crashed 6 times");
|
||||
await expect(rows.nth(0)).toContainText("14 times since");
|
||||
await expect(
|
||||
rows.nth(0).getByRole("button", { name: "Dismiss" }),
|
||||
).toBeVisible();
|
||||
).toHaveCount(0);
|
||||
await expect(rows.nth(1)).toContainText("Detector ov was restarted");
|
||||
await expect(rows.nth(1)).toContainText("3 times");
|
||||
await expect(
|
||||
@@ -98,10 +104,7 @@ test.describe("System — Health tab @medium", () => {
|
||||
req.url().includes("/api/notices/detector_stuck/dismiss") &&
|
||||
req.method() === "POST",
|
||||
);
|
||||
await frigateApp.page
|
||||
.getByTestId("health-problem-notice:detector_stuck")
|
||||
.getByRole("button", { name: "Dismiss" })
|
||||
.click();
|
||||
await frigateApp.page.getByRole("button", { name: "Dismiss" }).click();
|
||||
await request;
|
||||
|
||||
await expect(
|
||||
@@ -130,13 +133,13 @@ test.describe("System — Health tab @medium", () => {
|
||||
stats: QUIET_STATS,
|
||||
notices: [
|
||||
{
|
||||
id: "update_available:0.19.0",
|
||||
id: "update_available",
|
||||
kind: "update_available",
|
||||
mode: "state",
|
||||
severity: "info",
|
||||
category: "system",
|
||||
scope: "0.19.0",
|
||||
scope: null,
|
||||
params: { version: "0.19.0" },
|
||||
link: "https://github.com/blakeblackshear/frigate/releases/tag/v0.19.0",
|
||||
first_seen: NOW - 3600,
|
||||
last_seen: NOW,
|
||||
count: 1,
|
||||
@@ -147,7 +150,7 @@ test.describe("System — Health tab @medium", () => {
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
const row = frigateApp.page.getByTestId(
|
||||
"health-problem-notice:update_available:0.19.0",
|
||||
"health-problem-notice:update_available",
|
||||
);
|
||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||
await expect(row).toHaveAttribute("data-severity", "info");
|
||||
@@ -156,229 +159,7 @@ test.describe("System — Health tab @medium", () => {
|
||||
"href",
|
||||
"https://github.com/blakeblackshear/frigate/releases/tag/v0.19.0",
|
||||
);
|
||||
await expect(row.getByRole("button", { name: "Dismiss" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("the filter shows dismissed notices without a Dismiss button", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
stats: QUIET_STATS,
|
||||
notices: [EVENT_NOTICE],
|
||||
});
|
||||
await frigateApp.page.route(
|
||||
(url) =>
|
||||
url.pathname.endsWith("/api/notices") &&
|
||||
url.searchParams.get("include_dismissed") === "true",
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
json: [EVENT_NOTICE, { ...ERROR_NOTICE, dismissed_at: NOW - 120 }],
|
||||
}),
|
||||
);
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
await frigateApp.page.getByRole("button", { name: "Filter" }).click();
|
||||
const showDismissed = frigateApp.page.getByRole("switch", {
|
||||
name: "Show dismissed",
|
||||
});
|
||||
await expect(showDismissed).toHaveAttribute("aria-checked", "false");
|
||||
await showDismissed.click();
|
||||
|
||||
const row = frigateApp.page.getByTestId(
|
||||
"health-problem-notice:model_download_failed:yolo/model.onnx",
|
||||
);
|
||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||
await expect(row).toContainText("Dismissed");
|
||||
await expect(row.getByRole("button", { name: "Dismiss" })).toHaveCount(0);
|
||||
|
||||
await showDismissed.click();
|
||||
await expect(row).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("clear dismissed deletes the dismissed rows after confirming", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
stats: QUIET_STATS,
|
||||
notices: [EVENT_NOTICE],
|
||||
});
|
||||
|
||||
// the history loses its dismissed row once the DELETE lands
|
||||
let cleared = false;
|
||||
await frigateApp.page.route(
|
||||
(url) =>
|
||||
url.pathname.endsWith("/api/notices") &&
|
||||
url.searchParams.get("include_dismissed") === "true",
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
json: cleared
|
||||
? [EVENT_NOTICE]
|
||||
: [EVENT_NOTICE, { ...ERROR_NOTICE, dismissed_at: NOW - 120 }],
|
||||
}),
|
||||
);
|
||||
await frigateApp.page.route("**/api/notices/dismissed", (route) => {
|
||||
cleared = true;
|
||||
return 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 dismissed" })
|
||||
.click();
|
||||
const row = frigateApp.page.getByTestId(
|
||||
"health-problem-notice:model_download_failed:yolo/model.onnx",
|
||||
);
|
||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||
await frigateApp.page.keyboard.press("Escape");
|
||||
|
||||
await frigateApp.page
|
||||
.getByRole("button", { name: "Clear dismissed" })
|
||||
.click();
|
||||
const request = frigateApp.page.waitForRequest(
|
||||
(req) =>
|
||||
req.url().endsWith("/api/notices/dismissed") &&
|
||||
req.method() === "DELETE",
|
||||
);
|
||||
await frigateApp.page
|
||||
.getByRole("alertdialog")
|
||||
.getByRole("button", { name: "Clear dismissed" })
|
||||
.click();
|
||||
await request;
|
||||
|
||||
await expect(row).toHaveCount(0);
|
||||
await expect(
|
||||
frigateApp.page.getByText("No dismissed notices"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("severity switches hide notices of that severity", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
stats: QUIET_STATS,
|
||||
notices: [ERROR_NOTICE, EVENT_NOTICE],
|
||||
});
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
const rows = frigateApp.page.locator("[data-testid^='health-problem-']");
|
||||
await expect(rows).toHaveCount(2, { timeout: 15_000 });
|
||||
|
||||
await frigateApp.page.getByRole("button", { name: "Filter" }).click();
|
||||
await frigateApp.page.getByRole("switch", { name: "Warning" }).click();
|
||||
await expect(rows).toHaveCount(1);
|
||||
await expect(rows.first()).toHaveAttribute("data-severity", "error");
|
||||
|
||||
await frigateApp.page.getByRole("switch", { name: "Error" }).click();
|
||||
await expect(rows).toHaveCount(0);
|
||||
await expect(
|
||||
frigateApp.page.getByText("No notices match the filter"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("failed login bursts are per user and show their attempt count", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const burst = (user: string, start: number, count: number) => ({
|
||||
id: `failed_login:${user}:${start}`,
|
||||
kind: "failed_login",
|
||||
severity: "warning",
|
||||
category: "system",
|
||||
scope: `${user}:${start}`,
|
||||
params: { user },
|
||||
link: "/logs",
|
||||
first_seen: start,
|
||||
last_seen: start + 60,
|
||||
count,
|
||||
dismissed_at: null,
|
||||
});
|
||||
await frigateApp.installDefaults({
|
||||
stats: QUIET_STATS,
|
||||
notices: [burst("admin", NOW - 60, 7), burst("ghost", NOW - 3600, 1)],
|
||||
});
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
const attack = frigateApp.page.getByTestId(
|
||||
`health-problem-notice:failed_login:admin:${NOW - 60}`,
|
||||
);
|
||||
await expect(attack).toBeVisible({ timeout: 15_000 });
|
||||
await expect(attack).toContainText("Failed login attempts for admin");
|
||||
await expect(attack).toContainText("7 times");
|
||||
await expect(attack).not.toContainText(String(NOW - 60));
|
||||
await expect(
|
||||
attack.getByRole("link", { name: "Open settings" }),
|
||||
).toHaveAttribute("href", "/logs");
|
||||
await expect(
|
||||
frigateApp.page.getByTestId(
|
||||
`health-problem-notice:failed_login:ghost:${NOW - 3600}`,
|
||||
),
|
||||
).toContainText("Failed login attempt for ghost");
|
||||
});
|
||||
|
||||
test("skipped detections notice links to camera stats", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
stats: QUIET_STATS,
|
||||
notices: [
|
||||
{
|
||||
id: "skipped_detections:front_door",
|
||||
kind: "skipped_detections",
|
||||
severity: "warning",
|
||||
category: "camera",
|
||||
scope: "front_door",
|
||||
params: { pct: 12.5 },
|
||||
link: "/system#cameras",
|
||||
first_seen: NOW - 600,
|
||||
last_seen: NOW - 600,
|
||||
count: 1,
|
||||
dismissed_at: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
const row = frigateApp.page.getByTestId(
|
||||
"health-problem-notice:skipped_detections:front_door",
|
||||
);
|
||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||
await expect(row).toContainText("skipped 12.5% of frames");
|
||||
await expect(
|
||||
row.getByRole("link", { name: "Open settings" }),
|
||||
).toHaveAttribute("href", "/system#cameras");
|
||||
});
|
||||
|
||||
test("shm notice links to storage metrics", async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults({
|
||||
stats: QUIET_STATS,
|
||||
notices: [
|
||||
{
|
||||
id: "shm_too_low",
|
||||
kind: "shm_too_low",
|
||||
severity: "warning",
|
||||
category: "system",
|
||||
scope: null,
|
||||
params: { total: 64, min: 180 },
|
||||
link: "/system#storage",
|
||||
first_seen: NOW - 600,
|
||||
last_seen: NOW - 600,
|
||||
count: 1,
|
||||
dismissed_at: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
const row = frigateApp.page.getByTestId(
|
||||
"health-problem-notice:shm_too_low",
|
||||
);
|
||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||
await expect(row).toContainText(
|
||||
"/dev/shm allocation (64 MB) should be increased to at least 180 MB",
|
||||
);
|
||||
await expect(
|
||||
row.getByRole("link", { name: "Open settings" }),
|
||||
).toHaveAttribute("href", "/system#storage");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -388,13 +169,13 @@ test.describe("System — Health tab mobile @medium @mobile", () => {
|
||||
test("notices render at mobile viewport", async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults({
|
||||
stats: QUIET_STATS,
|
||||
notices: [ERROR_NOTICE],
|
||||
notices: [STATE_NOTICE],
|
||||
});
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
await expect(
|
||||
frigateApp.page.getByTestId(
|
||||
"health-problem-notice:model_download_failed:yolo/model.onnx",
|
||||
"health-problem-notice:ffmpeg_crash_loop:front_door",
|
||||
),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
@@ -517,17 +298,14 @@ test.describe("System — Health hardware pane @medium", () => {
|
||||
).toHaveAttribute("data-state", "warning");
|
||||
});
|
||||
|
||||
test("slow face recognition on the CPU warns when an accelerator exists", async ({
|
||||
test("face recognition row reflects the runtime device", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
config: { face_recognition: { enabled: true } },
|
||||
stats: {
|
||||
...QUIET_STATS,
|
||||
embeddings: {
|
||||
face_recognition_speed: 800,
|
||||
devices: { face_recognition: "CPU" },
|
||||
},
|
||||
embeddings: { devices: { face_recognition: "CPU" } },
|
||||
},
|
||||
});
|
||||
await frigateApp.goto("/system#health");
|
||||
@@ -543,27 +321,6 @@ test.describe("System — Health hardware pane @medium", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("fast face recognition on the CPU is ok", async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults({
|
||||
config: { face_recognition: { enabled: true } },
|
||||
stats: {
|
||||
...QUIET_STATS,
|
||||
embeddings: {
|
||||
face_recognition_speed: 40,
|
||||
devices: { face_recognition: "CPU" },
|
||||
},
|
||||
},
|
||||
});
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
const row = frigateApp.page.getByTestId(
|
||||
"hardware-row-enrichment:face_recognition",
|
||||
);
|
||||
await expect(row).toHaveAttribute("data-state", "ok", { timeout: 15_000 });
|
||||
await expect(row).toContainText("CPU");
|
||||
await expect(row).not.toContainText("although an accelerator");
|
||||
});
|
||||
|
||||
test("explicit GPU that loaded on CUDA is ok despite the probe", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
@@ -658,24 +415,36 @@ test.describe("System — Health hardware pane @medium", () => {
|
||||
});
|
||||
|
||||
test.describe("System — Health notices sources @medium", () => {
|
||||
test("config checks render with links", async ({ frigateApp }) => {
|
||||
test("live offline camera and config checks render with links", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
config: {
|
||||
cameras: {
|
||||
garage: { detect: { width: 2560, height: 1440, fps: 10 } },
|
||||
},
|
||||
},
|
||||
stats: QUIET_STATS,
|
||||
stats: { ...QUIET_STATS, cameras: { front_door: { camera_fps: 0 } } },
|
||||
});
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
const rows = frigateApp.page.locator("[data-testid^='health-problem-']");
|
||||
await expect(rows.first()).toBeVisible({ timeout: 15_000 });
|
||||
await expect(rows.filter({ hasText: "Front Door is offline" })).toHaveCount(
|
||||
1,
|
||||
);
|
||||
await expect(
|
||||
frigateApp.page.getByText(
|
||||
"This detect resolution is higher than recommended",
|
||||
),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
rows
|
||||
.filter({ hasText: "Front Door is offline" })
|
||||
.getByRole("link", { name: "Open settings" }),
|
||||
).toHaveAttribute("href", "/logs");
|
||||
const fpsRow = frigateApp.page.getByTestId(
|
||||
"health-problem-config:detect:fps-greater-than-five:camera.garage",
|
||||
"health-problem-config:detect:fps-greater-than-five:garage",
|
||||
);
|
||||
await expect(fpsRow).toHaveAttribute("data-severity", "info");
|
||||
await expect(
|
||||
@@ -683,28 +452,6 @@ test.describe("System — Health notices sources @medium", () => {
|
||||
).toHaveAttribute("href", "/settings?page=cameraDetect&camera=garage");
|
||||
});
|
||||
|
||||
test("status bar problems stay out of the list", async ({ frigateApp }) => {
|
||||
test.skip(frigateApp.isMobile, "Status bar is desktop-only");
|
||||
await frigateApp.installDefaults({
|
||||
stats: {
|
||||
service: { retention_unmet: true },
|
||||
cameras: { front_door: { camera_fps: 0 } },
|
||||
},
|
||||
});
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
// the status bar shows a problem, so stats have loaded
|
||||
await expect(
|
||||
frigateApp.page.getByText("Front Door is offline"),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await expect(
|
||||
frigateApp.page.locator("[data-testid^='health-problem-']"),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
frigateApp.page.getByText("Your Frigate installation is healthy"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// the fixture's cameras all have a record role with recording off, so
|
||||
// dropping the role isolates the gate on record.enabled
|
||||
const NO_RECORD_ROLE = {
|
||||
@@ -806,11 +553,43 @@ test.describe("System — Health notices sources @medium", () => {
|
||||
await expect(rows).toHaveCount(2);
|
||||
await expect(
|
||||
frigateApp.page.getByTestId(
|
||||
"health-problem-config:detect:detect-resolution-high:camera.garage",
|
||||
"health-problem-config:detect:detect-resolution-high:garage",
|
||||
),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("registry rows sort ahead of live rows and keep Dismiss", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
notices: [
|
||||
{
|
||||
id: "detector_stuck:cpu",
|
||||
kind: "detector_stuck",
|
||||
mode: "event",
|
||||
severity: "warning",
|
||||
category: "detector",
|
||||
scope: "cpu",
|
||||
params: { detector: "cpu" },
|
||||
first_seen: NOW - 60,
|
||||
last_seen: NOW - 60,
|
||||
count: 1,
|
||||
dismissed_at: null,
|
||||
},
|
||||
],
|
||||
// the default fixture's slow cpu detector is the live warning here
|
||||
});
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
const rows = frigateApp.page.locator("[data-severity='warning']");
|
||||
await expect(rows.first()).toBeVisible({ timeout: 15_000 });
|
||||
await expect(rows.nth(0)).toContainText("Detector cpu was restarted");
|
||||
await expect(
|
||||
rows.nth(0).getByRole("button", { name: "Dismiss" }),
|
||||
).toBeVisible();
|
||||
await expect(rows.nth(1)).toContainText("Cpu is slow");
|
||||
});
|
||||
|
||||
test("empty state when stats, config, and registry are clean", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
@@ -1018,7 +797,7 @@ test.describe("System — Health notices sources @medium", () => {
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
const row = frigateApp.page.getByTestId(
|
||||
"health-problem-config:lpr:global-disabled:camera.garage",
|
||||
"health-problem-config:lpr:global-disabled:garage",
|
||||
);
|
||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||
await expect(
|
||||
@@ -1045,120 +824,4 @@ test.describe("System — Health notices sources @medium", () => {
|
||||
.click();
|
||||
await expect(frigateApp.page).toHaveURL(/\/system#health/);
|
||||
});
|
||||
|
||||
test("status bar counts undismissed notices next to the health text", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "Status bar is desktop-only");
|
||||
await frigateApp.installDefaults({
|
||||
stats: QUIET_STATS,
|
||||
notices: [EVENT_NOTICE],
|
||||
});
|
||||
await frigateApp.goto("/");
|
||||
|
||||
const notices = frigateApp.page.getByRole("link", {
|
||||
name: "1 system notice",
|
||||
});
|
||||
await expect(notices).toBeVisible({ timeout: 15_000 });
|
||||
await expect(notices).toHaveAttribute("href", "/system#health");
|
||||
await expect(
|
||||
frigateApp.page.getByRole("link", { name: "System is healthy" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("status bar shows viewers no problems or health text", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "Status bar is desktop-only");
|
||||
await frigateApp.installDefaults({
|
||||
profile: viewerProfile(),
|
||||
stats: {
|
||||
cpu_usages: { "frigate.full_system": { cpu: "12.0" } },
|
||||
cameras: { front_door: { camera_fps: 0 } },
|
||||
},
|
||||
});
|
||||
await frigateApp.goto("/");
|
||||
|
||||
// the CPU reading comes from the same stats as the offline problem
|
||||
await expect(frigateApp.page.getByText("CPU 12%")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(
|
||||
frigateApp.page.getByText("Front Door is offline"),
|
||||
).toHaveCount(0);
|
||||
await expect(frigateApp.page.getByText("System is healthy")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("a config row can be dismissed", async ({ frigateApp }) => {
|
||||
const id = "config:detect:fps-greater-than-five:camera.garage";
|
||||
await frigateApp.installDefaults({
|
||||
config: {
|
||||
cameras: {
|
||||
garage: { detect: { width: 2560, height: 1440, fps: 10 } },
|
||||
},
|
||||
},
|
||||
stats: QUIET_STATS,
|
||||
});
|
||||
|
||||
// the list gains the dismissal after the POST so the refetch hides the row
|
||||
let dismissed = false;
|
||||
await frigateApp.page.route(`**/api/notices/${id}/dismiss`, (route) => {
|
||||
dismissed = true;
|
||||
return route.fulfill({ json: { success: true } });
|
||||
});
|
||||
await frigateApp.page.route("**/api/notices/dismissed_checks", (route) =>
|
||||
route.fulfill({ json: dismissed ? [{ id, dismissed_at: NOW }] : [] }),
|
||||
);
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
const row = frigateApp.page.getByTestId(`health-problem-${id}`);
|
||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||
const request = frigateApp.page.waitForRequest(
|
||||
(req) =>
|
||||
req.url().includes(`/api/notices/${id}/dismiss`) &&
|
||||
req.method() === "POST",
|
||||
);
|
||||
await row.getByRole("button", { name: "Dismiss" }).click();
|
||||
await request;
|
||||
await expect(row).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("dismissed config rows move to the dismissed list", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const id = "config:detect:fps-greater-than-five:camera.garage";
|
||||
await frigateApp.installDefaults({
|
||||
config: {
|
||||
cameras: {
|
||||
garage: { detect: { width: 2560, height: 1440, fps: 10 } },
|
||||
},
|
||||
},
|
||||
stats: QUIET_STATS,
|
||||
dismissedChecks: [{ id, dismissed_at: NOW - 120 }],
|
||||
});
|
||||
await frigateApp.page.route(
|
||||
(url) =>
|
||||
url.pathname.endsWith("/api/notices") &&
|
||||
url.searchParams.get("include_dismissed") === "true",
|
||||
(route) => route.fulfill({ json: [] }),
|
||||
);
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
await expect(
|
||||
frigateApp.page.getByText(
|
||||
"This detect resolution is higher than recommended",
|
||||
),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
const row = frigateApp.page.getByTestId(`health-problem-${id}`);
|
||||
await expect(row).toHaveCount(0);
|
||||
|
||||
await frigateApp.page.getByRole("button", { name: "Filter" }).click();
|
||||
await frigateApp.page
|
||||
.getByRole("switch", { name: "Show dismissed" })
|
||||
.click();
|
||||
|
||||
await expect(row).toBeVisible();
|
||||
await expect(row).toContainText("Dismissed");
|
||||
await expect(row.getByRole("button", { name: "Dismiss" })).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
<link rel="mask-icon" href="/images/branding/favicon.svg" color="#3b82f7" />
|
||||
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#000000" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
<link rel="mask-icon" href="/images/branding/favicon.svg" color="#3b82f7" />
|
||||
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#000000" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+182
-211
@@ -35,11 +35,11 @@
|
||||
"@radix-ui/react-toggle-group": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@rjsf/core": "^6.4.1",
|
||||
"@rjsf/shadcn": "^6.5.2",
|
||||
"@rjsf/shadcn": "^6.4.1",
|
||||
"@rjsf/utils": "^6.4.1",
|
||||
"@rjsf/validator-ajv8": "^6.4.1",
|
||||
"apexcharts": "^3.52.0",
|
||||
"axios": "^1.18.0",
|
||||
"axios": "^1.13.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
@@ -49,10 +49,10 @@
|
||||
"framer-motion": "^12.38.0",
|
||||
"hls.js": "^1.6.15",
|
||||
"i18next": "^24.2.0",
|
||||
"i18next-http-backend": "^4.0.2",
|
||||
"i18next-http-backend": "^3.0.1",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"immer": "^10.1.1",
|
||||
"js-yaml": "^4.3.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
"konva": "^10.2.3",
|
||||
"lodash": "^4.18.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
@@ -68,10 +68,10 @@
|
||||
"react-grid-layout": "^2.2.2",
|
||||
"react-hook-form": "^7.72.0",
|
||||
"react-i18next": "^15.2.0",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-konva": "^19.2.3",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-router-dom": "^6.30.6",
|
||||
"react-router-dom": "^6.30.3",
|
||||
"react-swipeable": "^7.0.2",
|
||||
"react-zoom-pan-pinch": "3.4.4",
|
||||
"remark-gfm": "^4.0.0",
|
||||
@@ -109,7 +109,7 @@
|
||||
"eslint-plugin-prettier": "^5.0.1",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.8",
|
||||
"eslint-plugin-vitest-globals": "^1.6.1",
|
||||
"eslint-plugin-vitest-globals": "^1.5.0",
|
||||
"fake-indexeddb": "^6.0.0",
|
||||
"i18next-cli": "^1.5.11",
|
||||
"jest-websocket-mock": "^2.5.0",
|
||||
@@ -117,7 +117,7 @@
|
||||
"monaco-editor": "^0.52.2",
|
||||
"msw": "^2.3.5",
|
||||
"patch-package": "^8.0.1",
|
||||
"postcss": "^8.5.12",
|
||||
"postcss": "^8.5.8",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
||||
"tailwindcss": "^3.4.9",
|
||||
@@ -4878,22 +4878,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz",
|
||||
"integrity": "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==",
|
||||
"version": "1.23.2",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
|
||||
"integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rjsf/core": {
|
||||
"version": "6.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@rjsf/core/-/core-6.5.2.tgz",
|
||||
"integrity": "sha512-Fx+aVNQRYQyoY0vM8zYDZkuOiNe+5PLsxUySUdHfjljlT23mJnTCpPKMkxWJwh4UEWeSN0xjmknW9LfIwuQmOg==",
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@rjsf/core/-/core-6.4.1.tgz",
|
||||
"integrity": "sha512-+QaiSgQnOuO6ghIsohH2u/QcylkN+Da2968a75g/i4oARYJRYVxXDm2u3JR5aXndpMb4t4jTFrYyG8cNIv6oEg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.18.1",
|
||||
"lodash": "^4.17.23",
|
||||
"lodash-es": "^4.17.23",
|
||||
"markdown-to-jsx": "^8.0.0",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
@@ -4901,14 +4901,14 @@
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@rjsf/utils": "^6.5.x",
|
||||
"@rjsf/utils": "^6.4.x",
|
||||
"react": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@rjsf/shadcn": {
|
||||
"version": "6.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@rjsf/shadcn/-/shadcn-6.5.2.tgz",
|
||||
"integrity": "sha512-h3FsGRy07Gw4MrORlxNTuSb7icuyiUM0KXimg1jgtlYLhYvMKaHzJgdL/SrAyBWiUtXIsxuBhPU3rFxnA9Ml6Q==",
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@rjsf/shadcn/-/shadcn-6.4.1.tgz",
|
||||
"integrity": "sha512-WzwXW3XY7K1jo9XrBv6M41ScdHrnQDKpSxip5i1N6xCgEE6hiyX+wn7pDO689OoidvL3lWQmtnoqMdcoJvEWjw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
@@ -4925,19 +4925,19 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.18.1",
|
||||
"lodash": "^4.17.23",
|
||||
"lodash-es": "^4.17.23",
|
||||
"lucide-react": "^0.548.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uuid": "^14.0.0"
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@rjsf/core": "^6.5.x",
|
||||
"@rjsf/utils": "^6.5.x",
|
||||
"@rjsf/core": "^6.4.x",
|
||||
"@rjsf/utils": "^6.4.x",
|
||||
"react": ">=18"
|
||||
}
|
||||
},
|
||||
@@ -4951,9 +4951,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rjsf/shadcn/node_modules/tailwind-merge": {
|
||||
"version": "3.5.0",
|
||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
|
||||
"integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==",
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
|
||||
"integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -4961,17 +4961,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rjsf/utils": {
|
||||
"version": "6.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.5.2.tgz",
|
||||
"integrity": "sha512-qBVQ5qf9BKMOQy/DjMl/IjD5s6akRDx18cgiSYunKt/CYc+kPzHyCVA2TU0rXCsigNlhgnfM8piC3LEHye6vpA==",
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.4.1.tgz",
|
||||
"integrity": "sha512-5NL3jwt3rIS5/WRTrKt++y40FS/ScKGVwYJ3jIrHSQHSwBdLnd4cHf2zcnA97L1Klj8I6tvS/ugh+blf/Diwuw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@x0k/json-schema-merge": "^1.0.3",
|
||||
"fast-equals": "^6.0.0",
|
||||
"@x0k/json-schema-merge": "^1.0.2",
|
||||
"fast-uri": "^3.1.0",
|
||||
"jsonpointer": "^5.0.1",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.18.1",
|
||||
"lodash": "^4.17.23",
|
||||
"lodash-es": "^4.17.23",
|
||||
"react-is": "^18.3.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -4981,15 +4980,6 @@
|
||||
"react": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@rjsf/utils/node_modules/fast-equals": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-6.0.0.tgz",
|
||||
"integrity": "sha512-PFhhIGgdM79r5Uztdj9Zb6Tt1zKafqVfdMGwVca1z5z6fbX7DmsySSuJd8HiP6I1j505DCS83cLxo5rmSNeVEA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rjsf/validator-ajv8": {
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-6.4.1.tgz",
|
||||
@@ -6183,9 +6173,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@x0k/json-schema-merge": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@x0k/json-schema-merge/-/json-schema-merge-1.0.3.tgz",
|
||||
"integrity": "sha512-lerJC4sI9CNUQWdff3PnU1YJOqazD6TjMcvxZIPXUBjn4j1cUiXE0LvzhMnGYzKKr271TkvXJtH7gEwksrtn+w==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@x0k/json-schema-merge/-/json-schema-merge-1.0.2.tgz",
|
||||
"integrity": "sha512-1734qiJHNX3+cJGDMMw2yz7R+7kpbAtl5NdPs1c/0gO5kYT6s4dMbLXiIfpZNsOYhGZI3aH7FWrj4Zxz7epXNg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/json-schema": "^7.0.15"
|
||||
@@ -6468,40 +6458,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.18.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz",
|
||||
"integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==",
|
||||
"version": "1.13.6",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
||||
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.16.0",
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"https-proxy-agent": "^5.0.1",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axios/node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axios/node_modules/https-proxy-agent": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "6",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bail": {
|
||||
@@ -6519,19 +6483,6 @@
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.11.23",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.23.tgz",
|
||||
"integrity": "sha512-le521dGVfxM7yRX0EikCoSz+rOK+hHzdDt/E7mG1jOJB/6WAAUuwVroLwaB7ApaUsz5Q0kFlDXLSA9MheUIfRQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"baseline-browser-mapping": "dist/cli.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/big-integer": {
|
||||
"version": "1.6.52",
|
||||
"resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz",
|
||||
@@ -6571,21 +6522,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||
"license": "MIT",
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
|
||||
"integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
|
||||
"dependencies": {
|
||||
"fill-range": "^7.1.1"
|
||||
"fill-range": "^7.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.9",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz",
|
||||
"integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==",
|
||||
"version": "4.23.3",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.3.tgz",
|
||||
"integrity": "sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -6603,11 +6553,10 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.11.20",
|
||||
"caniuse-lite": "^1.0.30001810",
|
||||
"electron-to-chromium": "^1.5.420",
|
||||
"node-releases": "^2.0.54",
|
||||
"update-browserslist-db": "^1.3.2"
|
||||
"caniuse-lite": "^1.0.30001646",
|
||||
"electron-to-chromium": "^1.5.4",
|
||||
"node-releases": "^2.0.18",
|
||||
"update-browserslist-db": "^1.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"browserslist": "cli.js"
|
||||
@@ -6709,9 +6658,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001810",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
|
||||
"integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==",
|
||||
"version": "1.0.30001757",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz",
|
||||
"integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -7063,6 +7012,15 @@
|
||||
"toggle-selection": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-fetch": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz",
|
||||
"integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-fetch": "^2.6.12"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -7425,9 +7383,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.427",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.427.tgz",
|
||||
"integrity": "sha512-n14zb3FdsChZ2BNobqNHAJMcP3ifFv4paox2LvCrfVAQcqGiSURgbJl+PfMpHVCNFkStnNc+RRVtPBTVW5PDgw==",
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.5.tgz",
|
||||
"integrity": "sha512-QR7/A7ZkMS8tZuoftC/jfqNkZLQO779SSW3YuZHP4eXpj3EffGLFcB/Xu9AAZQzLccTiCV+EmUo3ha4mQ9wnlA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@@ -7543,9 +7501,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz",
|
||||
"integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -7735,18 +7693,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-vitest-globals": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-vitest-globals/-/eslint-plugin-vitest-globals-1.6.1.tgz",
|
||||
"integrity": "sha512-ydMvr96XRaT913a8gy8TY/LLyGB49Wmj0+x/FCbaWSpWMSaWDLG63/5zSLLrdSGXJ0PTjWpYZOZ61YBuzNyyTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/saqqdy"
|
||||
}
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-vitest-globals/-/eslint-plugin-vitest-globals-1.5.0.tgz",
|
||||
"integrity": "sha512-ZSsVOaOIig0oVLzRTyk8lUfBfqzWxr/J3/NFMfGGRIkGQPejJYmDH3gXmSJxAojts77uzAGB/UmVrwi2DC4LYA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/eslint-scope": {
|
||||
"version": "7.2.2",
|
||||
@@ -8025,9 +7975,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
|
||||
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -8090,10 +8040,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||
"license": "MIT",
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
|
||||
"integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
|
||||
"dependencies": {
|
||||
"to-regex-range": "^5.0.1"
|
||||
},
|
||||
@@ -8150,9 +8099,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -8187,16 +8136,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.4",
|
||||
"mime-types": "^2.1.35"
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -8543,9 +8492,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
@@ -8927,12 +8876,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next-http-backend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-4.0.2.tgz",
|
||||
"integrity": "sha512-oay62dIB2kL7+WHzoUXBjWfL3+mwijD3pQkQkIEaRLhy6kjXxUhrRenV0gInwlCASAaJaDy94+XvYizK+cAGdA==",
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.1.tgz",
|
||||
"integrity": "sha512-XT2lYSkbAtDE55c6m7CtKxxrsfuRQO3rUfHzj8ZyRtY9CkIX3aRGwXGTkUhpGWce+J8n7sfu3J0f2wTzo7Lw0A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"dependencies": {
|
||||
"cross-fetch": "4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next-resources-for-ts": {
|
||||
@@ -9223,7 +9172,6 @@
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
@@ -9459,19 +9407,9 @@
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
|
||||
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
],
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
@@ -10710,12 +10648,11 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/micromatch": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||
"license": "MIT",
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz",
|
||||
"integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==",
|
||||
"dependencies": {
|
||||
"braces": "^3.0.3",
|
||||
"braces": "^3.0.2",
|
||||
"picomatch": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -11007,9 +10944,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.19",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
|
||||
"integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -11040,16 +10977,55 @@
|
||||
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.55",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz",
|
||||
"integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==",
|
||||
"dev": true,
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch/node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-fetch/node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/node-fetch/node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.18",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz",
|
||||
"integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/normalize-path": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
@@ -11629,9 +11605,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.12",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
|
||||
"integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
|
||||
"version": "8.5.8",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -11946,13 +11922,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
|
||||
},
|
||||
"node_modules/psl": {
|
||||
"version": "1.9.0",
|
||||
@@ -12161,9 +12133,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-icons": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.6.0.tgz",
|
||||
"integrity": "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==",
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
|
||||
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "*"
|
||||
@@ -12310,12 +12282,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "6.30.6",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz",
|
||||
"integrity": "sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==",
|
||||
"version": "6.30.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
|
||||
"integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.4"
|
||||
"@remix-run/router": "1.23.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
@@ -12325,13 +12297,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "6.30.6",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.6.tgz",
|
||||
"integrity": "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ==",
|
||||
"version": "6.30.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
|
||||
"integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.4",
|
||||
"react-router": "6.30.6"
|
||||
"@remix-run/router": "1.23.2",
|
||||
"react-router": "6.30.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
@@ -13603,9 +13575,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tmp": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
|
||||
"integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==",
|
||||
"version": "0.2.5",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
|
||||
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -13616,7 +13588,6 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-number": "^7.0.0"
|
||||
},
|
||||
@@ -13888,9 +13859,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz",
|
||||
"integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==",
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz",
|
||||
"integrity": "sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -13908,8 +13879,8 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"escalade": "^3.2.0",
|
||||
"picocolors": "^1.1.1"
|
||||
"escalade": "^3.1.2",
|
||||
"picocolors": "^1.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"update-browserslist-db": "cli.js"
|
||||
@@ -14006,9 +13977,9 @@
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz",
|
||||
"integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==",
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
|
||||
+8
-8
@@ -49,11 +49,11 @@
|
||||
"@radix-ui/react-toggle-group": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@rjsf/core": "^6.4.1",
|
||||
"@rjsf/shadcn": "^6.5.2",
|
||||
"@rjsf/shadcn": "^6.4.1",
|
||||
"@rjsf/utils": "^6.4.1",
|
||||
"@rjsf/validator-ajv8": "^6.4.1",
|
||||
"apexcharts": "^3.52.0",
|
||||
"axios": "^1.18.0",
|
||||
"axios": "^1.13.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
@@ -63,10 +63,10 @@
|
||||
"framer-motion": "^12.38.0",
|
||||
"hls.js": "^1.6.15",
|
||||
"i18next": "^24.2.0",
|
||||
"i18next-http-backend": "^4.0.2",
|
||||
"i18next-http-backend": "^3.0.1",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"immer": "^10.1.1",
|
||||
"js-yaml": "^4.3.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
"konva": "^10.2.3",
|
||||
"lodash": "^4.18.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
@@ -82,10 +82,10 @@
|
||||
"react-grid-layout": "^2.2.2",
|
||||
"react-hook-form": "^7.72.0",
|
||||
"react-i18next": "^15.2.0",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-konva": "^19.2.3",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-router-dom": "^6.30.6",
|
||||
"react-router-dom": "^6.30.3",
|
||||
"react-swipeable": "^7.0.2",
|
||||
"react-zoom-pan-pinch": "3.4.4",
|
||||
"remark-gfm": "^4.0.0",
|
||||
@@ -123,7 +123,7 @@
|
||||
"eslint-plugin-prettier": "^5.0.1",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.8",
|
||||
"eslint-plugin-vitest-globals": "^1.6.1",
|
||||
"eslint-plugin-vitest-globals": "^1.5.0",
|
||||
"fake-indexeddb": "^6.0.0",
|
||||
"i18next-cli": "^1.5.11",
|
||||
"jest-websocket-mock": "^2.5.0",
|
||||
@@ -131,7 +131,7 @@
|
||||
"monaco-editor": "^0.52.2",
|
||||
"msw": "^2.3.5",
|
||||
"patch-package": "^8.0.1",
|
||||
"postcss": "^8.5.12",
|
||||
"postcss": "^8.5.8",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
||||
"tailwindcss": "^3.4.9",
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"submit": "تقديم"
|
||||
},
|
||||
"livePlayerRequiredIOSVersion": "مطلوب نظام iOS 17.1 أو أكبر لهذا النوع من البث المباشر.",
|
||||
"cameraDisabled": "الكاميرا معطلة",
|
||||
"stats": {
|
||||
"streamType": {
|
||||
"title": "نوع الدفق:",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"detection": "لا توجد عمليات كشف لمراجعتها",
|
||||
"motion": "لم يتم العثور على بيانات الحركة"
|
||||
},
|
||||
"timeline": "التسلسل الزمني",
|
||||
"timeline.aria": "اختر التسلسل الزمني",
|
||||
"events": {
|
||||
"label": "اﻷحداث",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"search": "بحث",
|
||||
"noExports": "لا يوجد تصديرات",
|
||||
"documentTitle": "التصدير - فرايجيت",
|
||||
"deleteExport": "حذف التصدير",
|
||||
"deleteExport.desc": "هل أنت متأكد من رغبتك في حذف{{exportName}}؟",
|
||||
"editExport": {
|
||||
"title": "إعادة تسمية التصدير",
|
||||
|
||||
@@ -5,9 +5,13 @@
|
||||
"placeholder": "أدخل أسم لهذه المجموعة"
|
||||
},
|
||||
"details": {
|
||||
"person": "شخص",
|
||||
"subLabelScore": "نتيجة العلامة الفرعية",
|
||||
"timestamp": "الطابع الزمني",
|
||||
"unknown": "غير معروف",
|
||||
"scoreInfo": "النتيجة الفرعية هي النتيجة المرجحة لجميع درجات الثقة المعترف بها للوجه، لذلك قد تختلف عن النتيجة الموضحة في اللقطة."
|
||||
"scoreInfo": "النتيجة الفرعية هي النتيجة المرجحة لجميع درجات الثقة المعترف بها للوجه، لذلك قد تختلف عن النتيجة الموضحة في اللقطة.",
|
||||
"face": "تفاصيل الوجه",
|
||||
"faceDesc": "تفاصيل الكائن المتتبع الذي أنشأ هذا الوجه"
|
||||
},
|
||||
"documentTitle": "مكتبة الوجوه - Frigate",
|
||||
"uploadFaceImage": {
|
||||
@@ -16,6 +20,8 @@
|
||||
},
|
||||
"collections": "المجموعات",
|
||||
"createFaceLibrary": {
|
||||
"title": "إنشاء المجاميع",
|
||||
"desc": "إنشاء مجموعة جديدة",
|
||||
"new": "إضافة وجه جديد",
|
||||
"nextSteps": "لبناء أساس قوي:<li>استخدم علامة التبويب \"التعرّفات الأخيرة\" لاختيار الصور والتدريب عليها لكل شخص تم اكتشافه.</li> <li>ركّز على الصور الأمامية المباشرة للحصول على أفضل النتائج؛ وتجنّب صور التدريب التي تُظهر الوجوه بزاوية.</li>"
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"documentTitle": "بث حي - فرايجيت",
|
||||
"documentTitle.withCamera": "{{camera}} - بث حي - فرايجيت",
|
||||
"lowBandwidthMode": "وضع موفر للبيانات",
|
||||
"twoWayTalk": {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"documentTitle": {
|
||||
"camera": "إعدادات الكاميرا - فرايجيت",
|
||||
"default": "الإعدادات - فرايجيت",
|
||||
"authentication": "إعدادات المصادقة - فرايجيت",
|
||||
"enrichments": "إحصاء الاعدادات",
|
||||
|
||||
@@ -86,7 +86,11 @@
|
||||
"gpuTemperature": "درجة حرارة الـ GPU",
|
||||
"npuUsage": "معلومات وحدة معالجة الشبكة",
|
||||
"npuMemory": "استخدام وحدة المعالجة العصبية",
|
||||
"npuTemperature": "درجة حرارة الـ NPU"
|
||||
"npuTemperature": "درجة حرارة الـ NPU",
|
||||
"intelGpuWarning": {
|
||||
"title": "تحذير إحصائيات معالج Intel الرسومي",
|
||||
"description": "هذا خطأ برمي معروف في أدوات تقارير إحصائيات معالجات Intel الرسومية (intel_gpu_top)، حيث تتوقف الأداة عن العمل وتُظهر استهلاك المعالج الرسومي (GPU) بنسبة 0% بشكل متكرر، حتى في الحالات التي يعمل فيها تسريع العتاد وكشف الكائنات بشكل صحيح على المعالج الرسومي المدمج (iGPU). هذا ليس خطأً في برنامج فرايجيت (Frigate). يمكنك إعادة تشغيل الجهاز المضيف لحل المشكلة مؤقتاً والتأكد من أن المعالج الرسومي يعمل بشكل صحيح. علماً بأن هذا الخلل لا يؤثر على الأداء."
|
||||
}
|
||||
},
|
||||
"title": "لمحة عامة",
|
||||
"detector": {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user