mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-28 19:06:52 +03:00
Compare commits
20
Commits
40f8ba1f7f
..
fixes
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b26ecc1fd | ||
|
|
a30f9b41fe | ||
|
|
dce06d5852 | ||
|
|
0928324840 | ||
|
|
386b3d31aa | ||
|
|
3f00c75bbd | ||
|
|
dd5ba8bd8e | ||
|
|
f2bfcebbc4 | ||
|
|
90426cfba8 | ||
|
|
3adf2cf48e | ||
|
|
225e014979 | ||
|
|
85a2731510 | ||
|
|
71d7741e12 | ||
|
|
7164f4b4a2 | ||
|
|
3f7e2c4686 | ||
|
|
6791df7971 | ||
|
|
9664d9ceae | ||
|
|
c959df32c9 | ||
|
|
c5889ef35f | ||
|
|
9443289112 |
@@ -27,7 +27,7 @@ pydantic == 2.10.*
|
||||
git+https://github.com/fbcotter/py3nvml#egg=py3nvml
|
||||
pytz == 2025.*
|
||||
pyzmq == 27.1.*
|
||||
ruamel.yaml == 0.18.*
|
||||
ruamel.yaml == 0.19.*
|
||||
tzlocal == 5.2
|
||||
requests == 2.33.*
|
||||
types-requests == 2.32.*
|
||||
@@ -43,7 +43,7 @@ opencv-contrib-python == 4.11.0.*
|
||||
scipy == 1.16.*
|
||||
# OpenVino & ONNX
|
||||
openvino == 2025.4.*
|
||||
onnxruntime == 1.22.*
|
||||
onnxruntime == 1.30.*
|
||||
# Embeddings
|
||||
transformers == 4.45.*
|
||||
# Generative AI
|
||||
@@ -58,7 +58,7 @@ pyclipper == 1.4.*
|
||||
shapely == 2.0.*
|
||||
rapidfuzz==3.12.*
|
||||
# HailoRT
|
||||
argcomplete==2.0.*
|
||||
argcomplete==3.7.*
|
||||
contextlib2==0.6.*
|
||||
future==0.18.*
|
||||
netaddr==1.3.*
|
||||
|
||||
@@ -17,17 +17,19 @@ Hardware acceleration arguments tell FFmpeg to decode your camera's video stream
|
||||
|
||||
See [the hardware acceleration docs](/configuration/hardware_acceleration_video.md) for details on setting up hardware acceleration for your GPU / iGPU, then select the preset that matches your hardware.
|
||||
|
||||
| Preset (YAML config) | UI Label | Usage | Notes |
|
||||
| --------------------- | ----------------------- | --------------------------------- | --------------------------------------------------------------- |
|
||||
| preset-rpi-64-h264 | Raspberry Pi (H.264) | 64-bit Raspberry Pi, H.264 stream | |
|
||||
| preset-rpi-64-h265 | Raspberry Pi (H.265) | 64-bit Raspberry Pi, H.265 stream | |
|
||||
| preset-vaapi | VAAPI (Intel/AMD GPU) | Intel or AMD GPU via VAAPI | Check the hwaccel docs to ensure the correct driver is selected |
|
||||
| preset-intel-qsv-h264 | Intel QuickSync (H.264) | Intel QuickSync, H.264 stream | If you have issues, use the VAAPI preset instead |
|
||||
| preset-intel-qsv-h265 | Intel QuickSync (H.265) | Intel QuickSync, H.265 stream | If you have issues, use the VAAPI preset instead |
|
||||
| preset-nvidia | NVIDIA GPU | NVIDIA GPU | |
|
||||
| preset-jetson-h264 | NVIDIA Jetson (H.264) | NVIDIA Jetson, H.264 stream | |
|
||||
| preset-jetson-h265 | NVIDIA Jetson (H.265) | NVIDIA Jetson, H.265 stream | |
|
||||
| preset-rkmpp | Rockchip RKMPP | Rockchip MPP | Use an image with the `-rk` suffix and run in privileged mode |
|
||||
| Preset (YAML config) | UI Label | Usage | Notes |
|
||||
| ------------------------- | ----------------------- | --------------------------------------------- | --------------------------------------------------------------- |
|
||||
| preset-rpi-64-h264 | Raspberry Pi (H.264) | 64-bit Raspberry Pi, H.264 stream | |
|
||||
| preset-rpi-64-h265 | Raspberry Pi (H.265) | 64-bit Raspberry Pi, H.265 stream | |
|
||||
| preset-apple-silicon-h264 | Apple Silicon (H.264) | Apple Silicon Mac under lighter, H.264 stream | Needs the `lighter.sh/video` device |
|
||||
| preset-apple-silicon-h265 | Apple Silicon (H.265) | Apple Silicon Mac under lighter, H.265 stream | Needs the `lighter.sh/video` device |
|
||||
| preset-vaapi | VAAPI (Intel/AMD GPU) | Intel or AMD GPU via VAAPI | Check the hwaccel docs to ensure the correct driver is selected |
|
||||
| preset-intel-qsv-h264 | Intel QuickSync (H.264) | Intel QuickSync, H.264 stream | If you have issues, use the VAAPI preset instead |
|
||||
| preset-intel-qsv-h265 | Intel QuickSync (H.265) | Intel QuickSync, H.265 stream | If you have issues, use the VAAPI preset instead |
|
||||
| preset-nvidia | NVIDIA GPU | NVIDIA GPU | |
|
||||
| preset-jetson-h264 | NVIDIA Jetson (H.264) | NVIDIA Jetson, H.264 stream | |
|
||||
| preset-jetson-h265 | NVIDIA Jetson (H.265) | NVIDIA Jetson, H.265 stream | |
|
||||
| preset-rkmpp | Rockchip RKMPP | Rockchip MPP | Use an image with the `-rk` suffix and run in privileged mode |
|
||||
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
@@ -43,6 +43,10 @@ Frigate supports presets for optimal hardware accelerated video decoding:
|
||||
|
||||
- [RKNN](#rockchip-platform): Frigate can utilize the media engine in RockChip SOCs to accelerate video decoding.
|
||||
|
||||
**Apple Silicon Mac** <CommunityBadge />
|
||||
|
||||
- [lighter](#apple-silicon-mac-lighter): Frigate can utilize the media engine in Apple Silicon Macs to accelerate video decoding, when running under the lighter container runtime.
|
||||
|
||||
**Other Hardware**
|
||||
|
||||
Depending on your system, these presets may not be compatible, and you may need to use manual hwaccel args to take advantage of your hardware. More information on hardware accelerated decoding for ffmpeg can be found here: https://trac.ffmpeg.org/wiki/HWAccelIntro
|
||||
@@ -533,3 +537,35 @@ output_args:
|
||||
Make sure that your SoC supports hardware acceleration for your input stream and your input stream is h264 encoding. For example, if your camera streams with h264 encoding, your SoC must be able to de- and encode with it. If you are unsure whether your SoC meets the requirements, take a look at the datasheet.
|
||||
|
||||
:::
|
||||
|
||||
## Apple Silicon Mac (lighter)
|
||||
|
||||
[lighter](https://github.com/fieldwork-ai/lighter) is an open-source container runtime for macOS. It gives a container the Mac's media engine as a standard V4L2 decoder, backed by VideoToolbox, so Frigate decodes H.264 and H.265 streams in hardware with the ffmpeg it already ships. It works on M1 and newer Macs with lighter 0.9.2 or newer.
|
||||
|
||||
Give the container the video device. With Docker Compose:
|
||||
|
||||
```yaml {4-5}
|
||||
services:
|
||||
frigate:
|
||||
...
|
||||
devices:
|
||||
- lighter.sh/video=all
|
||||
```
|
||||
|
||||
Or with `docker run`, add `--device lighter.sh/video=all`.
|
||||
|
||||
Then set the preset for the codec your cameras stream. The decoder is specific to the codec, so if your cameras mix H.264 and H.265, set the preset for the most common codec globally and override it on the other cameras:
|
||||
|
||||
```yaml
|
||||
ffmpeg:
|
||||
hwaccel_args: preset-apple-silicon-h264
|
||||
|
||||
cameras:
|
||||
garage: # an H.265 camera
|
||||
ffmpeg:
|
||||
hwaccel_args: preset-apple-silicon-h265
|
||||
```
|
||||
|
||||
The presets decode on the media engine and encode the Birdseye restream and timelapses there too. Scaling to the detect resolution runs on the CPU, as ffmpeg's V4L2 decoders cannot scale.
|
||||
|
||||
lighter can also run object detection on the Mac's Neural Engine; see [Apple Neural Engine (lighter)](object_detectors.md#apple-neural-engine-lighter).
|
||||
|
||||
@@ -34,6 +34,7 @@ Frigate supports multiple different detectors that work on different types of ha
|
||||
**Apple Silicon**
|
||||
|
||||
- [Apple Silicon](#apple-silicon-detector): Apple Silicon can run on M1 and newer Apple Silicon devices.
|
||||
- <CommunityBadge /> [ONNX](#apple-neural-engine-lighter): the ONNX detector runs on the Neural Engine of M1 and newer Macs when Frigate runs under the lighter container runtime.
|
||||
|
||||
**Intel**
|
||||
|
||||
@@ -484,7 +485,7 @@ See [ONNX supported models](#onnx) for supported models, there are some caveats:
|
||||
|
||||
## ONNX
|
||||
|
||||
ONNX is an open format for building machine learning models, Frigate supports running ONNX models on CPU, OpenVINO, ROCm, and TensorRT. On startup Frigate will automatically try to use a GPU if one is available.
|
||||
ONNX is an open format for building machine learning models, Frigate supports running ONNX models on CPU, OpenVINO, ROCm, TensorRT, and a Mac's Neural Engine. On startup Frigate will automatically try to use a GPU if one is available.
|
||||
|
||||
:::info
|
||||
|
||||
@@ -500,6 +501,9 @@ If the correct build is used for your GPU then the GPU will be detected and used
|
||||
- Nvidia GPUs will automatically be detected and used with the ONNX detector in the `-tensorrt` Frigate image.
|
||||
- Jetson devices will automatically be detected and used with the ONNX detector in the `-tensorrt-jp6` Frigate image.
|
||||
|
||||
- **Apple Silicon Mac** <CommunityBadge />
|
||||
- The Neural Engine will automatically be detected and used with the ONNX detector when Frigate runs under lighter with its Neural Engine device. See [Apple Neural Engine (lighter)](#apple-neural-engine-lighter).
|
||||
|
||||
:::
|
||||
|
||||
:::tip
|
||||
@@ -515,6 +519,22 @@ models:
|
||||
|
||||
:::
|
||||
|
||||
### Apple Neural Engine (lighter) {#apple-neural-engine-lighter}
|
||||
|
||||
[lighter](https://github.com/fieldwork-ai/lighter) is an open-source container runtime for macOS. A container started with its `lighter.sh/ane` device gets an ONNX Runtime execution provider that runs models on the Mac's Neural Engine, and the ONNX detector uses it automatically, with the same models and configuration as on any other hardware. It works on M1 and newer Macs with lighter 0.9.2 or newer.
|
||||
|
||||
Give the Frigate container the Neural Engine device. With Docker Compose:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
frigate:
|
||||
image: ghcr.io/blakeblackshear/frigate:stable-standard-arm64
|
||||
devices:
|
||||
- lighter.sh/ane=all
|
||||
```
|
||||
|
||||
Or with `docker run`, add `--device lighter.sh/ane=all`. Frigate then reports the Neural Engine under **Settings > System > Detection models**, and the ONNX detector's model loads on it. lighter can also decode camera streams on the Mac's media engine; see [Video Decoding](hardware_acceleration_video.md#apple-silicon-mac-lighter).
|
||||
|
||||
### Configuration {#configuration-onnx}
|
||||
|
||||
<ModelConfigDropdown detectorTitle="ONNX" models={objectDetectorsModels.onnx.models} />
|
||||
|
||||
@@ -78,6 +78,10 @@ Frigate supports multiple different detectors that work on different types of ha
|
||||
|
||||
**Apple Silicon**
|
||||
|
||||
- [ONNX via lighter](#apple-silicon): The ONNX detector runs on the Neural Engine of M1 and newer Macs when Frigate runs in the lighter container runtime
|
||||
- [Supports the same model architectures as the ONNX detector](../../configuration/object_detectors#apple-neural-engine-lighter)
|
||||
- Runs inside the Frigate container, with no separate detector process to set up
|
||||
- The recommended way to run Frigate on a Mac
|
||||
- [Apple Silicon](#apple-silicon): Apple Silicon is usable on all M1 and newer Apple Silicon devices to provide efficient and fast object detection
|
||||
- [Supports primarily ssdlite and mobilenet model architectures](../../configuration/object_detectors#apple-silicon-detector)
|
||||
- Runs well with any size models including large
|
||||
@@ -211,7 +215,13 @@ Inference is done with the `onnx` detector type. Speeds will vary greatly depend
|
||||
|
||||
### Apple Silicon
|
||||
|
||||
With the [Apple Silicon](../configuration/object_detectors.md#apple-silicon-detector) detector Frigate can take advantage of the NPU in M1 and newer Apple Silicon.
|
||||
Frigate on a Mac is best run in the [lighter](https://github.com/fieldwork-ai/lighter) container runtime, where the [ONNX detector](../configuration/object_detectors.md#apple-neural-engine-lighter) runs on the Neural Engine of M1 and newer Macs from inside the Frigate container. There is no separate detector process to install or keep running, and the same container can decode video on the Mac's media engine.
|
||||
|
||||
| Name | YOLOv9 Inference Time | YOLO-NAS Inference Time | RF-DETR Inference Time |
|
||||
| ---- | -------------------------------------- | ----------------------- | ---------------------- |
|
||||
| M1 | t-320: 3.3 ms s-320: 7 ms s-640: 13 ms | 320: 6.6 ms | Nano-320: 38 ms |
|
||||
|
||||
Alternatively, with the [Apple Silicon](../configuration/object_detectors.md#apple-silicon-detector) detector Frigate can take advantage of the NPU in M1 and newer Apple Silicon.
|
||||
|
||||
:::warning
|
||||
|
||||
|
||||
@@ -184,6 +184,6 @@ Filters and masks only hide the incorrect result - they don't teach Frigate what
|
||||
|
||||
### Where do I see problems Frigate has detected?
|
||||
|
||||
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 keeps a record of problems Frigate has found. Acknowledge an entry to hide it until the problem happens again, or mute it to hide it for good. Hidden entries stay listed under Show hidden in the filter. Ongoing conditions, such as an offline camera or recordings deleted before their retention period, appear in the status bar for admins until they clear, and the status bar links to the Notices list while it has entries showing. On mobile, tap the warning icon in the bottom navigation bar to see them.
|
||||
|
||||
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.
|
||||
|
||||
Generated
+4
-4
@@ -11375,15 +11375,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/image-size": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz",
|
||||
"integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==",
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.4.tgz",
|
||||
"integrity": "sha512-QRUkFFsRV/6fuESxb9Vkq+a0LkSrgKXuc2NEqfikiXxxN/G3tjWt5EVUlMaImRBZRZK/jRBEbYvpPYZL8t08Zw==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"image-size": "bin/image-size.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.x"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
|
||||
Vendored
+82
-16
@@ -4174,19 +4174,20 @@ paths:
|
||||
Get notices, most severe first.
|
||||
|
||||
Args:
|
||||
include_dismissed: Also return dismissed notices, for the history view
|
||||
include_hidden: Also return acknowledged and muted notices, for the
|
||||
hidden list
|
||||
|
||||
Returns:
|
||||
The notices
|
||||
operationId: get_notices_notices_get
|
||||
parameters:
|
||||
- name: include_dismissed
|
||||
- name: include_hidden
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
title: Include Dismissed
|
||||
title: Include Hidden
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
@@ -4221,16 +4222,16 @@ paths:
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/notices/dismissed_checks:
|
||||
/notices/muted_checks:
|
||||
get:
|
||||
tags:
|
||||
- Notices
|
||||
summary: Get Dismissed Checks
|
||||
summary: Get Muted Checks
|
||||
description: |-
|
||||
**Access:** Admin role required.
|
||||
|
||||
Get the dismissed config and stream check rows, newest first.
|
||||
operationId: get_dismissed_checks_notices_dismissed_checks_get
|
||||
Get the muted config and stream check rows, newest first.
|
||||
operationId: get_muted_checks_notices_muted_checks_get
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
@@ -4240,16 +4241,16 @@ paths:
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/notices/dismissed:
|
||||
/notices/hidden:
|
||||
delete:
|
||||
tags:
|
||||
- Notices
|
||||
summary: Purge Dismissed
|
||||
summary: Unhide All Notices
|
||||
description: |-
|
||||
**Access:** Admin role required.
|
||||
|
||||
Delete every dismissed notice and check row so each can show again.
|
||||
operationId: purge_dismissed_notices_dismissed_delete
|
||||
Show every acknowledged and muted notice and check row again.
|
||||
operationId: unhide_all_notices_notices_hidden_delete
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
@@ -4259,18 +4260,83 @@ paths:
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/notices/{notice_id}/dismiss:
|
||||
/notices/{notice_id}/acknowledge:
|
||||
post:
|
||||
tags:
|
||||
- Notices
|
||||
summary: Dismiss Notice
|
||||
summary: Acknowledge Notice
|
||||
description: |-
|
||||
**Access:** Admin role required.
|
||||
|
||||
Hide a notice or a config or stream check row.
|
||||
Hide a notice until it happens again.
|
||||
|
||||
It stays hidden if the same problem happens again.
|
||||
operationId: dismiss_notice_notices__notice_id__dismiss_post
|
||||
Config and stream check rows and the update notice never repeat, so they
|
||||
can only be muted.
|
||||
operationId: acknowledge_notice_notices__notice_id__acknowledge_post
|
||||
parameters:
|
||||
- name: notice_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
title: Notice Id
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'422':
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/notices/{notice_id}/mute:
|
||||
post:
|
||||
tags:
|
||||
- Notices
|
||||
summary: Mute Notice
|
||||
description: |-
|
||||
**Access:** Admin role required.
|
||||
|
||||
Hide a notice or a config or stream check row for good.
|
||||
operationId: mute_notice_notices__notice_id__mute_post
|
||||
parameters:
|
||||
- name: notice_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
title: Notice Id
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'422':
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/notices/{notice_id}/hidden:
|
||||
delete:
|
||||
tags:
|
||||
- Notices
|
||||
summary: Unhide Notice
|
||||
description: |-
|
||||
**Access:** Admin role required.
|
||||
|
||||
Show an acknowledged or muted notice or check row again.
|
||||
operationId: unhide_notice_notices__notice_id__hidden_delete
|
||||
parameters:
|
||||
- name: notice_id
|
||||
in: path
|
||||
|
||||
@@ -393,6 +393,11 @@ def config(request: Request):
|
||||
model_dict["non_logo_attributes"] = model.non_logo_attributes
|
||||
model_dict["labelmap"] = model.merged_labelmap
|
||||
|
||||
# report the configured reference rather than the resolved cache path,
|
||||
# so saving the config back doesn't lose the Frigate+ model
|
||||
if model.plus_id:
|
||||
model_dict["path"] = f"plus://{model.plus_id}"
|
||||
|
||||
if not config["plus"]["enabled"]:
|
||||
continue
|
||||
|
||||
@@ -429,6 +434,8 @@ def ffmpeg_presets():
|
||||
hwaccel_presets = [
|
||||
"preset-rpi-64-h264",
|
||||
"preset-rpi-64-h265",
|
||||
"preset-apple-silicon-h264",
|
||||
"preset-apple-silicon-h265",
|
||||
"preset-jetson-h264",
|
||||
"preset-jetson-h265",
|
||||
"preset-rkmpp",
|
||||
|
||||
@@ -51,6 +51,7 @@ def swap_runtime_config(app: FastAPI, config: FrigateConfig) -> None:
|
||||
|
||||
if app.stats_emitter is not None:
|
||||
app.stats_emitter.config = config
|
||||
app.stats_emitter.hardware_stats.set_config(config)
|
||||
|
||||
if app.dispatcher is not None:
|
||||
app.dispatcher.config = config
|
||||
|
||||
@@ -63,7 +63,6 @@ from frigate.util.recording_coverage import (
|
||||
null_audio_glitches,
|
||||
plan_clip,
|
||||
resolve_coverage,
|
||||
stream_has_audio,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -681,15 +680,10 @@ async def _vod_response(
|
||||
end_ts,
|
||||
force_discontinuity,
|
||||
)
|
||||
intervals = resolve_coverage(camera_name, start_ts, end_ts)
|
||||
|
||||
# rows contradicting their stream's audio composition are
|
||||
# truncated-shutdown glitches
|
||||
main_audio = stream_has_audio(intervals, main=True)
|
||||
sub_audio = stream_has_audio(intervals, main=False)
|
||||
|
||||
spans = build_spans(
|
||||
null_audio_glitches(intervals, main_audio, sub_audio),
|
||||
null_audio_glitches(resolve_coverage(camera_name, start_ts, end_ts)),
|
||||
stream_preference,
|
||||
)
|
||||
|
||||
|
||||
+50
-22
@@ -14,17 +14,18 @@ router = APIRouter(tags=[Tags.notices])
|
||||
|
||||
|
||||
@router.get("/notices", dependencies=[Depends(require_role(["admin"]))])
|
||||
def get_notices(request: Request, include_dismissed: bool = False) -> JSONResponse:
|
||||
def get_notices(request: Request, include_hidden: bool = False) -> JSONResponse:
|
||||
"""Get notices, most severe first.
|
||||
|
||||
Args:
|
||||
include_dismissed: Also return dismissed notices, for the history view
|
||||
include_hidden: Also return acknowledged and muted notices, for the
|
||||
hidden list
|
||||
|
||||
Returns:
|
||||
The notices
|
||||
"""
|
||||
return JSONResponse(
|
||||
content=request.app.notice_registry.active(include_dismissed=include_dismissed)
|
||||
content=request.app.notice_registry.active(include_hidden=include_hidden)
|
||||
)
|
||||
|
||||
|
||||
@@ -34,37 +35,64 @@ 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.get("/notices/muted_checks", dependencies=[Depends(require_role(["admin"]))])
|
||||
def get_muted_checks(request: Request) -> JSONResponse:
|
||||
"""Get the muted config and stream check rows, newest first."""
|
||||
return JSONResponse(content=request.app.notice_registry.muted_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"}
|
||||
)
|
||||
@router.delete("/notices/hidden", dependencies=[Depends(require_role(["admin"]))])
|
||||
def unhide_all_notices(request: Request) -> JSONResponse:
|
||||
"""Show every acknowledged and muted notice and check row again."""
|
||||
request.app.notice_registry.unhide_all()
|
||||
return JSONResponse(content={"success": True, "message": "Notices shown again"})
|
||||
|
||||
|
||||
# model notice ids contain a slash, so the id is a path parameter
|
||||
@router.post(
|
||||
"/notices/{notice_id:path}/dismiss",
|
||||
"/notices/{notice_id:path}/acknowledge",
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
)
|
||||
def dismiss_notice(request: Request, notice_id: str) -> JSONResponse:
|
||||
"""Hide a notice or a config or stream check row.
|
||||
def acknowledge_notice(request: Request, notice_id: str) -> JSONResponse:
|
||||
"""Hide a notice until it happens again.
|
||||
|
||||
It stays hidden if the same problem happens again.
|
||||
Config and stream check rows and the update notice never repeat, so they
|
||||
can only be muted.
|
||||
"""
|
||||
if not request.app.notice_registry.dismiss(notice_id):
|
||||
if not request.app.notice_registry.acknowledge(notice_id):
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Notice not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
return JSONResponse(content={"success": True, "message": "Notice dismissed"})
|
||||
return JSONResponse(content={"success": True, "message": "Notice acknowledged"})
|
||||
|
||||
|
||||
@router.post(
|
||||
"/notices/{notice_id:path}/mute",
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
)
|
||||
def mute_notice(request: Request, notice_id: str) -> JSONResponse:
|
||||
"""Hide a notice or a config or stream check row for good."""
|
||||
if not request.app.notice_registry.mute(notice_id):
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Notice not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
return JSONResponse(content={"success": True, "message": "Notice muted"})
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/notices/{notice_id:path}/hidden",
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
)
|
||||
def unhide_notice(request: Request, notice_id: str) -> JSONResponse:
|
||||
"""Show an acknowledged or muted notice or check row again."""
|
||||
if not request.app.notice_registry.unhide(notice_id):
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Notice not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
return JSONResponse(content={"success": True, "message": "Notice shown again"})
|
||||
|
||||
@@ -46,8 +46,42 @@ _PROVIDER_LABELS = {
|
||||
"MIGraphXExecutionProvider": "MIGraphX",
|
||||
"OpenVINOExecutionProvider": "OpenVINO",
|
||||
"CPUExecutionProvider": "CPU",
|
||||
"LighterANE": "Neural Engine",
|
||||
}
|
||||
|
||||
# lighter (https://github.com/fieldwork-ai/lighter) places an ONNX Runtime plugin
|
||||
# execution provider in a container started with --device lighter.sh/ane=all,
|
||||
# which runs models on a Mac's Neural Engine; LIGHTER_ANE_EP names where it is
|
||||
LIGHTER_ANE_EP_NAME = "LighterANE"
|
||||
LIGHTER_ANE_LIBRARY = "/usr/lib/lighter/liblighter_ane_ep.so"
|
||||
|
||||
|
||||
def get_lighter_ane_devices() -> list[Any]:
|
||||
"""Get the Neural Engine devices lighter's provider offers, registering it once.
|
||||
|
||||
Returns:
|
||||
The provider's ONNX Runtime devices, or an empty list without lighter's device
|
||||
"""
|
||||
library = os.environ.get("LIGHTER_ANE_EP", LIGHTER_ANE_LIBRARY)
|
||||
|
||||
if not os.path.exists(library):
|
||||
return []
|
||||
|
||||
devices = [d for d in ort.get_ep_devices() if d.ep_name == LIGHTER_ANE_EP_NAME]
|
||||
|
||||
if not devices:
|
||||
try:
|
||||
ort.register_execution_provider_library(LIGHTER_ANE_EP_NAME, library)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to load the Neural Engine provider from {library}: {e}"
|
||||
)
|
||||
return []
|
||||
|
||||
devices = [d for d in ort.get_ep_devices() if d.ep_name == LIGHTER_ANE_EP_NAME]
|
||||
|
||||
return devices
|
||||
|
||||
|
||||
def is_arm64_platform() -> bool:
|
||||
"""Check if we're running on an ARM platform."""
|
||||
@@ -689,6 +723,23 @@ def get_optimized_runner(
|
||||
if rknn_path:
|
||||
return _record_runner(model_path, model_type, RKNNModelRunner(rknn_path))
|
||||
|
||||
if device != "CPU" and (ane_devices := get_lighter_ane_devices()):
|
||||
sess_options = get_ort_session_options(model_type) or ort.SessionOptions()
|
||||
sess_options.add_provider_for_devices(ane_devices, {})
|
||||
|
||||
try:
|
||||
session = ort.InferenceSession(model_path, sess_options=sess_options)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to load {model_path} on the Neural Engine, using the default providers: {e}"
|
||||
)
|
||||
else:
|
||||
return _record_runner(
|
||||
model_path,
|
||||
model_type,
|
||||
ONNXModelRunner(session, model_type=model_type),
|
||||
)
|
||||
|
||||
providers, options = get_ort_providers(device == "CPU", device, **kwargs)
|
||||
|
||||
if providers[0] == "CPUExecutionProvider":
|
||||
|
||||
@@ -123,6 +123,7 @@ class ModelConfig(BaseModel):
|
||||
_all_attributes: list[str] = PrivateAttr()
|
||||
_all_attribute_logos: list[str] = PrivateAttr()
|
||||
_model_hash: str = PrivateAttr()
|
||||
_plus_id: str | None = PrivateAttr(default=None)
|
||||
|
||||
@property
|
||||
def merged_labelmap(self) -> dict[int, str]:
|
||||
@@ -148,6 +149,11 @@ class ModelConfig(BaseModel):
|
||||
def model_hash(self) -> str:
|
||||
return self._model_hash
|
||||
|
||||
@property
|
||||
def plus_id(self) -> str | None:
|
||||
"""The Frigate+ model id, once a plus:// path has been resolved."""
|
||||
return self._plus_id
|
||||
|
||||
def __init__(self, **config):
|
||||
super().__init__(**config)
|
||||
|
||||
@@ -178,6 +184,7 @@ class ModelConfig(BaseModel):
|
||||
os.makedirs(MODEL_CACHE_DIR, exist_ok=True)
|
||||
|
||||
model_id = self.path[7:]
|
||||
self._plus_id = model_id
|
||||
self.path = os.path.join(MODEL_CACHE_DIR, model_id)
|
||||
model_info_path = f"{self.path}.json"
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ SYS_ROOT = "/sys"
|
||||
DEV_ROOT = "/dev"
|
||||
PROC_ROOT = "/proc"
|
||||
ETC_ROOT = "/etc"
|
||||
LIB_ROOT = "/usr/lib"
|
||||
|
||||
# a Coral reports as Global Unichip until its firmware is loaded, then as Google
|
||||
CORAL_USB_IDS = {("1a6e", "089a"), ("18d1", "9302")}
|
||||
@@ -317,6 +318,19 @@ def detect_synaptics() -> DetectionHardware | None:
|
||||
return _hardware("synaptics", "synaptics", "Synaptics NPU", units)
|
||||
|
||||
|
||||
def detect_lighter_ane() -> DetectionHardware | None:
|
||||
"""Find a Mac's Neural Engine by the provider library lighter's device places."""
|
||||
library = os.environ.get(
|
||||
"LIGHTER_ANE_EP", f"{LIB_ROOT}/lighter/liblighter_ane_ep.so"
|
||||
)
|
||||
if not os.path.exists(library):
|
||||
return None
|
||||
|
||||
# runs through onnx, whose session picks lighter's provider when it is present
|
||||
units = [HardwareUnit(device="onnx", label="Neural Engine")]
|
||||
return _hardware("onnx:lighter", "onnx", "Apple Neural Engine", units)
|
||||
|
||||
|
||||
def detect_cpu() -> DetectionHardware:
|
||||
"""The CPU, which is always available."""
|
||||
units = [HardwareUnit(device="cpu", label="CPU")]
|
||||
@@ -338,6 +352,7 @@ PROBES = (
|
||||
detect_rockchip,
|
||||
detect_axengine,
|
||||
detect_synaptics,
|
||||
detect_lighter_ane,
|
||||
detect_cpu,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Literal
|
||||
from typing import Any, ClassVar, Literal
|
||||
|
||||
import numpy as np
|
||||
import zmq
|
||||
@@ -21,6 +21,7 @@ class ZmqDetectorConfig(BaseDetectorConfig):
|
||||
model_config = ConfigDict(
|
||||
title="ZMQ IPC",
|
||||
)
|
||||
device_spec_field: ClassVar[str] = "endpoint"
|
||||
|
||||
type: Literal[DETECTOR_KEY]
|
||||
endpoint: str = Field(
|
||||
|
||||
@@ -84,6 +84,8 @@ _user_agent_args = [
|
||||
PRESETS_HW_ACCEL_DECODE = {
|
||||
"preset-rpi-64-h264": "-c:v:1 h264_v4l2m2m",
|
||||
"preset-rpi-64-h265": "-c:v:1 hevc_v4l2m2m",
|
||||
"preset-apple-silicon-h264": "-c:v h264_v4l2m2m",
|
||||
"preset-apple-silicon-h265": "-c:v hevc_v4l2m2m",
|
||||
FFMPEG_HWACCEL_VAAPI: "-hwaccel_flags allow_profile_mismatch -hwaccel vaapi -hwaccel_device {3} -hwaccel_output_format vaapi",
|
||||
"preset-intel-qsv-h264": f"-hwaccel qsv -qsv_device {{3}} -hwaccel_output_format qsv -c:v h264_qsv{' -bsf:v dump_extra' if LIBAVFORMAT_VERSION_MAJOR >= 61 else ''}", # https://trac.ffmpeg.org/ticket/9766#comment:17
|
||||
"preset-intel-qsv-h265": f"-load_plugin hevc_hw -hwaccel qsv -qsv_device {{3}} -hwaccel_output_format qsv{' -bsf:v dump_extra' if LIBAVFORMAT_VERSION_MAJOR >= 61 else ''}", # https://trac.ffmpeg.org/ticket/9766#comment:17
|
||||
@@ -120,6 +122,9 @@ PRESETS_HW_ACCEL_DECODE["preset-rk-h265"] = PRESETS_HW_ACCEL_DECODE[
|
||||
PRESETS_HW_ACCEL_SCALE = {
|
||||
"preset-rpi-64-h264": "-r {0} -vf fps={0},scale={1}:{2}",
|
||||
"preset-rpi-64-h265": "-r {0} -vf fps={0},scale={1}:{2}",
|
||||
# ffmpeg's v4l2m2m decoders cannot scale, so frames are scaled on the CPU
|
||||
"preset-apple-silicon-h264": "-r {0} -vf fps={0},scale={1}:{2}",
|
||||
"preset-apple-silicon-h265": "-r {0} -vf fps={0},scale={1}:{2}",
|
||||
FFMPEG_HWACCEL_VAAPI: "-r {0} -vf fps={0},scale_vaapi=w={1}:h={2},hwdownload,format=nv12",
|
||||
"preset-intel-qsv-h264": "-r {0} -vf vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,fps={0},format=yuv420p",
|
||||
"preset-intel-qsv-h265": "-r {0} -vf vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,fps={0},format=yuv420p",
|
||||
@@ -150,6 +155,8 @@ PRESETS_HW_ACCEL_SCALE["preset-rk-h265"] = PRESETS_HW_ACCEL_SCALE[FFMPEG_HWACCEL
|
||||
PRESETS_HW_ACCEL_ENCODE_BIRDSEYE = {
|
||||
"preset-rpi-64-h264": "{0} -hide_banner {1} -c:v h264_v4l2m2m {2}",
|
||||
"preset-rpi-64-h265": "{0} -hide_banner {1} -c:v hevc_v4l2m2m {2}",
|
||||
"preset-apple-silicon-h264": "{0} -hide_banner {1} -c:v h264_v4l2m2m {2}",
|
||||
"preset-apple-silicon-h265": "{0} -hide_banner {1} -c:v h264_v4l2m2m {2}",
|
||||
# -vaapi_device is required in addition to -hwaccel_device: this is the only
|
||||
# birdseye preset that uses hwupload, and ffmpeg 8 initializes filters before
|
||||
# the decoder creates a device, so hwupload cannot see an -hwaccel_device one.
|
||||
@@ -184,6 +191,8 @@ PRESETS_HW_ACCEL_ENCODE_BIRDSEYE["preset-rk-h264"] = PRESETS_HW_ACCEL_ENCODE_BIR
|
||||
PRESETS_HW_ACCEL_ENCODE_TIMELAPSE = {
|
||||
"preset-rpi-64-h264": "{0} -hide_banner {1} -c:v h264_v4l2m2m -pix_fmt yuv420p {2}",
|
||||
"preset-rpi-64-h265": "{0} -hide_banner {1} -c:v hevc_v4l2m2m -pix_fmt yuv420p {2}",
|
||||
"preset-apple-silicon-h264": "{0} -hide_banner {1} -c:v h264_v4l2m2m -pix_fmt yuv420p {2}",
|
||||
"preset-apple-silicon-h265": "{0} -hide_banner {1} -c:v hevc_v4l2m2m -pix_fmt yuv420p {2}",
|
||||
FFMPEG_HWACCEL_VAAPI: "{0} -hide_banner -hwaccel vaapi -hwaccel_output_format vaapi -hwaccel_device {3} {1} -c:v h264_vaapi {2}",
|
||||
"preset-intel-qsv-h264": "{0} -hide_banner {1} -c:v h264_qsv -profile:v high -level:v 4.1 -async_depth:v 1 {2}",
|
||||
"preset-intel-qsv-h265": "{0} -hide_banner {1} -c:v hevc_qsv -profile:v main -level:v 4.1 -async_depth:v 1 {2}",
|
||||
|
||||
+8
-3
@@ -195,15 +195,20 @@ class Notice(Model):
|
||||
first_seen = DateTimeField()
|
||||
last_seen = DateTimeField()
|
||||
count = IntegerField(default=1)
|
||||
dismissed_at = DateTimeField(null=True)
|
||||
# hidden until the next occurrence
|
||||
acknowledged_at = DateTimeField(null=True)
|
||||
# hidden for good
|
||||
muted_at = DateTimeField(null=True)
|
||||
|
||||
|
||||
class NoticeStats(Model):
|
||||
kind = CharField(null=False, primary_key=True, max_length=50)
|
||||
occurrences = IntegerField(default=0)
|
||||
dismissals = IntegerField(default=0)
|
||||
acknowledgements = IntegerField(default=0)
|
||||
mutes = IntegerField(default=0)
|
||||
first_seen = DateTimeField()
|
||||
last_seen = DateTimeField()
|
||||
# watermarks for a future analytics reporter; unused until then
|
||||
reported_occurrences = IntegerField(default=0)
|
||||
reported_dismissals = IntegerField(default=0)
|
||||
reported_acknowledgements = IntegerField(default=0)
|
||||
reported_mutes = IntegerField(default=0)
|
||||
|
||||
+110
-43
@@ -5,7 +5,7 @@ import threading
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
from frigate.const import REPLAY_CAMERA_PREFIX
|
||||
from frigate.models import Notice, NoticeStats
|
||||
@@ -77,9 +77,8 @@ class NoticeRegistry:
|
||||
) -> 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.
|
||||
Another occurrence shows an acknowledged notice again. A muted notice
|
||||
stays hidden.
|
||||
"""
|
||||
definition = NOTICE_KINDS.get(kind)
|
||||
|
||||
@@ -112,7 +111,6 @@ class NoticeRegistry:
|
||||
first_seen=now,
|
||||
last_seen=now,
|
||||
count=1,
|
||||
dismissed_at=None,
|
||||
)
|
||||
self._bump_occurrences(kind, 1, now)
|
||||
|
||||
@@ -175,7 +173,7 @@ class NoticeRegistry:
|
||||
self._notify()
|
||||
|
||||
def resolve_camera(self, camera: str) -> None:
|
||||
"""Drop the notices and check dismissals of a camera being deleted."""
|
||||
"""Drop the notices and check mutes of a camera being deleted."""
|
||||
camera_kinds = [
|
||||
key
|
||||
for key, definition in NOTICE_KINDS.items()
|
||||
@@ -193,7 +191,7 @@ class NoticeRegistry:
|
||||
)
|
||||
|
||||
# a stream id names its camera first; a config id ends with camera.<name>
|
||||
for check in self.dismissed_checks():
|
||||
for check in self.muted_checks():
|
||||
check_id = check["id"]
|
||||
|
||||
if check_id.startswith(f"stream:{camera}:") or (
|
||||
@@ -205,26 +203,50 @@ class NoticeRegistry:
|
||||
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 acknowledge(self, row_id: str) -> bool:
|
||||
"""Hide a notice until it happens again.
|
||||
|
||||
def dismiss(self, row_id: str) -> bool:
|
||||
"""Hide a notice or check row for good. Returns False for an unknown id."""
|
||||
Returns False for an unknown id or a kind that never repeats, such as
|
||||
a check row or the update notice.
|
||||
"""
|
||||
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
|
||||
return False
|
||||
|
||||
definition = NOTICE_KINDS.get(existing.kind)
|
||||
|
||||
if definition is None or not definition.counts_repeats:
|
||||
return False
|
||||
|
||||
if existing.acknowledged_at is not None or existing.muted_at is not None:
|
||||
return True
|
||||
|
||||
Notice.update(acknowledged_at=datetime.now().timestamp()).where(
|
||||
Notice.id == row_id
|
||||
).execute()
|
||||
NoticeStats.update(acknowledgements=NoticeStats.acknowledgements + 1).where(
|
||||
NoticeStats.kind == existing.kind
|
||||
).execute()
|
||||
|
||||
self._notify()
|
||||
return True
|
||||
|
||||
def mute(self, row_id: str) -> bool:
|
||||
"""Hide a notice or check row for good. Returns False for an unknown id."""
|
||||
now = datetime.now().timestamp()
|
||||
|
||||
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 muted
|
||||
kind, _, scope = row_id.partition(":")
|
||||
|
||||
if kind not in CHECK_KINDS or not scope:
|
||||
return False
|
||||
|
||||
now = datetime.now().timestamp()
|
||||
Notice.create(
|
||||
id=row_id,
|
||||
kind=kind,
|
||||
@@ -233,40 +255,78 @@ class NoticeRegistry:
|
||||
first_seen=now,
|
||||
last_seen=now,
|
||||
count=1,
|
||||
dismissed_at=now,
|
||||
muted_at=now,
|
||||
)
|
||||
return True
|
||||
|
||||
if existing.dismissed_at is not None:
|
||||
if existing.muted_at is not None:
|
||||
return True
|
||||
|
||||
if existing.kind not in NOTICE_KINDS:
|
||||
return False
|
||||
|
||||
Notice.update(dismissed_at=datetime.now().timestamp()).where(
|
||||
Notice.update(acknowledged_at=None, muted_at=now).where(
|
||||
Notice.id == row_id
|
||||
).execute()
|
||||
NoticeStats.update(dismissals=NoticeStats.dismissals + 1).where(
|
||||
NoticeStats.update(mutes=NoticeStats.mutes + 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."""
|
||||
def unhide(self, row_id: str) -> bool:
|
||||
"""Show an acknowledged or muted row again. Returns False for an unknown id."""
|
||||
with self._lock:
|
||||
existing = Notice.get_or_none(Notice.id == row_id)
|
||||
|
||||
if existing is None:
|
||||
return False
|
||||
|
||||
if existing.kind in CHECK_KINDS:
|
||||
Notice.delete_by_id(row_id)
|
||||
return True
|
||||
|
||||
Notice.update(acknowledged_at=None, muted_at=None).where(
|
||||
Notice.id == row_id
|
||||
).execute()
|
||||
|
||||
self._notify()
|
||||
return True
|
||||
|
||||
def unhide_all(self) -> None:
|
||||
"""Show every acknowledged and muted row again."""
|
||||
with self._lock:
|
||||
for check in self.muted_checks():
|
||||
Notice.delete_by_id(check["id"])
|
||||
|
||||
shown = (
|
||||
Notice.update(acknowledged_at=None, muted_at=None)
|
||||
.where(
|
||||
Notice.acknowledged_at.is_null(False)
|
||||
| Notice.muted_at.is_null(False)
|
||||
)
|
||||
.execute()
|
||||
)
|
||||
|
||||
if shown:
|
||||
self._notify()
|
||||
|
||||
def muted_checks(self) -> list[dict[str, Any]]:
|
||||
"""Muted config and stream check rows, newest first."""
|
||||
rows = (
|
||||
Notice.select()
|
||||
.where(Notice.kind.in_(list(CHECK_KINDS)))
|
||||
.order_by(Notice.dismissed_at.desc())
|
||||
.order_by(Notice.muted_at.desc())
|
||||
)
|
||||
return [{"id": row.id, "dismissed_at": row.dismissed_at} for row in rows]
|
||||
return [{"id": row.id, "muted_at": row.muted_at} for row in rows]
|
||||
|
||||
def active(self, include_dismissed: bool = False) -> list[dict[str, Any]]:
|
||||
def active(self, include_hidden: 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
|
||||
include_hidden: Also return acknowledged and muted notices, for the
|
||||
hidden list
|
||||
"""
|
||||
rows = []
|
||||
|
||||
@@ -276,7 +336,9 @@ class NoticeRegistry:
|
||||
if definition is None:
|
||||
continue
|
||||
|
||||
if row.dismissed_at is not None and not include_dismissed:
|
||||
hidden = row.acknowledged_at is not None or row.muted_at is not None
|
||||
|
||||
if hidden and not include_hidden:
|
||||
continue
|
||||
|
||||
rows.append(
|
||||
@@ -291,7 +353,9 @@ class NoticeRegistry:
|
||||
"first_seen": row.first_seen,
|
||||
"last_seen": row.last_seen,
|
||||
"count": row.count,
|
||||
"dismissed_at": row.dismissed_at,
|
||||
"acknowledgeable": definition.counts_repeats,
|
||||
"acknowledged_at": row.acknowledged_at,
|
||||
"muted_at": row.muted_at,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -309,11 +373,13 @@ class NoticeRegistry:
|
||||
{
|
||||
"kind": row.kind,
|
||||
"occurrences": row.occurrences,
|
||||
"dismissals": row.dismissals,
|
||||
"acknowledgements": row.acknowledgements,
|
||||
"mutes": row.mutes,
|
||||
"first_seen": row.first_seen,
|
||||
"last_seen": row.last_seen,
|
||||
"reported_occurrences": row.reported_occurrences,
|
||||
"reported_dismissals": row.reported_dismissals,
|
||||
"reported_acknowledgements": row.reported_acknowledgements,
|
||||
"reported_mutes": row.reported_mutes,
|
||||
}
|
||||
for row in NoticeStats.select()
|
||||
if row.kind in NOTICE_KINDS
|
||||
@@ -327,18 +393,18 @@ class NoticeRegistry:
|
||||
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
|
||||
# called with the lock held; held repeats from before an acknowledgement
|
||||
# still count but leave the notice hidden
|
||||
still_acknowledged = row.acknowledged_at is not None and (
|
||||
last_seen <= cast(float, row.acknowledged_at)
|
||||
)
|
||||
|
||||
if reopen_at is not None and row.count < reopen_at <= row.count + count:
|
||||
fields["dismissed_at"] = None
|
||||
|
||||
Notice.update(**fields).where(Notice.id == row.id).execute()
|
||||
Notice.update(
|
||||
count=row.count + count,
|
||||
last_seen=last_seen,
|
||||
params=params,
|
||||
acknowledged_at=row.acknowledged_at if still_acknowledged else None,
|
||||
).where(Notice.id == row.id).execute()
|
||||
self._bump_occurrences(kind, count, last_seen)
|
||||
|
||||
def _prune(self, kind: str, keep: int) -> None:
|
||||
@@ -362,7 +428,8 @@ class NoticeRegistry:
|
||||
NoticeStats.create(
|
||||
kind=kind,
|
||||
occurrences=count,
|
||||
dismissals=0,
|
||||
acknowledgements=0,
|
||||
mutes=0,
|
||||
first_seen=now,
|
||||
last_seen=now,
|
||||
)
|
||||
|
||||
@@ -28,9 +28,9 @@ class NoticeKind:
|
||||
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
|
||||
counts_repeats: whether raising an existing notice counts another
|
||||
occurrence, which also shows an acknowledged notice again
|
||||
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
|
||||
reportable: whether a future analytics reporter may send this kind's counts
|
||||
"""
|
||||
@@ -41,7 +41,6 @@ class NoticeKind:
|
||||
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
|
||||
|
||||
@@ -67,6 +66,12 @@ _KINDS = (
|
||||
"camera",
|
||||
link="/system#cameras",
|
||||
),
|
||||
NoticeKind(
|
||||
"ffmpeg_high_cpu", NoticeSeverity.warning, "camera", link="/system#cameras"
|
||||
),
|
||||
NoticeKind(
|
||||
"detect_high_cpu", NoticeSeverity.warning, "camera", link="/system#cameras"
|
||||
),
|
||||
NoticeKind("shm_too_low", NoticeSeverity.warning, "system", link="/system#storage"),
|
||||
# one row per user per burst; the login log lines carry the address
|
||||
NoticeKind(
|
||||
@@ -75,10 +80,9 @@ _KINDS = (
|
||||
"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
|
||||
# one row per release, so muting it lasts until the next release
|
||||
NoticeKind(
|
||||
"update_available",
|
||||
NoticeSeverity.info,
|
||||
@@ -92,7 +96,7 @@ _KINDS = (
|
||||
NOTICE_KINDS: dict[str, NoticeKind] = {kind.key: kind for kind in _KINDS}
|
||||
|
||||
# the Health tab builds config and stream check rows in the browser, so a notice
|
||||
# row of these kinds only records a dismissal; its other fields are placeholders
|
||||
# row of these kinds only records a mute; its other fields are placeholders
|
||||
CHECK_KINDS = frozenset({"config", "stream"})
|
||||
|
||||
|
||||
|
||||
+90
-22
@@ -42,6 +42,8 @@ from frigate.util.ownership import chown_to_runtime
|
||||
from frigate.util.recording_coverage import (
|
||||
build_spans,
|
||||
known_video_codecs,
|
||||
null_audio_glitches,
|
||||
realized_timeline,
|
||||
resolve_coverage,
|
||||
stream_media_summary,
|
||||
)
|
||||
@@ -84,10 +86,20 @@ class StreamRun:
|
||||
|
||||
@dataclass
|
||||
class _ChapterWindow:
|
||||
"""A merged-timeline slice, shaped like the recording rows chapters read."""
|
||||
"""A merged-timeline slice, shaped like the recording rows chapters read.
|
||||
|
||||
lead_in is the output time the slice's vod clip plays before start_time,
|
||||
from snapping its first frame back to a keyframe.
|
||||
"""
|
||||
|
||||
start_time: float
|
||||
end_time: float
|
||||
lead_in: float = 0.0
|
||||
|
||||
|
||||
def _lead_in(recording: Any) -> float:
|
||||
"""Output seconds a chapter source plays before its first wall second."""
|
||||
return recording.lead_in if isinstance(recording, _ChapterWindow) else 0.0
|
||||
|
||||
|
||||
# Matches the setpts factor used in timelapse exports (e.g. setpts=0.04*PTS).
|
||||
@@ -376,15 +388,18 @@ class RecordingExporter(threading.Thread):
|
||||
def _resolve_coverage(self) -> tuple[list[list[Any]], set[str], bool]:
|
||||
"""Resolve the export range into the spans the VOD manifest will serve.
|
||||
|
||||
Delegates to the same coverage resolution the manifest builder
|
||||
uses, so what we plan around and what nginx-vod emits agree by
|
||||
construction. Returns the spans (each [row, start, end, is_main]),
|
||||
the known video codecs, and whether audio survives the range.
|
||||
Delegates to the same coverage resolution and glitch nulling the
|
||||
manifest builder uses, so what we plan around and what nginx-vod
|
||||
emits agree by construction. Returns the spans (each [row, start,
|
||||
end, is_main]), the known video codecs, and whether audio survives
|
||||
the range.
|
||||
Memoized: several stages of the export ask the same question, and
|
||||
the recordings backing a finished range do not change under us.
|
||||
"""
|
||||
if self._coverage is None:
|
||||
intervals = resolve_coverage(self.camera, self.start_time, self.end_time)
|
||||
intervals = null_audio_glitches(
|
||||
resolve_coverage(self.camera, self.start_time, self.end_time)
|
||||
)
|
||||
self._coverage = (
|
||||
build_spans(intervals, self.pinned_stream),
|
||||
known_video_codecs(intervals),
|
||||
@@ -433,17 +448,57 @@ class RecordingExporter(threading.Thread):
|
||||
# hand-off to stage around
|
||||
return True
|
||||
|
||||
spans, codecs, keep_audio = self._resolve_coverage()
|
||||
runs = self._stream_runs(spans)
|
||||
_spans, codecs, keep_audio = self._resolve_coverage()
|
||||
runs = self._planned_stream_runs()
|
||||
|
||||
# a range one stream covers end to end has nothing to hand off,
|
||||
# so it stays on the existing path however long it is
|
||||
if len(runs) < 2:
|
||||
return True
|
||||
|
||||
runs = [piece for run in runs for piece in self._split_long_run(run)]
|
||||
return self._stage_stream_runs(runs, codecs, keep_audio)
|
||||
|
||||
def _planned_stream_runs(self) -> list[StreamRun]:
|
||||
"""The runs a mixed range is staged as, one pinned vod playlist each."""
|
||||
runs = self._stream_runs(self._merged_spans())
|
||||
|
||||
if len(runs) < 2:
|
||||
return runs
|
||||
|
||||
return [piece for run in runs for piece in self._split_long_run(run)]
|
||||
|
||||
def _staged_chapter_windows(self) -> list[_ChapterWindow]:
|
||||
"""Chapter windows for the staged files as they were rendered.
|
||||
|
||||
Each staged run comes from its own pinned vod playlist, whose first
|
||||
clip snaps back to the preceding keyframe, so a staged file runs up
|
||||
to a GOP longer than its slice of the merged timeline. Planning each
|
||||
run the way its playlist does carries that lead-in into the chapter
|
||||
offsets instead of letting it accumulate at every hand-off.
|
||||
"""
|
||||
windows: list[_ChapterWindow] = []
|
||||
|
||||
for run in self._planned_stream_runs():
|
||||
intervals = null_audio_glitches(
|
||||
resolve_coverage(self.camera, run.start_time, run.end_time)
|
||||
)
|
||||
|
||||
for clip in realized_timeline(intervals, run.stream_type):
|
||||
# a skipped clip is absent from the playlist and the file
|
||||
if clip["duration"] <= 0:
|
||||
continue
|
||||
|
||||
span = clip["end_time"] - clip["start_time"]
|
||||
windows.append(
|
||||
_ChapterWindow(
|
||||
clip["start_time"],
|
||||
clip["end_time"],
|
||||
max(0.0, clip["duration"] / 1000 - span),
|
||||
)
|
||||
)
|
||||
|
||||
return windows
|
||||
|
||||
def _stream_runs(self, spans: list[list[Any]]) -> list[StreamRun]:
|
||||
"""Collapse the merged spans into contiguous runs of one stream type.
|
||||
|
||||
@@ -840,6 +895,8 @@ class RecordingExporter(threading.Thread):
|
||||
clipped_end = min(float(rec.end_time), float(self.end_time))
|
||||
if clipped_end <= clipped_start:
|
||||
continue
|
||||
# a staged window's keyframe lead-in plays before it
|
||||
output_offset += _lead_in(rec)
|
||||
windows.append((clipped_start, clipped_end, output_offset))
|
||||
output_offset += clipped_end - clipped_start
|
||||
|
||||
@@ -987,9 +1044,13 @@ class RecordingExporter(threading.Thread):
|
||||
if duration_ms <= 0:
|
||||
continue
|
||||
|
||||
title = datetime.datetime.fromtimestamp(clipped_start, tz=tz).isoformat(
|
||||
timespec="seconds"
|
||||
)
|
||||
# a staged window's keyframe lead-in opens its chapter, with
|
||||
# frames captured that long before the window
|
||||
lead_in = _lead_in(rec)
|
||||
duration_ms += int(round(lead_in * 1000))
|
||||
title = datetime.datetime.fromtimestamp(
|
||||
clipped_start - lead_in, tz=tz
|
||||
).isoformat(timespec="seconds")
|
||||
chapter_blocks.append(
|
||||
"[CHAPTER]\n"
|
||||
"TIMEBASE=1/1000\n"
|
||||
@@ -1128,12 +1189,12 @@ class RecordingExporter(threading.Thread):
|
||||
if self.staged_runs:
|
||||
# each run was already rendered to a temp file with a common
|
||||
# track timescale, so the concat demuxer has nothing left to
|
||||
# reconcile and every chapter offset lines up with the merged
|
||||
# timeline the staged files reproduce
|
||||
recordings = [
|
||||
_ChapterWindow(span_start, span_end)
|
||||
for _row, span_start, span_end, _is_main in self._merged_spans()
|
||||
]
|
||||
# reconcile
|
||||
recordings = (
|
||||
self._staged_chapter_windows()
|
||||
if self.chapters not in (None, ChaptersEnum.none)
|
||||
else []
|
||||
)
|
||||
playlist_lines: list[str] = [f"file '{path}'" for path in self.staged_runs]
|
||||
ffmpeg_input = (
|
||||
"-y -protocol_whitelist pipe,file -f concat -safe 0 -i /dev/stdin"
|
||||
@@ -1149,11 +1210,18 @@ class RecordingExporter(threading.Thread):
|
||||
# its own rows are the ones the chapters describe
|
||||
recordings = self._get_recordings_for_range(pin)
|
||||
else:
|
||||
# never mix streams in one playlist; use main when available
|
||||
# and fall back to sub for expired-main history
|
||||
recordings = self._get_recordings_for_range(STREAM_TYPE_MAIN)
|
||||
# an unstaged auto range resolves to at most one stream run, and
|
||||
# its rows are the ones the chapters describe. Main rows the
|
||||
# manifest drops (glitches, slivers at the edges of a sub range)
|
||||
# must not stand in for it.
|
||||
runs = self._stream_runs(self._merged_spans())
|
||||
recordings = self._get_recordings_for_range(
|
||||
runs[0].stream_type if runs else STREAM_TYPE_MAIN
|
||||
)
|
||||
|
||||
if not recordings:
|
||||
# never mix streams in one playlist; fall back to sub for
|
||||
# expired-main history
|
||||
if not recordings and not runs:
|
||||
recordings = self._get_recordings_for_range(STREAM_TYPE_SUB)
|
||||
|
||||
playlist_lines = []
|
||||
|
||||
@@ -490,9 +490,17 @@ class RecordingMaintainer(threading.Thread):
|
||||
)
|
||||
reviews = reviews_by_camera[camera]
|
||||
|
||||
tasks.extend(
|
||||
[self.validate_and_move_segment(camera, reviews, r) for r in recordings]
|
||||
)
|
||||
# probes run concurrently, but each segment's start chains off the
|
||||
# previous segment's end, so starts resolve in segment order
|
||||
previous: asyncio.Event | None = None
|
||||
for recording in recordings:
|
||||
resolved = asyncio.Event()
|
||||
tasks.append(
|
||||
self._validate_in_order(
|
||||
camera, reviews, recording, previous, resolved
|
||||
)
|
||||
)
|
||||
previous = resolved
|
||||
|
||||
# publish most recently available recording time and None if disabled
|
||||
if stream_type == STREAM_TYPE_MAIN:
|
||||
@@ -550,12 +558,33 @@ class RecordingMaintainer(threading.Thread):
|
||||
while info and info[0][0] < expire_before:
|
||||
info.pop(0)
|
||||
|
||||
async def _validate_in_order(
|
||||
self,
|
||||
camera: str,
|
||||
reviews: Any,
|
||||
recording: dict[str, Any],
|
||||
previous_start: asyncio.Event | None,
|
||||
start_resolved: asyncio.Event,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Validate a segment, always releasing the next one in its stream."""
|
||||
try:
|
||||
return await self.validate_and_move_segment(
|
||||
camera, reviews, recording, previous_start, start_resolved
|
||||
)
|
||||
finally:
|
||||
start_resolved.set()
|
||||
|
||||
def drop_segment(self, cache_path: str) -> None:
|
||||
Path(cache_path).unlink(missing_ok=True)
|
||||
self.end_time_cache.pop(cache_path, None)
|
||||
|
||||
async def validate_and_move_segment(
|
||||
self, camera: str, reviews: Any, recording: dict[str, Any]
|
||||
self,
|
||||
camera: str,
|
||||
reviews: Any,
|
||||
recording: dict[str, Any],
|
||||
previous_start: asyncio.Event | None = None,
|
||||
start_resolved: asyncio.Event | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
cache_path: str = recording["cache_path"]
|
||||
start_time: datetime.datetime = recording["start_time"]
|
||||
@@ -617,6 +646,9 @@ class RecordingMaintainer(threading.Thread):
|
||||
async with self.probe_semaphore:
|
||||
keyframes = await get_keyframe_offsets(cache_path)
|
||||
|
||||
if previous_start is not None:
|
||||
await previous_start.wait()
|
||||
|
||||
start_time = self._resolve_segment_start(
|
||||
camera, stream_type, start_time, duration, cache_path
|
||||
)
|
||||
@@ -654,6 +686,11 @@ class RecordingMaintainer(threading.Thread):
|
||||
RecordingsDataTypeEnum.valid.value,
|
||||
)
|
||||
|
||||
# the start is settled, so the next segment of the stream can chain
|
||||
# off it while this one waits on retention and the move
|
||||
if start_resolved is not None:
|
||||
start_resolved.set()
|
||||
|
||||
record_config = self.config.cameras[camera].record
|
||||
|
||||
# sub's alerts/detections carry the retain mode directly, unlike
|
||||
|
||||
+61
-15
@@ -29,38 +29,59 @@ 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
|
||||
# lifetime CPU averages at or above these are high; they match
|
||||
# CameraFfmpegThreshold.error and CameraDetectThreshold.error in the UI
|
||||
FFMPEG_HIGH_CPU_PCT = 20
|
||||
DETECT_HIGH_CPU_PCT = 40
|
||||
|
||||
# a camera stays over a threshold this long before it becomes a notice
|
||||
EPISODE_HOLD_S = 60
|
||||
|
||||
# detectors warm up after a start; the status bar waits this long too
|
||||
STARTUP_GRACE_S = 120
|
||||
|
||||
|
||||
class SkippedDetectionsTracker:
|
||||
"""Finds cameras whose skipped share stays high long enough for a notice."""
|
||||
def cpu_average(cpu_usages: dict[str, Any], pid: int | None) -> float | None:
|
||||
"""A process's CPU use averaged over its lifetime, or None if unknown."""
|
||||
usage = cpu_usages.get(str(pid)) if pid else None
|
||||
|
||||
def __init__(self) -> None:
|
||||
try:
|
||||
return float(usage["cpu_average"]) if usage else None
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
class EpisodeTracker:
|
||||
"""Finds cameras whose value stays over a threshold long enough for a notice."""
|
||||
|
||||
def __init__(self, threshold: float) -> None:
|
||||
self._threshold = threshold
|
||||
self._since: dict[str, float] = {}
|
||||
self._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."""
|
||||
def update(self, values: dict[str, float | None], now: float) -> list[str]:
|
||||
"""Return the cameras whose episode qualified on this sample.
|
||||
|
||||
Args:
|
||||
values: Each camera's current value, or None when it has none
|
||||
now: Sample time in seconds
|
||||
"""
|
||||
qualified: list[str] = []
|
||||
|
||||
# a removed camera that comes back starts a new episode
|
||||
for camera in self._since.keys() - cameras.keys():
|
||||
for camera in self._since.keys() - values.keys():
|
||||
self._since.pop(camera)
|
||||
self._raised.discard(camera)
|
||||
|
||||
for camera, camera_stats in cameras.items():
|
||||
if camera_stats["skipped_pct"] < SKIPPED_DETECTIONS_PCT:
|
||||
for camera, value in values.items():
|
||||
if value is None or value < self._threshold:
|
||||
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:
|
||||
if camera not in self._raised and now - since >= EPISODE_HOLD_S:
|
||||
self._raised.add(camera)
|
||||
qualified.append(camera)
|
||||
|
||||
@@ -80,7 +101,9 @@ class StatsEmitter(threading.Thread):
|
||||
self.stop_event = stop_event
|
||||
self.hardware_stats = HardwareStats(config)
|
||||
self.stats_history: list[dict[str, Any]] = []
|
||||
self.skipped_detections = SkippedDetectionsTracker()
|
||||
self.skipped_detections = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
|
||||
self.ffmpeg_cpu = EpisodeTracker(FFMPEG_HIGH_CPU_PCT)
|
||||
self.detect_cpu = EpisodeTracker(DETECT_HIGH_CPU_PCT)
|
||||
|
||||
# the shm notice's params as last sent, so only a change is written
|
||||
self._shm_checked = False
|
||||
@@ -227,15 +250,38 @@ class StatsEmitter(threading.Thread):
|
||||
|
||||
def _update_notices(self, stats: dict[str, Any], now: float) -> None:
|
||||
"""Update notices based on current stats or time."""
|
||||
# skipped detections
|
||||
cameras = stats["cameras"]
|
||||
|
||||
# absent when CPU collection timed out or failed on this tick
|
||||
cpu_usages = stats.get("cpu_usages", {})
|
||||
|
||||
if stats["service"]["uptime"] >= STARTUP_GRACE_S:
|
||||
for camera in self.skipped_detections.update(stats["cameras"], now):
|
||||
# skipped detections
|
||||
skipped = {
|
||||
camera: camera_stats["skipped_pct"]
|
||||
for camera, camera_stats in cameras.items()
|
||||
}
|
||||
|
||||
for camera in self.skipped_detections.update(skipped, now):
|
||||
raise_notice(
|
||||
"skipped_detections",
|
||||
scope=camera,
|
||||
params={"pct": stats["cameras"][camera]["skipped_pct"]},
|
||||
params={"pct": skipped[camera]},
|
||||
)
|
||||
|
||||
# high ffmpeg and detect CPU
|
||||
for tracker, kind, pid_key in (
|
||||
(self.ffmpeg_cpu, "ffmpeg_high_cpu", "ffmpeg_pid"),
|
||||
(self.detect_cpu, "detect_high_cpu", "pid"),
|
||||
):
|
||||
averages = {
|
||||
camera: cpu_average(cpu_usages, camera_stats.get(pid_key))
|
||||
for camera, camera_stats in cameras.items()
|
||||
}
|
||||
|
||||
for camera in tracker.update(averages, now):
|
||||
raise_notice(kind, scope=camera, params={"cpu": averages[camera]})
|
||||
|
||||
# shm too small for the cameras
|
||||
self._update_shm_notice(stats["service"]["storage"]["/dev/shm"])
|
||||
|
||||
|
||||
@@ -256,6 +256,17 @@ class HardwareStats:
|
||||
)
|
||||
self.update_config()
|
||||
|
||||
def set_config(self, config: FrigateConfig) -> None:
|
||||
"""Follow a runtime config swap and recalculate the monitored hardware.
|
||||
|
||||
The camera update subscriber has to follow too, or later camera updates
|
||||
would land on the discarded config.
|
||||
"""
|
||||
self.config = config
|
||||
self._config_subscriber.config = config
|
||||
self._config_subscriber.camera_configs = config.cameras
|
||||
self.update_config()
|
||||
|
||||
def update_config(self) -> None:
|
||||
"""Recalculate all hardware that needs to be monitored from the config."""
|
||||
names = self._scan_ffmpeg() | self._scan_detectors() | self._scan_enrichments()
|
||||
|
||||
@@ -111,15 +111,18 @@ def get_detector_stats(
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Get stats for all detectors, including temperatures based on detector type."""
|
||||
detector_stats: dict[str, dict[str, Any]] = {}
|
||||
detector_type_indices: dict[str, int] = {}
|
||||
# detector type -> device -> index into that type's temperatures
|
||||
device_indices: dict[str, dict[str, int]] = {}
|
||||
|
||||
for name, detector in stats_tracking["detectors"].items():
|
||||
pid = detector.detect_process.pid if detector.detect_process else None
|
||||
detector_type = detector.detector_config.type
|
||||
|
||||
# Keep track of the index for each detector type to match temperatures correctly
|
||||
current_index = detector_type_indices.get(detector_type, 0)
|
||||
detector_type_indices[detector_type] = current_index + 1
|
||||
# temperatures are per physical unit, so a repeated device
|
||||
# ("hailo:PCIe#2", see runner_names) shares its unit's reading
|
||||
device = name.partition("#")[0]
|
||||
type_devices = device_indices.setdefault(detector_type, {})
|
||||
current_index = type_devices.setdefault(device, len(type_devices))
|
||||
|
||||
detector_stat = {
|
||||
"inference_speed": round(detector.avg_inference_speed.value * 1000, 2), # type: ignore[attr-defined]
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import frigate.genai
|
||||
from frigate.config import GenAIProviderEnum
|
||||
from frigate.const import REDACTED_CREDENTIAL_SENTINEL
|
||||
from frigate.const import MODEL_CACHE_DIR, REDACTED_CREDENTIAL_SENTINEL
|
||||
from frigate.genai import GenAIClient
|
||||
from frigate.models import Event, Recordings, ReviewSegment
|
||||
from frigate.stats.emitter import StatsEmitter
|
||||
@@ -90,6 +92,44 @@ class TestHttpApp(BaseTestHttp):
|
||||
mqtt = response.json()["mqtt"]
|
||||
assert mqtt["password"] == REDACTED_CREDENTIAL_SENTINEL
|
||||
|
||||
def test_config_response_keeps_plus_model_reference(self):
|
||||
model_id = "test_plus_reference"
|
||||
model_path = os.path.join(MODEL_CACHE_DIR, model_id)
|
||||
os.makedirs(MODEL_CACHE_DIR, exist_ok=True)
|
||||
|
||||
with open(model_path, "w") as f:
|
||||
f.write("model")
|
||||
|
||||
with open(f"{model_path}.json", "w") as f:
|
||||
json.dump(
|
||||
{
|
||||
"id": model_id,
|
||||
"type": "ssd",
|
||||
"supportedDetectors": ["cpu"],
|
||||
"width": 320,
|
||||
"height": 320,
|
||||
"inputShape": "nhwc",
|
||||
"pixelFormat": "rgb",
|
||||
"labelMap": {"0": "person"},
|
||||
},
|
||||
f,
|
||||
)
|
||||
|
||||
self.addCleanup(os.remove, model_path)
|
||||
self.addCleanup(os.remove, f"{model_path}.json")
|
||||
self.minimal_config["models"] = [
|
||||
{"path": f"plus://{model_id}", "devices": ["cpu"]}
|
||||
]
|
||||
app = super().create_app()
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
response = client.get("/config")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["models"][0]["path"] == f"plus://{model_id}"
|
||||
|
||||
# detection still loads the resolved cache file
|
||||
assert app.frigate_config.models[0].path == model_path
|
||||
|
||||
####################################################################################################################
|
||||
################################### POST /genai/probe Endpoint ##################################################
|
||||
####################################################################################################################
|
||||
|
||||
@@ -53,92 +53,108 @@ 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):
|
||||
def test_acknowledge_hides_and_the_hidden_list_keeps_it(self):
|
||||
self.registry.raise_notice(
|
||||
"detector_stuck", scope="ov", params={"detector": "ov"}
|
||||
)
|
||||
|
||||
with self.client() as client:
|
||||
response = client.post("/notices/detector_stuck:ov/dismiss")
|
||||
response = client.post("/notices/detector_stuck:ov/acknowledge")
|
||||
listed = client.get("/notices").json()
|
||||
history = client.get("/notices", params={"include_dismissed": True}).json()
|
||||
hidden = client.get("/notices", params={"include_hidden": 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"])
|
||||
self.assertEqual([n["id"] for n in hidden], ["detector_stuck:ov"])
|
||||
self.assertIsNotNone(hidden[0]["acknowledged_at"])
|
||||
|
||||
def test_dismiss_an_id_with_a_slash(self):
|
||||
def test_acknowledging_a_check_is_404(self):
|
||||
with self.client() as client:
|
||||
response = client.post(f"/notices/{CONFIG_CHECK}/acknowledge")
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_mute_an_id_with_a_slash(self):
|
||||
self.registry.raise_notice(
|
||||
"model_download_failed", scope="yolo/model.onnx", params={}
|
||||
)
|
||||
|
||||
with self.client() as client:
|
||||
response = client.post(
|
||||
"/notices/model_download_failed:yolo/model.onnx/dismiss"
|
||||
"/notices/model_download_failed:yolo/model.onnx/mute"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(self.registry.active(), [])
|
||||
|
||||
def test_dismiss_unknown_is_404(self):
|
||||
def test_unknown_ids_are_404(self):
|
||||
with self.client() as client:
|
||||
response = client.post("/notices/nope/dismiss")
|
||||
responses = [
|
||||
client.post("/notices/nope/acknowledge"),
|
||||
client.post("/notices/nope/mute"),
|
||||
client.delete("/notices/nope/hidden"),
|
||||
]
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertEqual([r.status_code for r in responses], [404, 404, 404])
|
||||
|
||||
def test_requires_admin(self):
|
||||
viewer = {"remote-user": "viewer", "remote-role": "viewer"}
|
||||
|
||||
with TestClient(self.app) as client:
|
||||
response = client.get(
|
||||
"/notices", headers={"remote-user": "viewer", "remote-role": "viewer"}
|
||||
)
|
||||
responses = [
|
||||
client.get("/notices", headers=viewer),
|
||||
client.get("/notices/muted_checks", headers=viewer),
|
||||
client.post("/notices/detector_stuck:ov/acknowledge", headers=viewer),
|
||||
client.post("/notices/detector_stuck:ov/mute", headers=viewer),
|
||||
client.delete("/notices/detector_stuck:ov/hidden", headers=viewer),
|
||||
client.delete("/notices/hidden", headers=viewer),
|
||||
]
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual([r.status_code for r in responses], [403] * 6)
|
||||
|
||||
def test_dismissed_checks_are_listed_once(self):
|
||||
def test_muted_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()
|
||||
first = client.post(f"/notices/{CONFIG_CHECK}/mute")
|
||||
again = client.post(f"/notices/{CONFIG_CHECK}/mute")
|
||||
listed = client.get("/notices/muted_checks").json()
|
||||
hidden = client.get("/notices", params={"include_hidden": 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"])
|
||||
self.assertIsNotNone(listed[0]["muted_at"])
|
||||
# the Health tab builds the row itself, so it never comes back as a notice
|
||||
self.assertEqual(history, [])
|
||||
self.assertEqual(hidden, [])
|
||||
|
||||
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):
|
||||
def test_unhide_one_row(self):
|
||||
self.registry.raise_notice(
|
||||
"detector_stuck", scope="ov", params={"detector": "ov"}
|
||||
)
|
||||
self.registry.dismiss("detector_stuck:ov")
|
||||
self.registry.dismiss(CONFIG_CHECK)
|
||||
self.registry.mute("detector_stuck:ov")
|
||||
self.registry.mute(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()
|
||||
notice = client.delete("/notices/detector_stuck:ov/hidden")
|
||||
check = client.delete(f"/notices/{CONFIG_CHECK}/hidden")
|
||||
listed = client.get("/notices").json()
|
||||
checks = client.get("/notices/muted_checks").json()
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(history, [])
|
||||
self.assertEqual([notice.status_code, check.status_code], [200, 200])
|
||||
self.assertEqual([n["id"] for n in listed], ["detector_stuck:ov"])
|
||||
self.assertEqual(checks, [])
|
||||
|
||||
def test_purge_dismissed_requires_admin(self):
|
||||
with TestClient(self.app) as client:
|
||||
response = client.delete(
|
||||
"/notices/dismissed",
|
||||
headers={"remote-user": "viewer", "remote-role": "viewer"},
|
||||
)
|
||||
def test_unhide_all_shows_notices_and_drops_check_mutes(self):
|
||||
self.registry.raise_notice(
|
||||
"detector_stuck", scope="ov", params={"detector": "ov"}
|
||||
)
|
||||
self.registry.acknowledge("detector_stuck:ov")
|
||||
self.registry.mute(CONFIG_CHECK)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
with self.client() as client:
|
||||
response = client.delete("/notices/hidden")
|
||||
listed = client.get("/notices").json()
|
||||
checks = client.get("/notices/muted_checks").json()
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual([n["id"] for n in listed], ["detector_stuck:ov"])
|
||||
self.assertEqual(checks, [])
|
||||
|
||||
@@ -26,6 +26,7 @@ class TestSwapRuntimeConfig(unittest.TestCase):
|
||||
app.genai_manager.update_config.assert_called_once_with(config)
|
||||
app.profile_manager.update_config.assert_called_once_with(config)
|
||||
self.assertIs(app.stats_emitter.config, config)
|
||||
app.stats_emitter.hardware_stats.set_config.assert_called_once_with(config)
|
||||
self.assertIs(app.dispatcher.config, config)
|
||||
for comm in app.dispatcher.comms:
|
||||
self.assertIs(comm.config, config)
|
||||
|
||||
@@ -59,6 +59,10 @@ class TestBuildDetectorConfig(unittest.TestCase):
|
||||
def test_detectors_that_name_the_field_something_else(self):
|
||||
self.assertEqual(self._build("cpu:4").num_threads, 4)
|
||||
self.assertEqual(self._build("rknn:2").num_cores, 2)
|
||||
self.assertEqual(
|
||||
self._build("zmq:tcp://host.docker.internal:5555").endpoint,
|
||||
"tcp://host.docker.internal:5555",
|
||||
)
|
||||
|
||||
def test_device_is_coerced_to_the_detector_field_type(self):
|
||||
self.assertEqual(self._build("tensorrt:1").device, 1)
|
||||
@@ -66,6 +70,7 @@ class TestBuildDetectorConfig(unittest.TestCase):
|
||||
def test_omitted_device_falls_back_to_the_detector_default(self):
|
||||
self.assertEqual(self._build("cpu").num_threads, 3)
|
||||
self.assertEqual(self._build("rknn").num_cores, 0)
|
||||
self.assertEqual(self._build("zmq").endpoint, "ipc:///tmp/cache/zmq_detector")
|
||||
self.assertEqual(self._build("openvino").device, "AUTO")
|
||||
self.assertIsNone(self._build("edgetpu").device)
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ class HardwareProbeTestCase(unittest.TestCase):
|
||||
self.root = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.root.cleanup)
|
||||
|
||||
for name in ("SYS_ROOT", "DEV_ROOT", "PROC_ROOT", "ETC_ROOT"):
|
||||
for name in ("SYS_ROOT", "DEV_ROOT", "PROC_ROOT", "ETC_ROOT", "LIB_ROOT"):
|
||||
sub = os.path.join(self.root.name, name.split("_")[0].lower())
|
||||
os.makedirs(sub, exist_ok=True)
|
||||
patcher = patch.object(hardware, name, sub)
|
||||
@@ -167,6 +167,29 @@ class TestNvidia(HardwareProbeTestCase):
|
||||
|
||||
|
||||
class TestAccelerators(HardwareProbeTestCase):
|
||||
def test_the_neural_engine_is_found_by_lighters_provider_library(self):
|
||||
write(os.path.join(self.lib_root, "lighter", "liblighter_ane_ep.so"))
|
||||
|
||||
with patch.dict(os.environ, clear=False) as env:
|
||||
env.pop("LIGHTER_ANE_EP", None)
|
||||
ane = self.probe()["onnx:lighter"]
|
||||
|
||||
self.assertEqual(ane.detector, "onnx")
|
||||
self.assertEqual(ane.units[0].device, "onnx")
|
||||
self.assertEqual(ane.units[0].label, "Neural Engine")
|
||||
|
||||
def test_the_neural_engine_is_found_where_lighter_ane_ep_points(self):
|
||||
library = os.path.join(self.root.name, "elsewhere", "liblighter_ane_ep.so")
|
||||
write(library)
|
||||
|
||||
with patch.dict(os.environ, {"LIGHTER_ANE_EP": library}):
|
||||
self.assertIn("onnx:lighter", self.probe())
|
||||
|
||||
def test_no_neural_engine_is_reported_without_the_library(self):
|
||||
with patch.dict(os.environ, clear=False) as env:
|
||||
env.pop("LIGHTER_ANE_EP", None)
|
||||
self.assertNotIn("onnx:lighter", self.probe())
|
||||
|
||||
def test_hailo_is_found_by_its_device_node(self):
|
||||
write(os.path.join(self.dev_root, "hailo0"))
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Tests for per-detector stats."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from frigate.stats.util import get_detector_stats
|
||||
|
||||
|
||||
def _detector(detector_type: str) -> MagicMock:
|
||||
detector = MagicMock()
|
||||
detector.detector_config.type = detector_type
|
||||
detector.avg_inference_speed.value = 0.01
|
||||
detector.detection_start.value = 0.0
|
||||
detector.detect_process.pid = 1
|
||||
return detector
|
||||
|
||||
|
||||
class TestDetectorTemperatures(unittest.TestCase):
|
||||
def test_repeated_device_shares_its_unit_temperature(self):
|
||||
stats_tracking = {
|
||||
"detectors": {
|
||||
"hailo:PCIe": _detector("hailo8l"),
|
||||
"hailo:PCIe#2": _detector("hailo8l"),
|
||||
"hailo:PCIe:1": _detector("hailo8l"),
|
||||
}
|
||||
}
|
||||
|
||||
with patch(
|
||||
"frigate.stats.util.get_hardware_temperatures", return_value=[50.0, 60.0]
|
||||
):
|
||||
stats = get_detector_stats(stats_tracking)
|
||||
|
||||
self.assertEqual(stats["hailo:PCIe"]["temperature"], 50.0)
|
||||
self.assertEqual(stats["hailo:PCIe#2"]["temperature"], 50.0)
|
||||
self.assertEqual(stats["hailo:PCIe:1"]["temperature"], 60.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -543,6 +543,56 @@ class TestPinnedStream(unittest.TestCase):
|
||||
self.assertFalse(any("/vod/front/main/" in token for token in cmd))
|
||||
|
||||
|
||||
class TestExportTimelineAlignment(unittest.TestCase):
|
||||
def test_unstaged_auto_reads_the_stream_it_serves(self) -> None:
|
||||
# a sub-only range whose main rows are glitches the manifest drops
|
||||
exporter = _make_exporter([_span("/s1.mp4", 1_000, 1_040, False)], {"h264"})
|
||||
streams: list[str] = []
|
||||
|
||||
def rows(stream: str) -> list:
|
||||
streams.append(stream)
|
||||
return [_FakeRow(f"/{stream}.mp4")]
|
||||
|
||||
exporter._get_recordings_for_range = rows # type: ignore[method-assign]
|
||||
exporter.get_record_export_command("/exports/out.mp4")
|
||||
|
||||
self.assertEqual(streams, ["sub"])
|
||||
|
||||
def test_staged_chapters_carry_keyframe_lead_in(self) -> None:
|
||||
exporter = _make_exporter(
|
||||
[
|
||||
_span("/m1.mp4", 1_000, 1_020, True),
|
||||
_span("/s1.mp4", 1_020, 1_040, False),
|
||||
],
|
||||
{"h264"},
|
||||
)
|
||||
exporter.config.ui.timezone = None
|
||||
|
||||
# each run's vod clip snaps 1.5s back to a keyframe
|
||||
def timeline(_intervals: list, stream: str) -> list[dict]:
|
||||
start, end = (1_000, 1_020) if stream == "main" else (1_020, 1_040)
|
||||
return [
|
||||
{
|
||||
"start_time": start,
|
||||
"end_time": end,
|
||||
"duration": (end - start + 1.5) * 1000,
|
||||
}
|
||||
]
|
||||
|
||||
with (
|
||||
patch("frigate.record.export.resolve_coverage", return_value=[]),
|
||||
patch("frigate.record.export.realized_timeline", side_effect=timeline),
|
||||
):
|
||||
windows = exporter._staged_chapter_windows()
|
||||
|
||||
path = exporter._build_recording_segment_chapter_metadata_file(windows)
|
||||
self.addCleanup(os.remove, path)
|
||||
content = Path(path).read_text()
|
||||
|
||||
self.assertIn("START=0\nEND=21500", content)
|
||||
self.assertIn("START=21500\nEND=43000", content)
|
||||
|
||||
|
||||
class TestStagedFileCleanup(unittest.TestCase):
|
||||
"""A staged path must be tracked before ffmpeg can write to it."""
|
||||
|
||||
|
||||
@@ -51,6 +51,16 @@ class TestFfmpegPresets(unittest.TestCase):
|
||||
" ".join(frigate_config.cameras["back"].ffmpeg_cmds[0]["cmd"])
|
||||
)
|
||||
|
||||
def test_ffmpeg_hwaccel_apple_preset_decodes_every_stream(self):
|
||||
self.default_ffmpeg["cameras"]["back"]["ffmpeg"]["hwaccel_args"] = (
|
||||
"preset-apple-silicon-h265"
|
||||
)
|
||||
frigate_config = FrigateConfig(**self.default_ffmpeg)
|
||||
cmd = " ".join(frigate_config.cameras["back"].ffmpeg_cmds[0]["cmd"])
|
||||
assert "-c:v hevc_v4l2m2m" in cmd
|
||||
assert "-c:v:1" not in cmd
|
||||
assert "scale=1920:1080" in cmd
|
||||
|
||||
def test_ffmpeg_hwaccel_not_preset(self):
|
||||
self.default_ffmpeg["cameras"]["back"]["ffmpeg"]["hwaccel_args"] = (
|
||||
"-other-hwaccel args"
|
||||
|
||||
@@ -288,6 +288,17 @@ class TestUpdateConfig(HardwareStatsTestCase):
|
||||
|
||||
self.assertEqual(set(stats._monitored), {"rockchip"})
|
||||
|
||||
def test_follows_a_runtime_config_swap(self):
|
||||
stats = self.make_stats(self.make_config())
|
||||
self.assertEqual(set(stats._monitored), set())
|
||||
|
||||
swapped = self.make_config("preset-rk-h264")
|
||||
stats.set_config(swapped)
|
||||
|
||||
self.assertEqual(set(stats._monitored), {"rockchip"})
|
||||
self.assertIs(self.subscriber.return_value.config, swapped)
|
||||
self.assertIs(self.subscriber.return_value.camera_configs, swapped.cameras)
|
||||
|
||||
|
||||
class TestUpdateStats(HardwareStatsTestCase):
|
||||
def run_stats(self, stats: HardwareStats) -> dict:
|
||||
|
||||
@@ -34,6 +34,12 @@ class HwaccelRecommendationTestCase(unittest.TestCase):
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
self.sys_root = os.path.join(self.root.name, "sys")
|
||||
os.makedirs(self.sys_root)
|
||||
patcher = patch.object(hwaccel, "SYS_ROOT", self.sys_root)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
drm = patch.object(hwaccel, "enumerate_drm_devices", return_value={})
|
||||
self.drm = drm.start()
|
||||
self.addCleanup(drm.stop)
|
||||
@@ -66,6 +72,12 @@ class HwaccelRecommendationTestCase(unittest.TestCase):
|
||||
with open(os.path.join(self.proc_root, "cpuinfo"), "w") as f:
|
||||
f.write(f"processor\t: 0\nmodel name\t: {model_name}\n")
|
||||
|
||||
def write_video_device(self, vendor: str) -> None:
|
||||
device = os.path.join(self.sys_root, "class", "video4linux", "video0", "device")
|
||||
os.makedirs(device)
|
||||
with open(os.path.join(device, "vendor"), "w") as f:
|
||||
f.write(f"{vendor}\n")
|
||||
|
||||
def write_device_tree(self) -> None:
|
||||
os.makedirs(os.path.join(self.proc_root, "device-tree"), exist_ok=True)
|
||||
with open(os.path.join(self.proc_root, "device-tree", "compatible"), "w") as f:
|
||||
@@ -191,6 +203,22 @@ class TestAvailableFamilies(HwaccelRecommendationTestCase):
|
||||
self.assertIn(recommended, [family.key for family in families])
|
||||
|
||||
|
||||
class TestLighter(HwaccelRecommendationTestCase):
|
||||
def test_lighters_media_engine_is_recommended(self):
|
||||
self.write_video_device(hwaccel.LIGHTER_VIRTIO_VENDOR)
|
||||
|
||||
self.assertEqual(self.recommend(codecs={"h264"}), "apple-silicon")
|
||||
self.assertEqual(
|
||||
self.presets()["apple-silicon"],
|
||||
{"h264": "preset-apple-silicon-h264", "h265": "preset-apple-silicon-h265"},
|
||||
)
|
||||
|
||||
def test_another_virtio_media_device_is_not_lighters(self):
|
||||
self.write_video_device("0x554d4551")
|
||||
|
||||
self.assertEqual(self.available(), [])
|
||||
|
||||
|
||||
class TestCodecCoverage(HwaccelRecommendationTestCase):
|
||||
def test_a_family_carries_a_preset_per_codec(self):
|
||||
self.assertEqual(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import datetime
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
# Mock complex imports before importing maintainer, saving originals so we can
|
||||
# restore them after import and avoid polluting sys.modules for other tests.
|
||||
@@ -48,8 +48,11 @@ class TestMaintainer(unittest.IsolatedAsyncioTestCase):
|
||||
"frigate.record.maintainer.psutil.process_iter", return_value=[]
|
||||
):
|
||||
with patch("frigate.record.maintainer.logger.warning") as warn:
|
||||
# Mock validate_and_move_segment to avoid further logic
|
||||
maintainer.validate_and_move_segment = MagicMock()
|
||||
# Mock validate_and_move_segment to avoid further logic.
|
||||
# The requestor is real when another test imported the
|
||||
# maintainer first, and it would block on a reply.
|
||||
maintainer.validate_and_move_segment = AsyncMock()
|
||||
maintainer.requestor = MagicMock()
|
||||
|
||||
try:
|
||||
await maintainer.move_files()
|
||||
|
||||
@@ -16,9 +16,7 @@ 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
|
||||
)
|
||||
BATCHED = NoticeKind("batched", NoticeSeverity.warning, "system", batch_repeats=True)
|
||||
PRUNED = NoticeKind("pruned", NoticeSeverity.info, "system", keep_latest=2)
|
||||
TEST_KINDS = {kind.key: kind for kind in (BATCHED, PRUNED)}
|
||||
|
||||
@@ -77,6 +75,11 @@ class RegistryTestCase(unittest.TestCase):
|
||||
mock_datetime.now.return_value.timestamp.return_value = timestamp
|
||||
self.registry.raise_notice(kind, **kwargs)
|
||||
|
||||
def _acknowledge_at(self, timestamp: float, row_id: str) -> None:
|
||||
with patch("frigate.notices.registry.datetime") as mock_datetime:
|
||||
mock_datetime.now.return_value.timestamp.return_value = timestamp
|
||||
self.registry.acknowledge(row_id)
|
||||
|
||||
|
||||
class TestNoticeRegistry(RegistryTestCase):
|
||||
def test_raise_inserts_and_counts_one_occurrence(self):
|
||||
@@ -122,34 +125,98 @@ class TestNoticeRegistry(RegistryTestCase):
|
||||
self.assertEqual(self.registry.stats()[0]["occurrences"], 1)
|
||||
self.listener.assert_not_called()
|
||||
|
||||
def test_dismissal_survives_a_re_raise(self):
|
||||
def test_acknowledged_notice_returns_on_a_re_raise(self):
|
||||
self.registry.raise_notice("detector_stuck", scope="ov", params={})
|
||||
self.assertTrue(self.registry.acknowledge("detector_stuck:ov"))
|
||||
self.assertEqual(self.registry.active(), [])
|
||||
|
||||
self.registry.raise_notice("detector_stuck", scope="ov", params={})
|
||||
|
||||
notice = self.registry.active()[0]
|
||||
self.assertEqual(notice["count"], 2)
|
||||
self.assertIsNone(notice["acknowledged_at"])
|
||||
|
||||
def test_muted_notice_stays_hidden_on_a_re_raise(self):
|
||||
self.registry.raise_notice("skipped_detections", scope="front_door", params={})
|
||||
self.assertTrue(self.registry.dismiss("skipped_detections:front_door"))
|
||||
self.assertTrue(self.registry.mute("skipped_detections:front_door"))
|
||||
|
||||
self.registry.raise_notice("skipped_detections", scope="front_door", params={})
|
||||
|
||||
self.assertEqual(self.registry.active(), [])
|
||||
history = self.registry.active(include_dismissed=True)
|
||||
self.assertEqual(history[0]["count"], 2)
|
||||
self.assertIsNotNone(history[0]["dismissed_at"])
|
||||
hidden = self.registry.active(include_hidden=True)
|
||||
self.assertEqual(hidden[0]["count"], 2)
|
||||
self.assertIsNotNone(hidden[0]["muted_at"])
|
||||
|
||||
def test_dismiss_counts_once(self):
|
||||
def test_muting_an_acknowledged_notice_replaces_the_acknowledgement(self):
|
||||
self.registry.raise_notice("detector_stuck", scope="ov", params={})
|
||||
self.registry.acknowledge("detector_stuck:ov")
|
||||
|
||||
self.assertTrue(self.registry.dismiss("detector_stuck:ov"))
|
||||
self.assertTrue(self.registry.dismiss("detector_stuck:ov"))
|
||||
self.assertTrue(self.registry.mute("detector_stuck:ov"))
|
||||
|
||||
self.assertEqual(self.registry.stats()[0]["dismissals"], 1)
|
||||
notice = self.registry.active(include_hidden=True)[0]
|
||||
self.assertIsNone(notice["acknowledged_at"])
|
||||
self.assertIsNotNone(notice["muted_at"])
|
||||
|
||||
def test_dismiss_unknown_id(self):
|
||||
self.assertFalse(self.registry.dismiss("nope"))
|
||||
|
||||
def test_dismissed_hidden_unless_requested(self):
|
||||
def test_acknowledging_a_muted_notice_keeps_it_muted(self):
|
||||
self.registry.raise_notice("detector_stuck", scope="ov", params={})
|
||||
self.registry.dismiss("detector_stuck:ov")
|
||||
self.registry.mute("detector_stuck:ov")
|
||||
|
||||
self.assertTrue(self.registry.acknowledge("detector_stuck:ov"))
|
||||
|
||||
notice = self.registry.active(include_hidden=True)[0]
|
||||
self.assertIsNone(notice["acknowledged_at"])
|
||||
self.assertIsNotNone(notice["muted_at"])
|
||||
|
||||
def test_acknowledge_and_mute_each_count_once(self):
|
||||
self.registry.raise_notice("detector_stuck", scope="ov", params={})
|
||||
self.registry.raise_notice("shm_too_low", params={})
|
||||
|
||||
self.assertTrue(self.registry.acknowledge("detector_stuck:ov"))
|
||||
self.assertTrue(self.registry.acknowledge("detector_stuck:ov"))
|
||||
self.assertTrue(self.registry.mute("shm_too_low"))
|
||||
self.assertTrue(self.registry.mute("shm_too_low"))
|
||||
|
||||
stats = {s["kind"]: s for s in self.registry.stats()}
|
||||
self.assertEqual(stats["detector_stuck"]["acknowledgements"], 1)
|
||||
self.assertEqual(stats["detector_stuck"]["mutes"], 0)
|
||||
self.assertEqual(stats["shm_too_low"]["mutes"], 1)
|
||||
|
||||
def test_unknown_ids_are_rejected(self):
|
||||
self.assertFalse(self.registry.acknowledge("nope"))
|
||||
self.assertFalse(self.registry.mute("nope"))
|
||||
self.assertFalse(self.registry.unhide("nope"))
|
||||
|
||||
def test_kinds_that_never_repeat_cannot_be_acknowledged(self):
|
||||
self.registry.raise_notice(
|
||||
"update_available", scope="0.19.1", params={"version": "0.19.1"}
|
||||
)
|
||||
|
||||
self.assertFalse(self.registry.acknowledge("update_available:0.19.1"))
|
||||
self.assertFalse(
|
||||
self.registry.acknowledge("config:detect:fps-greater-than-five:global")
|
||||
)
|
||||
self.assertEqual(self.registry.active()[0]["acknowledgeable"], False)
|
||||
|
||||
def test_hidden_notices_listed_only_when_requested(self):
|
||||
self.registry.raise_notice("detector_stuck", scope="ov", params={})
|
||||
self.registry.acknowledge("detector_stuck:ov")
|
||||
|
||||
self.assertEqual(self.registry.active(), [])
|
||||
self.assertEqual(len(self.registry.active(include_dismissed=True)), 1)
|
||||
self.assertEqual(len(self.registry.active(include_hidden=True)), 1)
|
||||
|
||||
def test_unhide_shows_a_notice_and_drops_a_check_mute(self):
|
||||
self.registry.raise_notice("detector_stuck", scope="ov", params={})
|
||||
self.registry.mute("detector_stuck:ov")
|
||||
self.registry.mute("config:detect:fps-greater-than-five:global")
|
||||
|
||||
self.assertTrue(self.registry.unhide("detector_stuck:ov"))
|
||||
self.assertTrue(
|
||||
self.registry.unhide("config:detect:fps-greater-than-five:global")
|
||||
)
|
||||
|
||||
notice = self.registry.active()[0]
|
||||
self.assertIsNone(notice["muted_at"])
|
||||
self.assertEqual(self.registry.muted_checks(), [])
|
||||
|
||||
def test_resolve_deletes_and_keeps_stats(self):
|
||||
self.registry.raise_notice(
|
||||
@@ -160,7 +227,7 @@ class TestNoticeRegistry(RegistryTestCase):
|
||||
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.active(include_hidden=True), [])
|
||||
self.assertEqual(self.registry.stats()[0]["occurrences"], 1)
|
||||
self.listener.assert_called_once()
|
||||
|
||||
@@ -237,45 +304,47 @@ class TestNoticeRegistry(RegistryTestCase):
|
||||
ids, ["model_download_failed:front_door", "skipped_detections:garage"]
|
||||
)
|
||||
|
||||
def test_resolve_camera_drops_that_cameras_check_dismissals(self):
|
||||
def test_resolve_camera_drops_that_cameras_check_mutes(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.assertTrue(self.registry.mute(check_id))
|
||||
|
||||
self.registry.resolve_camera("front_door")
|
||||
|
||||
ids = sorted(check["id"] for check in self.registry.dismissed_checks())
|
||||
ids = sorted(check["id"] for check in self.registry.muted_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")
|
||||
def test_camera_named_global_keeps_global_check_mutes(self):
|
||||
self.registry.mute("config:detect:fps-greater-than-five:global")
|
||||
self.registry.mute("config:detect:fps-greater-than-five:camera.global")
|
||||
|
||||
self.registry.resolve_camera("global")
|
||||
|
||||
ids = [check["id"] for check in self.registry.dismissed_checks()]
|
||||
ids = [check["id"] for check in self.registry.muted_checks()]
|
||||
self.assertEqual(ids, ["config:detect:fps-greater-than-five:global"])
|
||||
|
||||
def test_purge_dismissed_keeps_active_notices_and_counts(self):
|
||||
def test_unhide_all_shows_every_notice_and_keeps_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.registry.acknowledge("detector_stuck:ov")
|
||||
self.registry.mute("skipped_detections:garage")
|
||||
self.registry.mute("config:detect:fps-greater-than-five:camera.garage")
|
||||
|
||||
self.assertEqual(self.registry.purge_dismissed(), 2)
|
||||
self.registry.unhide_all()
|
||||
|
||||
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)
|
||||
ids = sorted(n["id"] for n in self.registry.active())
|
||||
self.assertEqual(ids, ["detector_stuck:ov", "skipped_detections:garage"])
|
||||
self.assertEqual(self.registry.muted_checks(), [])
|
||||
stats = {s["kind"]: s for s in self.registry.stats()}
|
||||
self.assertEqual(stats["detector_stuck"]["acknowledgements"], 1)
|
||||
self.assertEqual(stats["skipped_detections"]["mutes"], 1)
|
||||
|
||||
|
||||
class TestApply(RegistryTestCase):
|
||||
@@ -349,7 +418,7 @@ class TestLifecycleKnobs(RegistryTestCase):
|
||||
|
||||
self.registry.flush()
|
||||
|
||||
self.assertEqual(self.registry.active(include_dismissed=True), [])
|
||||
self.assertEqual(self.registry.active(include_hidden=True), [])
|
||||
self.listener.assert_not_called()
|
||||
|
||||
def test_a_new_row_starts_without_stale_repeats(self):
|
||||
@@ -362,32 +431,26 @@ class TestLifecycleKnobs(RegistryTestCase):
|
||||
|
||||
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")
|
||||
def test_an_acknowledged_notice_returns_when_a_later_repeat_flushes(self):
|
||||
self._raise_at(1000.0, "batched")
|
||||
self._acknowledge_at(1010.0, "batched")
|
||||
|
||||
self.registry.raise_notice("batched")
|
||||
self.registry.flush()
|
||||
self._raise_at(1020.0, "batched")
|
||||
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"])
|
||||
self.assertEqual(self.registry.active()[0]["count"], 2)
|
||||
|
||||
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")
|
||||
def test_repeats_held_from_before_an_acknowledgement_stay_hidden(self):
|
||||
self._raise_at(1000.0, "batched")
|
||||
self._raise_at(1005.0, "batched")
|
||||
self._acknowledge_at(1010.0, "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)
|
||||
self.assertEqual(self.registry.active(include_hidden=True)[0]["count"], 2)
|
||||
|
||||
def test_keep_latest_drops_the_oldest_rows(self):
|
||||
for index, scope in enumerate(("a", "b", "c")):
|
||||
@@ -410,14 +473,13 @@ class TestCatalogLifecycles(RegistryTestCase):
|
||||
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):
|
||||
def test_an_acknowledged_failed_login_burst_returns_on_the_next_attempt(self):
|
||||
self.registry.raise_notice("failed_login", scope="1000")
|
||||
self.registry.dismiss("failed_login:1000")
|
||||
self.registry.acknowledge("failed_login:1000")
|
||||
|
||||
for _ in range(4):
|
||||
self.registry.raise_notice("failed_login", scope="1000")
|
||||
self.registry.raise_notice("failed_login", scope="1000")
|
||||
self.registry.flush()
|
||||
|
||||
burst = self.registry.active()[0]
|
||||
self.assertEqual(burst["count"], 5)
|
||||
self.assertIsNone(burst["dismissed_at"])
|
||||
self.assertEqual(burst["count"], 2)
|
||||
self.assertIsNone(burst["acknowledged_at"])
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Tests for sub stream cache segment handling in the recording maintainer."""
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import os
|
||||
import tempfile
|
||||
@@ -532,6 +533,59 @@ class TestSegmentStartChaining(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertAlmostEqual(calls[1].args[2].timestamp(), self.T0 + 10.4, places=3)
|
||||
self.assertAlmostEqual(calls[1].args[3].timestamp(), self.T0 + 20.8, places=3)
|
||||
|
||||
async def test_out_of_order_probes_chain_in_segment_order(self):
|
||||
maintainer = _build_chaining_maintainer(self.T0)
|
||||
|
||||
async def probe(_ffmpeg, cache_path, get_duration=False):
|
||||
# the earlier segment's probe finishes last
|
||||
if "chain0" in cache_path:
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
return {"has_valid_video": True, "duration": 10.4}
|
||||
|
||||
recordings = [
|
||||
{
|
||||
"start_time": datetime.datetime.fromtimestamp(
|
||||
self.T0 + offset, tz=datetime.UTC
|
||||
),
|
||||
"cache_path": f"/tmp/cache/test_cam@chain{offset}.mp4",
|
||||
"stream_type": "main",
|
||||
}
|
||||
for offset in (0, 10)
|
||||
]
|
||||
first_resolved = asyncio.Event()
|
||||
|
||||
with (
|
||||
patch("frigate.record.maintainer.get_video_properties", probe),
|
||||
patch(
|
||||
"frigate.record.maintainer.get_keyframe_offsets",
|
||||
AsyncMock(return_value=[0]),
|
||||
),
|
||||
patch(
|
||||
"frigate.record.maintainer.os.path.getmtime",
|
||||
MagicMock(side_effect=OSError("missing")),
|
||||
),
|
||||
):
|
||||
await asyncio.gather(
|
||||
maintainer._validate_in_order(
|
||||
"test_cam", [], recordings[0], None, first_resolved
|
||||
),
|
||||
maintainer._validate_in_order(
|
||||
"test_cam", [], recordings[1], first_resolved, asyncio.Event()
|
||||
),
|
||||
)
|
||||
|
||||
starts = sorted(
|
||||
call.args[2].timestamp() for call in maintainer.move_segment.await_args_list
|
||||
)
|
||||
self.assertEqual(starts[0], self.T0)
|
||||
self.assertAlmostEqual(starts[1], self.T0 + 10.4, places=3)
|
||||
self.assertAlmostEqual(
|
||||
maintainer.last_segment_end[("test_cam", "main")],
|
||||
self.T0 + 20.8,
|
||||
places=3,
|
||||
)
|
||||
|
||||
async def test_genuine_gap_is_not_snapped(self):
|
||||
maintainer = _build_chaining_maintainer(self.T0)
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tests for the device each model runner reports after loading."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -38,6 +40,7 @@ class TestRunnerDeviceName(unittest.TestCase):
|
||||
self._onnx(["OpenVINOExecutionProvider"]).device_name, "OpenVINO"
|
||||
)
|
||||
self.assertEqual(self._onnx(["CPUExecutionProvider"]).device_name, "CPU")
|
||||
self.assertEqual(self._onnx(["LighterANE"]).device_name, "Neural Engine")
|
||||
self.assertEqual(self._onnx(["ROCMExecutionProvider"]).device_name, "ROCM")
|
||||
self.assertEqual(self._onnx([]).device_name, "CPU")
|
||||
|
||||
@@ -129,3 +132,147 @@ class TestLoadedDeviceSnapshot(unittest.TestCase):
|
||||
finally:
|
||||
stop.set()
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
class TestLighterANE(unittest.TestCase):
|
||||
"""lighter's plugin provider, picked up by the ONNX session setup."""
|
||||
|
||||
def setUp(self):
|
||||
loaded_devices.clear()
|
||||
self.root = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.root.cleanup)
|
||||
self.library = os.path.join(self.root.name, "liblighter_ane_ep.so")
|
||||
env = patch.dict(os.environ, {"LIGHTER_ANE_EP": self.library})
|
||||
env.start()
|
||||
self.addCleanup(env.stop)
|
||||
|
||||
def _device(self) -> MagicMock:
|
||||
device = MagicMock()
|
||||
device.ep_name = "LighterANE"
|
||||
return device
|
||||
|
||||
def test_no_devices_without_the_library(self):
|
||||
with patch.object(detection_runners.ort, "get_ep_devices") as get_ep_devices:
|
||||
self.assertEqual(detection_runners.get_lighter_ane_devices(), [])
|
||||
get_ep_devices.assert_not_called()
|
||||
|
||||
def test_the_provider_is_registered_once(self):
|
||||
open(self.library, "w").close()
|
||||
device = self._device()
|
||||
other = MagicMock()
|
||||
other.ep_name = "CPUExecutionProvider"
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
detection_runners.ort,
|
||||
"get_ep_devices",
|
||||
side_effect=[[other], [other, device], [other, device]],
|
||||
),
|
||||
patch.object(
|
||||
detection_runners.ort, "register_execution_provider_library"
|
||||
) as register,
|
||||
):
|
||||
self.assertEqual(detection_runners.get_lighter_ane_devices(), [device])
|
||||
self.assertEqual(detection_runners.get_lighter_ane_devices(), [device])
|
||||
|
||||
register.assert_called_once_with("LighterANE", self.library)
|
||||
|
||||
def test_a_provider_that_will_not_load_is_skipped(self):
|
||||
open(self.library, "w").close()
|
||||
|
||||
with (
|
||||
patch.object(detection_runners.ort, "get_ep_devices", return_value=[]),
|
||||
patch.object(
|
||||
detection_runners.ort,
|
||||
"register_execution_provider_library",
|
||||
side_effect=RuntimeError("not a provider"),
|
||||
),
|
||||
):
|
||||
self.assertEqual(detection_runners.get_lighter_ane_devices(), [])
|
||||
|
||||
def test_the_session_runs_on_the_neural_engine(self):
|
||||
device = self._device()
|
||||
session = MagicMock()
|
||||
session.get_providers.return_value = ["LighterANE", "CPUExecutionProvider"]
|
||||
options = MagicMock()
|
||||
|
||||
with (
|
||||
patch.object(detection_runners, "is_rknn_compatible", return_value=False),
|
||||
patch.object(
|
||||
detection_runners, "get_lighter_ane_devices", return_value=[device]
|
||||
),
|
||||
patch.object(
|
||||
detection_runners, "get_ort_session_options", return_value=options
|
||||
),
|
||||
patch.object(
|
||||
detection_runners.ort, "InferenceSession", return_value=session
|
||||
) as inference_session,
|
||||
):
|
||||
runner = get_optimized_runner("/models/yolo.onnx", "AUTO", "yolo-generic")
|
||||
|
||||
options.add_provider_for_devices.assert_called_once_with([device], {})
|
||||
inference_session.assert_called_once_with(
|
||||
"/models/yolo.onnx", sess_options=options
|
||||
)
|
||||
self.assertIsInstance(runner, ONNXModelRunner)
|
||||
self.assertEqual(
|
||||
loaded_devices["/models/yolo.onnx"], ("yolo-generic", "Neural Engine")
|
||||
)
|
||||
|
||||
def test_a_model_the_neural_engine_cannot_load_uses_the_default_providers(self):
|
||||
session = MagicMock()
|
||||
session.get_providers.return_value = ["CPUExecutionProvider"]
|
||||
|
||||
with (
|
||||
patch.object(detection_runners, "is_rknn_compatible", return_value=False),
|
||||
patch.object(
|
||||
detection_runners, "get_lighter_ane_devices", return_value=[MagicMock()]
|
||||
),
|
||||
patch.object(
|
||||
detection_runners,
|
||||
"get_ort_providers",
|
||||
return_value=(["CPUExecutionProvider"], [{}]),
|
||||
),
|
||||
patch.object(
|
||||
detection_runners, "is_openvino_gpu_npu_available", return_value=False
|
||||
),
|
||||
patch.object(
|
||||
detection_runners.ort,
|
||||
"InferenceSession",
|
||||
side_effect=[RuntimeError("unsupported"), session],
|
||||
) as inference_session,
|
||||
patch.object(
|
||||
detection_runners, "get_ort_session_options", return_value=MagicMock()
|
||||
),
|
||||
self.assertLogs(detection_runners.logger, level="WARNING"),
|
||||
):
|
||||
runner = get_optimized_runner("/models/jina.onnx", "AUTO", "jina-v2")
|
||||
|
||||
self.assertEqual(inference_session.call_count, 2)
|
||||
self.assertIsInstance(runner, ONNXModelRunner)
|
||||
self.assertEqual(loaded_devices["/models/jina.onnx"], ("jina-v2", "CPU"))
|
||||
|
||||
def test_a_cpu_model_stays_on_the_cpu(self):
|
||||
session = MagicMock()
|
||||
session.get_providers.return_value = ["CPUExecutionProvider"]
|
||||
|
||||
with (
|
||||
patch.object(detection_runners, "is_rknn_compatible", return_value=False),
|
||||
patch.object(
|
||||
detection_runners, "get_lighter_ane_devices", return_value=[MagicMock()]
|
||||
) as ane,
|
||||
patch.object(
|
||||
detection_runners,
|
||||
"get_ort_providers",
|
||||
return_value=(["CPUExecutionProvider"], [{}]),
|
||||
),
|
||||
patch.object(
|
||||
detection_runners.ort, "InferenceSession", return_value=session
|
||||
),
|
||||
patch.object(
|
||||
detection_runners, "get_ort_session_options", return_value=None
|
||||
),
|
||||
):
|
||||
get_optimized_runner("/models/arcface.onnx", "CPU", "arcface")
|
||||
|
||||
ane.assert_not_called()
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
"""Tests for the skipped detection rate and the notice it can raise."""
|
||||
"""Tests for the per-camera episode notices: skipped detections and high CPU."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import call, patch
|
||||
|
||||
from frigate.stats import emitter
|
||||
from frigate.stats.emitter import SkippedDetectionsTracker
|
||||
from frigate.stats.emitter import (
|
||||
SKIPPED_DETECTIONS_PCT,
|
||||
EpisodeTracker,
|
||||
cpu_average,
|
||||
)
|
||||
from frigate.stats.util import skipped_percent
|
||||
|
||||
|
||||
@@ -19,18 +23,18 @@ class TestSkippedPercent(unittest.TestCase):
|
||||
self.assertEqual(skipped_percent(2.0, 5.0, False), 0.0)
|
||||
|
||||
|
||||
class TestSkippedDetectionsTracker(unittest.TestCase):
|
||||
class TestEpisodeTracker(unittest.TestCase):
|
||||
def _cameras(self, pct: float) -> dict:
|
||||
return {"front_door": {"skipped_pct": pct}}
|
||||
return {"front_door": pct}
|
||||
|
||||
def test_below_threshold_never_qualifies(self):
|
||||
tracker = SkippedDetectionsTracker()
|
||||
tracker = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
|
||||
|
||||
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()
|
||||
tracker = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
|
||||
|
||||
results = [
|
||||
tracker.update(self._cameras(10.0), tick * 15.0) for tick in range(8)
|
||||
@@ -42,7 +46,7 @@ class TestSkippedDetectionsTracker(unittest.TestCase):
|
||||
self.assertEqual(results[5:], [[], [], []])
|
||||
|
||||
def test_a_dip_restarts_the_hold(self):
|
||||
tracker = SkippedDetectionsTracker()
|
||||
tracker = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
|
||||
|
||||
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), [])
|
||||
@@ -51,7 +55,7 @@ class TestSkippedDetectionsTracker(unittest.TestCase):
|
||||
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 = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
|
||||
tracker.update(self._cameras(10.0), 0.0)
|
||||
self.assertEqual(tracker.update(self._cameras(10.0), 60.0), ["front_door"])
|
||||
|
||||
@@ -61,17 +65,15 @@ class TestSkippedDetectionsTracker(unittest.TestCase):
|
||||
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)
|
||||
tracker = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
|
||||
tracker.update({"a": 10.0, "b": 0.0}, 0.0)
|
||||
|
||||
qualified = tracker.update(
|
||||
{"a": {"skipped_pct": 10.0}, "b": {"skipped_pct": 10.0}}, 60.0
|
||||
)
|
||||
qualified = tracker.update({"a": 10.0, "b": 10.0}, 60.0)
|
||||
|
||||
self.assertEqual(qualified, ["a"])
|
||||
|
||||
def test_a_removed_camera_starts_a_new_episode(self):
|
||||
tracker = SkippedDetectionsTracker()
|
||||
tracker = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
|
||||
tracker.update(self._cameras(10.0), 0.0)
|
||||
tracker.update({}, 15.0)
|
||||
tracker.update(self._cameras(10.0), 30.0)
|
||||
@@ -79,6 +81,25 @@ class TestSkippedDetectionsTracker(unittest.TestCase):
|
||||
self.assertEqual(tracker.update(self._cameras(10.0), 60.0), [])
|
||||
self.assertEqual(tracker.update(self._cameras(10.0), 90.0), ["front_door"])
|
||||
|
||||
def test_a_missing_value_ends_the_episode(self):
|
||||
tracker = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
|
||||
tracker.update(self._cameras(10.0), 0.0)
|
||||
tracker.update({"front_door": None}, 30.0)
|
||||
|
||||
self.assertEqual(tracker.update(self._cameras(10.0), 60.0), [])
|
||||
|
||||
|
||||
class TestCpuAverage(unittest.TestCase):
|
||||
def test_reads_the_lifetime_average(self):
|
||||
usages = {"42": {"cpu": "80.0", "cpu_average": "25.0"}}
|
||||
|
||||
self.assertEqual(cpu_average(usages, 42), 25.0)
|
||||
|
||||
def test_unknown_process_has_no_average(self):
|
||||
self.assertIsNone(cpu_average({}, 42))
|
||||
self.assertIsNone(cpu_average({"42": {"cpu_average": "25.0"}}, None))
|
||||
self.assertIsNone(cpu_average({"42": {"cpu_average": ""}}, 42))
|
||||
|
||||
|
||||
class TestEmitterNotices(unittest.TestCase):
|
||||
def setUp(self):
|
||||
@@ -94,15 +115,25 @@ class TestEmitterNotices(unittest.TestCase):
|
||||
|
||||
def _emitter(self) -> emitter.StatsEmitter:
|
||||
stats_emitter = emitter.StatsEmitter.__new__(emitter.StatsEmitter)
|
||||
stats_emitter.skipped_detections = SkippedDetectionsTracker()
|
||||
stats_emitter.skipped_detections = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
|
||||
stats_emitter.ffmpeg_cpu = EpisodeTracker(emitter.FFMPEG_HIGH_CPU_PCT)
|
||||
stats_emitter.detect_cpu = EpisodeTracker(emitter.DETECT_HIGH_CPU_PCT)
|
||||
stats_emitter._shm_checked = False
|
||||
stats_emitter._shm_params = None
|
||||
return stats_emitter
|
||||
|
||||
def _stats(self, uptime: int, pct: float) -> dict:
|
||||
def _stats(
|
||||
self, uptime: int, pct: float, ffmpeg_cpu: str = "5.0", detect_cpu: str = "5.0"
|
||||
) -> dict:
|
||||
return {
|
||||
"service": {"uptime": uptime, "storage": {"/dev/shm": {}}},
|
||||
"cameras": {"front_door": {"skipped_pct": pct}},
|
||||
"cameras": {
|
||||
"front_door": {"skipped_pct": pct, "ffmpeg_pid": 10, "pid": 11}
|
||||
},
|
||||
"cpu_usages": {
|
||||
"10": {"cpu_average": ffmpeg_cpu},
|
||||
"11": {"cpu_average": detect_cpu},
|
||||
},
|
||||
}
|
||||
|
||||
def test_qualified_camera_raises_a_notice(self):
|
||||
@@ -115,6 +146,43 @@ class TestEmitterNotices(unittest.TestCase):
|
||||
"skipped_detections", scope="front_door", params={"pct": 12.5}
|
||||
)
|
||||
|
||||
def test_high_cpu_raises_a_notice_per_process(self):
|
||||
stats_emitter = self._emitter()
|
||||
|
||||
for uptime, now in ((300, 0.0), (360, 60.0)):
|
||||
stats_emitter._update_notices(
|
||||
self._stats(uptime, 0.0, ffmpeg_cpu="25.0", detect_cpu="45.0"), now
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
self.raise_notice.call_args_list,
|
||||
[
|
||||
call("ffmpeg_high_cpu", scope="front_door", params={"cpu": 25.0}),
|
||||
call("detect_high_cpu", scope="front_door", params={"cpu": 45.0}),
|
||||
],
|
||||
)
|
||||
|
||||
def test_cpu_below_each_threshold_raises_nothing(self):
|
||||
stats_emitter = self._emitter()
|
||||
|
||||
for uptime, now in ((300, 0.0), (360, 60.0)):
|
||||
stats_emitter._update_notices(
|
||||
self._stats(uptime, 0.0, ffmpeg_cpu="19.0", detect_cpu="39.0"), now
|
||||
)
|
||||
|
||||
self.raise_notice.assert_not_called()
|
||||
|
||||
def test_missing_cpu_stats_raise_nothing(self):
|
||||
stats_emitter = self._emitter()
|
||||
|
||||
for uptime, now in ((300, 0.0), (360, 60.0)):
|
||||
stats = self._stats(uptime, 0.0)
|
||||
del stats["cpu_usages"]
|
||||
stats_emitter._update_notices(stats, now)
|
||||
|
||||
self.raise_notice.assert_not_called()
|
||||
self.assertEqual(self.flush_notices.call_count, 2)
|
||||
|
||||
def test_startup_window_is_ignored(self):
|
||||
stats_emitter = self._emitter()
|
||||
|
||||
|
||||
@@ -14,6 +14,10 @@ from frigate.util.file import FileLock
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# (connect, read) seconds. The read timeout bounds each socket read rather than
|
||||
# the whole download, so large models still finish.
|
||||
DOWNLOAD_TIMEOUT = (15, 60)
|
||||
|
||||
# target path -> first line of the last download error for it; every existing
|
||||
# download function swallows its exceptions, so this is how the downloader
|
||||
# thread learns why a file is still missing
|
||||
@@ -124,7 +128,9 @@ class ModelDownloader:
|
||||
logger.info(f"Downloading model file from: {url}")
|
||||
|
||||
try:
|
||||
with requests.get(url, stream=True, allow_redirects=True) as r:
|
||||
with requests.get(
|
||||
url, stream=True, allow_redirects=True, timeout=DOWNLOAD_TIMEOUT
|
||||
) as r:
|
||||
r.raise_for_status()
|
||||
with open(temporary_filename, "wb") as f:
|
||||
for chunk in r.iter_content(chunk_size=8192):
|
||||
|
||||
+33
-1
@@ -9,6 +9,7 @@ and resolve it per camera against that camera's detect stream.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -23,14 +24,20 @@ from frigate.util.services import enumerate_drm_devices
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# root the /proc reads use, so tests can point them at a fixture tree
|
||||
# roots the /proc and /sys reads use, so tests can point them at a fixture tree
|
||||
PROC_ROOT = "/proc"
|
||||
SYS_ROOT = "/sys"
|
||||
|
||||
ANY_CODEC = "any"
|
||||
|
||||
# a Raspberry Pi has no detection hardware of its own, so it gets a key here
|
||||
RASPBERRY_PI = "raspberrypi"
|
||||
|
||||
# lighter (https://github.com/fieldwork-ai/lighter) decodes on a Mac's media
|
||||
# engine through virtio-media devices, which carry its virtio vendor id "LGHT"
|
||||
LIGHTER_MEDIA = "lighter"
|
||||
LIGHTER_VIRTIO_VENDOR = "0x4c474854"
|
||||
|
||||
# ffprobe names h265 streams hevc
|
||||
CODEC_ALIASES = {"hevc": "h265"}
|
||||
|
||||
@@ -52,6 +59,7 @@ DECODE_HARDWARE = (
|
||||
"rknn",
|
||||
"openvino:GPU",
|
||||
"onnx:amd",
|
||||
LIGHTER_MEDIA,
|
||||
RASPBERRY_PI,
|
||||
)
|
||||
|
||||
@@ -98,6 +106,10 @@ FAMILY_RPI = HwaccelFamily(
|
||||
key="rpi",
|
||||
presets={"h264": "preset-rpi-64-h264", "h265": "preset-rpi-64-h265"},
|
||||
)
|
||||
FAMILY_APPLE = HwaccelFamily(
|
||||
key="apple-silicon",
|
||||
presets={"h264": "preset-apple-silicon-h264", "h265": "preset-apple-silicon-h265"},
|
||||
)
|
||||
|
||||
|
||||
def _read(path: str) -> str | None:
|
||||
@@ -139,6 +151,20 @@ def _is_raspberry_pi() -> bool:
|
||||
return "raspberrypi" in compatible
|
||||
|
||||
|
||||
def _has_lighter_media() -> bool:
|
||||
video = f"{SYS_ROOT}/class/video4linux"
|
||||
|
||||
try:
|
||||
devices = os.listdir(video)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
return any(
|
||||
_read(f"{video}/{device}/device/vendor") == LIGHTER_VIRTIO_VENDOR
|
||||
for device in devices
|
||||
)
|
||||
|
||||
|
||||
def _intel_families(generation: int | None) -> list[HwaccelFamily]:
|
||||
"""vaapi drives every Intel GPU, qsv only those from gen8 on."""
|
||||
if generation is not None and generation < INTEL_QSV_SUPPORTED_GEN:
|
||||
@@ -164,6 +190,9 @@ def _families(key: str, generation: int | None) -> list[HwaccelFamily]:
|
||||
if key == "onnx:amd":
|
||||
return [FAMILY_VAAPI]
|
||||
|
||||
if key == LIGHTER_MEDIA:
|
||||
return [FAMILY_APPLE]
|
||||
|
||||
if key == RASPBERRY_PI:
|
||||
return [FAMILY_RPI]
|
||||
|
||||
@@ -193,6 +222,9 @@ def _decode_hardware(detector_key: str | None) -> list[str]:
|
||||
"""
|
||||
present = {found.key for found in hardware_prober.probe()}
|
||||
|
||||
if _has_lighter_media():
|
||||
present.add(LIGHTER_MEDIA)
|
||||
|
||||
if _is_raspberry_pi():
|
||||
present.add(RASPBERRY_PI)
|
||||
|
||||
|
||||
@@ -213,17 +213,19 @@ def stream_has_audio(intervals: list[CoverageInterval], main: bool) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def null_audio_glitches(
|
||||
intervals: list[CoverageInterval], main_audio: bool, sub_audio: bool
|
||||
) -> list[CoverageInterval]:
|
||||
def null_audio_glitches(intervals: list[CoverageInterval]) -> list[CoverageInterval]:
|
||||
"""Treat video-only glitch rows on audio-bearing streams as no recording.
|
||||
|
||||
nginx-vod requires every clip in a sequence to carry the same track
|
||||
count, so a truncated video-only segment (a backend restart can flush
|
||||
a sub-second file before any audio packet landed) poisons every
|
||||
manifest that includes it. Nulling the row turns the glitch into a
|
||||
hole the span builder skips like any recording gap.
|
||||
hole the span builder skips like any recording gap. Every consumer of
|
||||
a window's coverage (the vod manifest, its realized timelines, and
|
||||
exports) goes through here, so they all agree on which rows exist.
|
||||
"""
|
||||
main_audio = stream_has_audio(intervals, main=True)
|
||||
sub_audio = stream_has_audio(intervals, main=False)
|
||||
result: list[CoverageInterval] = []
|
||||
for interval in intervals:
|
||||
main = interval.main
|
||||
@@ -449,9 +451,7 @@ def realized_timelines(
|
||||
assembles each variant's realized spans. Keyframe snapping reads the
|
||||
per-row index stored at record time, so no file is touched.
|
||||
"""
|
||||
main_audio = stream_has_audio(intervals, main=True)
|
||||
sub_audio = stream_has_audio(intervals, main=False)
|
||||
nulled = null_audio_glitches(intervals, main_audio, sub_audio)
|
||||
nulled = null_audio_glitches(intervals)
|
||||
|
||||
return {
|
||||
"auto": realized_timeline(nulled, None),
|
||||
|
||||
@@ -18,18 +18,21 @@ def migrate(migrator, database, fake=False, **kwargs):
|
||||
'"first_seen" DATETIME NOT NULL, '
|
||||
'"last_seen" DATETIME NOT NULL, '
|
||||
'"count" INTEGER NOT NULL, '
|
||||
'"dismissed_at" DATETIME)'
|
||||
'"acknowledged_at" DATETIME, '
|
||||
'"muted_at" DATETIME)'
|
||||
)
|
||||
migrator.sql('CREATE INDEX IF NOT EXISTS "notice_kind" ON "notice" ("kind")')
|
||||
migrator.sql(
|
||||
'CREATE TABLE IF NOT EXISTS "noticestats" ('
|
||||
'"kind" VARCHAR(50) NOT NULL PRIMARY KEY, '
|
||||
'"occurrences" INTEGER NOT NULL, '
|
||||
'"dismissals" INTEGER NOT NULL, '
|
||||
'"acknowledgements" INTEGER NOT NULL, '
|
||||
'"mutes" INTEGER NOT NULL, '
|
||||
'"first_seen" DATETIME NOT NULL, '
|
||||
'"last_seen" DATETIME NOT NULL, '
|
||||
'"reported_occurrences" INTEGER NOT NULL DEFAULT 0, '
|
||||
'"reported_dismissals" INTEGER NOT NULL DEFAULT 0)'
|
||||
'"reported_acknowledgements" INTEGER NOT NULL DEFAULT 0, '
|
||||
'"reported_mutes" INTEGER NOT NULL DEFAULT 0)'
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ export interface ApiMockOverrides {
|
||||
};
|
||||
users?: { username: string; role: string }[];
|
||||
notices?: unknown[];
|
||||
dismissedChecks?: unknown[];
|
||||
mutedChecks?: unknown[];
|
||||
/** camera name to the ffprobe entries returned for `paths=camera:<name>` */
|
||||
ffprobe?: Record<string, unknown[]>;
|
||||
}
|
||||
@@ -238,8 +238,8 @@ export class ApiMocker {
|
||||
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/muted_checks", (route) =>
|
||||
route.fulfill({ json: overrides?.mutedChecks ?? [] }),
|
||||
);
|
||||
|
||||
// Users. GET lists them; POST/PUT (create, password) just succeed, so
|
||||
|
||||
@@ -217,6 +217,23 @@ test.describe("Detection models settings @high", () => {
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test("shareable hardware another model uses can still be picked", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installRoutes(frigateApp.page, [
|
||||
{ scene: "all", devices: ["openvino:GPU.0"] },
|
||||
{ scene: "outdoor", devices: ["openvino:GPU.1"] },
|
||||
]);
|
||||
await openPage(frigateApp);
|
||||
|
||||
await expect(
|
||||
frigateApp.page.locator("#models-0-openvino\\:GPU\\.1").first(),
|
||||
).toBeEnabled();
|
||||
await expect(frigateApp.page.locator("#pageRoot")).not.toContainText(
|
||||
"used by outdoor",
|
||||
);
|
||||
});
|
||||
|
||||
test("adding a model appends a card with an unused scene", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
@@ -248,15 +265,13 @@ test.describe("Detection models settings @high", () => {
|
||||
test("a saved Frigate+ model opens on the Frigate+ tab", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
// the backend resolves plus:// to a cache path before serving the config
|
||||
// back, so the plus metadata is the only signal the model is a Plus one
|
||||
await installRoutes(
|
||||
frigateApp.page,
|
||||
[
|
||||
{
|
||||
scene: "all",
|
||||
devices: ["openvino:GPU.0"],
|
||||
path: "/config/model_cache/abc123",
|
||||
path: "plus://abc123",
|
||||
plus: PLUS_MODEL,
|
||||
},
|
||||
],
|
||||
@@ -302,6 +317,40 @@ test.describe("Detection models settings @high", () => {
|
||||
expect(saves.at(-1)?.config_data?.models?.[0].path).toBe("plus://abc123");
|
||||
});
|
||||
|
||||
test("saving a Frigate+ model keeps its reference without the Frigate+ fields", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
// the backend fills these in from the Frigate+ model info when it loads
|
||||
const saves = await installRoutes(
|
||||
frigateApp.page,
|
||||
[
|
||||
{
|
||||
scene: "all",
|
||||
devices: ["openvino:GPU.0"],
|
||||
path: "plus://abc123",
|
||||
plus: PLUS_MODEL,
|
||||
width: 320,
|
||||
height: 320,
|
||||
input_tensor: "nchw",
|
||||
model_type: "yolo-generic",
|
||||
},
|
||||
],
|
||||
true,
|
||||
);
|
||||
await openPage(frigateApp);
|
||||
|
||||
await frigateApp.page.locator("#models-0-openvino\\:GPU\\.1").click();
|
||||
await frigateApp.page.getByRole("button", { name: /^Save$/ }).click();
|
||||
await expect.poll(() => saves.length).toBeGreaterThan(0);
|
||||
|
||||
const model = saves.at(-1)?.config_data?.models?.[0];
|
||||
expect(model?.path).toBe("plus://abc123");
|
||||
expect(model?.devices).toEqual(["openvino:GPU.0", "openvino:GPU.1"]);
|
||||
expect(model).not.toHaveProperty("width");
|
||||
expect(model).not.toHaveProperty("input_tensor");
|
||||
expect(model).not.toHaveProperty("model_type");
|
||||
});
|
||||
|
||||
test("a Frigate+ Hailo model is listed by the device it was built for", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
|
||||
+258
-121
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Health tab tests -- MEDIUM tier.
|
||||
*
|
||||
* Default tab, notice list rendering, dismiss, empty state, update notice.
|
||||
* Default tab, notice list rendering, acknowledge and mute, empty state,
|
||||
* update notice.
|
||||
*/
|
||||
|
||||
import { test, expect } from "../fixtures/frigate-test";
|
||||
@@ -23,7 +24,9 @@ const ERROR_NOTICE = {
|
||||
first_seen: NOW - 600,
|
||||
last_seen: NOW,
|
||||
count: 2,
|
||||
dismissed_at: null,
|
||||
acknowledgeable: true,
|
||||
acknowledged_at: null,
|
||||
muted_at: null,
|
||||
};
|
||||
|
||||
const EVENT_NOTICE = {
|
||||
@@ -37,7 +40,9 @@ const EVENT_NOTICE = {
|
||||
first_seen: NOW - 7200,
|
||||
last_seen: NOW - 60,
|
||||
count: 3,
|
||||
dismissed_at: null,
|
||||
acknowledgeable: true,
|
||||
acknowledged_at: null,
|
||||
muted_at: null,
|
||||
};
|
||||
|
||||
test.describe("System — Health tab @medium", () => {
|
||||
@@ -63,54 +68,61 @@ test.describe("System — Health tab @medium", () => {
|
||||
"Downloading model.onnx for yolo failed: timeout",
|
||||
);
|
||||
await expect(rows.nth(0)).toContainText("2 times");
|
||||
await expect(
|
||||
rows.nth(0).getByRole("button", { name: "Dismiss" }),
|
||||
).toBeVisible();
|
||||
await expect(rows.nth(1)).toContainText("Detector ov was restarted");
|
||||
await expect(rows.nth(1)).toContainText("3 times");
|
||||
await expect(
|
||||
rows.nth(1).getByRole("button", { name: "Dismiss" }),
|
||||
).toBeVisible();
|
||||
for (const index of [0, 1]) {
|
||||
await expect(
|
||||
rows.nth(index).getByRole("button", { name: "Acknowledge" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
rows.nth(index).getByRole("button", { name: "Mute", exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("dismiss posts and removes the row", async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults({
|
||||
stats: QUIET_STATS,
|
||||
notices: [EVENT_NOTICE],
|
||||
for (const action of ["acknowledge", "mute"] as const) {
|
||||
test(`${action} posts and removes the row`, async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults({
|
||||
stats: QUIET_STATS,
|
||||
notices: [EVENT_NOTICE],
|
||||
});
|
||||
|
||||
// the list shrinks after the POST so the refetch shows the row gone
|
||||
let hidden = false;
|
||||
await frigateApp.page.route("**/api/notices", (route) =>
|
||||
route.fulfill({ json: hidden ? [] : [EVENT_NOTICE] }),
|
||||
);
|
||||
await frigateApp.page.route(
|
||||
`**/api/notices/detector_stuck/${action}`,
|
||||
(route) => {
|
||||
hidden = true;
|
||||
return route.fulfill({ json: { success: true } });
|
||||
},
|
||||
);
|
||||
|
||||
await frigateApp.goto("/system#health");
|
||||
const request = frigateApp.page.waitForRequest(
|
||||
(req) =>
|
||||
req.url().includes(`/api/notices/detector_stuck/${action}`) &&
|
||||
req.method() === "POST",
|
||||
);
|
||||
await frigateApp.page
|
||||
.getByTestId("health-problem-notice:detector_stuck")
|
||||
.getByRole("button", {
|
||||
name: action === "mute" ? "Mute" : "Acknowledge",
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await request;
|
||||
|
||||
await expect(
|
||||
frigateApp.page.locator("[data-testid^='health-problem-']"),
|
||||
).toHaveCount(0, { timeout: 5_000 });
|
||||
await expect(
|
||||
frigateApp.page.getByText("Your Frigate installation is healthy"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// the list shrinks after the dismiss so the refetch shows the row gone
|
||||
let dismissed = false;
|
||||
await frigateApp.page.route("**/api/notices", (route) =>
|
||||
route.fulfill({ json: dismissed ? [] : [EVENT_NOTICE] }),
|
||||
);
|
||||
await frigateApp.page.route(
|
||||
"**/api/notices/detector_stuck/dismiss",
|
||||
(route) => {
|
||||
dismissed = true;
|
||||
return route.fulfill({ json: { success: true } });
|
||||
},
|
||||
);
|
||||
|
||||
await frigateApp.goto("/system#health");
|
||||
const request = frigateApp.page.waitForRequest(
|
||||
(req) =>
|
||||
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 request;
|
||||
|
||||
await expect(
|
||||
frigateApp.page.locator("[data-testid^='health-problem-']"),
|
||||
).toHaveCount(0, { timeout: 5_000 });
|
||||
await expect(
|
||||
frigateApp.page.getByText("Your Frigate installation is healthy"),
|
||||
).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
test("empty state with no notices", async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults({ stats: QUIET_STATS });
|
||||
@@ -140,7 +152,9 @@ test.describe("System — Health tab @medium", () => {
|
||||
first_seen: NOW - 3600,
|
||||
last_seen: NOW,
|
||||
count: 1,
|
||||
dismissed_at: null,
|
||||
acknowledgeable: false,
|
||||
acknowledged_at: null,
|
||||
muted_at: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -156,10 +170,23 @@ 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();
|
||||
await expect(
|
||||
row.getByRole("button", { name: "Mute", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(row.getByRole("button", { name: "Acknowledge" })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test("the filter shows dismissed notices without a Dismiss button", async ({
|
||||
const ACKNOWLEDGED_NOTICE = {
|
||||
...EVENT_NOTICE,
|
||||
id: "detector_stuck:coral",
|
||||
params: { detector: "coral" },
|
||||
acknowledged_at: NOW - 60,
|
||||
};
|
||||
const MUTED_NOTICE = { ...ERROR_NOTICE, muted_at: NOW - 120 };
|
||||
|
||||
test("the filter shows hidden notices marked by how they were hidden", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
@@ -169,33 +196,85 @@ test.describe("System — Health tab @medium", () => {
|
||||
await frigateApp.page.route(
|
||||
(url) =>
|
||||
url.pathname.endsWith("/api/notices") &&
|
||||
url.searchParams.get("include_dismissed") === "true",
|
||||
url.searchParams.get("include_hidden") === "true",
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
json: [EVENT_NOTICE, { ...ERROR_NOTICE, dismissed_at: NOW - 120 }],
|
||||
json: [EVENT_NOTICE, ACKNOWLEDGED_NOTICE, MUTED_NOTICE],
|
||||
}),
|
||||
);
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
await frigateApp.page.getByRole("button", { name: "Filter" }).click();
|
||||
const showDismissed = frigateApp.page.getByRole("switch", {
|
||||
name: "Show dismissed",
|
||||
const showHidden = frigateApp.page.getByRole("switch", {
|
||||
name: "Show hidden",
|
||||
});
|
||||
await expect(showDismissed).toHaveAttribute("aria-checked", "false");
|
||||
await showDismissed.click();
|
||||
await expect(showHidden).toHaveAttribute("aria-checked", "false");
|
||||
await showHidden.click();
|
||||
// mobile opens the filter as a modal drawer, which hides the rows' roles
|
||||
await frigateApp.page.keyboard.press("Escape");
|
||||
|
||||
const row = frigateApp.page.getByTestId(
|
||||
const acknowledged = frigateApp.page.getByTestId(
|
||||
"health-problem-notice:detector_stuck:coral",
|
||||
);
|
||||
await expect(acknowledged).toBeVisible({ timeout: 15_000 });
|
||||
await expect(acknowledged).toContainText("Acknowledged");
|
||||
await expect(
|
||||
acknowledged.getByRole("button", { name: "Show again" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
acknowledged.getByRole("button", { name: "Acknowledge" }),
|
||||
).toHaveCount(0);
|
||||
|
||||
const muted = frigateApp.page.getByTestId(
|
||||
"health-problem-notice:model_download_failed:yolo/model.onnx",
|
||||
);
|
||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||
await expect(row).toContainText("Dismissed");
|
||||
await expect(row.getByRole("button", { name: "Dismiss" })).toHaveCount(0);
|
||||
await expect(muted).toContainText("Muted");
|
||||
await expect(muted.getByRole("button", { name: "Unmute" })).toBeVisible();
|
||||
await expect(
|
||||
muted.getByRole("button", { name: "Mute", exact: true }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await showDismissed.click();
|
||||
await expect(row).toHaveCount(0);
|
||||
await frigateApp.page.getByRole("button", { name: "Filter" }).click();
|
||||
await showHidden.click();
|
||||
await expect(muted).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("clear dismissed deletes the dismissed rows after confirming", async ({
|
||||
test("unmute deletes the row's hidden state", async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults({ stats: QUIET_STATS, notices: [] });
|
||||
await frigateApp.page.route(
|
||||
(url) =>
|
||||
url.pathname.endsWith("/api/notices") &&
|
||||
url.searchParams.get("include_hidden") === "true",
|
||||
(route) => route.fulfill({ json: [MUTED_NOTICE] }),
|
||||
);
|
||||
await frigateApp.page.route(
|
||||
"**/api/notices/model_download_failed:yolo/model.onnx/hidden",
|
||||
(route) => route.fulfill({ json: { success: true } }),
|
||||
);
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
await frigateApp.page.getByRole("button", { name: "Filter" }).click();
|
||||
await frigateApp.page.getByRole("switch", { name: "Show hidden" }).click();
|
||||
await frigateApp.page.keyboard.press("Escape");
|
||||
|
||||
const request = frigateApp.page.waitForRequest(
|
||||
(req) =>
|
||||
req
|
||||
.url()
|
||||
.endsWith(
|
||||
"/api/notices/model_download_failed:yolo/model.onnx/hidden",
|
||||
) && req.method() === "DELETE",
|
||||
);
|
||||
await frigateApp.page
|
||||
.getByTestId(
|
||||
"health-problem-notice:model_download_failed:yolo/model.onnx",
|
||||
)
|
||||
.getByRole("button", { name: "Unmute" })
|
||||
.click({ timeout: 15_000 });
|
||||
await request;
|
||||
});
|
||||
|
||||
test("show all again unhides every row after confirming", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
@@ -203,29 +282,25 @@ test.describe("System — Health tab @medium", () => {
|
||||
notices: [EVENT_NOTICE],
|
||||
});
|
||||
|
||||
// the history loses its dismissed row once the DELETE lands
|
||||
// the hidden list loses its muted row once the DELETE lands
|
||||
let cleared = false;
|
||||
await frigateApp.page.route(
|
||||
(url) =>
|
||||
url.pathname.endsWith("/api/notices") &&
|
||||
url.searchParams.get("include_dismissed") === "true",
|
||||
url.searchParams.get("include_hidden") === "true",
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
json: cleared
|
||||
? [EVENT_NOTICE]
|
||||
: [EVENT_NOTICE, { ...ERROR_NOTICE, dismissed_at: NOW - 120 }],
|
||||
json: cleared ? [EVENT_NOTICE] : [EVENT_NOTICE, MUTED_NOTICE],
|
||||
}),
|
||||
);
|
||||
await frigateApp.page.route("**/api/notices/dismissed", (route) => {
|
||||
await frigateApp.page.route("**/api/notices/hidden", (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();
|
||||
await frigateApp.page.getByRole("switch", { name: "Show hidden" }).click();
|
||||
const row = frigateApp.page.getByTestId(
|
||||
"health-problem-notice:model_download_failed:yolo/model.onnx",
|
||||
);
|
||||
@@ -233,23 +308,20 @@ test.describe("System — Health tab @medium", () => {
|
||||
await frigateApp.page.keyboard.press("Escape");
|
||||
|
||||
await frigateApp.page
|
||||
.getByRole("button", { name: "Clear dismissed" })
|
||||
.getByRole("button", { name: "Show all again" })
|
||||
.click();
|
||||
const request = frigateApp.page.waitForRequest(
|
||||
(req) =>
|
||||
req.url().endsWith("/api/notices/dismissed") &&
|
||||
req.method() === "DELETE",
|
||||
req.url().endsWith("/api/notices/hidden") && req.method() === "DELETE",
|
||||
);
|
||||
await frigateApp.page
|
||||
.getByRole("alertdialog")
|
||||
.getByRole("button", { name: "Clear dismissed" })
|
||||
.getByRole("button", { name: "Show all again" })
|
||||
.click();
|
||||
await request;
|
||||
|
||||
await expect(row).toHaveCount(0);
|
||||
await expect(
|
||||
frigateApp.page.getByText("No dismissed notices"),
|
||||
).toBeVisible();
|
||||
await expect(frigateApp.page.getByText("No hidden notices")).toBeVisible();
|
||||
});
|
||||
|
||||
test("severity switches hide notices of that severity", async ({
|
||||
@@ -290,7 +362,9 @@ test.describe("System — Health tab @medium", () => {
|
||||
first_seen: start,
|
||||
last_seen: start + 60,
|
||||
count,
|
||||
dismissed_at: null,
|
||||
acknowledgeable: true,
|
||||
acknowledged_at: null,
|
||||
muted_at: null,
|
||||
});
|
||||
await frigateApp.installDefaults({
|
||||
stats: QUIET_STATS,
|
||||
@@ -332,7 +406,9 @@ test.describe("System — Health tab @medium", () => {
|
||||
first_seen: NOW - 600,
|
||||
last_seen: NOW - 600,
|
||||
count: 1,
|
||||
dismissed_at: null,
|
||||
acknowledgeable: true,
|
||||
acknowledged_at: null,
|
||||
muted_at: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -363,7 +439,9 @@ test.describe("System — Health tab @medium", () => {
|
||||
first_seen: NOW - 600,
|
||||
last_seen: NOW - 600,
|
||||
count: 1,
|
||||
dismissed_at: null,
|
||||
acknowledgeable: true,
|
||||
acknowledged_at: null,
|
||||
muted_at: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -695,7 +773,7 @@ test.describe("System — Health notices sources @medium", () => {
|
||||
|
||||
// the status bar shows a problem, so stats have loaded
|
||||
await expect(
|
||||
frigateApp.page.getByText("Front Door is offline"),
|
||||
frigateApp.page.getByText("Recordings are being deleted"),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await expect(
|
||||
frigateApp.page.locator("[data-testid^='health-problem-']"),
|
||||
@@ -1046,7 +1124,7 @@ test.describe("System — Health notices sources @medium", () => {
|
||||
await expect(frigateApp.page).toHaveURL(/\/system#health/);
|
||||
});
|
||||
|
||||
test("status bar counts undismissed notices next to the health text", async ({
|
||||
test("status bar counts shown notices next to the health text", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "Status bar is desktop-only");
|
||||
@@ -1089,41 +1167,61 @@ test.describe("System — Health notices sources @medium", () => {
|
||||
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 slow detector warning is added before the offline error
|
||||
const TWO_PROBLEM_STATS = {
|
||||
detectors: { cpu: { inference_speed: 60 } },
|
||||
cameras: { front_door: { camera_fps: 0 } },
|
||||
};
|
||||
|
||||
// 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");
|
||||
test("status bar collapses several problems behind the most severe", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "Status bar is desktop-only");
|
||||
await frigateApp.installDefaults({ stats: TWO_PROBLEM_STATS });
|
||||
await frigateApp.goto("/");
|
||||
|
||||
const 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);
|
||||
const summary = frigateApp.page.getByRole("button", {
|
||||
name: "Front Door is offline +1",
|
||||
});
|
||||
await expect(summary).toBeVisible({ timeout: 15_000 });
|
||||
await expect(frigateApp.page.getByText("Cpu is slow")).toHaveCount(0);
|
||||
|
||||
await summary.click();
|
||||
const list = frigateApp.page.getByTestId("status-message-list");
|
||||
await expect(list.getByRole("link")).toHaveText([
|
||||
"Front Door is offline",
|
||||
"Cpu is slow (60 ms)",
|
||||
]);
|
||||
|
||||
await list.getByRole("link", { name: "Cpu is slow (60 ms)" }).click();
|
||||
await expect(frigateApp.page).toHaveURL(/\/system#general/);
|
||||
await expect(list).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("dismissed config rows move to the dismissed list", async ({
|
||||
test("mobile status drawer stacks every problem", async ({ frigateApp }) => {
|
||||
test.skip(!frigateApp.isMobile, "Mobile-only");
|
||||
await frigateApp.installDefaults({ stats: TWO_PROBLEM_STATS });
|
||||
await frigateApp.goto("/");
|
||||
|
||||
await frigateApp.page
|
||||
.getByTestId("status-alert-trigger")
|
||||
.click({ timeout: 15_000 });
|
||||
const items = frigateApp.page
|
||||
.getByTestId("status-message-list")
|
||||
.getByRole("link");
|
||||
await expect(items).toHaveText([
|
||||
"Front Door is offline",
|
||||
"Cpu is slow (60 ms)",
|
||||
]);
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
items.nth(0).boundingBox(),
|
||||
items.nth(1).boundingBox(),
|
||||
]);
|
||||
expect(second!.y).toBeGreaterThan(first!.y);
|
||||
});
|
||||
|
||||
test("a config row can be muted but not acknowledged", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const id = "config:detect:fps-greater-than-five:camera.garage";
|
||||
@@ -1134,12 +1232,49 @@ test.describe("System — Health notices sources @medium", () => {
|
||||
},
|
||||
},
|
||||
stats: QUIET_STATS,
|
||||
dismissedChecks: [{ id, dismissed_at: NOW - 120 }],
|
||||
});
|
||||
|
||||
// the list gains the mute after the POST so the refetch hides the row
|
||||
let muted = false;
|
||||
await frigateApp.page.route(`**/api/notices/${id}/mute`, (route) => {
|
||||
muted = true;
|
||||
return route.fulfill({ json: { success: true } });
|
||||
});
|
||||
await frigateApp.page.route("**/api/notices/muted_checks", (route) =>
|
||||
route.fulfill({ json: muted ? [{ id, muted_at: NOW }] : [] }),
|
||||
);
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
const row = frigateApp.page.getByTestId(`health-problem-${id}`);
|
||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||
await expect(row.getByRole("button", { name: "Acknowledge" })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
const request = frigateApp.page.waitForRequest(
|
||||
(req) =>
|
||||
req.url().includes(`/api/notices/${id}/mute`) &&
|
||||
req.method() === "POST",
|
||||
);
|
||||
await row.getByRole("button", { name: "Mute", exact: true }).click();
|
||||
await request;
|
||||
await expect(row).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("muted config rows move to the hidden 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,
|
||||
mutedChecks: [{ id, muted_at: NOW - 120 }],
|
||||
});
|
||||
await frigateApp.page.route(
|
||||
(url) =>
|
||||
url.pathname.endsWith("/api/notices") &&
|
||||
url.searchParams.get("include_dismissed") === "true",
|
||||
url.searchParams.get("include_hidden") === "true",
|
||||
(route) => route.fulfill({ json: [] }),
|
||||
);
|
||||
await frigateApp.goto("/system#health");
|
||||
@@ -1153,12 +1288,14 @@ test.describe("System — Health notices sources @medium", () => {
|
||||
await expect(row).toHaveCount(0);
|
||||
|
||||
await frigateApp.page.getByRole("button", { name: "Filter" }).click();
|
||||
await frigateApp.page
|
||||
.getByRole("switch", { name: "Show dismissed" })
|
||||
.click();
|
||||
await frigateApp.page.getByRole("switch", { name: "Show hidden" }).click();
|
||||
await frigateApp.page.keyboard.press("Escape");
|
||||
|
||||
await expect(row).toBeVisible();
|
||||
await expect(row).toContainText("Dismissed");
|
||||
await expect(row.getByRole("button", { name: "Dismiss" })).toHaveCount(0);
|
||||
await expect(row).toContainText("Muted");
|
||||
await expect(row.getByRole("button", { name: "Unmute" })).toBeVisible();
|
||||
await expect(
|
||||
row.getByRole("button", { name: "Mute", exact: true }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1567,6 +1567,8 @@
|
||||
"presetLabels": {
|
||||
"preset-rpi-64-h264": "Raspberry Pi (H.264)",
|
||||
"preset-rpi-64-h265": "Raspberry Pi (H.265)",
|
||||
"preset-apple-silicon-h264": "Apple Silicon (H.264)",
|
||||
"preset-apple-silicon-h265": "Apple Silicon (H.265)",
|
||||
"preset-vaapi": "VAAPI (Intel/AMD GPU)",
|
||||
"preset-intel-qsv-h264": "Intel QuickSync (H.264)",
|
||||
"preset-intel-qsv-h265": "Intel QuickSync (H.265)",
|
||||
@@ -2051,7 +2053,7 @@
|
||||
"unrecognized": "This model is configured for hardware that was not found on this system: {{devices}}",
|
||||
"description": "The hardware this model runs its detection on.",
|
||||
"detectorCountDescription": "How many detection processes to run on this hardware. More detectors keep up with more cameras, at the cost of extra device memory.",
|
||||
"unitsDescription": "Each unit runs its own detection process. A unit already used by another model can not be selected."
|
||||
"unitsDescription": "Each unit runs its own detection process. A unit that can't be shared, such as a Coral, can only be used by one model."
|
||||
},
|
||||
"tabs": {
|
||||
"plus": "Frigate+",
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"rkmpp": "RKMPP (Rockchip)",
|
||||
"jetson": "NVIDIA Jetson",
|
||||
"rpi": "V4L2 (Raspberry Pi)",
|
||||
"apple-silicon": "Media engine (Apple Silicon)",
|
||||
"none": "None (software decoding)"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -19,20 +19,26 @@
|
||||
"notices": {
|
||||
"title": "Notices",
|
||||
"empty": "Your Frigate installation is healthy",
|
||||
"dismiss": "Dismiss",
|
||||
"acknowledge": "Acknowledge",
|
||||
"acknowledgeHint": "Hide until this happens again",
|
||||
"mute": "Mute",
|
||||
"muteHint": "Never show this again",
|
||||
"unmute": "Unmute",
|
||||
"showAgain": "Show again",
|
||||
"openSettings": "Open settings",
|
||||
"openLink": "Open link",
|
||||
"noMatches": "No notices match the filter",
|
||||
"dismissedTitle": "Dismissed",
|
||||
"noneDismissed": "No dismissed notices",
|
||||
"clearDismissed": "Clear dismissed",
|
||||
"clearDismissedTitle": "Clear dismissed notices?",
|
||||
"clearDismissedDesc": "Every dismissed notice is deleted. Config and stream checks show again right away, and other notices return the next time they happen.",
|
||||
"hiddenTitle": "Hidden",
|
||||
"noneHidden": "No hidden notices",
|
||||
"showAll": "Show all again",
|
||||
"showAllTitle": "Show all hidden notices?",
|
||||
"showAllDesc": "Every acknowledged and muted notice returns to the Notices list, including config and stream checks.",
|
||||
"firstSeen_one": "First seen {{time}}",
|
||||
"firstSeen_other": "First seen {{time}} · {{count}} times",
|
||||
"dismissedAt": "Dismissed {{time}}",
|
||||
"acknowledgedAt": "Acknowledged {{time}}",
|
||||
"mutedAt": "Muted {{time}}",
|
||||
"filter": {
|
||||
"showDismissed": "Show dismissed",
|
||||
"showHidden": "Show hidden",
|
||||
"severity": "Severity",
|
||||
"error": "Error",
|
||||
"warning": "Warning",
|
||||
@@ -42,6 +48,8 @@
|
||||
"detector_stuck": "Detector {{detector}} was restarted after it stopped responding",
|
||||
"model_download_failed": "Downloading {{file}} for {{model}} failed: {{error}}",
|
||||
"skipped_detections": "Detection could not keep up and skipped {{pct}}% of frames for over a minute",
|
||||
"ffmpeg_high_cpu": "FFmpeg CPU usage is high ({{cpu}}% average)",
|
||||
"detect_high_cpu": "Detection CPU usage is high ({{cpu}}% average)",
|
||||
"shm_too_low": "/dev/shm allocation ({{total}} MB) should be increased to at least {{min}} MB",
|
||||
"failed_login_one": "Failed login attempt for {{user}}",
|
||||
"failed_login_other": "Failed login attempts for {{user}}",
|
||||
@@ -324,12 +332,12 @@
|
||||
},
|
||||
"lastRefreshed": "Last refreshed: ",
|
||||
"stats": {
|
||||
"ffmpegHighCpuUsage": "{{camera}} has high FFmpeg CPU usage ({{ffmpegAvg}}%)",
|
||||
"cameraSkippedDetections": "{{camera}} is skipping detection on {{pct}}% of frames",
|
||||
"detectHighCpuUsage": "{{camera}} has high detect CPU usage ({{detectAvg}}%)",
|
||||
"healthy": "System is healthy",
|
||||
"systemNotices_one": "{{count}} system notice",
|
||||
"systemNotices_other": "{{count}} system notices",
|
||||
"moreMessages_one": "+{{count}}",
|
||||
"moreMessages_other": "+{{count}}",
|
||||
"reindexingEmbeddings": "Reindexing embeddings ({{processed}}% complete)",
|
||||
"cameraIsOffline": "{{camera}} is offline",
|
||||
"detectIsSlow": "{{detect}} is slow ({{speed}} ms)",
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { StatusMessage } from "@/context/statusbar-context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ProblemSeverity } from "@/types/stats";
|
||||
import { IoIosWarning } from "react-icons/io";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
const SEVERITY_COLOR: Record<ProblemSeverity, string> = {
|
||||
error: "text-danger",
|
||||
warning: "text-orange-400",
|
||||
info: "text-selected",
|
||||
};
|
||||
|
||||
type StatusMessageItemProps = {
|
||||
message: StatusMessage;
|
||||
className?: string;
|
||||
onNavigate?: () => void;
|
||||
};
|
||||
|
||||
/** One status bar message, a link when it has one. */
|
||||
export function StatusMessageItem({
|
||||
message,
|
||||
className,
|
||||
onNavigate,
|
||||
}: StatusMessageItemProps) {
|
||||
const content = (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm",
|
||||
message.link && "cursor-pointer hover:underline",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<IoIosWarning
|
||||
className={cn("size-5 shrink-0", SEVERITY_COLOR[message.severity])}
|
||||
/>
|
||||
{message.text}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!message.link) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link to={message.link} onClick={onNavigate}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
type StatusMessageListProps = {
|
||||
messages: StatusMessage[];
|
||||
className?: string;
|
||||
onNavigate?: () => void;
|
||||
};
|
||||
|
||||
/** Status bar messages stacked one per line. */
|
||||
export default function StatusMessageList({
|
||||
messages,
|
||||
className,
|
||||
onNavigate,
|
||||
}: StatusMessageListProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
data-testid="status-message-list"
|
||||
>
|
||||
{messages.map((message, index) => (
|
||||
// ids are unique only within a message key
|
||||
<StatusMessageItem
|
||||
key={`${index}:${message.id}`}
|
||||
message={message}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,24 @@
|
||||
import { useEmbeddingsReindexProgress } from "@/api/ws";
|
||||
import { StatusMessage } from "@/context/statusbar-context";
|
||||
import { useAutoFrigateStats } from "@/hooks/use-stats";
|
||||
import useStatusMessages from "@/hooks/use-status-messages";
|
||||
import StatusMessageList, {
|
||||
StatusMessageItem,
|
||||
} from "@/components/StatusMessageList";
|
||||
import {
|
||||
StatusBarMessagesContext,
|
||||
StatusMessage,
|
||||
} from "@/context/statusbar-context";
|
||||
import useStats, { useAutoFrigateStats } from "@/hooks/use-stats";
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import StatusBarNotices from "@/components/health/StatusBarNotices";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ProfilesApiResponse } from "@/types/profile";
|
||||
import { getProfileColor } from "@/utils/profileColors";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import { useContext, useEffect, useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useSWR from "swr";
|
||||
|
||||
import { FaCheck } from "react-icons/fa";
|
||||
import { IoIosWarning } from "react-icons/io";
|
||||
import { MdCircle } from "react-icons/md";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
@@ -22,9 +26,7 @@ export default function Statusbar() {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
const { messages, addMessage, clearMessages } = useContext(
|
||||
StatusBarMessagesContext,
|
||||
)!;
|
||||
const messages = useStatusMessages();
|
||||
|
||||
const stats = useAutoFrigateStats();
|
||||
|
||||
@@ -38,21 +40,6 @@ export default function Statusbar() {
|
||||
return parseInt(systemCpu);
|
||||
}, [stats]);
|
||||
|
||||
const { potentialProblems } = useStats(stats);
|
||||
|
||||
useEffect(() => {
|
||||
clearMessages("stats");
|
||||
potentialProblems.forEach((problem) => {
|
||||
addMessage(
|
||||
"stats",
|
||||
problem.text,
|
||||
problem.color,
|
||||
undefined,
|
||||
problem.relevantLink,
|
||||
);
|
||||
});
|
||||
}, [potentialProblems, addMessage, clearMessages]);
|
||||
|
||||
const { data: profilesData } = useSWR<ProfilesApiResponse>("profiles");
|
||||
|
||||
const activeProfile = useMemo(() => {
|
||||
@@ -68,28 +55,6 @@ export default function Statusbar() {
|
||||
};
|
||||
}, [profilesData]);
|
||||
|
||||
const { payload: reindexState } = useEmbeddingsReindexProgress();
|
||||
|
||||
useEffect(() => {
|
||||
if (reindexState) {
|
||||
if (reindexState.status == "indexing") {
|
||||
clearMessages("embeddings-reindex");
|
||||
addMessage(
|
||||
"embeddings-reindex",
|
||||
t("stats.reindexingEmbeddings", {
|
||||
processed: Math.floor(
|
||||
(reindexState.processed_objects / reindexState.total_objects) *
|
||||
100,
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (reindexState.status === "completed") {
|
||||
clearMessages("embeddings-reindex");
|
||||
}
|
||||
}
|
||||
}, [reindexState, addMessage, clearMessages, t]);
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-0 left-0 right-0 z-10 flex h-8 w-full items-center justify-between border-t border-secondary-highlight bg-background_alt px-4 dark:text-secondary-foreground">
|
||||
<div className="flex h-full items-center gap-2">
|
||||
@@ -187,42 +152,57 @@ export default function Statusbar() {
|
||||
))}
|
||||
</div>
|
||||
<div className="no-scrollbar flex h-full max-w-[50%] items-center gap-2 overflow-x-auto">
|
||||
{!isAdmin ? null : Object.entries(messages).length === 0 ? (
|
||||
{!isAdmin ? null : messages.length === 0 ? (
|
||||
<Link to="/system#health" className="flex items-center gap-2 text-sm">
|
||||
<FaCheck className="size-3 text-green-500" />
|
||||
{t("stats.healthy")}
|
||||
</Link>
|
||||
) : messages.length === 1 ? (
|
||||
<StatusMessageItem
|
||||
message={messages[0]}
|
||||
className="whitespace-nowrap"
|
||||
/>
|
||||
) : (
|
||||
Object.entries(messages).map(([key, messageArray]) => (
|
||||
<div key={key} className="flex h-full items-center gap-2">
|
||||
{messageArray.map(({ text, color, link }: StatusMessage) => {
|
||||
const message = (
|
||||
<div
|
||||
key={text}
|
||||
className={`flex items-center gap-2 whitespace-nowrap text-sm ${link ? "cursor-pointer hover:underline" : ""}`}
|
||||
>
|
||||
<IoIosWarning
|
||||
className={`size-5 ${color || "text-danger"}`}
|
||||
/>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (link) {
|
||||
return (
|
||||
<Link key={text} to={link}>
|
||||
{message}
|
||||
</Link>
|
||||
);
|
||||
} else {
|
||||
return message;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
<StatusMessagesPopover messages={messages} />
|
||||
)}
|
||||
{isAdmin && <StatusBarNotices />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type StatusMessagesPopoverProps = {
|
||||
messages: StatusMessage[];
|
||||
};
|
||||
|
||||
/** The most severe message and a count of the rest, which open the full list. */
|
||||
function StatusMessagesPopover({ messages }: StatusMessagesPopoverProps) {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [first, ...rest] = messages;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 text-sm hover:underline"
|
||||
>
|
||||
<StatusMessageItem
|
||||
message={{ ...first, link: undefined }}
|
||||
className="whitespace-nowrap"
|
||||
/>
|
||||
<span className="shrink-0 rounded-full bg-secondary px-1.5 text-xs text-secondary-foreground">
|
||||
{t("stats.moreMessages", { count: rest.length })}
|
||||
</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="top" align="end" className="w-auto max-w-md">
|
||||
<StatusMessageList
|
||||
messages={messages}
|
||||
onNavigate={() => setOpen(false)}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import { RJSFSchema } from "@rjsf/utils";
|
||||
import { omit } from "lodash";
|
||||
import { applySchemaDefaults } from "@/lib/config-schema";
|
||||
import { isJsonObject } from "@/lib/utils";
|
||||
import { HiddenFieldContext, JsonObject, JsonValue } from "@/types/configForm";
|
||||
@@ -352,6 +353,17 @@ export function synthesizeMissingFilters(
|
||||
return { ...(data as JsonObject), filters: newFilters };
|
||||
}
|
||||
|
||||
// the backend fills these from the Frigate+ model info when it loads a
|
||||
// plus:// model, so saving them would only pin values Frigate+ owns
|
||||
const PLUS_SUPPLIED_MODEL_FIELDS = [
|
||||
"width",
|
||||
"height",
|
||||
"input_tensor",
|
||||
"input_pixel_format",
|
||||
"input_dtype",
|
||||
"model_type",
|
||||
];
|
||||
|
||||
/**
|
||||
* Sanitize overrides payloads for section-specific quirks.
|
||||
*/
|
||||
@@ -360,6 +372,21 @@ export function sanitizeOverridesForSection(
|
||||
level: string,
|
||||
overrides: unknown,
|
||||
): unknown {
|
||||
// the models list is saved whole
|
||||
if (sectionPath === "models" && Array.isArray(overrides)) {
|
||||
return overrides.map((model) => {
|
||||
if (
|
||||
!isJsonObject(model) ||
|
||||
typeof model.path !== "string" ||
|
||||
!model.path.startsWith("plus://")
|
||||
) {
|
||||
return model;
|
||||
}
|
||||
|
||||
return omit(model, PLUS_SUPPLIED_MODEL_FIELDS);
|
||||
});
|
||||
}
|
||||
|
||||
if (!overrides || !isJsonObject(overrides)) {
|
||||
return overrides;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@ type HardwarePickerProps = {
|
||||
// scopes the unit checkbox ids, since several models can list the same unit
|
||||
idPrefix: string;
|
||||
devices: string[];
|
||||
// device strings already taken by another model, mapped to that model's scene
|
||||
// device strings already taken by another model, mapped to that model's
|
||||
// scene. Only binding for hardware that can't be shared.
|
||||
claimedElsewhere: Record<string, string>;
|
||||
cameraCount: number;
|
||||
disabled?: boolean;
|
||||
@@ -84,8 +85,11 @@ export function HardwarePicker({
|
||||
return;
|
||||
}
|
||||
|
||||
// start with the first unit no other model has taken
|
||||
const free = entry.units.find((unit) => !claimedElsewhere[unit.device]);
|
||||
// start with the first unit no other model has taken, falling back to
|
||||
// a taken one when the hardware can be shared
|
||||
const free =
|
||||
entry.units.find((unit) => !claimedElsewhere[unit.device]) ??
|
||||
(entry.unlimited ? entry.units[0] : undefined);
|
||||
|
||||
if (!free) {
|
||||
onChange([]);
|
||||
@@ -184,7 +188,9 @@ export function HardwarePicker({
|
||||
{t("detectionModels.hardware.unitsDescription")}
|
||||
</p>
|
||||
{selected.units.map((unit) => {
|
||||
const claimedBy = claimedElsewhere[unit.device];
|
||||
const claimedBy = selected.unlimited
|
||||
? undefined
|
||||
: claimedElsewhere[unit.device];
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -38,9 +38,7 @@ function plusModelId(path: unknown): string | undefined {
|
||||
|
||||
type ModelSourcePickerProps = {
|
||||
path: unknown;
|
||||
// Frigate+ metadata the backend attaches to a saved model, and the only
|
||||
// reliable signal that one is active: it resolves `plus://<id>` to a local
|
||||
// cache path before serving the config back
|
||||
// Frigate+ metadata the backend attaches to a saved model
|
||||
plus?: { id: string } | null;
|
||||
// the detector this model runs on, used to filter incompatible Plus models
|
||||
detector?: string;
|
||||
|
||||
@@ -169,20 +169,23 @@ export function ModelsField(props: FieldProps) {
|
||||
[savedModels],
|
||||
);
|
||||
|
||||
// a model serves the cameras naming its scene, plus every camera that names
|
||||
// no scene at all when it is the "all" model
|
||||
// a model serves the cameras naming its scene, and like the backend, the
|
||||
// "all" model also serves every camera whose scene has no model of its own
|
||||
const cameraCountForScene = useCallback(
|
||||
(scene: string | undefined): number => {
|
||||
if (!cameras) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const modelScenes = new Set(models.map((model) => model.scene ?? "all"));
|
||||
|
||||
return Object.values(cameras).filter((camera) => {
|
||||
const cameraScene = camera?.detect?.scene;
|
||||
return cameraScene ? cameraScene === scene : scene === "all";
|
||||
const cameraScene = camera?.detect?.scene ?? "all";
|
||||
const servedBy = modelScenes.has(cameraScene) ? cameraScene : "all";
|
||||
return servedBy === (scene ?? "all");
|
||||
}).length;
|
||||
},
|
||||
[cameras],
|
||||
[cameras, models],
|
||||
);
|
||||
|
||||
const claimedByOtherModels = useCallback(
|
||||
@@ -314,7 +317,9 @@ export function ModelsField(props: FieldProps) {
|
||||
);
|
||||
|
||||
return (
|
||||
<Card key={`${baseId}-${index}`} className="w-full">
|
||||
// keyed by scene, which is unique per model, so deleting a card
|
||||
// doesn't hand its state (such as the model source tab) to the next
|
||||
<Card key={`${baseId}-${model.scene ?? index}`} className="w-full">
|
||||
<Collapsible
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) =>
|
||||
|
||||
@@ -2,7 +2,11 @@ import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaTriangleExclamation } from "react-icons/fa6";
|
||||
import {
|
||||
LuBell,
|
||||
LuBellOff,
|
||||
LuCheck,
|
||||
LuExternalLink,
|
||||
LuEye,
|
||||
LuInfo,
|
||||
LuSlidersHorizontal,
|
||||
LuX,
|
||||
@@ -51,7 +55,13 @@ export default function HealthProblemRow({ problem }: HealthProblemRowProps) {
|
||||
problem.link ||
|
||||
problem.docLink ||
|
||||
problem.externalLink ||
|
||||
problem.onDismiss;
|
||||
problem.onAcknowledge ||
|
||||
problem.onMute ||
|
||||
problem.onUnhide;
|
||||
const unhideLabel =
|
||||
problem.hidden === "muted"
|
||||
? t("health.notices.unmute")
|
||||
: t("health.notices.showAgain");
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -150,16 +160,46 @@ export default function HealthProblemRow({ problem }: HealthProblemRowProps) {
|
||||
</Button>
|
||||
</RowAction>
|
||||
)}
|
||||
{problem.onDismiss && (
|
||||
<RowAction label={t("health.notices.dismiss")}>
|
||||
{problem.onAcknowledge && (
|
||||
<RowAction label={t("health.notices.acknowledgeHint")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={ICON_BUTTON_CLASS}
|
||||
aria-label={t("health.notices.dismiss")}
|
||||
onClick={problem.onDismiss}
|
||||
aria-label={t("health.notices.acknowledge")}
|
||||
onClick={problem.onAcknowledge}
|
||||
>
|
||||
<LuX className="size-3.5" />
|
||||
<LuCheck className="size-3.5" />
|
||||
</Button>
|
||||
</RowAction>
|
||||
)}
|
||||
{problem.onMute && (
|
||||
<RowAction label={t("health.notices.muteHint")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={ICON_BUTTON_CLASS}
|
||||
aria-label={t("health.notices.mute")}
|
||||
onClick={problem.onMute}
|
||||
>
|
||||
<LuBellOff className="size-3.5" />
|
||||
</Button>
|
||||
</RowAction>
|
||||
)}
|
||||
{problem.onUnhide && (
|
||||
<RowAction label={unhideLabel}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={ICON_BUTTON_CLASS}
|
||||
aria-label={unhideLabel}
|
||||
onClick={problem.onUnhide}
|
||||
>
|
||||
{problem.hidden === "muted" ? (
|
||||
<LuBell className="size-3.5" />
|
||||
) : (
|
||||
<LuEye className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</RowAction>
|
||||
)}
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function NoticeFilterButton({
|
||||
const { t } = useTranslation(["views/system", "components/filter"]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const active =
|
||||
filter.showDismissed ||
|
||||
filter.showHidden ||
|
||||
filter.severities.length < DEFAULT_NOTICE_FILTER.severities.length;
|
||||
|
||||
const severityLabels: Record<HealthSeverity, string> = {
|
||||
@@ -59,10 +59,10 @@ export default function NoticeFilterButton({
|
||||
const content = (
|
||||
<div className="space-y-3 p-4">
|
||||
<FilterSwitch
|
||||
label={t("health.notices.filter.showDismissed")}
|
||||
isChecked={filter.showDismissed}
|
||||
onCheckedChange={(showDismissed) =>
|
||||
onFilterChange({ ...filter, showDismissed })
|
||||
label={t("health.notices.filter.showHidden")}
|
||||
isChecked={filter.showHidden}
|
||||
onCheckedChange={(showHidden) =>
|
||||
onFilterChange({ ...filter, showHidden })
|
||||
}
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
@@ -23,9 +23,9 @@ type NoticesPaneProps = {
|
||||
|
||||
export default function NoticesPane({ filter }: NoticesPaneProps) {
|
||||
const { t } = useTranslation(["views/system", "views/settings", "common"]);
|
||||
const { problems, dismissed, loading, clearDismissed } = useHealthProblems(
|
||||
const { problems, hidden, loading, unhideAll } = useHealthProblems(
|
||||
t,
|
||||
filter.showDismissed,
|
||||
filter.showHidden,
|
||||
);
|
||||
const [confirmClear, setConfirmClear] = useState(false);
|
||||
|
||||
@@ -37,12 +37,10 @@ export default function NoticesPane({ filter }: NoticesPaneProps) {
|
||||
[problems, filter.severities],
|
||||
);
|
||||
|
||||
const shownDismissed = useMemo(
|
||||
const shownHidden = useMemo(
|
||||
() =>
|
||||
dismissed?.filter((problem) =>
|
||||
filter.severities.includes(problem.severity),
|
||||
),
|
||||
[dismissed, filter.severities],
|
||||
hidden?.filter((problem) => filter.severities.includes(problem.severity)),
|
||||
[hidden, filter.severities],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -70,32 +68,32 @@ export default function NoticesPane({ filter }: NoticesPaneProps) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{filter.showDismissed && (
|
||||
{filter.showHidden && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("health.notices.dismissedTitle")}
|
||||
{t("health.notices.hiddenTitle")}
|
||||
</div>
|
||||
{dismissed && dismissed.length > 0 && (
|
||||
{hidden && hidden.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setConfirmClear(true)}
|
||||
>
|
||||
{t("health.notices.clearDismissed")}
|
||||
{t("health.notices.showAll")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
|
||||
{shownDismissed === undefined ? (
|
||||
{shownHidden === undefined ? (
|
||||
<Skeleton className="h-10 w-full" />
|
||||
) : shownDismissed.length === 0 ? (
|
||||
) : shownHidden.length === 0 ? (
|
||||
<div className="px-1 py-2 text-sm text-muted-foreground">
|
||||
{t("health.notices.noneDismissed")}
|
||||
{t("health.notices.noneHidden")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{shownDismissed.map((problem) => (
|
||||
{shownHidden.map((problem) => (
|
||||
<HealthProblemRow key={problem.id} problem={problem} />
|
||||
))}
|
||||
</div>
|
||||
@@ -107,10 +105,10 @@ export default function NoticesPane({ filter }: NoticesPaneProps) {
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("health.notices.clearDismissedTitle")}
|
||||
{t("health.notices.showAllTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("health.notices.clearDismissedDesc")}
|
||||
{t("health.notices.showAllDesc")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
@@ -119,9 +117,9 @@ export default function NoticesPane({ filter }: NoticesPaneProps) {
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className={buttonVariants({ variant: "destructive" })}
|
||||
onClick={clearDismissed}
|
||||
onClick={unhideAll}
|
||||
>
|
||||
{t("health.notices.clearDismissed")}
|
||||
{t("health.notices.showAll")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useHealthProblems } from "@/hooks/use-health-problems";
|
||||
|
||||
/** The count of undismissed Notices rows, shown before the status bar's health text. */
|
||||
/** The count of shown Notices rows, shown before the status bar's health text. */
|
||||
export default function StatusBarNotices() {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
const { problems, loading } = useHealthProblems(t);
|
||||
|
||||
@@ -1,30 +1,15 @@
|
||||
import NavItem from "./NavItem";
|
||||
import { IoIosWarning } from "react-icons/io";
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
|
||||
import useSWR from "swr";
|
||||
import { FrigateStats } from "@/types/stats";
|
||||
import { useEmbeddingsReindexProgress, useFrigateStats } from "@/api/ws";
|
||||
import {
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import useStats from "@/hooks/use-stats";
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import useStatusMessages from "@/hooks/use-status-messages";
|
||||
import StatusMessageList from "../StatusMessageList";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import GeneralSettings from "../menu/GeneralSettings";
|
||||
import useNavigation from "@/hooks/use-navigation";
|
||||
import {
|
||||
StatusBarMessagesContext,
|
||||
StatusMessage,
|
||||
} from "@/context/statusbar-context";
|
||||
import { Link } from "react-router-dom";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { isPWA } from "@/utils/isPWA";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function Bottombar() {
|
||||
const navItems = useNavigation("secondary");
|
||||
@@ -98,64 +83,12 @@ type StatusAlertNavProps = {
|
||||
large?: boolean;
|
||||
};
|
||||
function StatusAlertNav({ className, large }: StatusAlertNavProps) {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
const { data: initialStats } = useSWR<FrigateStats>("stats", {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
const latestStats = useFrigateStats();
|
||||
|
||||
const { messages, addMessage, clearMessages } = useContext(
|
||||
StatusBarMessagesContext,
|
||||
)!;
|
||||
|
||||
const stats = useMemo(() => {
|
||||
if (latestStats) {
|
||||
return latestStats;
|
||||
}
|
||||
|
||||
return initialStats;
|
||||
}, [initialStats, latestStats]);
|
||||
const { potentialProblems } = useStats(stats);
|
||||
|
||||
useEffect(() => {
|
||||
clearMessages("stats");
|
||||
potentialProblems.forEach((problem) => {
|
||||
addMessage(
|
||||
"stats",
|
||||
problem.text,
|
||||
problem.color,
|
||||
undefined,
|
||||
problem.relevantLink,
|
||||
);
|
||||
});
|
||||
}, [potentialProblems, addMessage, clearMessages]);
|
||||
|
||||
const { payload: reindexState } = useEmbeddingsReindexProgress();
|
||||
|
||||
useEffect(() => {
|
||||
if (reindexState) {
|
||||
if (reindexState.status == "indexing") {
|
||||
clearMessages("embeddings-reindex");
|
||||
addMessage(
|
||||
"embeddings-reindex",
|
||||
t("stats.reindexingEmbeddings", {
|
||||
processed: Math.floor(
|
||||
(reindexState.processed_objects / reindexState.total_objects) *
|
||||
100,
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (reindexState.status === "completed") {
|
||||
clearMessages("embeddings-reindex");
|
||||
}
|
||||
}
|
||||
}, [reindexState, addMessage, clearMessages, t]);
|
||||
const messages = useStatusMessages();
|
||||
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
// problems link to admin-only pages
|
||||
if (!isAdmin || !messages || Object.keys(messages).length === 0) {
|
||||
if (!isAdmin || messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -163,6 +96,7 @@ function StatusAlertNav({ className, large }: StatusAlertNavProps) {
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>
|
||||
<div
|
||||
data-testid="status-alert-trigger"
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center p-2",
|
||||
large && "size-12",
|
||||
@@ -182,32 +116,10 @@ function StatusAlertNav({ className, large }: StatusAlertNavProps) {
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="scrollbar-container flex h-auto w-full flex-col items-center gap-2 overflow-y-auto overflow-x-hidden px-2 py-4">
|
||||
{Object.entries(messages).map(([key, messageArray]) => (
|
||||
<div key={key} className="flex w-full items-center gap-2">
|
||||
{messageArray.map(({ id, text, color, link }: StatusMessage) => {
|
||||
const message = (
|
||||
<div key={id} className="flex items-center gap-2 text-xs">
|
||||
<IoIosWarning
|
||||
className={`size-5 ${color || "text-danger"}`}
|
||||
/>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (link) {
|
||||
return (
|
||||
<Link key={id} to={link}>
|
||||
{message}
|
||||
</Link>
|
||||
);
|
||||
} else {
|
||||
return message;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<StatusMessageList
|
||||
messages={messages}
|
||||
className="scrollbar-container w-full overflow-y-auto overflow-x-hidden px-4 py-4"
|
||||
/>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
|
||||
@@ -26,6 +26,7 @@ import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
// Android native hls does not seek correctly
|
||||
const USE_NATIVE_HLS = false;
|
||||
const HLS_MIME_TYPE = "application/vnd.apple.mpegurl" as const;
|
||||
const DEFAULT_MAX_BUFFER_LENGTH_S = 10;
|
||||
const unsupportedErrorCodes: number[] = [
|
||||
MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED,
|
||||
MediaError.MEDIA_ERR_DECODE,
|
||||
@@ -148,6 +149,42 @@ export default function HlsVideoPlayer({
|
||||
// a ref rather than an effect-scoped counter so the element error
|
||||
// handler can hold its toast while a recovery is still possible
|
||||
const mediaRecoveryBudgetRef = useRef(0);
|
||||
// hls.js and the element can both report one failure, so each source
|
||||
// toasts at most once
|
||||
const failureReportedRef = useRef(false);
|
||||
|
||||
const reportPlaybackFailure = useCallback(
|
||||
(code: number, message: string) => {
|
||||
if (failureReportedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
failureReportedRef.current = true;
|
||||
toast.error(t("toast.error.playRecordingsFailed", { code, message }), {
|
||||
position: "top-center",
|
||||
});
|
||||
},
|
||||
[t],
|
||||
);
|
||||
const reportPlaybackFailureRef = useRef(reportPlaybackFailure);
|
||||
|
||||
useEffect(() => {
|
||||
reportPlaybackFailureRef.current = reportPlaybackFailure;
|
||||
}, [reportPlaybackFailure]);
|
||||
|
||||
// a quality switch changes the buffer length a commit before the new
|
||||
// source arrives, so it updates the live instance instead of rebuilding
|
||||
// it on the outgoing playlist
|
||||
const bufferLengthRef = useRef(bufferLength);
|
||||
|
||||
useEffect(() => {
|
||||
bufferLengthRef.current = bufferLength;
|
||||
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.config.maxBufferLength =
|
||||
bufferLength ?? DEFAULT_MAX_BUFFER_LENGTH_S;
|
||||
}
|
||||
}, [bufferLength]);
|
||||
|
||||
const applyVideoDimensions = useCallback(
|
||||
(width: number, height: number) => {
|
||||
@@ -225,6 +262,7 @@ export default function HlsVideoPlayer({
|
||||
// the element already holds a decoded frame, and keeping it visible
|
||||
// bridges the gap while the new source loads
|
||||
const currentPlaybackRate = videoRef.current.playbackRate;
|
||||
failureReportedRef.current = false;
|
||||
|
||||
if (!useHlsCompat) {
|
||||
nativeRetryRef.current = 0;
|
||||
@@ -236,7 +274,7 @@ export default function HlsVideoPlayer({
|
||||
|
||||
// Base HLS configuration
|
||||
const hlsConfig: Partial<HlsConfig> = {
|
||||
maxBufferLength: bufferLength ?? 10,
|
||||
maxBufferLength: bufferLengthRef.current ?? DEFAULT_MAX_BUFFER_LENGTH_S,
|
||||
maxBufferSize: 20 * 1000 * 1000,
|
||||
startPosition: currentSource.startPosition,
|
||||
};
|
||||
@@ -269,10 +307,18 @@ export default function HlsVideoPlayer({
|
||||
data.details ===
|
||||
Hls.ErrorDetails.BUFFER_INCOMPATIBLE_CODECS_ERROR ||
|
||||
data.details === Hls.ErrorDetails.BUFFER_ADD_CODEC_ERROR;
|
||||
if (isCodecError && qualitySignalsRef.current.onFatalCodecError?.()) {
|
||||
if (isCodecError) {
|
||||
// with no stream to fall back to, the element may never raise
|
||||
// an error of its own, so report the failure here
|
||||
if (!qualitySignalsRef.current.onFatalCodecError?.()) {
|
||||
reportPlaybackFailureRef.current(
|
||||
MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED,
|
||||
data.details,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!isCodecError && mediaRecoveryBudgetRef.current > 0) {
|
||||
if (mediaRecoveryBudgetRef.current > 0) {
|
||||
mediaRecoveryBudgetRef.current -= 1;
|
||||
hls.recoverMediaError();
|
||||
}
|
||||
@@ -308,7 +354,7 @@ export default function HlsVideoPlayer({
|
||||
hlsRef.current.destroy();
|
||||
}
|
||||
};
|
||||
}, [videoRef, hlsRef, useHlsCompat, currentSource, bufferLength]);
|
||||
}, [videoRef, hlsRef, useHlsCompat, currentSource]);
|
||||
|
||||
// state handling
|
||||
|
||||
@@ -714,15 +760,7 @@ export default function HlsVideoPlayer({
|
||||
}
|
||||
}
|
||||
|
||||
toast.error(
|
||||
t("toast.error.playRecordingsFailed", {
|
||||
code: mediaError.code,
|
||||
message: mediaError.message,
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
reportPlaybackFailure(mediaError.code, mediaError.message);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -340,8 +340,11 @@ export class AutoQualityGovernor {
|
||||
private triggerDownswitch(reason: DownswitchReason): boolean {
|
||||
const handled = this.requestDownswitch(reason);
|
||||
if (handled) {
|
||||
// the low stream starts with a clean record
|
||||
// the low stream starts with a clean record, and the probe lets a
|
||||
// recovered connection (or a wrong downswitch) return to full
|
||||
// quality mid-chunk rather than at the next boundary
|
||||
this.resetStallHistory();
|
||||
this.armUpswitchProbe();
|
||||
}
|
||||
return handled;
|
||||
}
|
||||
|
||||
@@ -484,9 +484,6 @@ export default function DynamicVideoPlayer({
|
||||
}
|
||||
setAutoLowQuality(true);
|
||||
setAutoLowReason(reason === "codec" ? "codec" : "bandwidth");
|
||||
// so a recovered connection (or a wrong downswitch) returns to
|
||||
// full quality mid-chunk rather than at the next boundary
|
||||
governor.armUpswitchProbe();
|
||||
return true;
|
||||
};
|
||||
tryUpswitchRef.current = () => {
|
||||
@@ -495,7 +492,7 @@ export default function DynamicVideoPlayer({
|
||||
setAutoLowReason(undefined);
|
||||
}
|
||||
};
|
||||
}, [resolvedQuality, subAvailable, governor]);
|
||||
}, [resolvedQuality, subAvailable]);
|
||||
|
||||
// persisted across sessions so a device on a known-slow connection
|
||||
// starts low instead of paying the first stall to find out
|
||||
@@ -616,7 +613,19 @@ export default function DynamicVideoPlayer({
|
||||
governor.sourceLoadStarted();
|
||||
}, [source, isScrubbing, governor]);
|
||||
|
||||
const lastChunkRef = useRef(timeRange);
|
||||
useEffect(() => {
|
||||
// the seed effect picks the starting quality, so only a later chunk
|
||||
// boundary reconsiders it
|
||||
const lastChunk = lastChunkRef.current;
|
||||
lastChunkRef.current = timeRange;
|
||||
if (
|
||||
lastChunk.after === timeRange.after &&
|
||||
lastChunk.before === timeRange.before
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// a chunk boundary is where full quality may be retried, and a
|
||||
// natural point to persist what the governor has learned
|
||||
setAutoLowQuality((prev) => prev && !governor.shouldRetryMain());
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createContext } from "react";
|
||||
import { ProblemSeverity } from "@/types/stats";
|
||||
|
||||
export type StatusMessage = {
|
||||
id: string;
|
||||
text: string;
|
||||
color?: string;
|
||||
severity: ProblemSeverity;
|
||||
link?: string;
|
||||
};
|
||||
|
||||
@@ -16,7 +17,7 @@ type StatusBarMessagesContextValue = {
|
||||
addMessage: (
|
||||
key: string,
|
||||
message: string,
|
||||
color?: string,
|
||||
severity?: ProblemSeverity,
|
||||
messageId?: string,
|
||||
link?: string,
|
||||
) => string | undefined;
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
StatusBarMessagesContext,
|
||||
StatusMessagesState,
|
||||
} from "@/context/statusbar-context";
|
||||
import { ProblemSeverity } from "@/types/stats";
|
||||
|
||||
type StatusBarMessagesProviderProps = {
|
||||
children: ReactNode;
|
||||
@@ -19,21 +20,21 @@ export function StatusBarMessagesProvider({
|
||||
(
|
||||
key: string,
|
||||
message: string,
|
||||
color?: string,
|
||||
severity: ProblemSeverity = "error",
|
||||
messageId?: string,
|
||||
link?: string,
|
||||
) => {
|
||||
if (!key || !message) return;
|
||||
|
||||
const id = messageId ?? Date.now().toString();
|
||||
const msgColor = color ?? "text-danger";
|
||||
// the text is the fallback id, so repeating a message replaces it
|
||||
const id = messageId ?? message;
|
||||
|
||||
setMessagesState((prevMessages) => {
|
||||
const existingMessages = prevMessages[key] || [];
|
||||
// Check if a message with the same ID already exists
|
||||
const messageIndex = existingMessages.findIndex((msg) => msg.id === id);
|
||||
|
||||
const newMessage = { id, text: message, color: msgColor, link };
|
||||
const newMessage = { id, text: message, severity, link };
|
||||
|
||||
// If the message exists, replace it, otherwise add the new message
|
||||
let updatedMessages;
|
||||
|
||||
@@ -5,25 +5,25 @@ import useSWR from "swr";
|
||||
import { useDateLocale } from "@/hooks/use-date-locale";
|
||||
import { useTimezone } from "@/hooks/use-date-utils";
|
||||
import { useHealthChecks } from "@/hooks/use-health-checks";
|
||||
import { useNotices } from "@/hooks/use-notices";
|
||||
import { hiddenAt, useNotices } from "@/hooks/use-notices";
|
||||
import { evaluateConfigHealth } from "@/utils/configHealth";
|
||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||
import { sortHealthProblems } from "@/utils/healthSort";
|
||||
import { streamHealth } from "@/utils/streamHealth";
|
||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
import type { HealthProblem } from "@/types/health";
|
||||
import type { DismissedCheck, Notice } from "@/types/notice";
|
||||
import type { MutedCheck, Notice } from "@/types/notice";
|
||||
|
||||
const EXTERNAL_LINK = /^https?:\/\//;
|
||||
|
||||
type HealthProblems = {
|
||||
/** undismissed rows, most severe first */
|
||||
/** shown rows, most severe first */
|
||||
problems: HealthProblem[];
|
||||
/** dismissed rows, most recently dismissed first; undefined until loaded */
|
||||
dismissed?: HealthProblem[];
|
||||
/** acknowledged and muted rows, most recently hidden first; undefined until loaded */
|
||||
hidden?: HealthProblem[];
|
||||
loading: boolean;
|
||||
/** delete every dismissed row, so each can show again */
|
||||
clearDismissed: () => Promise<void>;
|
||||
/** show every hidden row again */
|
||||
unhideAll: () => Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -34,7 +34,7 @@ type HealthProblems = {
|
||||
*/
|
||||
export function useHealthProblems(
|
||||
t: TFunction,
|
||||
showDismissed = false,
|
||||
showHidden = false,
|
||||
): HealthProblems {
|
||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||
revalidateOnFocus: false,
|
||||
@@ -43,30 +43,40 @@ export function useHealthProblems(
|
||||
const locale = useDateLocale();
|
||||
const {
|
||||
notices,
|
||||
dismissed: dismissedNotices,
|
||||
dismiss,
|
||||
mutateDismissed,
|
||||
} = useNotices(showDismissed);
|
||||
const { data: dismissedChecks, mutate: mutateDismissedChecks } = useSWR<
|
||||
DismissedCheck[]
|
||||
>("notices/dismissed_checks");
|
||||
hidden: hiddenNotices,
|
||||
acknowledge,
|
||||
mute,
|
||||
unhide,
|
||||
mutateHidden,
|
||||
} = useNotices(showHidden);
|
||||
const { data: mutedChecks, mutate: mutateMutedChecks } = useSWR<MutedCheck[]>(
|
||||
"notices/muted_checks",
|
||||
);
|
||||
const {
|
||||
stream: { results },
|
||||
} = useHealthChecks();
|
||||
|
||||
const dismissCheck = useCallback(
|
||||
const muteCheck = useCallback(
|
||||
async (id: string) => {
|
||||
await axios.post(`notices/${id}/dismiss`);
|
||||
mutateDismissedChecks();
|
||||
await axios.post(`notices/${id}/mute`);
|
||||
mutateMutedChecks();
|
||||
},
|
||||
[mutateDismissedChecks],
|
||||
[mutateMutedChecks],
|
||||
);
|
||||
|
||||
const clearDismissed = useCallback(async () => {
|
||||
await axios.delete("notices/dismissed");
|
||||
mutateDismissed();
|
||||
mutateDismissedChecks();
|
||||
}, [mutateDismissed, mutateDismissedChecks]);
|
||||
const unmuteCheck = useCallback(
|
||||
async (id: string) => {
|
||||
await axios.delete(`notices/${id}/hidden`);
|
||||
mutateMutedChecks();
|
||||
},
|
||||
[mutateMutedChecks],
|
||||
);
|
||||
|
||||
const unhideAll = useCallback(async () => {
|
||||
await axios.delete("notices/hidden");
|
||||
mutateHidden();
|
||||
mutateMutedChecks();
|
||||
}, [mutateHidden, mutateMutedChecks]);
|
||||
|
||||
const formatTime = useCallback(
|
||||
(timestamp: number) =>
|
||||
@@ -98,23 +108,37 @@ export function useHealthProblems(
|
||||
count: notice.count,
|
||||
}),
|
||||
meta:
|
||||
notice.dismissed_at === null
|
||||
? t("health.notices.firstSeen", {
|
||||
notice.muted_at !== null
|
||||
? t("health.notices.mutedAt", {
|
||||
ns: "views/system",
|
||||
time: formatTime(notice.first_seen),
|
||||
count: notice.count,
|
||||
time: formatTime(notice.muted_at),
|
||||
})
|
||||
: t("health.notices.dismissedAt", {
|
||||
ns: "views/system",
|
||||
time: formatTime(notice.dismissed_at),
|
||||
}),
|
||||
: notice.acknowledged_at !== null
|
||||
? t("health.notices.acknowledgedAt", {
|
||||
ns: "views/system",
|
||||
time: formatTime(notice.acknowledged_at),
|
||||
})
|
||||
: t("health.notices.firstSeen", {
|
||||
ns: "views/system",
|
||||
time: formatTime(notice.first_seen),
|
||||
count: notice.count,
|
||||
}),
|
||||
link: external ? undefined : link,
|
||||
externalLink: external ? link : undefined,
|
||||
onDismiss:
|
||||
notice.dismissed_at === null ? () => dismiss(notice.id) : undefined,
|
||||
...(hiddenAt(notice) > 0
|
||||
? {
|
||||
hidden: notice.muted_at !== null ? "muted" : "acknowledged",
|
||||
onUnhide: () => unhide(notice.id),
|
||||
}
|
||||
: {
|
||||
onAcknowledge: notice.acknowledgeable
|
||||
? () => acknowledge(notice.id)
|
||||
: undefined,
|
||||
onMute: () => mute(notice.id),
|
||||
}),
|
||||
};
|
||||
},
|
||||
[dismiss, formatTime, t],
|
||||
[acknowledge, mute, unhide, formatTime, t],
|
||||
);
|
||||
|
||||
const checks = useMemo<HealthProblem[]>(
|
||||
@@ -128,12 +152,10 @@ export function useHealthProblems(
|
||||
[config, results, t],
|
||||
);
|
||||
|
||||
const dismissedAt = useMemo(
|
||||
const mutedAt = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
(dismissedChecks ?? []).map((check) => [check.id, check.dismissed_at]),
|
||||
),
|
||||
[dismissedChecks],
|
||||
new Map((mutedChecks ?? []).map((check) => [check.id, check.muted_at])),
|
||||
[mutedChecks],
|
||||
);
|
||||
|
||||
const problems = useMemo(
|
||||
@@ -141,27 +163,27 @@ export function useHealthProblems(
|
||||
sortHealthProblems([
|
||||
...(notices ?? []).map(noticeRow),
|
||||
...checks
|
||||
.filter((check) => !dismissedAt.has(check.id))
|
||||
.filter((check) => !mutedAt.has(check.id))
|
||||
.map((check) => ({
|
||||
...check,
|
||||
onDismiss: () => dismissCheck(check.id),
|
||||
onMute: () => muteCheck(check.id),
|
||||
})),
|
||||
]),
|
||||
[notices, noticeRow, checks, dismissedAt, dismissCheck],
|
||||
[notices, noticeRow, checks, mutedAt, muteCheck],
|
||||
);
|
||||
|
||||
const dismissed = useMemo(() => {
|
||||
if (!showDismissed || dismissedNotices === undefined) {
|
||||
const hidden = useMemo(() => {
|
||||
if (!showHidden || hiddenNotices === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const rows = [
|
||||
...dismissedNotices.map((notice) => ({
|
||||
at: notice.dismissed_at ?? 0,
|
||||
...hiddenNotices.map((notice) => ({
|
||||
at: hiddenAt(notice),
|
||||
row: noticeRow(notice),
|
||||
})),
|
||||
...checks.flatMap((check) => {
|
||||
const at = dismissedAt.get(check.id);
|
||||
const at = mutedAt.get(check.id);
|
||||
|
||||
return at === undefined
|
||||
? []
|
||||
@@ -170,10 +192,12 @@ export function useHealthProblems(
|
||||
at,
|
||||
row: {
|
||||
...check,
|
||||
meta: t("health.notices.dismissedAt", {
|
||||
meta: t("health.notices.mutedAt", {
|
||||
ns: "views/system",
|
||||
time: formatTime(at),
|
||||
}),
|
||||
hidden: "muted" as const,
|
||||
onUnhide: () => unmuteCheck(check.id),
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -182,17 +206,17 @@ export function useHealthProblems(
|
||||
|
||||
return rows.sort((a, b) => b.at - a.at).map(({ row }) => row);
|
||||
}, [
|
||||
showDismissed,
|
||||
dismissedNotices,
|
||||
showHidden,
|
||||
hiddenNotices,
|
||||
noticeRow,
|
||||
checks,
|
||||
dismissedAt,
|
||||
mutedAt,
|
||||
formatTime,
|
||||
unmuteCheck,
|
||||
t,
|
||||
]);
|
||||
|
||||
const loading =
|
||||
notices === undefined || dismissedChecks === undefined || !config;
|
||||
const loading = notices === undefined || mutedChecks === undefined || !config;
|
||||
|
||||
return { problems, dismissed, loading, clearDismissed };
|
||||
return { problems, hidden, loading, unhideAll };
|
||||
}
|
||||
|
||||
@@ -4,19 +4,22 @@ import useSWR from "swr";
|
||||
import { useWs } from "@/api/ws";
|
||||
import type { Notice } from "@/types/notice";
|
||||
|
||||
/** When a hidden notice was acknowledged or muted. */
|
||||
export function hiddenAt(notice: Notice): number {
|
||||
return notice.muted_at ?? notice.acknowledged_at ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Active notices come from a REST snapshot, then from every `notices`
|
||||
* websocket payload. Dismissed notices are fetched only while the history is
|
||||
* shown, and again when the active list changes or the tab regains focus. A
|
||||
* purge in another tab leaves the active list unchanged, so only focus
|
||||
* catches it.
|
||||
* websocket payload. Hidden notices are fetched only while the hidden list is
|
||||
* shown, and again when the active list changes or the tab regains focus.
|
||||
*/
|
||||
export function useNotices(showDismissed: boolean) {
|
||||
export function useNotices(showHidden: boolean) {
|
||||
const { data: initial, mutate } = useSWR<Notice[]>("notices", {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
const { data: history, mutate: mutateHistory } = useSWR<Notice[]>(
|
||||
showDismissed ? ["notices", { include_dismissed: true }] : null,
|
||||
const { data: all, mutate: mutateHidden } = useSWR<Notice[]>(
|
||||
showHidden ? ["notices", { include_hidden: true }] : null,
|
||||
);
|
||||
const {
|
||||
value: { payload },
|
||||
@@ -30,31 +33,44 @@ export function useNotices(showDismissed: boolean) {
|
||||
[payload],
|
||||
);
|
||||
|
||||
// once a websocket frame has arrived it is the source of truth; a dismiss
|
||||
// still shows up because the registry publishes a new frame after it
|
||||
// once a websocket frame has arrived it is the source of truth; every
|
||||
// acknowledge, mute, and unhide publishes a new frame
|
||||
const notices = live ?? initial;
|
||||
|
||||
// refetch the history whenever the active list changes; SWR ignores the
|
||||
// call while the history is hidden
|
||||
// refetch the hidden list whenever the active list changes; SWR ignores the
|
||||
// call while the hidden list is not shown
|
||||
useEffect(() => {
|
||||
mutateHistory();
|
||||
}, [live, mutateHistory]);
|
||||
mutateHidden();
|
||||
}, [live, mutateHidden]);
|
||||
|
||||
const dismissed = useMemo(
|
||||
const hidden = useMemo(
|
||||
() =>
|
||||
history
|
||||
?.filter((notice) => notice.dismissed_at !== null)
|
||||
.sort((a, b) => (b.dismissed_at ?? 0) - (a.dismissed_at ?? 0)),
|
||||
[history],
|
||||
all
|
||||
?.filter((notice) => hiddenAt(notice) > 0)
|
||||
.sort((a, b) => hiddenAt(b) - hiddenAt(a)),
|
||||
[all],
|
||||
);
|
||||
|
||||
const dismiss = useCallback(
|
||||
async (id: string) => {
|
||||
await axios.post(`notices/${id}/dismiss`);
|
||||
const act = useCallback(
|
||||
async (request: Promise<unknown>) => {
|
||||
await request;
|
||||
mutate();
|
||||
},
|
||||
[mutate],
|
||||
);
|
||||
|
||||
return { notices, dismissed, dismiss, mutateDismissed: mutateHistory };
|
||||
const acknowledge = useCallback(
|
||||
(id: string) => act(axios.post(`notices/${id}/acknowledge`)),
|
||||
[act],
|
||||
);
|
||||
const mute = useCallback(
|
||||
(id: string) => act(axios.post(`notices/${id}/mute`)),
|
||||
[act],
|
||||
);
|
||||
const unhide = useCallback(
|
||||
(id: string) => act(axios.delete(`notices/${id}/hidden`)),
|
||||
[act],
|
||||
);
|
||||
|
||||
return { notices, hidden, acknowledge, mute, unhide, mutateHidden };
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import {
|
||||
CameraDetectThreshold,
|
||||
CameraFfmpegThreshold,
|
||||
InferenceThreshold,
|
||||
} from "@/types/graph";
|
||||
import { InferenceThreshold } from "@/types/graph";
|
||||
import { FrigateStats, PotentialProblem, ProblemSeverity } from "@/types/stats";
|
||||
import { useMemo } from "react";
|
||||
import useSWR from "swr";
|
||||
@@ -15,20 +11,12 @@ import { useIsAdmin } from "./use-is-admin";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// the status bar has always rendered these exact classes; keep them byte for
|
||||
// byte so its output does not change
|
||||
const SEVERITY_COLOR: Record<ProblemSeverity, string> = {
|
||||
error: "text-danger",
|
||||
warning: "text-orange-400",
|
||||
info: "text-selected",
|
||||
};
|
||||
|
||||
function problem(
|
||||
severity: ProblemSeverity,
|
||||
text: string,
|
||||
relevantLink?: string,
|
||||
): PotentialProblem {
|
||||
return { text, severity, color: SEVERITY_COLOR[severity], relevantLink };
|
||||
return { text, severity, relevantLink };
|
||||
}
|
||||
|
||||
// matches SKIPPED_DETECTIONS_PCT in frigate/stats/emitter.py
|
||||
@@ -122,20 +110,13 @@ export default function useStats(stats: FrigateStats | undefined) {
|
||||
}
|
||||
});
|
||||
|
||||
// check camera cpu usages
|
||||
// check for skipped detections
|
||||
Object.entries(memoizedStats["cameras"]).forEach(([name, cam]) => {
|
||||
// Skip replay cameras
|
||||
if (isReplayCamera(name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ffmpegAvg = parseFloat(
|
||||
memoizedStats["cpu_usages"][cam["ffmpeg_pid"]]?.cpu_average,
|
||||
);
|
||||
const detectAvg = parseFloat(
|
||||
memoizedStats["cpu_usages"][cam["pid"]]?.cpu_average,
|
||||
);
|
||||
|
||||
const cameraName = config?.cameras?.[name]?.friendly_name ?? name;
|
||||
|
||||
if (
|
||||
@@ -153,32 +134,6 @@ export default function useStats(stats: FrigateStats | undefined) {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!isNaN(ffmpegAvg) && ffmpegAvg >= CameraFfmpegThreshold.error) {
|
||||
problems.push(
|
||||
problem(
|
||||
"error",
|
||||
t("stats.ffmpegHighCpuUsage", {
|
||||
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
|
||||
ffmpegAvg,
|
||||
}),
|
||||
"/system#cameras",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!isNaN(detectAvg) && detectAvg >= CameraDetectThreshold.error) {
|
||||
problems.push(
|
||||
problem(
|
||||
"error",
|
||||
t("stats.detectHighCpuUsage", {
|
||||
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
|
||||
detectAvg,
|
||||
}),
|
||||
"/system#cameras",
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Add message if debug replay is active
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useEmbeddingsReindexProgress } from "@/api/ws";
|
||||
import {
|
||||
StatusBarMessagesContext,
|
||||
StatusMessage,
|
||||
} from "@/context/statusbar-context";
|
||||
import useStats, { useAutoFrigateStats } from "@/hooks/use-stats";
|
||||
import { ProblemSeverity } from "@/types/stats";
|
||||
import { useContext, useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const SEVERITY_ORDER: Record<ProblemSeverity, number> = {
|
||||
error: 0,
|
||||
warning: 1,
|
||||
info: 2,
|
||||
};
|
||||
|
||||
/**
|
||||
* Publishes the stats problems and reindex progress to the status bar, then
|
||||
* returns every status bar message, most severe first.
|
||||
*/
|
||||
export default function useStatusMessages(): StatusMessage[] {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
const { messages, addMessage, clearMessages } = useContext(
|
||||
StatusBarMessagesContext,
|
||||
)!;
|
||||
|
||||
const stats = useAutoFrigateStats();
|
||||
const { potentialProblems } = useStats(stats);
|
||||
|
||||
useEffect(() => {
|
||||
clearMessages("stats");
|
||||
potentialProblems.forEach((problem) => {
|
||||
addMessage(
|
||||
"stats",
|
||||
problem.text,
|
||||
problem.severity,
|
||||
undefined,
|
||||
problem.relevantLink,
|
||||
);
|
||||
});
|
||||
}, [potentialProblems, addMessage, clearMessages]);
|
||||
|
||||
const { payload: reindexState } = useEmbeddingsReindexProgress();
|
||||
|
||||
useEffect(() => {
|
||||
if (reindexState) {
|
||||
if (reindexState.status == "indexing") {
|
||||
clearMessages("embeddings-reindex");
|
||||
addMessage(
|
||||
"embeddings-reindex",
|
||||
t("stats.reindexingEmbeddings", {
|
||||
processed: Math.floor(
|
||||
(reindexState.processed_objects / reindexState.total_objects) *
|
||||
100,
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (reindexState.status === "completed") {
|
||||
clearMessages("embeddings-reindex");
|
||||
}
|
||||
}
|
||||
}, [reindexState, addMessage, clearMessages, t]);
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
Object.values(messages)
|
||||
.flat()
|
||||
.sort(
|
||||
(a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity],
|
||||
),
|
||||
[messages],
|
||||
);
|
||||
}
|
||||
@@ -79,7 +79,6 @@ export function evaluateStreamWebRTCAvailability(args: {
|
||||
/** Console detail for a reason decided by the global availability check. */
|
||||
function describeGlobalReason(
|
||||
reason: WebRTCUnavailableReason,
|
||||
testStream: string | undefined,
|
||||
probeDetail: string | undefined,
|
||||
): string {
|
||||
switch (reason) {
|
||||
@@ -88,7 +87,7 @@ function describeGlobalReason(
|
||||
case "not-configured":
|
||||
return "No candidates or ice_servers are set under go2rtc.webrtc.";
|
||||
case "unreachable":
|
||||
return `The connectivity probe against stream '${testStream}' failed: ${probeDetail ?? "unknown cause"}.`;
|
||||
return `The connectivity probe failed against ${probeDetail ?? "an unknown stream"}.`;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
@@ -98,9 +97,13 @@ function describeGlobalReason(
|
||||
let lastProbeSignature: string | null = null;
|
||||
|
||||
/**
|
||||
* Once-per-session: browser support, go2rtc config, and a live handshake probe.
|
||||
* Browser support, go2rtc config, and a live handshake probe shared by the
|
||||
* page. A failed probe is retried on a later mount or when the page becomes
|
||||
* visible again once its cached result has expired.
|
||||
*/
|
||||
export function useWebRTCGloballyAvailable(): GlobalAvailability {
|
||||
export function useWebRTCGloballyAvailable(
|
||||
preferredStream?: string,
|
||||
): GlobalAvailability {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const [probe, setProbe] = useState<{
|
||||
state: "pending" | "pass" | "fail";
|
||||
@@ -118,11 +121,19 @@ export function useWebRTCGloballyAvailable(): GlobalAvailability {
|
||||
);
|
||||
}, [config]);
|
||||
|
||||
// Representative restreamed stream to probe against.
|
||||
const testStream = useMemo(() => {
|
||||
const streams = config?.go2rtc?.streams ?? {};
|
||||
return Object.keys(streams)[0];
|
||||
}, [config]);
|
||||
// the stream being viewed is the one most likely to be online
|
||||
const testStreams = useMemo(() => {
|
||||
const streams = Object.keys(config?.go2rtc?.streams ?? {});
|
||||
|
||||
if (!preferredStream || !streams.includes(preferredStream)) {
|
||||
return streams;
|
||||
}
|
||||
|
||||
return [preferredStream, ...streams.filter((s) => s !== preferredStream)];
|
||||
}, [config, preferredStream]);
|
||||
const testStreamsKey = testStreams.join("\n");
|
||||
|
||||
const [retryToken, setRetryToken] = useState(0);
|
||||
|
||||
const iceServers = useMemo(
|
||||
() => webRTCIceServers(config?.go2rtc?.webrtc?.ice_servers),
|
||||
@@ -136,9 +147,9 @@ export function useWebRTCGloballyAvailable(): GlobalAvailability {
|
||||
JSON.stringify({
|
||||
candidates: config?.go2rtc?.webrtc?.candidates ?? [],
|
||||
iceServers,
|
||||
testStream,
|
||||
streams: Object.keys(config?.go2rtc?.streams ?? {}),
|
||||
}),
|
||||
[config, iceServers, testStream],
|
||||
[config, iceServers],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -153,11 +164,29 @@ export function useWebRTCGloballyAvailable(): GlobalAvailability {
|
||||
}, [probeSignature]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!browserOk || !configured || !testStream) {
|
||||
if (probe.state !== "fail") {
|
||||
return;
|
||||
}
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
setRetryToken((token) => token + 1);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
return () =>
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
}, [probe.state]);
|
||||
|
||||
useEffect(() => {
|
||||
const streams = testStreamsKey ? testStreamsKey.split("\n") : [];
|
||||
|
||||
if (!browserOk || !configured || streams.length === 0) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
probeWebRTCAvailability(testStream, iceServers).then((result) => {
|
||||
probeWebRTCAvailability(streams, iceServers).then((result) => {
|
||||
if (!cancelled) {
|
||||
setProbe({
|
||||
state: result.ok ? "pass" : "fail",
|
||||
@@ -168,7 +197,7 @@ export function useWebRTCGloballyAvailable(): GlobalAvailability {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [browserOk, configured, testStream, iceServers]);
|
||||
}, [browserOk, configured, testStreamsKey, iceServers, retryToken]);
|
||||
|
||||
const availability = useMemo<GlobalAvailability>(() => {
|
||||
if (!browserOk) {
|
||||
@@ -196,9 +225,9 @@ export function useWebRTCGloballyAvailable(): GlobalAvailability {
|
||||
logWebRTCUnavailable(
|
||||
undefined,
|
||||
reason,
|
||||
describeGlobalReason(reason, testStream, probe.detail),
|
||||
describeGlobalReason(reason, probe.detail),
|
||||
);
|
||||
}, [config, availability, testStream, probe.detail]);
|
||||
}, [config, availability, probe.detail]);
|
||||
|
||||
return availability;
|
||||
}
|
||||
@@ -208,7 +237,8 @@ export function useWebRTCAvailableForStream(
|
||||
metadata: LiveStreamMetadata | null | undefined,
|
||||
streamName?: string,
|
||||
): StreamAvailability {
|
||||
const { globallyAvailable, globalReason } = useWebRTCGloballyAvailable();
|
||||
const { globallyAvailable, globalReason } =
|
||||
useWebRTCGloballyAvailable(streamName);
|
||||
|
||||
const availability = useMemo(
|
||||
() =>
|
||||
|
||||
+10
-3
@@ -27,16 +27,23 @@ export type HealthProblem = {
|
||||
externalLink?: string;
|
||||
/** render with a spinner instead of the severity icon (stream check running) */
|
||||
pending?: boolean;
|
||||
onDismiss?: () => void;
|
||||
/** why a hidden row is hidden */
|
||||
hidden?: "acknowledged" | "muted";
|
||||
/** hide until the next occurrence; only for kinds that repeat */
|
||||
onAcknowledge?: () => void;
|
||||
/** hide for good */
|
||||
onMute?: () => void;
|
||||
/** show a hidden row again */
|
||||
onUnhide?: () => void;
|
||||
};
|
||||
|
||||
/** What the Health tab's filter shows. */
|
||||
export type NoticeFilter = {
|
||||
showDismissed: boolean;
|
||||
showHidden: boolean;
|
||||
severities: HealthSeverity[];
|
||||
};
|
||||
|
||||
export const DEFAULT_NOTICE_FILTER: NoticeFilter = {
|
||||
showDismissed: false,
|
||||
showHidden: false,
|
||||
severities: ["error", "warning", "info"],
|
||||
};
|
||||
|
||||
@@ -13,11 +13,16 @@ export type Notice = {
|
||||
first_seen: number;
|
||||
last_seen: number;
|
||||
count: number;
|
||||
dismissed_at: number | null;
|
||||
/** whether the kind repeats, so acknowledging it can hide it until next time */
|
||||
acknowledgeable: boolean;
|
||||
/** hidden until the next occurrence */
|
||||
acknowledged_at: number | null;
|
||||
/** hidden for good */
|
||||
muted_at: number | null;
|
||||
};
|
||||
|
||||
/** a config or stream check row an admin dismissed */
|
||||
export type DismissedCheck = {
|
||||
/** a config or stream check row an admin muted */
|
||||
export type MutedCheck = {
|
||||
id: string;
|
||||
dismissed_at: number;
|
||||
muted_at: number;
|
||||
};
|
||||
|
||||
@@ -129,7 +129,6 @@ export type ProblemSeverity = "error" | "warning" | "info";
|
||||
export type PotentialProblem = {
|
||||
text: string;
|
||||
severity: ProblemSeverity;
|
||||
color: string;
|
||||
relevantLink?: string;
|
||||
};
|
||||
|
||||
|
||||
+10
-1
@@ -256,7 +256,13 @@ export function detectionRows({
|
||||
// ------------------------------------------------------------------ hwaccel
|
||||
|
||||
export type HwaccelFamilyKey =
|
||||
"nvidia" | "vaapi" | "intel-qsv" | "rkmpp" | "jetson" | "rpi";
|
||||
| "nvidia"
|
||||
| "vaapi"
|
||||
| "intel-qsv"
|
||||
| "rkmpp"
|
||||
| "jetson"
|
||||
| "rpi"
|
||||
| "apple-silicon";
|
||||
|
||||
export type HwaccelClass =
|
||||
| { kind: "none" }
|
||||
@@ -270,6 +276,7 @@ const PRESET_FAMILIES: [string, HwaccelFamilyKey][] = [
|
||||
["preset-rk", "rkmpp"],
|
||||
["preset-jetson", "jetson"],
|
||||
["preset-rpi", "rpi"],
|
||||
["preset-apple-silicon", "apple-silicon"],
|
||||
];
|
||||
|
||||
export function hwaccelFamily(value: string | string[]): HwaccelClass {
|
||||
@@ -294,6 +301,8 @@ const FAMILY_VENDORS: Record<HwaccelFamilyKey, GpuVendor[]> = {
|
||||
vaapi: ["intel", "amd"],
|
||||
rkmpp: ["rockchip"],
|
||||
rpi: ["rpi"],
|
||||
// lighter reports no decoder usage
|
||||
"apple-silicon": [],
|
||||
};
|
||||
|
||||
function decoderUsage(
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
|
||||
/**
|
||||
* Performs a single real WebRTC handshake against go2rtc to verify that a
|
||||
* media connection can actually be established (validates candidates, port
|
||||
* 8555 reachability, and STUN/TURN end-to-end). Result is cached per page
|
||||
* session via a module-level promise.
|
||||
* Performs a real WebRTC handshake against go2rtc to verify that a media
|
||||
* connection can actually be established (validates candidates, port 8555
|
||||
* reachability, and STUN/TURN end-to-end). A success is cached for the page
|
||||
* session, a failure only for PROBE_FAILURE_TTL_MS.
|
||||
*/
|
||||
|
||||
export type WebRTCProbeResult = {
|
||||
ok: boolean;
|
||||
detail?: string;
|
||||
// go2rtc could not open the stream's source, which says nothing about
|
||||
// whether WebRTC itself can connect
|
||||
streamError?: boolean;
|
||||
};
|
||||
|
||||
let probePromise: Promise<WebRTCProbeResult> | null = null;
|
||||
const PROBE_FAILURE_TTL_MS = 30_000;
|
||||
|
||||
// streams tried per probe when go2rtc refuses the earlier ones
|
||||
const MAX_PROBE_STREAMS = 3;
|
||||
|
||||
let cachedProbe: {
|
||||
promise: Promise<WebRTCProbeResult>;
|
||||
failedAt?: number;
|
||||
} | null = null;
|
||||
|
||||
function describeError(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
@@ -118,23 +129,67 @@ function runProbe(
|
||||
pc.setRemoteDescription({ type: "answer", sdp: msg.value }).catch(
|
||||
(err) => fail(`remote answer rejected: ${describeError(err)}`),
|
||||
);
|
||||
} else if (msg.type === "error") {
|
||||
cleanup({ ok: false, streamError: true, detail: msg.value });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function runProbes(
|
||||
testStreams: string[],
|
||||
iceServers: RTCIceServer[],
|
||||
timeoutMs: number,
|
||||
): Promise<WebRTCProbeResult> {
|
||||
let result: WebRTCProbeResult = { ok: false, detail: "no stream to probe" };
|
||||
|
||||
for (const stream of testStreams.slice(0, MAX_PROBE_STREAMS)) {
|
||||
result = await runProbe(stream, iceServers, timeoutMs);
|
||||
|
||||
if (!result.ok) {
|
||||
result.detail = `stream '${stream}': ${result.detail}`;
|
||||
}
|
||||
|
||||
if (!result.streamError) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Probes the streams in order, moving on only when go2rtc refuses one. */
|
||||
export function probeWebRTCAvailability(
|
||||
testStream: string,
|
||||
testStreams: string[],
|
||||
iceServers: RTCIceServer[],
|
||||
timeoutMs: number = 5000,
|
||||
): Promise<WebRTCProbeResult> {
|
||||
if (!probePromise) {
|
||||
probePromise = runProbe(testStream, iceServers, timeoutMs);
|
||||
if (
|
||||
cachedProbe &&
|
||||
(cachedProbe.failedAt === undefined ||
|
||||
Date.now() - cachedProbe.failedAt < PROBE_FAILURE_TTL_MS)
|
||||
) {
|
||||
return cachedProbe.promise;
|
||||
}
|
||||
return probePromise;
|
||||
|
||||
const entry: NonNullable<typeof cachedProbe> = {
|
||||
promise: runProbes(testStreams, iceServers, timeoutMs)
|
||||
.catch((err): WebRTCProbeResult => ({
|
||||
ok: false,
|
||||
detail: describeError(err),
|
||||
}))
|
||||
.then((result) => {
|
||||
if (!result.ok) {
|
||||
entry.failedAt = Date.now();
|
||||
}
|
||||
return result;
|
||||
}),
|
||||
};
|
||||
cachedProbe = entry;
|
||||
return entry.promise;
|
||||
}
|
||||
|
||||
/** Clears the cached probe result (e.g. when go2rtc config changes). */
|
||||
export function resetWebRTCProbe(): void {
|
||||
probePromise = null;
|
||||
cachedProbe = null;
|
||||
}
|
||||
|
||||
@@ -27,10 +27,7 @@ import {
|
||||
} from "@/components/ui/popover";
|
||||
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||
import {
|
||||
useWebRTCAvailableForStream,
|
||||
useWebRTCGloballyAvailable,
|
||||
} from "@/hooks/use-webrtc-availability";
|
||||
import { useWebRTCAvailableForStream } from "@/hooks/use-webrtc-availability";
|
||||
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
||||
import {
|
||||
LivePlayerError,
|
||||
@@ -219,15 +216,16 @@ export default function LiveCameraView({
|
||||
);
|
||||
const isWebRTCAvailable = webRTCAvailability.available;
|
||||
|
||||
// Two-way talk is the sendonly backchannel: global, not per-stream.
|
||||
const { globallyAvailable: webRTCGloballyAvailable } =
|
||||
useWebRTCGloballyAvailable();
|
||||
|
||||
// "checking" means the probe has not answered yet, and it re-enters that on
|
||||
// every mount, so treating it as unavailable downgrades the saved choice.
|
||||
const webRTCVerdictPending = webRTCAvailability.reason === "checking";
|
||||
const webRTCUsable = isWebRTCAvailable || webRTCVerdictPending;
|
||||
|
||||
// Two-way talk is a sendonly backchannel, so playback audio that WebRTC
|
||||
// can't carry doesn't rule it out, but the stream's video must connect
|
||||
const talkAvailable =
|
||||
isWebRTCAvailable || webRTCAvailability.reason === "audio-codec";
|
||||
|
||||
// Resolves the saved preference without overwriting it. Transient error
|
||||
// fallbacks layer on top in preferredLiveMode.
|
||||
const resolvedUserMode = useMemo<LivePlayerMode>(() => {
|
||||
@@ -398,6 +396,14 @@ export default function LiveCameraView({
|
||||
|
||||
const [audio, setAudio] = useSessionPersistence("liveAudio", false);
|
||||
const [mic, setMic] = useState(false);
|
||||
|
||||
// the mic only connects through the WebRTC player, so it can't stay on
|
||||
// for a stream that has lost it
|
||||
useEffect(() => {
|
||||
if (!talkAvailable) {
|
||||
setMic(false);
|
||||
}
|
||||
}, [talkAvailable]);
|
||||
const [webRTC, setWebRTC] = useState(false);
|
||||
const [pip, setPip] = useState(false);
|
||||
const [lowBandwidth, setLowBandwidth] = useState(false);
|
||||
@@ -424,7 +430,7 @@ export default function LiveCameraView({
|
||||
});
|
||||
|
||||
const preferredLiveMode = useMemo(() => {
|
||||
if (mic && isWebRTCAvailable) {
|
||||
if (mic && talkAvailable) {
|
||||
return "webrtc";
|
||||
}
|
||||
|
||||
@@ -456,6 +462,7 @@ export default function LiveCameraView({
|
||||
lowBandwidth,
|
||||
forceLowBandwidth,
|
||||
mic,
|
||||
talkAvailable,
|
||||
webRTC,
|
||||
isRestreamed,
|
||||
resolvedUserMode,
|
||||
@@ -489,7 +496,7 @@ export default function LiveCameraView({
|
||||
}
|
||||
break;
|
||||
case "t":
|
||||
if (supports2WayTalk) {
|
||||
if (supports2WayTalk && talkAvailable) {
|
||||
setMic(!mic);
|
||||
return true;
|
||||
}
|
||||
@@ -737,11 +744,15 @@ export default function LiveCameraView({
|
||||
Icon={mic ? FaMicrophone : FaMicrophoneSlash}
|
||||
isActive={mic}
|
||||
title={
|
||||
!webRTCGloballyAvailable
|
||||
? t("twoWayTalk.requiresWebRTC", { ns: "views/live" })
|
||||
: mic
|
||||
? t("twoWayTalk.disable", { ns: "views/live" })
|
||||
: t("twoWayTalk.enable", { ns: "views/live" })
|
||||
webRTCVerdictPending
|
||||
? t("stream.technology.unavailable.checking", {
|
||||
ns: "views/live",
|
||||
})
|
||||
: !talkAvailable
|
||||
? t("twoWayTalk.requiresWebRTC", { ns: "views/live" })
|
||||
: mic
|
||||
? t("twoWayTalk.disable", { ns: "views/live" })
|
||||
: t("twoWayTalk.enable", { ns: "views/live" })
|
||||
}
|
||||
onClick={() => {
|
||||
setMic(!mic);
|
||||
@@ -749,7 +760,7 @@ export default function LiveCameraView({
|
||||
setAudio(true);
|
||||
}
|
||||
}}
|
||||
disabled={!cameraEnabled || debug || !webRTCGloballyAvailable}
|
||||
disabled={!cameraEnabled || debug || !talkAvailable}
|
||||
/>
|
||||
)}
|
||||
{supportsAudioOutput && preferredLiveMode != "jsmpeg" && (
|
||||
|
||||
Reference in New Issue
Block a user