Compare commits

...

30 Commits

Author SHA1 Message Date
Josh Hawkins
33825f6d96 Add h8l and rocm to release workflow (#14648)
* Add h8l to release workflow

* Add rocm to release workflow

* Variants
2024-10-28 20:00:14 -05:00
Nicolas Mowen
eca504cb07 More bug fixes (#14593)
* Adjust mqtt logging behavior

* Set disconnect

* Only consider intel gpu stats error if None is returned
2024-10-25 09:45:11 -05:00
Nicolas Mowen
4c75440af4 Docs updates (#14590)
* Update motion docs to make note of recordings

* Make note of genai on CPU
2024-10-25 08:09:25 -05:00
Nicolas Mowen
94f7528885 Bug fixes (#14588)
* Get intel stats manually if parsing fails

* Fix assignment

* Clean up mqtt

* Formatting

* Fix logic
2024-10-25 06:47:56 -06:00
Josh Hawkins
4dadf6d353 Bugfixes (#14587)
* Ensure review and search item mobile pages reopen correctly

* disable pan/pinch/zoom when native browser video controls are displayed

* report 0 for storage usage when api returns null
2024-10-25 06:24:04 -06:00
Corwin
2d27e72ed9 fix: hailo driver wrong version name (#14575) 2024-10-25 06:07:01 -06:00
Nicolas Mowen
4ff0c8a8d1 Better review sub-labels (#14563)
* Better review sub-labels

* Handle init
2024-10-24 17:00:39 -05:00
Nicolas Mowen
f9fba94863 Slightly downgrade onnxruntime-gpu (#14558) 2024-10-24 13:17:11 -05:00
Nicolas Mowen
f9b246dbd0 Deps updates (#14556)
* Update nvidia deps

* Update python deps

* Update web deps
2024-10-24 08:48:14 -05:00
Josh Hawkins
8fefded8dc Fix score in search details dialog for old events (#14541) 2024-10-23 10:31:20 -05:00
Nicolas Mowen
18824830fd Export preview via api (#14535)
* Break out recording to separate function

* Implement preview exporting

* Formatting
2024-10-23 08:36:52 -05:00
Rui Alves
fa81d87dc0 Updated Documentation for the Review endpoints (#14401)
* Updated documentation for the review endpoint

* Updated documentation for the review/summary endpoint

* Updated documentation for the review/summary endpoint

* Documentation for the review activity audio and motion endpoints

* Added responses for more review.py endpoints

* Added responses for more review.py endpoints

* Fixed review.py responses and proper path parameter names

* Added body model for /reviews/viewed and /reviews/delete

* Updated OpenAPI specification for the review controller endpoints

* Run ruff format frigate

* Drop significant_motion

* Updated frigate-api.yaml

* Deleted total_motion

* Combine 2 models into generic
2024-10-23 08:35:49 -05:00
Josh Hawkins
8bc145472a Error message and search reset for explore pane (#14534) 2024-10-23 07:31:48 -06:00
Josh Hawkins
7afc1e9762 Improve error message when semantic search is not enabled with genai (#14528) 2024-10-23 06:14:50 -06:00
Josh Hawkins
fc59c83e16 Add download chips to search item details video and snapshot panes (#14525) 2024-10-22 21:09:57 -06:00
Nicolas Mowen
e4048be088 Increase download output (#14523) 2024-10-22 21:33:41 -05:00
Nicolas Mowen
d715a8c290 Catch empty bytes (#14521) 2024-10-22 19:07:54 -05:00
Josh Hawkins
ad308252a1 Accessibility features (#14518)
* Add screen reader aria labels to buttons and menu items

* Fix sub_label score in search detail dialog
2024-10-22 16:07:42 -06:00
Josh Hawkins
c7d9f83638 UI changes and fixes (#14516)
* Add camera webui link to debug view

* fix optimistic description update

* simplify

* clean up

* params
2024-10-22 15:11:05 -06:00
Josh Hawkins
828fdbfd2d UI tweaks (#14505)
* Add reindex progress to mobile bottom bar status alert

* move menu to new component

* actions component in search footer thumbnail

* context menu for explore summary thumbnail images

* readd top_score to search query for old events
2024-10-22 08:01:01 -06:00
Nicolas Mowen
40c6fda19d Various fixes and improvements (#14492)
* Refactor preprocessing of images

* Cleanup preprocessing

* Improve naming and handling of embeddings

* Handle invalid intel json

* remove unused

* Use enum for model types

* Formatting
2024-10-21 16:19:34 -06:00
Josh Hawkins
b69816c2f9 reenable revalidation of first page (#14493) 2024-10-21 16:14:36 -06:00
leccelecce
46f5234bd9 Don't run pull_request builds on docs-only change (#14485) 2024-10-21 16:00:17 -06:00
leccelecce
81b8d7a66b Don't run CI builds on docs-only change (#14486) 2024-10-21 15:59:10 -06:00
Josh Hawkins
b1285a16c1 Update tracked object description optimistically (#14490) 2024-10-21 15:14:57 -06:00
leccelecce
90140e7710 Ollama: minor docs tweak to specify command (#14482) 2024-10-21 09:54:55 -06:00
Josh Hawkins
8364e68667 Model and genai fixes (#14481)
* disable mem arena in options for cpu only

* add try/except around ollama initialization

* update docs
2024-10-21 09:00:45 -06:00
gtsiam
4bb420d049 Add service manager infrastructure (#14150)
* Add service manager infrastructure

The changes are (This will be a bit long):
- A ServiceManager class that spawns a background thread and deals with
  service lifecycle management. The idea is that service lifecycle code
  will run in async functions, so a single thread is enough to manage
  any (reasonable) amount of services.

- A Service class, that offers start(), stop() and restart() methods
  that simply notify the service manager to... well. Start, stop or
  restart a service.

(!) Warning: Note that this differs from mp.Process.start/stop in that
  the service commands are sent asynchronously and will complete
  "eventually". This is good because it means that business logic is
  fast when booting up and shutting down, but we need to make sure
  that code does not rely on start() and stop() being instant
  (Mainly pid assignments).

  Subclasses of the Service class should use the on_start and on_stop
  methods to monitor for service events. These will be run by the
  service manager thread, so we need to be careful not to block
  execution here. Standard async stuff.

(!) Note on service names: Service names should be unique within a
  ServiceManager. Make sure that you pass the name you want to
  super().__init__(name="...") if you plan to spawn multiple instances
  of a service.

- A ServiceProcess class: A Service that wraps a multiprocessing.Process
  into a Service. It offers a run() method subclasses can override and
  can support in-place restarting using the service manager.

And finally, I lied a bit about this whole thing using a single thread.
I can't find any way to run python multiprocessing in async, so there is
a MultiprocessingWaiter thread that waits for multiprocessing events and
notifies any pending futures. This was uhhh... fun? No, not really.
But it works. Using this part of the code just involves calling the
provided wait method. See the implementation of ServiceProcess for more
details.

Mirror util.Process hooks onto service process

Remove Service.__name attribute

Do not serialize process object on ServiceProcess start.

asd

* Update frigate dictionary

* Convert AudioProcessor to service process
2024-10-21 10:00:38 -05:00
Nicolas Mowen
560dc68120 Fixes (#14480)
* Catch case where object does not have thumbnail data

* Catch intel stats json decoding error

* Catch division by zero
2024-10-21 09:38:48 -05:00
Josh Hawkins
8fcb8e54f7 fix websocket from spreading stale state (#14466) 2024-10-20 20:38:11 -06:00
112 changed files with 2897 additions and 1180 deletions

View File

@@ -42,6 +42,7 @@ codeproject
colormap colormap
colorspace colorspace
comms comms
coro
ctypeslib ctypeslib
CUDA CUDA
Cuvid Cuvid
@@ -59,6 +60,7 @@ dsize
dtype dtype
ECONNRESET ECONNRESET
edgetpu edgetpu
fastapi
faststart faststart
fflags fflags
ffprobe ffprobe
@@ -237,6 +239,7 @@ sleeptime
SNDMORE SNDMORE
socs socs
sqliteq sqliteq
sqlitevecq
ssdlite ssdlite
statm statm
stimeout stimeout
@@ -271,6 +274,7 @@ unraid
unreviewed unreviewed
userdata userdata
usermod usermod
uvicorn
vaapi vaapi
vainfo vainfo
variations variations

View File

@@ -6,6 +6,8 @@ on:
branches: branches:
- dev - dev
- master - master
paths-ignore:
- 'docs/**'
# only run the latest commit to avoid cache overwrites # only run the latest commit to avoid cache overwrites
concurrency: concurrency:

View File

@@ -1,6 +1,9 @@
name: On pull request name: On pull request
on: pull_request on:
pull_request:
paths-ignore:
- 'docs/**'
env: env:
DEFAULT_PYTHON: 3.9 DEFAULT_PYTHON: 3.9

View File

@@ -34,14 +34,14 @@ jobs:
STABLE_TAG=${BASE}:stable STABLE_TAG=${BASE}:stable
PULL_TAG=${BASE}:${BUILD_TAG} PULL_TAG=${BASE}:${BUILD_TAG}
docker run --rm -v $HOME/.docker/config.json:/config.json quay.io/skopeo/stable:latest copy --authfile /config.json --multi-arch all docker://${PULL_TAG} docker://${VERSION_TAG} docker run --rm -v $HOME/.docker/config.json:/config.json quay.io/skopeo/stable:latest copy --authfile /config.json --multi-arch all docker://${PULL_TAG} docker://${VERSION_TAG}
for variant in standard-arm64 tensorrt tensorrt-jp4 tensorrt-jp5 rk; do for variant in standard-arm64 tensorrt tensorrt-jp4 tensorrt-jp5 rk h8l rocm; do
docker run --rm -v $HOME/.docker/config.json:/config.json quay.io/skopeo/stable:latest copy --authfile /config.json --multi-arch all docker://${PULL_TAG}-${variant} docker://${VERSION_TAG}-${variant} docker run --rm -v $HOME/.docker/config.json:/config.json quay.io/skopeo/stable:latest copy --authfile /config.json --multi-arch all docker://${PULL_TAG}-${variant} docker://${VERSION_TAG}-${variant}
done done
# stable tag # stable tag
if [[ "${BUILD_TYPE}" == "stable" ]]; then if [[ "${BUILD_TYPE}" == "stable" ]]; then
docker run --rm -v $HOME/.docker/config.json:/config.json quay.io/skopeo/stable:latest copy --authfile /config.json --multi-arch all docker://${PULL_TAG} docker://${STABLE_TAG} docker run --rm -v $HOME/.docker/config.json:/config.json quay.io/skopeo/stable:latest copy --authfile /config.json --multi-arch all docker://${PULL_TAG} docker://${STABLE_TAG}
for variant in standard-arm64 tensorrt tensorrt-jp4 tensorrt-jp5 rk; do for variant in standard-arm64 tensorrt tensorrt-jp4 tensorrt-jp5 rk h8l rocm; do
docker run --rm -v $HOME/.docker/config.json:/config.json quay.io/skopeo/stable:latest copy --authfile /config.json --multi-arch all docker://${PULL_TAG}-${variant} docker://${STABLE_TAG}-${variant} docker run --rm -v $HOME/.docker/config.json:/config.json quay.io/skopeo/stable:latest copy --authfile /config.json --multi-arch all docker://${PULL_TAG}-${variant} docker://${STABLE_TAG}-${variant}
done done
fi fi

View File

@@ -38,7 +38,7 @@ cd ../../
if [ ! -d /lib/firmware/hailo ]; then if [ ! -d /lib/firmware/hailo ]; then
sudo mkdir /lib/firmware/hailo sudo mkdir /lib/firmware/hailo
fi fi
sudo mv hailo8_fw.4.17.0.bin /lib/firmware/hailo/hailo8_fw.bin sudo mv hailo8_fw.4.18.0.bin /lib/firmware/hailo/hailo8_fw.bin
# Install udev rules # Install udev rules
sudo cp ./linux/pcie/51-hailo-udev.rules /etc/udev/rules.d/ sudo cp ./linux/pcie/51-hailo-udev.rules /etc/udev/rules.d/

View File

@@ -1,9 +1,9 @@
click == 8.1.* click == 8.1.*
# FastAPI # FastAPI
starlette-context == 0.3.6 starlette-context == 0.3.6
fastapi == 0.115.0 fastapi == 0.115.*
uvicorn == 0.30.* uvicorn == 0.30.*
slowapi == 0.1.9 slowapi == 0.1.*
imutils == 0.5.* imutils == 0.5.*
joserfc == 1.0.* joserfc == 1.0.*
pathvalidate == 3.2.* pathvalidate == 3.2.*
@@ -16,10 +16,10 @@ paho-mqtt == 2.1.*
pandas == 2.2.* pandas == 2.2.*
peewee == 3.17.* peewee == 3.17.*
peewee_migrate == 1.13.* peewee_migrate == 1.13.*
psutil == 5.9.* psutil == 6.1.*
pydantic == 2.8.* pydantic == 2.8.*
git+https://github.com/fbcotter/py3nvml#egg=py3nvml git+https://github.com/fbcotter/py3nvml#egg=py3nvml
pytz == 2024.1 pytz == 2024.*
pyzmq == 26.2.* pyzmq == 26.2.*
ruamel.yaml == 0.18.* ruamel.yaml == 0.18.*
tzlocal == 5.2 tzlocal == 5.2

View File

@@ -9,6 +9,6 @@ nvidia-cuda-runtime-cu11 == 11.8.*; platform_machine == 'x86_64'
nvidia-cublas-cu11 == 11.11.3.6; platform_machine == 'x86_64' nvidia-cublas-cu11 == 11.11.3.6; platform_machine == 'x86_64'
nvidia-cudnn-cu11 == 8.6.0.*; platform_machine == 'x86_64' nvidia-cudnn-cu11 == 8.6.0.*; platform_machine == 'x86_64'
nvidia-cufft-cu11==10.*; platform_machine == 'x86_64' nvidia-cufft-cu11==10.*; platform_machine == 'x86_64'
onnx==1.14.0; platform_machine == 'x86_64' onnx==1.16.*; platform_machine == 'x86_64'
onnxruntime-gpu==1.17.*; platform_machine == 'x86_64' onnxruntime-gpu==1.18.*; platform_machine == 'x86_64'
protobuf==3.20.3; platform_machine == 'x86_64' protobuf==3.20.3; platform_machine == 'x86_64'

View File

@@ -29,11 +29,21 @@ cameras:
## Ollama ## Ollama
[Ollama](https://ollama.com/) allows you to self-host large language models and keep everything running locally. It provides a nice API over [llama.cpp](https://github.com/ggerganov/llama.cpp). It is highly recommended to host this server on a machine with an Nvidia graphics card, or on a Apple silicon Mac for best performance. Most of the 7b parameter 4-bit vision models will fit inside 8GB of VRAM. There is also a [docker container](https://hub.docker.com/r/ollama/ollama) available. :::warning
Using Ollama on CPU is not recommended, high inference times make using generative AI impractical.
:::
[Ollama](https://ollama.com/) allows you to self-host large language models and keep everything running locally. It provides a nice API over [llama.cpp](https://github.com/ggerganov/llama.cpp). It is highly recommended to host this server on a machine with an Nvidia graphics card, or on a Apple silicon Mac for best performance.
Most of the 7b parameter 4-bit vision models will fit inside 8GB of VRAM. There is also a [docker container](https://hub.docker.com/r/ollama/ollama) available.
Parallel requests also come with some caveats. See the [Ollama documentation](https://github.com/ollama/ollama/blob/main/docs/faq.md#how-does-ollama-handle-concurrent-requests).
### Supported Models ### Supported Models
You must use a vision capable model with Frigate. Current model variants can be found [in their model library](https://ollama.com/library). At the time of writing, this includes `llava`, `llava-llama3`, `llava-phi3`, and `moondream`. You must use a vision capable model with Frigate. Current model variants can be found [in their model library](https://ollama.com/library). At the time of writing, this includes `llava`, `llava-llama3`, `llava-phi3`, and `moondream`. Note that Frigate will not automatically download the model you specify in your config, you must download the model to your local instance of Ollama first i.e. by running `ollama pull llava:7b` on your Ollama server/Docker container. Note that the model specified in Frigate's config must match the downloaded model tag.
:::note :::note
@@ -48,7 +58,7 @@ genai:
enabled: True enabled: True
provider: ollama provider: ollama
base_url: http://localhost:11434 base_url: http://localhost:11434
model: llava model: llava:7b
``` ```
## Google Gemini ## Google Gemini

View File

@@ -92,10 +92,16 @@ motion:
lightning_threshold: 0.8 lightning_threshold: 0.8
``` ```
:::tip :::warning
Some cameras like doorbell cameras may have missed detections when someone walks directly in front of the camera and the lightning_threshold causes motion detection to be re-calibrated. In this case, it may be desirable to increase the `lightning_threshold` to ensure these objects are not missed. Some cameras like doorbell cameras may have missed detections when someone walks directly in front of the camera and the lightning_threshold causes motion detection to be re-calibrated. In this case, it may be desirable to increase the `lightning_threshold` to ensure these objects are not missed.
::: :::
:::note
Lightning threshold does not stop motion based recordings from being saved.
:::
Large changes in motion like PTZ moves and camera switches between Color and IR mode should result in no motion detection. This is done via the `lightning_threshold` configuration. It is defined as the percentage of the image used to detect lightning or other substantial changes where motion detection needs to recalibrate. Increasing this value will make motion detection more likely to consider lightning or IR mode changes as valid motion. Decreasing this value will make motion detection more likely to ignore large amounts of motion such as a person approaching a doorbell camera. Large changes in motion like PTZ moves and camera switches between Color and IR mode should result in no motion detection. This is done via the `lightning_threshold` configuration. It is defined as the percentage of the image used to detect lightning or other substantial changes where motion detection needs to recalibrate. Increasing this value will make motion detection more likely to consider lightning or IR mode changes as valid motion. Decreasing this value will make motion detection more likely to ignore large amounts of motion such as a person approaching a doorbell camera.

View File

@@ -172,76 +172,65 @@ paths:
in: query in: query
required: false required: false
schema: schema:
anyOf: type: string
- type: string
- type: 'null'
default: all default: all
title: Cameras title: Cameras
- name: labels - name: labels
in: query in: query
required: false required: false
schema: schema:
anyOf: type: string
- type: string
- type: 'null'
default: all default: all
title: Labels title: Labels
- name: zones - name: zones
in: query in: query
required: false required: false
schema: schema:
anyOf: type: string
- type: string
- type: 'null'
default: all default: all
title: Zones title: Zones
- name: reviewed - name: reviewed
in: query in: query
required: false required: false
schema: schema:
anyOf: type: integer
- type: integer
- type: 'null'
default: 0 default: 0
title: Reviewed title: Reviewed
- name: limit - name: limit
in: query in: query
required: false required: false
schema: schema:
anyOf: type: integer
- type: integer
- type: 'null'
title: Limit title: Limit
- name: severity - name: severity
in: query in: query
required: false required: false
schema: schema:
anyOf: allOf:
- type: string - $ref: '#/components/schemas/SeverityEnum'
- type: 'null'
title: Severity title: Severity
- name: before - name: before
in: query in: query
required: false required: false
schema: schema:
anyOf: type: number
- type: number
- type: 'null'
title: Before title: Before
- name: after - name: after
in: query in: query
required: false required: false
schema: schema:
anyOf: type: number
- type: number
- type: 'null'
title: After title: After
responses: responses:
'200': '200':
description: Successful Response description: Successful Response
content: content:
application/json: application/json:
schema: { } schema:
type: array
items:
$ref: '#/components/schemas/ReviewSegmentResponse'
title: Response Review Review Get
'422': '422':
description: Validation Error description: Validation Error
content: content:
@@ -259,36 +248,28 @@ paths:
in: query in: query
required: false required: false
schema: schema:
anyOf: type: string
- type: string
- type: 'null'
default: all default: all
title: Cameras title: Cameras
- name: labels - name: labels
in: query in: query
required: false required: false
schema: schema:
anyOf: type: string
- type: string
- type: 'null'
default: all default: all
title: Labels title: Labels
- name: zones - name: zones
in: query in: query
required: false required: false
schema: schema:
anyOf: type: string
- type: string
- type: 'null'
default: all default: all
title: Zones title: Zones
- name: timezone - name: timezone
in: query in: query
required: false required: false
schema: schema:
anyOf: type: string
- type: string
- type: 'null'
default: utc default: utc
title: Timezone title: Timezone
responses: responses:
@@ -296,7 +277,8 @@ paths:
description: Successful Response description: Successful Response
content: content:
application/json: application/json:
schema: { } schema:
$ref: '#/components/schemas/ReviewSummaryResponse'
'422': '422':
description: Validation Error description: Validation Error
content: content:
@@ -310,17 +292,18 @@ paths:
summary: Set Multiple Reviewed summary: Set Multiple Reviewed
operationId: set_multiple_reviewed_reviews_viewed_post operationId: set_multiple_reviewed_reviews_viewed_post
requestBody: requestBody:
required: true
content: content:
application/json: application/json:
schema: schema:
type: object $ref: '#/components/schemas/ReviewSetMultipleReviewedBody'
title: Body
responses: responses:
'200': '200':
description: Successful Response description: Successful Response
content: content:
application/json: application/json:
schema: { } schema:
$ref: '#/components/schemas/GenericResponse'
'422': '422':
description: Validation Error description: Validation Error
content: content:
@@ -334,17 +317,18 @@ paths:
summary: Delete Reviews summary: Delete Reviews
operationId: delete_reviews_reviews_delete_post operationId: delete_reviews_reviews_delete_post
requestBody: requestBody:
required: true
content: content:
application/json: application/json:
schema: schema:
type: object $ref: '#/components/schemas/ReviewDeleteMultipleReviewsBody'
title: Body
responses: responses:
'200': '200':
description: Successful Response description: Successful Response
content: content:
application/json: application/json:
schema: { } schema:
$ref: '#/components/schemas/GenericResponse'
'422': '422':
description: Validation Error description: Validation Error
content: content:
@@ -363,96 +347,38 @@ paths:
in: query in: query
required: false required: false
schema: schema:
anyOf: type: string
- type: string
- type: 'null'
default: all default: all
title: Cameras title: Cameras
- name: before - name: before
in: query in: query
required: false required: false
schema: schema:
anyOf: type: number
- type: number
- type: 'null'
title: Before title: Before
- name: after - name: after
in: query in: query
required: false required: false
schema: schema:
anyOf: type: number
- type: number
- type: 'null'
title: After title: After
- name: scale - name: scale
in: query in: query
required: false required: false
schema: schema:
anyOf: type: integer
- type: integer
- type: 'null'
default: 30 default: 30
title: Scale title: Scale
responses: responses:
'200': '200':
description: Successful Response description: Successful Response
content:
application/json:
schema: { }
'422':
description: Validation Error
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/HTTPValidationError' type: array
/review/activity/audio: items:
get: $ref: '#/components/schemas/ReviewActivityMotionResponse'
tags: title: Response Motion Activity Review Activity Motion Get
- Review
summary: Audio Activity
description: Get motion and audio activity.
operationId: audio_activity_review_activity_audio_get
parameters:
- name: cameras
in: query
required: false
schema:
anyOf:
- type: string
- type: 'null'
default: all
title: Cameras
- name: before
in: query
required: false
schema:
anyOf:
- type: number
- type: 'null'
title: Before
- name: after
in: query
required: false
schema:
anyOf:
- type: number
- type: 'null'
title: After
- name: scale
in: query
required: false
schema:
anyOf:
- type: integer
- type: 'null'
default: 30
title: Scale
responses:
'200':
description: Successful Response
content:
application/json:
schema: { }
'422': '422':
description: Validation Error description: Validation Error
content: content:
@@ -477,57 +403,60 @@ paths:
description: Successful Response description: Successful Response
content: content:
application/json: application/json:
schema: { } schema:
$ref: '#/components/schemas/ReviewSegmentResponse'
'422': '422':
description: Validation Error description: Validation Error
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/HTTPValidationError' $ref: '#/components/schemas/HTTPValidationError'
/review/{event_id}: /review/{review_id}:
get: get:
tags: tags:
- Review - Review
summary: Get Review summary: Get Review
operationId: get_review_review__event_id__get operationId: get_review_review__review_id__get
parameters: parameters:
- name: event_id - name: review_id
in: path in: path
required: true required: true
schema: schema:
type: string type: string
title: Event Id title: Review Id
responses: responses:
'200': '200':
description: Successful Response description: Successful Response
content: content:
application/json: application/json:
schema: { } schema:
$ref: '#/components/schemas/ReviewSegmentResponse'
'422': '422':
description: Validation Error description: Validation Error
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/HTTPValidationError' $ref: '#/components/schemas/HTTPValidationError'
/review/{event_id}/viewed: /review/{review_id}/viewed:
delete: delete:
tags: tags:
- Review - Review
summary: Set Not Reviewed summary: Set Not Reviewed
operationId: set_not_reviewed_review__event_id__viewed_delete operationId: set_not_reviewed_review__review_id__viewed_delete
parameters: parameters:
- name: event_id - name: review_id
in: path in: path
required: true required: true
schema: schema:
type: string type: string
title: Event Id title: Review Id
responses: responses:
'200': '200':
description: Successful Response description: Successful Response
content: content:
application/json: application/json:
schema: { } schema:
$ref: '#/components/schemas/GenericResponse'
'422': '422':
description: Validation Error description: Validation Error
content: content:
@@ -763,13 +692,25 @@ paths:
content: content:
application/json: application/json:
schema: { } schema: { }
/nvinfo:
get:
tags:
- App
summary: Nvinfo
operationId: nvinfo_nvinfo_get
responses:
'200':
description: Successful Response
content:
application/json:
schema: { }
/logs/{service}: /logs/{service}:
get: get:
tags: tags:
- App - App
- Logs - Logs
summary: Logs summary: Logs
description: Get logs for the requested service (frigate/nginx/go2rtc/chroma) description: Get logs for the requested service (frigate/nginx/go2rtc)
operationId: logs_logs__service__get operationId: logs_logs__service__get
parameters: parameters:
- name: service - name: service
@@ -781,7 +722,6 @@ paths:
- frigate - frigate
- nginx - nginx
- go2rtc - go2rtc
- chroma
title: Service title: Service
- name: download - name: download
in: query in: query
@@ -1042,7 +982,8 @@ paths:
- Preview - Preview
summary: Preview Hour summary: Preview Hour
description: Get all mp4 previews relevant for time period given the timezone description: Get all mp4 previews relevant for time period given the timezone
operationId: preview_hour_preview__year_month___day___hour___camera_name___tz_name__get operationId: >-
preview_hour_preview__year_month___day___hour___camera_name___tz_name__get
parameters: parameters:
- name: year_month - name: year_month
in: path in: path
@@ -1092,7 +1033,8 @@ paths:
- Preview - Preview
summary: Get Preview Frames From Cache summary: Get Preview Frames From Cache
description: Get list of cached preview frames description: Get list of cached preview frames
operationId: get_preview_frames_from_cache_preview__camera_name__start__start_ts__end__end_ts__frames_get operationId: >-
get_preview_frames_from_cache_preview__camera_name__start__start_ts__end__end_ts__frames_get
parameters: parameters:
- name: camera_name - name: camera_name
in: path in: path
@@ -1177,7 +1119,8 @@ paths:
tags: tags:
- Export - Export
summary: Export Recording summary: Export Recording
operationId: export_recording_export__camera_name__start__start_time__end__end_time__post operationId: >-
export_recording_export__camera_name__start__start_time__end__end_time__post
parameters: parameters:
- name: camera_name - name: camera_name
in: path in: path
@@ -1656,6 +1599,30 @@ paths:
- type: 'null' - type: 'null'
default: utc default: utc
title: Timezone title: Timezone
- name: min_score
in: query
required: false
schema:
anyOf:
- type: number
- type: 'null'
title: Min Score
- name: max_score
in: query
required: false
schema:
anyOf:
- type: number
- type: 'null'
title: Max Score
- name: sort
in: query
required: false
schema:
anyOf:
- type: string
- type: 'null'
title: Sort
responses: responses:
'200': '200':
description: Successful Response description: Successful Response
@@ -1942,6 +1909,15 @@ paths:
schema: schema:
type: string type: string
title: Event Id title: Event Id
- name: source
in: query
required: false
schema:
anyOf:
- $ref: '#/components/schemas/RegenerateDescriptionEnum'
- type: 'null'
default: thumbnails
title: Source
responses: responses:
'200': '200':
description: Successful Response description: Successful Response
@@ -2029,12 +2005,12 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/HTTPValidationError' $ref: '#/components/schemas/HTTPValidationError'
'{camera_name}': /{camera_name}:
get: get:
tags: tags:
- Media - Media
summary: Mjpeg Feed summary: Mjpeg Feed
operationId: mjpeg_feed_camera_name__get operationId: mjpeg_feed__camera_name__get
parameters: parameters:
- name: camera_name - name: camera_name
in: path in: path
@@ -2241,7 +2217,8 @@ paths:
tags: tags:
- Media - Media
summary: Get Snapshot From Recording summary: Get Snapshot From Recording
operationId: get_snapshot_from_recording__camera_name__recordings__frame_time__snapshot__format__get operationId: >-
get_snapshot_from_recording__camera_name__recordings__frame_time__snapshot__format__get
parameters: parameters:
- name: camera_name - name: camera_name
in: path in: path
@@ -2363,7 +2340,9 @@ paths:
tags: tags:
- Media - Media
summary: Recordings summary: Recordings
description: Return specific camera recordings between the given 'after'/'end' times. If not provided the last hour will be used description: >-
Return specific camera recordings between the given 'after'/'end' times.
If not provided the last hour will be used
operationId: recordings__camera_name__recordings_get operationId: recordings__camera_name__recordings_get
parameters: parameters:
- name: camera_name - name: camera_name
@@ -2377,14 +2356,14 @@ paths:
required: false required: false
schema: schema:
type: number type: number
default: 1727542549.303557 default: 1729274204.653048
title: After title: After
- name: before - name: before
in: query in: query
required: false required: false
schema: schema:
type: number type: number
default: 1727546149.303926 default: 1729277804.653095
title: Before title: Before
responses: responses:
'200': '200':
@@ -2423,13 +2402,6 @@ paths:
schema: schema:
type: number type: number
title: End Ts title: End Ts
- name: download
in: query
required: false
schema:
type: boolean
default: false
title: Download
responses: responses:
'200': '200':
description: Successful Response description: Successful Response
@@ -2800,13 +2772,6 @@ paths:
schema: schema:
type: string type: string
title: Event Id title: Event Id
- name: download
in: query
required: false
schema:
type: boolean
default: false
title: Download
responses: responses:
'200': '200':
description: Successful Response description: Successful Response
@@ -3121,7 +3086,9 @@ paths:
tags: tags:
- Media - Media
summary: Label Snapshot summary: Label Snapshot
description: Returns the snapshot image from the latest event for the given camera and label combo description: >-
Returns the snapshot image from the latest event for the given camera
and label combo
operationId: label_snapshot__camera_name___label__snapshot_jpg_get operationId: label_snapshot__camera_name___label__snapshot_jpg_get
parameters: parameters:
- name: camera_name - name: camera_name
@@ -3193,6 +3160,32 @@ components:
required: required:
- password - password
title: AppPutPasswordBody title: AppPutPasswordBody
DayReview:
properties:
day:
type: string
format: date-time
title: Day
reviewed_alert:
type: integer
title: Reviewed Alert
reviewed_detection:
type: integer
title: Reviewed Detection
total_alert:
type: integer
title: Total Alert
total_detection:
type: integer
title: Total Detection
type: object
required:
- day
- reviewed_alert
- reviewed_detection
- total_alert
- total_detection
title: DayReview
EventsCreateBody: EventsCreateBody:
properties: properties:
source_type: source_type:
@@ -3237,7 +3230,6 @@ components:
description: description:
anyOf: anyOf:
- type: string - type: string
minLength: 1
- type: 'null' - type: 'null'
title: The description of the event title: The description of the event
type: object type: object
@@ -3278,6 +3270,19 @@ components:
- jpg - jpg
- jpeg - jpeg
title: Extension title: Extension
GenericResponse:
properties:
success:
type: boolean
title: Success
message:
type: string
title: Message
type: object
required:
- success
- message
title: GenericResponse
HTTPValidationError: HTTPValidationError:
properties: properties:
detail: detail:
@@ -3287,6 +3292,133 @@ components:
title: Detail title: Detail
type: object type: object
title: HTTPValidationError title: HTTPValidationError
Last24HoursReview:
properties:
reviewed_alert:
type: integer
title: Reviewed Alert
reviewed_detection:
type: integer
title: Reviewed Detection
total_alert:
type: integer
title: Total Alert
total_detection:
type: integer
title: Total Detection
type: object
required:
- reviewed_alert
- reviewed_detection
- total_alert
- total_detection
title: Last24HoursReview
RegenerateDescriptionEnum:
type: string
enum:
- thumbnails
- snapshot
title: RegenerateDescriptionEnum
ReviewActivityMotionResponse:
properties:
start_time:
type: integer
title: Start Time
motion:
type: number
title: Motion
camera:
type: string
title: Camera
type: object
required:
- start_time
- motion
- camera
title: ReviewActivityMotionResponse
ReviewDeleteMultipleReviewsBody:
properties:
ids:
items:
type: string
minLength: 1
type: array
minItems: 1
title: Ids
type: object
required:
- ids
title: ReviewDeleteMultipleReviewsBody
ReviewSegmentResponse:
properties:
id:
type: string
title: Id
camera:
type: string
title: Camera
start_time:
type: string
format: date-time
title: Start Time
end_time:
type: string
format: date-time
title: End Time
has_been_reviewed:
type: boolean
title: Has Been Reviewed
severity:
$ref: '#/components/schemas/SeverityEnum'
thumb_path:
type: string
title: Thumb Path
data:
title: Data
type: object
required:
- id
- camera
- start_time
- end_time
- has_been_reviewed
- severity
- thumb_path
- data
title: ReviewSegmentResponse
ReviewSetMultipleReviewedBody:
properties:
ids:
items:
type: string
minLength: 1
type: array
minItems: 1
title: Ids
type: object
required:
- ids
title: ReviewSetMultipleReviewedBody
ReviewSummaryResponse:
properties:
last24Hours:
$ref: '#/components/schemas/Last24HoursReview'
root:
additionalProperties:
$ref: '#/components/schemas/DayReview'
type: object
title: Root
type: object
required:
- last24Hours
- root
title: ReviewSummaryResponse
SeverityEnum:
type: string
enum:
- alert
- detection
title: SeverityEnum
SubmitPlusBody: SubmitPlusBody:
properties: properties:
include_annotation: include_annotation:

View File

@@ -0,0 +1,6 @@
from pydantic import BaseModel
class GenericResponse(BaseModel):
success: bool
message: str

View File

@@ -0,0 +1,6 @@
from pydantic import BaseModel, conlist, constr
class ReviewModifyMultipleBody(BaseModel):
# List of string with at least one element and each element with at least one char
ids: conlist(constr(min_length=1), min_length=1)

View File

@@ -1,28 +1,31 @@
from typing import Optional from typing import Union
from pydantic import BaseModel from pydantic import BaseModel
from pydantic.json_schema import SkipJsonSchema
from frigate.review.maintainer import SeverityEnum
class ReviewQueryParams(BaseModel): class ReviewQueryParams(BaseModel):
cameras: Optional[str] = "all" cameras: str = "all"
labels: Optional[str] = "all" labels: str = "all"
zones: Optional[str] = "all" zones: str = "all"
reviewed: Optional[int] = 0 reviewed: int = 0
limit: Optional[int] = None limit: Union[int, SkipJsonSchema[None]] = None
severity: Optional[str] = None severity: Union[SeverityEnum, SkipJsonSchema[None]] = None
before: Optional[float] = None before: Union[float, SkipJsonSchema[None]] = None
after: Optional[float] = None after: Union[float, SkipJsonSchema[None]] = None
class ReviewSummaryQueryParams(BaseModel): class ReviewSummaryQueryParams(BaseModel):
cameras: Optional[str] = "all" cameras: str = "all"
labels: Optional[str] = "all" labels: str = "all"
zones: Optional[str] = "all" zones: str = "all"
timezone: Optional[str] = "utc" timezone: str = "utc"
class ReviewActivityMotionQueryParams(BaseModel): class ReviewActivityMotionQueryParams(BaseModel):
cameras: Optional[str] = "all" cameras: str = "all"
before: Optional[float] = None before: Union[float, SkipJsonSchema[None]] = None
after: Optional[float] = None after: Union[float, SkipJsonSchema[None]] = None
scale: Optional[int] = 30 scale: int = 30

View File

@@ -0,0 +1,43 @@
from datetime import datetime
from typing import Dict
from pydantic import BaseModel, Json
from frigate.review.maintainer import SeverityEnum
class ReviewSegmentResponse(BaseModel):
id: str
camera: str
start_time: datetime
end_time: datetime
has_been_reviewed: bool
severity: SeverityEnum
thumb_path: str
data: Json
class Last24HoursReview(BaseModel):
reviewed_alert: int
reviewed_detection: int
total_alert: int
total_detection: int
class DayReview(BaseModel):
day: datetime
reviewed_alert: int
reviewed_detection: int
total_alert: int
total_detection: int
class ReviewSummaryResponse(BaseModel):
last24Hours: Last24HoursReview
root: Dict[str, DayReview]
class ReviewActivityMotionResponse(BaseModel):
start_time: int
motion: float
camera: str

View File

@@ -394,6 +394,7 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
Event.end_time, Event.end_time,
Event.has_clip, Event.has_clip,
Event.has_snapshot, Event.has_snapshot,
Event.top_score,
Event.data, Event.data,
Event.plus_id, Event.plus_id,
ReviewSegment.thumb_path, ReviewSegment.thumb_path,
@@ -1014,7 +1015,7 @@ def regenerate_description(
content=( content=(
{ {
"success": False, "success": False,
"message": "Semantic search and generative AI are not enabled", "message": "Semantic Search and Generative AI must be enabled to regenerate a description",
} }
), ),
status_code=400, status_code=400,

View File

@@ -13,8 +13,12 @@ from peewee import DoesNotExist
from frigate.api.defs.tags import Tags from frigate.api.defs.tags import Tags
from frigate.const import EXPORT_DIR from frigate.const import EXPORT_DIR
from frigate.models import Export, Recordings from frigate.models import Export, Previews, Recordings
from frigate.record.export import PlaybackFactorEnum, RecordingExporter from frigate.record.export import (
PlaybackFactorEnum,
PlaybackSourceEnum,
RecordingExporter,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -45,6 +49,7 @@ def export_recording(
json: dict[str, any] = body or {} json: dict[str, any] = body or {}
playback_factor = json.get("playback", "realtime") playback_factor = json.get("playback", "realtime")
playback_source = json.get("source", "recordings")
friendly_name: Optional[str] = json.get("name") friendly_name: Optional[str] = json.get("name")
if len(friendly_name or "") > 256: if len(friendly_name or "") > 256:
@@ -55,25 +60,48 @@ def export_recording(
existing_image = json.get("image_path") existing_image = json.get("image_path")
recordings_count = ( if playback_source == "recordings":
Recordings.select() recordings_count = (
.where( Recordings.select()
Recordings.start_time.between(start_time, end_time) .where(
| Recordings.end_time.between(start_time, end_time) Recordings.start_time.between(start_time, end_time)
| ((start_time > Recordings.start_time) & (end_time < Recordings.end_time)) | Recordings.end_time.between(start_time, end_time)
| (
(start_time > Recordings.start_time)
& (end_time < Recordings.end_time)
)
)
.where(Recordings.camera == camera_name)
.count()
) )
.where(Recordings.camera == camera_name)
.count()
)
if recordings_count <= 0: if recordings_count <= 0:
return JSONResponse( return JSONResponse(
content=( content=(
{"success": False, "message": "No recordings found for time range"} {"success": False, "message": "No recordings found for time range"}
), ),
status_code=400, status_code=400,
)
else:
previews_count = (
Previews.select()
.where(
Previews.start_time.between(start_time, end_time)
| Previews.end_time.between(start_time, end_time)
| ((start_time > Previews.start_time) & (end_time < Previews.end_time))
)
.where(Previews.camera == camera_name)
.count()
) )
if previews_count <= 0:
return JSONResponse(
content=(
{"success": False, "message": "No previews found for time range"}
),
status_code=400,
)
export_id = f"{camera_name}_{''.join(random.choices(string.ascii_lowercase + string.digits, k=6))}" export_id = f"{camera_name}_{''.join(random.choices(string.ascii_lowercase + string.digits, k=6))}"
exporter = RecordingExporter( exporter = RecordingExporter(
request.app.frigate_config, request.app.frigate_config,
@@ -88,6 +116,11 @@ def export_recording(
if playback_factor in PlaybackFactorEnum.__members__.values() if playback_factor in PlaybackFactorEnum.__members__.values()
else PlaybackFactorEnum.realtime else PlaybackFactorEnum.realtime
), ),
(
PlaybackSourceEnum[playback_source]
if playback_source in PlaybackSourceEnum.__members__.values()
else PlaybackSourceEnum.recordings
),
) )
exporter.start() exporter.start()
return JSONResponse( return JSONResponse(

View File

@@ -82,6 +82,10 @@ def create_fastapi_app(
database.close() database.close()
return response return response
@app.on_event("startup")
async def startup():
logger.info("FastAPI started")
# Rate limiter (used for login endpoint) # Rate limiter (used for login endpoint)
auth.rateLimiter.set_limit(frigate_config.auth.failed_login_rate_limit or "") auth.rateLimiter.set_limit(frigate_config.auth.failed_login_rate_limit or "")
app.state.limiter = limiter app.state.limiter = limiter

View File

@@ -460,8 +460,8 @@ def recording_clip(
text=False, text=False,
) as ffmpeg: ) as ffmpeg:
while True: while True:
data = ffmpeg.stdout.read(1024) data = ffmpeg.stdout.read(8192)
if data is not None: if data is not None and len(data) > 0:
yield data yield data
else: else:
if ffmpeg.returncode and ffmpeg.returncode != 0: if ffmpeg.returncode and ffmpeg.returncode != 0:

View File

@@ -12,11 +12,18 @@ from fastapi.responses import JSONResponse
from peewee import Case, DoesNotExist, fn, operator from peewee import Case, DoesNotExist, fn, operator
from playhouse.shortcuts import model_to_dict from playhouse.shortcuts import model_to_dict
from frigate.api.defs.generic_response import GenericResponse
from frigate.api.defs.review_body import ReviewModifyMultipleBody
from frigate.api.defs.review_query_parameters import ( from frigate.api.defs.review_query_parameters import (
ReviewActivityMotionQueryParams, ReviewActivityMotionQueryParams,
ReviewQueryParams, ReviewQueryParams,
ReviewSummaryQueryParams, ReviewSummaryQueryParams,
) )
from frigate.api.defs.review_responses import (
ReviewActivityMotionResponse,
ReviewSegmentResponse,
ReviewSummaryResponse,
)
from frigate.api.defs.tags import Tags from frigate.api.defs.tags import Tags
from frigate.models import Recordings, ReviewSegment from frigate.models import Recordings, ReviewSegment
from frigate.util.builtin import get_tz_modifiers from frigate.util.builtin import get_tz_modifiers
@@ -26,7 +33,7 @@ logger = logging.getLogger(__name__)
router = APIRouter(tags=[Tags.review]) router = APIRouter(tags=[Tags.review])
@router.get("/review") @router.get("/review", response_model=list[ReviewSegmentResponse])
def review(params: ReviewQueryParams = Depends()): def review(params: ReviewQueryParams = Depends()):
cameras = params.cameras cameras = params.cameras
labels = params.labels labels = params.labels
@@ -102,7 +109,7 @@ def review(params: ReviewQueryParams = Depends()):
return JSONResponse(content=[r for r in review]) return JSONResponse(content=[r for r in review])
@router.get("/review/summary") @router.get("/review/summary", response_model=ReviewSummaryResponse)
def review_summary(params: ReviewSummaryQueryParams = Depends()): def review_summary(params: ReviewSummaryQueryParams = Depends()):
hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(params.timezone) hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(params.timezone)
day_ago = (datetime.datetime.now() - datetime.timedelta(hours=24)).timestamp() day_ago = (datetime.datetime.now() - datetime.timedelta(hours=24)).timestamp()
@@ -173,18 +180,6 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
0, 0,
) )
).alias("reviewed_detection"), ).alias("reviewed_detection"),
fn.SUM(
Case(
None,
[
(
(ReviewSegment.severity == "significant_motion"),
ReviewSegment.has_been_reviewed,
)
],
0,
)
).alias("reviewed_motion"),
fn.SUM( fn.SUM(
Case( Case(
None, None,
@@ -209,18 +204,6 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
0, 0,
) )
).alias("total_detection"), ).alias("total_detection"),
fn.SUM(
Case(
None,
[
(
(ReviewSegment.severity == "significant_motion"),
1,
)
],
0,
)
).alias("total_motion"),
) )
.where(reduce(operator.and_, clauses)) .where(reduce(operator.and_, clauses))
.dicts() .dicts()
@@ -282,18 +265,6 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
0, 0,
) )
).alias("reviewed_detection"), ).alias("reviewed_detection"),
fn.SUM(
Case(
None,
[
(
(ReviewSegment.severity == "significant_motion"),
ReviewSegment.has_been_reviewed,
)
],
0,
)
).alias("reviewed_motion"),
fn.SUM( fn.SUM(
Case( Case(
None, None,
@@ -318,18 +289,6 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
0, 0,
) )
).alias("total_detection"), ).alias("total_detection"),
fn.SUM(
Case(
None,
[
(
(ReviewSegment.severity == "significant_motion"),
1,
)
],
0,
)
).alias("total_motion"),
) )
.where(reduce(operator.and_, clauses)) .where(reduce(operator.and_, clauses))
.group_by( .group_by(
@@ -348,19 +307,10 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
return JSONResponse(content=data) return JSONResponse(content=data)
@router.post("/reviews/viewed") @router.post("/reviews/viewed", response_model=GenericResponse)
def set_multiple_reviewed(body: dict = None): def set_multiple_reviewed(body: ReviewModifyMultipleBody):
json: dict[str, any] = body or {}
list_of_ids = json.get("ids", "")
if not list_of_ids or len(list_of_ids) == 0:
return JSONResponse(
context=({"success": False, "message": "Not a valid list of ids"}),
status_code=404,
)
ReviewSegment.update(has_been_reviewed=True).where( ReviewSegment.update(has_been_reviewed=True).where(
ReviewSegment.id << list_of_ids ReviewSegment.id << body.ids
).execute() ).execute()
return JSONResponse( return JSONResponse(
@@ -369,17 +319,9 @@ def set_multiple_reviewed(body: dict = None):
) )
@router.post("/reviews/delete") @router.post("/reviews/delete", response_model=GenericResponse)
def delete_reviews(body: dict = None): def delete_reviews(body: ReviewModifyMultipleBody):
json: dict[str, any] = body or {} list_of_ids = body.ids
list_of_ids = json.get("ids", "")
if not list_of_ids or len(list_of_ids) == 0:
return JSONResponse(
content=({"success": False, "message": "Not a valid list of ids"}),
status_code=404,
)
reviews = ( reviews = (
ReviewSegment.select( ReviewSegment.select(
ReviewSegment.camera, ReviewSegment.camera,
@@ -424,7 +366,9 @@ def delete_reviews(body: dict = None):
) )
@router.get("/review/activity/motion") @router.get(
"/review/activity/motion", response_model=list[ReviewActivityMotionResponse]
)
def motion_activity(params: ReviewActivityMotionQueryParams = Depends()): def motion_activity(params: ReviewActivityMotionQueryParams = Depends()):
"""Get motion and audio activity.""" """Get motion and audio activity."""
cameras = params.cameras cameras = params.cameras
@@ -498,98 +442,44 @@ def motion_activity(params: ReviewActivityMotionQueryParams = Depends()):
return JSONResponse(content=normalized) return JSONResponse(content=normalized)
@router.get("/review/activity/audio") @router.get("/review/event/{event_id}", response_model=ReviewSegmentResponse)
def audio_activity(params: ReviewActivityMotionQueryParams = Depends()):
"""Get motion and audio activity."""
cameras = params.cameras
before = params.before or datetime.datetime.now().timestamp()
after = (
params.after
or (datetime.datetime.now() - datetime.timedelta(hours=1)).timestamp()
)
# get scale in seconds
scale = params.scale
clauses = [(Recordings.start_time > after) & (Recordings.end_time < before)]
if cameras != "all":
camera_list = cameras.split(",")
clauses.append((Recordings.camera << camera_list))
all_recordings: list[Recordings] = (
Recordings.select(
Recordings.start_time,
Recordings.duration,
Recordings.objects,
Recordings.dBFS,
)
.where(reduce(operator.and_, clauses))
.order_by(Recordings.start_time.asc())
.iterator()
)
# format is: { timestamp: segment_start_ts, motion: [0-100], audio: [0 - -100] }
# periods where active objects / audio was detected will cause audio to be scaled down
data: list[dict[str, float]] = []
for rec in all_recordings:
data.append(
{
"start_time": rec.start_time,
"audio": rec.dBFS if rec.objects == 0 else 0,
}
)
# resample data using pandas to get activity on scaled basis
df = pd.DataFrame(data, columns=["start_time", "audio"])
df = df.astype(dtype={"audio": "float16"})
# set date as datetime index
df["start_time"] = pd.to_datetime(df["start_time"], unit="s")
df.set_index(["start_time"], inplace=True)
# normalize data
df = df.resample(f"{scale}S").mean().fillna(0.0)
df["audio"] = (
(df["audio"] - df["audio"].max())
/ (df["audio"].min() - df["audio"].max())
* -100
)
# change types for output
df.index = df.index.astype(int) // (10**9)
normalized = df.reset_index().to_dict("records")
return JSONResponse(content=normalized)
@router.get("/review/event/{event_id}")
def get_review_from_event(event_id: str): def get_review_from_event(event_id: str):
try: try:
return model_to_dict( return JSONResponse(
ReviewSegment.get( model_to_dict(
ReviewSegment.data["detections"].cast("text") % f'*"{event_id}"*' ReviewSegment.get(
ReviewSegment.data["detections"].cast("text") % f'*"{event_id}"*'
)
) )
) )
except DoesNotExist: except DoesNotExist:
return "Review item not found", 404 return JSONResponse(
content={"success": False, "message": "Review item not found"},
status_code=404,
)
@router.get("/review/{event_id}") @router.get("/review/{review_id}", response_model=ReviewSegmentResponse)
def get_review(event_id: str): def get_review(review_id: str):
try: try:
return model_to_dict(ReviewSegment.get(ReviewSegment.id == event_id)) return JSONResponse(
content=model_to_dict(ReviewSegment.get(ReviewSegment.id == review_id))
)
except DoesNotExist: except DoesNotExist:
return "Review item not found", 404 return JSONResponse(
content={"success": False, "message": "Review item not found"},
status_code=404,
)
@router.delete("/review/{event_id}/viewed") @router.delete("/review/{review_id}/viewed", response_model=GenericResponse)
def set_not_reviewed(event_id: str): def set_not_reviewed(review_id: str):
try: try:
review: ReviewSegment = ReviewSegment.get(ReviewSegment.id == event_id) review: ReviewSegment = ReviewSegment.get(ReviewSegment.id == review_id)
except DoesNotExist: except DoesNotExist:
return JSONResponse( return JSONResponse(
content=( content=(
{"success": False, "message": "Review " + event_id + " not found"} {"success": False, "message": "Review " + review_id + " not found"}
), ),
status_code=404, status_code=404,
) )
@@ -598,6 +488,8 @@ def set_not_reviewed(event_id: str):
review.save() review.save()
return JSONResponse( return JSONResponse(
content=({"success": True, "message": "Reviewed " + event_id + " not viewed"}), content=(
{"success": True, "message": "Set Review " + review_id + " as not viewed"}
),
status_code=200, status_code=200,
) )

View File

@@ -63,6 +63,7 @@ from frigate.record.cleanup import RecordingCleanup
from frigate.record.export import migrate_exports from frigate.record.export import migrate_exports
from frigate.record.record import manage_recordings from frigate.record.record import manage_recordings
from frigate.review.review import manage_review_segments from frigate.review.review import manage_review_segments
from frigate.service_manager import ServiceManager
from frigate.stats.emitter import StatsEmitter from frigate.stats.emitter import StatsEmitter
from frigate.stats.util import stats_init from frigate.stats.util import stats_init
from frigate.storage import StorageMaintainer from frigate.storage import StorageMaintainer
@@ -78,7 +79,6 @@ logger = logging.getLogger(__name__)
class FrigateApp: class FrigateApp:
def __init__(self, config: FrigateConfig) -> None: def __init__(self, config: FrigateConfig) -> None:
self.audio_process: Optional[mp.Process] = None
self.stop_event: MpEvent = mp.Event() self.stop_event: MpEvent = mp.Event()
self.detection_queue: Queue = mp.Queue() self.detection_queue: Queue = mp.Queue()
self.detectors: dict[str, ObjectDetectProcess] = {} self.detectors: dict[str, ObjectDetectProcess] = {}
@@ -449,9 +449,8 @@ class FrigateApp:
] ]
if audio_cameras: if audio_cameras:
self.audio_process = AudioProcessor(audio_cameras, self.camera_metrics) proc = AudioProcessor(audio_cameras, self.camera_metrics).start(wait=True)
self.audio_process.start() self.processes["audio_detector"] = proc.pid or 0
self.processes["audio_detector"] = self.audio_process.pid or 0
def start_timeline_processor(self) -> None: def start_timeline_processor(self) -> None:
self.timeline_processor = TimelineProcessor( self.timeline_processor = TimelineProcessor(
@@ -639,11 +638,6 @@ class FrigateApp:
ReviewSegment.end_time == None ReviewSegment.end_time == None
).execute() ).execute()
# stop the audio process
if self.audio_process:
self.audio_process.terminate()
self.audio_process.join()
# ensure the capture processes are done # ensure the capture processes are done
for camera, metrics in self.camera_metrics.items(): for camera, metrics in self.camera_metrics.items():
capture_process = metrics.capture_process capture_process = metrics.capture_process
@@ -712,4 +706,6 @@ class FrigateApp:
shm.close() shm.close()
shm.unlink() shm.unlink()
ServiceManager.current().shutdown(wait=True)
os._exit(os.EX_OK) os._exit(os.EX_OK)

View File

@@ -17,7 +17,7 @@ class MqttClient(Communicator): # type: ignore[misc]
def __init__(self, config: FrigateConfig) -> None: def __init__(self, config: FrigateConfig) -> None:
self.config = config self.config = config
self.mqtt_config = config.mqtt self.mqtt_config = config.mqtt
self.connected: bool = False self.connected = False
def subscribe(self, receiver: Callable) -> None: def subscribe(self, receiver: Callable) -> None:
"""Wrapper for allowing dispatcher to subscribe.""" """Wrapper for allowing dispatcher to subscribe."""
@@ -27,7 +27,7 @@ class MqttClient(Communicator): # type: ignore[misc]
def publish(self, topic: str, payload: Any, retain: bool = False) -> None: def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
"""Wrapper for publishing when client is in valid state.""" """Wrapper for publishing when client is in valid state."""
if not self.connected: if not self.connected:
logger.error(f"Unable to publish to {topic}: client is not connected") logger.debug(f"Unable to publish to {topic}: client is not connected")
return return
self.client.publish( self.client.publish(
@@ -173,6 +173,7 @@ class MqttClient(Communicator): # type: ignore[misc]
client_id=self.mqtt_config.client_id, client_id=self.mqtt_config.client_id,
) )
self.client.on_connect = self._on_connect self.client.on_connect = self._on_connect
self.client.on_disconnect = self._on_disconnect
self.client.will_set( self.client.will_set(
self.mqtt_config.topic_prefix + "/available", self.mqtt_config.topic_prefix + "/available",
payload="offline", payload="offline",
@@ -197,14 +198,6 @@ class MqttClient(Communicator): # type: ignore[misc]
for name in self.config.cameras.keys(): for name in self.config.cameras.keys():
for callback in callback_types: for callback in callback_types:
# We need to pre-clear existing set topics because in previous
# versions the webUI retained on the /set topic but this is
# no longer the case.
self.client.publish(
f"{self.mqtt_config.topic_prefix}/{name}/{callback}/set",
None,
retain=True,
)
self.client.message_callback_add( self.client.message_callback_add(
f"{self.mqtt_config.topic_prefix}/{name}/{callback}/set", f"{self.mqtt_config.topic_prefix}/{name}/{callback}/set",
self.on_mqtt_command, self.on_mqtt_command,

View File

@@ -1,13 +1,11 @@
"""SQLite-vec embeddings database.""" """SQLite-vec embeddings database."""
import base64 import base64
import io
import logging import logging
import os import os
import time import time
from numpy import ndarray from numpy import ndarray
from PIL import Image
from playhouse.shortcuts import model_to_dict from playhouse.shortcuts import model_to_dict
from frigate.comms.inter_process import InterProcessRequestor from frigate.comms.inter_process import InterProcessRequestor
@@ -22,7 +20,7 @@ from frigate.models import Event
from frigate.types import ModelStatusTypesEnum from frigate.types import ModelStatusTypesEnum
from frigate.util.builtin import serialize from frigate.util.builtin import serialize
from .functions.onnx import GenericONNXEmbedding from .functions.onnx import GenericONNXEmbedding, ModelTypeEnum
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -97,7 +95,7 @@ class Embeddings:
"text_model_fp16.onnx": "https://huggingface.co/jinaai/jina-clip-v1/resolve/main/onnx/text_model_fp16.onnx", "text_model_fp16.onnx": "https://huggingface.co/jinaai/jina-clip-v1/resolve/main/onnx/text_model_fp16.onnx",
}, },
model_size=config.model_size, model_size=config.model_size,
model_type="text", model_type=ModelTypeEnum.text,
requestor=self.requestor, requestor=self.requestor,
device="CPU", device="CPU",
) )
@@ -118,83 +116,102 @@ class Embeddings:
model_file=model_file, model_file=model_file,
download_urls=download_urls, download_urls=download_urls,
model_size=config.model_size, model_size=config.model_size,
model_type="vision", model_type=ModelTypeEnum.vision,
requestor=self.requestor, requestor=self.requestor,
device="GPU" if config.model_size == "large" else "CPU", device="GPU" if config.model_size == "large" else "CPU",
) )
def upsert_thumbnail(self, event_id: str, thumbnail: bytes) -> ndarray: def embed_thumbnail(
# Convert thumbnail bytes to PIL Image self, event_id: str, thumbnail: bytes, upsert: bool = True
image = Image.open(io.BytesIO(thumbnail)).convert("RGB") ) -> ndarray:
embedding = self.vision_embedding([image])[0] """Embed thumbnail and optionally insert into DB.
self.db.execute_sql( @param: event_id in Events DB
""" @param: thumbnail bytes in jpg format
INSERT OR REPLACE INTO vec_thumbnails(id, thumbnail_embedding) @param: upsert If embedding should be upserted into vec DB
VALUES(?, ?) """
""", # Convert thumbnail bytes to PIL Image
(event_id, serialize(embedding)), embedding = self.vision_embedding([thumbnail])[0]
)
if upsert:
self.db.execute_sql(
"""
INSERT OR REPLACE INTO vec_thumbnails(id, thumbnail_embedding)
VALUES(?, ?)
""",
(event_id, serialize(embedding)),
)
return embedding return embedding
def batch_upsert_thumbnail(self, event_thumbs: dict[str, bytes]) -> list[ndarray]: def batch_embed_thumbnail(
images = [ self, event_thumbs: dict[str, bytes], upsert: bool = True
Image.open(io.BytesIO(thumb)).convert("RGB") ) -> list[ndarray]:
for thumb in event_thumbs.values() """Embed thumbnails and optionally insert into DB.
]
@param: event_thumbs Map of Event IDs in DB to thumbnail bytes in jpg format
@param: upsert If embedding should be upserted into vec DB
"""
ids = list(event_thumbs.keys()) ids = list(event_thumbs.keys())
embeddings = self.vision_embedding(images) embeddings = self.vision_embedding(list(event_thumbs.values()))
items = [] if upsert:
items = []
for i in range(len(ids)): for i in range(len(ids)):
items.append(ids[i]) items.append(ids[i])
items.append(serialize(embeddings[i])) items.append(serialize(embeddings[i]))
self.db.execute_sql(
"""
INSERT OR REPLACE INTO vec_thumbnails(id, thumbnail_embedding)
VALUES {}
""".format(", ".join(["(?, ?)"] * len(ids))),
items,
)
self.db.execute_sql(
"""
INSERT OR REPLACE INTO vec_thumbnails(id, thumbnail_embedding)
VALUES {}
""".format(", ".join(["(?, ?)"] * len(ids))),
items,
)
return embeddings return embeddings
def upsert_description(self, event_id: str, description: str) -> ndarray: def embed_description(
self, event_id: str, description: str, upsert: bool = True
) -> ndarray:
embedding = self.text_embedding([description])[0] embedding = self.text_embedding([description])[0]
self.db.execute_sql(
""" if upsert:
INSERT OR REPLACE INTO vec_descriptions(id, description_embedding) self.db.execute_sql(
VALUES(?, ?) """
""", INSERT OR REPLACE INTO vec_descriptions(id, description_embedding)
(event_id, serialize(embedding)), VALUES(?, ?)
) """,
(event_id, serialize(embedding)),
)
return embedding return embedding
def batch_upsert_description(self, event_descriptions: dict[str, str]) -> ndarray: def batch_embed_description(
self, event_descriptions: dict[str, str], upsert: bool = True
) -> ndarray:
# upsert embeddings one by one to avoid token limit # upsert embeddings one by one to avoid token limit
embeddings = [] embeddings = []
for desc in event_descriptions.values(): for desc in event_descriptions.values():
embeddings.append(self.text_embedding([desc])[0]) embeddings.append(self.text_embedding([desc])[0])
ids = list(event_descriptions.keys()) if upsert:
ids = list(event_descriptions.keys())
items = []
items = [] for i in range(len(ids)):
items.append(ids[i])
items.append(serialize(embeddings[i]))
for i in range(len(ids)): self.db.execute_sql(
items.append(ids[i]) """
items.append(serialize(embeddings[i])) INSERT OR REPLACE INTO vec_descriptions(id, description_embedding)
VALUES {}
self.db.execute_sql( """.format(", ".join(["(?, ?)"] * len(ids))),
""" items,
INSERT OR REPLACE INTO vec_descriptions(id, description_embedding) )
VALUES {}
""".format(", ".join(["(?, ?)"] * len(ids))),
items,
)
return embeddings return embeddings
@@ -261,10 +278,10 @@ class Embeddings:
totals["processed_objects"] += 1 totals["processed_objects"] += 1
# run batch embedding # run batch embedding
self.batch_upsert_thumbnail(batch_thumbs) self.batch_embed_thumbnail(batch_thumbs)
if batch_descs: if batch_descs:
self.batch_upsert_description(batch_descs) self.batch_embed_description(batch_descs)
# report progress every batch so we don't spam the logs # report progress every batch so we don't spam the logs
progress = (totals["processed_objects"] / total_events) * 100 progress = (totals["processed_objects"] / total_events) * 100

View File

@@ -1,6 +1,7 @@
import logging import logging
import os import os
import warnings import warnings
from enum import Enum
from io import BytesIO from io import BytesIO
from typing import Dict, List, Optional, Union from typing import Dict, List, Optional, Union
@@ -31,6 +32,12 @@ disable_progress_bar()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class ModelTypeEnum(str, Enum):
face = "face"
vision = "vision"
text = "text"
class GenericONNXEmbedding: class GenericONNXEmbedding:
"""Generic embedding function for ONNX models (text and vision).""" """Generic embedding function for ONNX models (text and vision)."""
@@ -88,7 +95,10 @@ class GenericONNXEmbedding:
file_name = os.path.basename(path) file_name = os.path.basename(path)
if file_name in self.download_urls: if file_name in self.download_urls:
ModelDownloader.download_from_url(self.download_urls[file_name], path) ModelDownloader.download_from_url(self.download_urls[file_name], path)
elif file_name == self.tokenizer_file and self.model_type == "text": elif (
file_name == self.tokenizer_file
and self.model_type == ModelTypeEnum.text
):
if not os.path.exists(path + "/" + self.model_name): if not os.path.exists(path + "/" + self.model_name):
logger.info(f"Downloading {self.model_name} tokenizer") logger.info(f"Downloading {self.model_name} tokenizer")
tokenizer = AutoTokenizer.from_pretrained( tokenizer = AutoTokenizer.from_pretrained(
@@ -119,7 +129,7 @@ class GenericONNXEmbedding:
if self.runner is None: if self.runner is None:
if self.downloader: if self.downloader:
self.downloader.wait_for_download() self.downloader.wait_for_download()
if self.model_type == "text": if self.model_type == ModelTypeEnum.text:
self.tokenizer = self._load_tokenizer() self.tokenizer = self._load_tokenizer()
else: else:
self.feature_extractor = self._load_feature_extractor() self.feature_extractor = self._load_feature_extractor()
@@ -143,11 +153,35 @@ class GenericONNXEmbedding:
f"{MODEL_CACHE_DIR}/{self.model_name}", f"{MODEL_CACHE_DIR}/{self.model_name}",
) )
def _preprocess_inputs(self, raw_inputs: any) -> any:
if self.model_type == ModelTypeEnum.text:
max_length = max(len(self.tokenizer.encode(text)) for text in raw_inputs)
return [
self.tokenizer(
text,
padding="max_length",
truncation=True,
max_length=max_length,
return_tensors="np",
)
for text in raw_inputs
]
elif self.model_type == ModelTypeEnum.vision:
processed_images = [self._process_image(img) for img in raw_inputs]
return [
self.feature_extractor(images=image, return_tensors="np")
for image in processed_images
]
else:
raise ValueError(f"Unable to preprocess inputs for {self.model_type}")
def _process_image(self, image): def _process_image(self, image):
if isinstance(image, str): if isinstance(image, str):
if image.startswith("http"): if image.startswith("http"):
response = requests.get(image) response = requests.get(image)
image = Image.open(BytesIO(response.content)).convert("RGB") image = Image.open(BytesIO(response.content)).convert("RGB")
elif isinstance(image, bytes):
image = Image.open(BytesIO(image)).convert("RGB")
return image return image
@@ -163,25 +197,7 @@ class GenericONNXEmbedding:
) )
return [] return []
if self.model_type == "text": processed_inputs = self._preprocess_inputs(inputs)
max_length = max(len(self.tokenizer.encode(text)) for text in inputs)
processed_inputs = [
self.tokenizer(
text,
padding="max_length",
truncation=True,
max_length=max_length,
return_tensors="np",
)
for text in inputs
]
else:
processed_images = [self._process_image(img) for img in inputs]
processed_inputs = [
self.feature_extractor(images=image, return_tensors="np")
for image in processed_images
]
input_names = self.runner.get_input_names() input_names = self.runner.get_input_names()
onnx_inputs = {name: [] for name in input_names} onnx_inputs = {name: [] for name in input_names}
input: dict[str, any] input: dict[str, any]

View File

@@ -86,7 +86,7 @@ class EmbeddingMaintainer(threading.Thread):
try: try:
if topic == EmbeddingsRequestEnum.embed_description.value: if topic == EmbeddingsRequestEnum.embed_description.value:
return serialize( return serialize(
self.embeddings.upsert_description( self.embeddings.embed_description(
data["id"], data["description"] data["id"], data["description"]
), ),
pack=False, pack=False,
@@ -94,7 +94,7 @@ class EmbeddingMaintainer(threading.Thread):
elif topic == EmbeddingsRequestEnum.embed_thumbnail.value: elif topic == EmbeddingsRequestEnum.embed_thumbnail.value:
thumbnail = base64.b64decode(data["thumbnail"]) thumbnail = base64.b64decode(data["thumbnail"])
return serialize( return serialize(
self.embeddings.upsert_thumbnail(data["id"], thumbnail), self.embeddings.embed_thumbnail(data["id"], thumbnail),
pack=False, pack=False,
) )
elif topic == EmbeddingsRequestEnum.generate_search.value: elif topic == EmbeddingsRequestEnum.generate_search.value:
@@ -270,7 +270,7 @@ class EmbeddingMaintainer(threading.Thread):
def _embed_thumbnail(self, event_id: str, thumbnail: bytes) -> None: def _embed_thumbnail(self, event_id: str, thumbnail: bytes) -> None:
"""Embed the thumbnail for an event.""" """Embed the thumbnail for an event."""
self.embeddings.upsert_thumbnail(event_id, thumbnail) self.embeddings.embed_thumbnail(event_id, thumbnail)
def _embed_description(self, event: Event, thumbnails: list[bytes]) -> None: def _embed_description(self, event: Event, thumbnails: list[bytes]) -> None:
"""Embed the description for an event.""" """Embed the description for an event."""
@@ -290,8 +290,8 @@ class EmbeddingMaintainer(threading.Thread):
{"id": event.id, "description": description}, {"id": event.id, "description": description},
) )
# Encode the description # Embed the description
self.embeddings.upsert_description(event.id, description) self.embeddings.embed_description(event.id, description)
logger.debug( logger.debug(
"Generated description for %s (%d images): %s", "Generated description for %s (%d images): %s",

View File

@@ -9,7 +9,6 @@ from typing import Tuple
import numpy as np import numpy as np
import requests import requests
import frigate.util as util
from frigate.camera import CameraMetrics from frigate.camera import CameraMetrics
from frigate.comms.config_updater import ConfigSubscriber from frigate.comms.config_updater import ConfigSubscriber
from frigate.comms.detections_updater import DetectionPublisher, DetectionTypeEnum from frigate.comms.detections_updater import DetectionPublisher, DetectionTypeEnum
@@ -26,6 +25,7 @@ from frigate.const import (
from frigate.ffmpeg_presets import parse_preset_input from frigate.ffmpeg_presets import parse_preset_input
from frigate.log import LogPipe from frigate.log import LogPipe
from frigate.object_detection import load_labels from frigate.object_detection import load_labels
from frigate.service_manager import ServiceProcess
from frigate.util.builtin import get_ffmpeg_arg_list from frigate.util.builtin import get_ffmpeg_arg_list
from frigate.video import start_or_restart_ffmpeg, stop_ffmpeg from frigate.video import start_or_restart_ffmpeg, stop_ffmpeg
@@ -63,13 +63,15 @@ def get_ffmpeg_command(ffmpeg: FfmpegConfig) -> list[str]:
) )
class AudioProcessor(util.Process): class AudioProcessor(ServiceProcess):
name = "frigate.audio_manager"
def __init__( def __init__(
self, self,
cameras: list[CameraConfig], cameras: list[CameraConfig],
camera_metrics: dict[str, CameraMetrics], camera_metrics: dict[str, CameraMetrics],
): ):
super().__init__(name="frigate.audio_manager", daemon=True) super().__init__()
self.camera_metrics = camera_metrics self.camera_metrics = camera_metrics
self.cameras = cameras self.cameras = cameras

View File

@@ -21,12 +21,20 @@ class OllamaClient(GenAIClient):
def _init_provider(self): def _init_provider(self):
"""Initialize the client.""" """Initialize the client."""
client = ApiClient(host=self.genai_config.base_url, timeout=self.timeout) try:
response = client.pull(self.genai_config.model) client = ApiClient(host=self.genai_config.base_url, timeout=self.timeout)
if response["status"] != "success": # ensure the model is available locally
logger.error("Failed to pull %s model from Ollama", self.genai_config.model) response = client.show(self.genai_config.model)
if response.get("error"):
logger.error(
"Ollama error: %s",
response["error"],
)
return None
return client
except Exception as e:
logger.warning("Error initializing Ollama: %s", str(e))
return None return None
return client
def _send(self, prompt: str, images: list[bytes]) -> Optional[str]: def _send(self, prompt: str, images: list[bytes]) -> Optional[str]:
"""Submit a request to Ollama""" """Submit a request to Ollama"""

View File

@@ -93,7 +93,7 @@ class ReviewSegment(Model): # type: ignore[misc]
start_time = DateTimeField() start_time = DateTimeField()
end_time = DateTimeField() end_time = DateTimeField()
has_been_reviewed = BooleanField(default=False) has_been_reviewed = BooleanField(default=False)
severity = CharField(max_length=30) # alert, detection, significant_motion severity = CharField(max_length=30) # alert, detection
thumb_path = CharField(unique=True) thumb_path = CharField(unique=True)
data = JSONField() # additional data about detection like list of labels, zone, areas of significant motion data = JSONField() # additional data about detection like list of labels, zone, areas of significant motion

View File

@@ -59,3 +59,7 @@ ignore_errors = false
[mypy-frigate.watchdog] [mypy-frigate.watchdog]
ignore_errors = false ignore_errors = false
disallow_untyped_calls = false disallow_untyped_calls = false
[mypy-frigate.service_manager.*]
ignore_errors = false

View File

@@ -344,6 +344,7 @@ class CameraState:
# if the object's thumbnail is not from the current frame, skip # if the object's thumbnail is not from the current frame, skip
if ( if (
current_frame is None current_frame is None
or obj.thumbnail_data is None
or obj.false_positive or obj.false_positive
or obj.thumbnail_data["frame_time"] != frame_time or obj.thumbnail_data["frame_time"] != frame_time
): ):

View File

@@ -43,6 +43,11 @@ class PlaybackFactorEnum(str, Enum):
timelapse_25x = "timelapse_25x" timelapse_25x = "timelapse_25x"
class PlaybackSourceEnum(str, Enum):
recordings = "recordings"
preview = "preview"
class RecordingExporter(threading.Thread): class RecordingExporter(threading.Thread):
"""Exports a specific set of recordings for a camera to storage as a single file.""" """Exports a specific set of recordings for a camera to storage as a single file."""
@@ -56,6 +61,7 @@ class RecordingExporter(threading.Thread):
start_time: int, start_time: int,
end_time: int, end_time: int,
playback_factor: PlaybackFactorEnum, playback_factor: PlaybackFactorEnum,
playback_source: PlaybackSourceEnum,
) -> None: ) -> None:
super().__init__() super().__init__()
self.config = config self.config = config
@@ -66,6 +72,7 @@ class RecordingExporter(threading.Thread):
self.start_time = start_time self.start_time = start_time
self.end_time = end_time self.end_time = end_time
self.playback_factor = playback_factor self.playback_factor = playback_factor
self.playback_source = playback_source
# ensure export thumb dir # ensure export thumb dir
Path(os.path.join(CLIPS_DIR, "export")).mkdir(exist_ok=True) Path(os.path.join(CLIPS_DIR, "export")).mkdir(exist_ok=True)
@@ -170,30 +177,7 @@ class RecordingExporter(threading.Thread):
return thumb_path return thumb_path
def run(self) -> None: def get_record_export_command(self, video_path: str) -> list[str]:
logger.debug(
f"Beginning export for {self.camera} from {self.start_time} to {self.end_time}"
)
export_name = (
self.user_provided_name
or f"{self.camera.replace('_', ' ')} {self.get_datetime_from_timestamp(self.start_time)} {self.get_datetime_from_timestamp(self.end_time)}"
)
video_path = f"{EXPORT_DIR}/{self.export_id}.mp4"
thumb_path = self.save_thumbnail(self.export_id)
Export.insert(
{
Export.id: self.export_id,
Export.camera: self.camera,
Export.name: export_name,
Export.date: self.start_time,
Export.video_path: video_path,
Export.thumb_path: thumb_path,
Export.in_progress: True,
}
).execute()
if (self.end_time - self.start_time) <= MAX_PLAYLIST_SECONDS: if (self.end_time - self.start_time) <= MAX_PLAYLIST_SECONDS:
playlist_lines = f"http://127.0.0.1:5000/vod/{self.camera}/start/{self.start_time}/end/{self.end_time}/index.m3u8" playlist_lines = f"http://127.0.0.1:5000/vod/{self.camera}/start/{self.start_time}/end/{self.end_time}/index.m3u8"
ffmpeg_input = ( ffmpeg_input = (
@@ -204,7 +188,10 @@ class RecordingExporter(threading.Thread):
# get full set of recordings # get full set of recordings
export_recordings = ( export_recordings = (
Recordings.select() Recordings.select(
Recordings.start_time,
Recordings.end_time,
)
.where( .where(
Recordings.start_time.between(self.start_time, self.end_time) Recordings.start_time.between(self.start_time, self.end_time)
| Recordings.end_time.between(self.start_time, self.end_time) | Recordings.end_time.between(self.start_time, self.end_time)
@@ -229,6 +216,65 @@ class RecordingExporter(threading.Thread):
ffmpeg_input = "-y -protocol_whitelist pipe,file,http,tcp -f concat -safe 0 -i /dev/stdin" ffmpeg_input = "-y -protocol_whitelist pipe,file,http,tcp -f concat -safe 0 -i /dev/stdin"
if self.playback_factor == PlaybackFactorEnum.realtime:
ffmpeg_cmd = (
f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input} -c copy -movflags +faststart {video_path}"
).split(" ")
elif self.playback_factor == PlaybackFactorEnum.timelapse_25x:
ffmpeg_cmd = (
parse_preset_hardware_acceleration_encode(
self.config.ffmpeg.ffmpeg_path,
self.config.ffmpeg.hwaccel_args,
f"-an {ffmpeg_input}",
f"{self.config.cameras[self.camera].record.export.timelapse_args} -movflags +faststart {video_path}",
EncodeTypeEnum.timelapse,
)
).split(" ")
return ffmpeg_cmd, playlist_lines
def get_preview_export_command(self, video_path: str) -> list[str]:
playlist_lines = []
# get full set of previews
export_previews = (
Previews.select(
Previews.path,
Previews.start_time,
Previews.end_time,
)
.where(
Previews.start_time.between(self.start_time, self.end_time)
| Previews.end_time.between(self.start_time, self.end_time)
| (
(self.start_time > Previews.start_time)
& (self.end_time < Previews.end_time)
)
)
.where(Previews.camera == self.camera)
.order_by(Previews.start_time.asc())
.namedtuples()
.iterator()
)
preview: Previews
for preview in export_previews:
playlist_lines.append(f"file '{preview.path}'")
if preview.start_time < self.start_time:
playlist_lines.append(
f"inpoint {int(self.start_time - preview.start_time)}"
)
if preview.end_time > self.end_time:
playlist_lines.append(
f"outpoint {int(preview.end_time - self.end_time)}"
)
ffmpeg_input = (
"-y -protocol_whitelist pipe,file,tcp -f concat -safe 0 -i /dev/stdin"
)
if self.playback_factor == PlaybackFactorEnum.realtime: if self.playback_factor == PlaybackFactorEnum.realtime:
ffmpeg_cmd = ( ffmpeg_cmd = (
f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input} -c copy -movflags +faststart {video_path}" f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input} -c copy -movflags +faststart {video_path}"
@@ -244,6 +290,36 @@ class RecordingExporter(threading.Thread):
) )
).split(" ") ).split(" ")
return ffmpeg_cmd, playlist_lines
def run(self) -> None:
logger.debug(
f"Beginning export for {self.camera} from {self.start_time} to {self.end_time}"
)
export_name = (
self.user_provided_name
or f"{self.camera.replace('_', ' ')} {self.get_datetime_from_timestamp(self.start_time)} {self.get_datetime_from_timestamp(self.end_time)}"
)
video_path = f"{EXPORT_DIR}/{self.export_id}.mp4"
thumb_path = self.save_thumbnail(self.export_id)
Export.insert(
{
Export.id: self.export_id,
Export.camera: self.camera,
Export.name: export_name,
Export.date: self.start_time,
Export.video_path: video_path,
Export.thumb_path: thumb_path,
Export.in_progress: True,
}
).execute()
if self.playback_source == PlaybackSourceEnum.recordings:
ffmpeg_cmd, playlist_lines = self.get_record_export_command(video_path)
else:
ffmpeg_cmd, playlist_lines = self.get_preview_export_command(video_path)
p = sp.run( p = sp.run(
ffmpeg_cmd, ffmpeg_cmd,
input="\n".join(playlist_lines), input="\n".join(playlist_lines),
@@ -254,7 +330,7 @@ class RecordingExporter(threading.Thread):
if p.returncode != 0: if p.returncode != 0:
logger.error( logger.error(
f"Failed to export recording for command {' '.join(ffmpeg_cmd)}" f"Failed to export {self.playback_source.value} for command {' '.join(ffmpeg_cmd)}"
) )
logger.error(p.stderr) logger.error(p.stderr)
Path(video_path).unlink(missing_ok=True) Path(video_path).unlink(missing_ok=True)

View File

@@ -51,7 +51,7 @@ class PendingReviewSegment:
frame_time: float, frame_time: float,
severity: SeverityEnum, severity: SeverityEnum,
detections: dict[str, str], detections: dict[str, str],
sub_labels: set[str], sub_labels: dict[str, str],
zones: list[str], zones: list[str],
audio: set[str], audio: set[str],
): ):
@@ -135,7 +135,7 @@ class PendingReviewSegment:
ReviewSegment.data.name: { ReviewSegment.data.name: {
"detections": list(set(self.detections.keys())), "detections": list(set(self.detections.keys())),
"objects": list(set(self.detections.values())), "objects": list(set(self.detections.values())),
"sub_labels": list(self.sub_labels), "sub_labels": list(self.sub_labels.values()),
"zones": self.zones, "zones": self.zones,
"audio": list(self.audio), "audio": list(self.audio),
}, },
@@ -261,7 +261,7 @@ class ReviewSegmentMaintainer(threading.Thread):
segment.detections[object["id"]] = object["sub_label"][0] segment.detections[object["id"]] = object["sub_label"][0]
else: else:
segment.detections[object["id"]] = f'{object["label"]}-verified' segment.detections[object["id"]] = f'{object["label"]}-verified'
segment.sub_labels.add(object["sub_label"][0]) segment.sub_labels[object["id"]] = object["sub_label"][0]
# if object is alert label # if object is alert label
# and has entered required zones or required zones is not set # and has entered required zones or required zones is not set
@@ -347,7 +347,7 @@ class ReviewSegmentMaintainer(threading.Thread):
if len(active_objects) > 0: if len(active_objects) > 0:
detections: dict[str, str] = {} detections: dict[str, str] = {}
sub_labels = set() sub_labels: dict[str, str] = {}
zones: list[str] = [] zones: list[str] = []
severity = None severity = None
@@ -358,7 +358,7 @@ class ReviewSegmentMaintainer(threading.Thread):
detections[object["id"]] = object["sub_label"][0] detections[object["id"]] = object["sub_label"][0]
else: else:
detections[object["id"]] = f'{object["label"]}-verified' detections[object["id"]] = f'{object["label"]}-verified'
sub_labels.add(object["sub_label"][0]) sub_labels[object["id"]] = object["sub_label"][0]
# if object is alert label # if object is alert label
# and has entered required zones or required zones is not set # and has entered required zones or required zones is not set
@@ -566,7 +566,7 @@ class ReviewSegmentMaintainer(threading.Thread):
frame_time, frame_time,
severity, severity,
{}, {},
set(), {},
[], [],
detections, detections,
) )
@@ -576,7 +576,7 @@ class ReviewSegmentMaintainer(threading.Thread):
frame_time, frame_time,
SeverityEnum.alert, SeverityEnum.alert,
{manual_info["event_id"]: manual_info["label"]}, {manual_info["event_id"]: manual_info["label"]},
set(), {},
[], [],
set(), set(),
) )

View File

@@ -0,0 +1,4 @@
from .multiprocessing import ServiceProcess
from .service import Service, ServiceManager
__all__ = ["Service", "ServiceProcess", "ServiceManager"]

View File

@@ -0,0 +1,164 @@
import asyncio
import faulthandler
import logging
import multiprocessing as mp
import signal
import sys
import threading
from abc import ABC, abstractmethod
from asyncio.exceptions import TimeoutError
from logging.handlers import QueueHandler
from types import FrameType
from typing import Optional
import frigate.log
from .multiprocessing_waiter import wait as mp_wait
from .service import Service, ServiceManager
DEFAULT_STOP_TIMEOUT = 10 # seconds
class BaseServiceProcess(Service, ABC):
"""A Service the manages a multiprocessing.Process."""
_process: Optional[mp.Process]
def __init__(
self,
*,
name: Optional[str] = None,
manager: Optional[ServiceManager] = None,
) -> None:
super().__init__(name=name, manager=manager)
self._process = None
async def on_start(self) -> None:
if self._process is not None:
if self._process.is_alive():
return # Already started.
else:
self._process.close()
# At this point, the process is either stopped or dead, so we can recreate it.
self._process = mp.Process(target=self._run)
self._process.name = self.name
self._process.daemon = True
self.before_start()
self._process.start()
self.after_start()
self.manager.logger.info(f"Started {self.name} (pid: {self._process.pid})")
async def on_stop(
self,
*,
force: bool = False,
timeout: Optional[float] = None,
) -> None:
if timeout is None:
timeout = DEFAULT_STOP_TIMEOUT
if self._process is None:
return # Already stopped.
running = True
if not force:
self._process.terminate()
try:
await asyncio.wait_for(mp_wait(self._process), timeout)
running = False
except TimeoutError:
self.manager.logger.warning(
f"{self.name} is still running after "
f"{timeout} seconds. Killing."
)
if running:
self._process.kill()
await mp_wait(self._process)
self._process.close()
self._process = None
self.manager.logger.info(f"{self.name} stopped")
@property
def pid(self) -> Optional[int]:
return self._process.pid if self._process else None
def _run(self) -> None:
self.before_run()
self.run()
self.after_run()
def before_start(self) -> None:
pass
def after_start(self) -> None:
pass
def before_run(self) -> None:
pass
def after_run(self) -> None:
pass
@abstractmethod
def run(self) -> None:
pass
def __getstate__(self) -> dict:
return {
k: v
for k, v in self.__dict__.items()
if not (k.startswith("_Service__") or k == "_process")
}
class ServiceProcess(BaseServiceProcess):
logger: logging.Logger
@property
def stop_event(self) -> threading.Event:
# Lazily create the stop_event. This allows the signal handler to tell if anyone is
# monitoring the stop event, and to raise a SystemExit if not.
if "stop_event" not in self.__dict__:
stop_event = threading.Event()
self.__dict__["stop_event"] = stop_event
else:
stop_event = self.__dict__["stop_event"]
assert isinstance(stop_event, threading.Event)
return stop_event
def before_start(self) -> None:
if frigate.log.log_listener is None:
raise RuntimeError("Logging has not yet been set up.")
self.__log_queue = frigate.log.log_listener.queue
def before_run(self) -> None:
super().before_run()
faulthandler.enable()
def receiveSignal(signalNumber: int, frame: Optional[FrameType]) -> None:
# Get the stop_event through the dict to bypass lazy initialization.
stop_event = self.__dict__.get("stop_event")
if stop_event is not None:
# Someone is monitoring stop_event. We should set it.
stop_event.set()
else:
# Nobody is monitoring stop_event. We should raise SystemExit.
sys.exit()
signal.signal(signal.SIGTERM, receiveSignal)
signal.signal(signal.SIGINT, receiveSignal)
self.logger = logging.getLogger(self.name)
logging.basicConfig(handlers=[], force=True)
logging.getLogger().addHandler(QueueHandler(self.__log_queue))
del self.__log_queue

View File

@@ -0,0 +1,150 @@
import asyncio
import functools
import logging
import multiprocessing as mp
import queue
import threading
from multiprocessing.connection import Connection
from multiprocessing.connection import wait as mp_wait
from socket import socket
from typing import Any, Optional, Union
logger = logging.getLogger(__name__)
class MultiprocessingWaiter(threading.Thread):
"""A background thread that manages futures for the multiprocessing.connection.wait() method."""
def __init__(self) -> None:
super().__init__(daemon=True)
# Queue of objects to wait for and futures to set results for.
self._queue: queue.Queue[tuple[Any, asyncio.Future[None]]] = queue.Queue()
# This is required to get mp_wait() to wake up when new objects to wait for are received.
receive, send = mp.Pipe(duplex=False)
self._receive_connection = receive
self._send_connection = send
def wait_for_sentinel(self, sentinel: Any) -> asyncio.Future[None]:
"""Create an asyncio.Future tracking a sentinel for multiprocessing.connection.wait()
Warning: This method is NOT thread-safe.
"""
# This would be incredibly stupid, but you never know.
assert sentinel != self._receive_connection
# Send the future to the background thread for processing.
future = asyncio.get_running_loop().create_future()
self._queue.put((sentinel, future))
# Notify the background thread.
#
# This is the non-thread-safe part, but since this method is not really meant to be called
# by users, we can get away with not adding a lock at this point (to avoid adding 2 locks).
self._send_connection.send_bytes(b".")
return future
def run(self) -> None:
logger.debug("Started background thread")
wait_dict: dict[Any, set[asyncio.Future[None]]] = {
self._receive_connection: set()
}
while True:
for ready_obj in mp_wait(wait_dict.keys()):
# Make sure we never remove the receive connection from the wait dict
if ready_obj is self._receive_connection:
continue
logger.debug(
f"Sentinel {ready_obj!r} is ready. "
f"Notifying {len(wait_dict[ready_obj])} future(s)."
)
# Go over all the futures attached to this object and mark them as ready.
for fut in wait_dict.pop(ready_obj):
if fut.cancelled():
logger.debug(
f"A future for sentinel {ready_obj!r} is ready, "
"but the future is cancelled. Skipping."
)
else:
fut.get_loop().call_soon_threadsafe(
# Note: We need to check fut.cancelled() again, since it might
# have been set before the event loop's definition of "soon".
functools.partial(
lambda fut: fut.cancelled() or fut.set_result(None), fut
)
)
# Check for cancellations in the remaining futures.
done_objects = []
for obj, fut_set in wait_dict.items():
if obj is self._receive_connection:
continue
# Find any cancelled futures and remove them.
cancelled = [fut for fut in fut_set if fut.cancelled()]
fut_set.difference_update(cancelled)
logger.debug(
f"Removing {len(cancelled)} future(s) from sentinel: {obj!r}"
)
# Mark objects with no remaining futures for removal.
if len(fut_set) == 0:
done_objects.append(obj)
# Remove any objects that are done after removing cancelled futures.
for obj in done_objects:
logger.debug(
f"Sentinel {obj!r} no longer has any futures waiting for it."
)
del wait_dict[obj]
# Get new objects to wait for from the queue.
while True:
try:
obj, fut = self._queue.get_nowait()
self._receive_connection.recv_bytes(maxlength=1)
self._queue.task_done()
logger.debug(f"Received new sentinel: {obj!r}")
wait_dict.setdefault(obj, set()).add(fut)
except queue.Empty:
break
waiter_lock = threading.Lock()
waiter_thread: Optional[MultiprocessingWaiter] = None
async def wait(object: Union[mp.Process, Connection, socket]) -> None:
"""Wait for the supplied object to be ready.
Under the hood, this uses multiprocessing.connection.wait() and a background thread manage the
returned futures.
"""
global waiter_thread, waiter_lock
sentinel: Union[Connection, socket, int]
if isinstance(object, mp.Process):
sentinel = object.sentinel
elif isinstance(object, Connection) or isinstance(object, socket):
sentinel = object
else:
raise ValueError(f"Cannot wait for object of type {type(object).__qualname__}")
with waiter_lock:
if waiter_thread is None:
# Start a new waiter thread.
waiter_thread = MultiprocessingWaiter()
waiter_thread.start()
# Create the future while still holding the lock,
# since wait_for_sentinel() is not thread safe.
fut = waiter_thread.wait_for_sentinel(sentinel)
await fut

View File

@@ -0,0 +1,446 @@
from __future__ import annotations
import asyncio
import atexit
import logging
import threading
from abc import ABC, abstractmethod
from contextvars import ContextVar
from dataclasses import dataclass
from functools import partial
from typing import Coroutine, Optional, Union, cast
from typing_extensions import Self
class Service(ABC):
"""An abstract service instance."""
def __init__(
self,
*,
name: Optional[str] = None,
manager: Optional[ServiceManager] = None,
):
if name:
self.__dict__["name"] = name
self.__manager = manager or ServiceManager.current()
self.__lock = asyncio.Lock(loop=self.__manager._event_loop)
self.__manager._register(self)
@property
def name(self) -> str:
try:
return cast(str, self.__dict__["name"])
except KeyError:
return type(self).__qualname__
@property
def manager(self) -> ServiceManager:
"""The service manager this service is registered with."""
try:
return self.__manager
except AttributeError:
raise RuntimeError("Cannot access associated service manager")
def start(
self,
*,
wait: bool = False,
wait_timeout: Optional[float] = None,
) -> Self:
"""Start this service.
:param wait: If set, this function will block until the task is complete.
:param wait_timeout: If set, this function will not return until the task is complete or the
specified timeout has elapsed.
"""
self.manager.run_task(
self.on_start(),
wait=wait,
wait_timeout=wait_timeout,
lock=self.__lock,
)
return self
def stop(
self,
*,
force: bool = False,
timeout: Optional[float] = None,
wait: bool = False,
wait_timeout: Optional[float] = None,
) -> Self:
"""Stop this service.
:param force: If set, the service will be killed immediately.
:param timeout: Maximum amount of time to wait before force-killing the service.
:param wait: If set, this function will block until the task is complete.
:param wait_timeout: If set, this function will not return until the task is complete or the
specified timeout has elapsed.
"""
self.manager.run_task(
self.on_stop(force=force, timeout=timeout),
wait=wait,
wait_timeout=wait_timeout,
lock=self.__lock,
)
return self
def restart(
self,
*,
force: bool = False,
stop_timeout: Optional[float] = None,
wait: bool = False,
wait_timeout: Optional[float] = None,
) -> Self:
"""Restart this service.
:param force: If set, the service will be killed immediately.
:param timeout: Maximum amount of time to wait before force-killing the service.
:param wait: If set, this function will block until the task is complete.
:param wait_timeout: If set, this function will not return until the task is complete or the
specified timeout has elapsed.
"""
self.manager.run_task(
self.on_restart(force=force, stop_timeout=stop_timeout),
wait=wait,
wait_timeout=wait_timeout,
lock=self.__lock,
)
return self
@abstractmethod
async def on_start(self) -> None:
pass
@abstractmethod
async def on_stop(
self,
*,
force: bool = False,
timeout: Optional[float] = None,
) -> None:
pass
async def on_restart(
self,
*,
force: bool = False,
stop_timeout: Optional[float] = None,
) -> None:
await self.on_stop(force=force, timeout=stop_timeout)
await self.on_start()
default_service_manager_lock = threading.Lock()
default_service_manager: Optional[ServiceManager] = None
current_service_manager: ContextVar[ServiceManager] = ContextVar(
"current_service_manager"
)
@dataclass
class Command:
"""A coroutine to execute in the service manager thread.
Attributes:
coro: The coroutine to execute.
lock: An async lock to acquire before calling the coroutine.
done: If specified, the service manager will set this event after the command completes.
"""
coro: Coroutine
lock: Optional[asyncio.Lock] = None
done: Optional[threading.Event] = None
class ServiceManager:
"""A set of services, along with the global state required to manage them efficiently.
Typically users of the service infrastructure will not interact with a service manager directly,
but rather through individual Service subclasses that will automatically manage a service
manager instance.
Each service manager instance has a background thread in which service lifecycle tasks are
executed in an async executor. This is done to avoid head-of-line blocking in the business logic
that spins up individual services. This thread is automatically started when the service manager
is created and stopped either manually, or on application exit.
All (public) service manager methods are thread-safe.
"""
_name: str
_logger: logging.Logger
# The set of services this service manager knows about.
_services: dict[str, Service]
_services_lock: threading.Lock
# Commands will be queued with associated event loop. Queueing `None` signals shutdown.
_command_queue: asyncio.Queue[Union[Command, None]]
_event_loop: asyncio.AbstractEventLoop
# The pending command counter is used to ensure all commands have been queued before shutdown.
_pending_commands: AtomicCounter
# The set of pending tasks after they have been received by the background thread and spawned.
_tasks: set
# Fired after the async runtime starts. Object initialization completes after this is set.
_setup_event: threading.Event
# Will be acquired to ensure the shutdown sentinel is sent only once. Never released.
_shutdown_lock: threading.Lock
def __init__(self, *, name: Optional[str] = None):
self._name = name if name is not None else (__package__ or __name__)
self._logger = logging.getLogger(self.name)
self._services = dict()
self._services_lock = threading.Lock()
self._pending_commands = AtomicCounter()
self._tasks = set()
self._shutdown_lock = threading.Lock()
# --- Start the manager thread and wait for it to be ready. ---
self._setup_event = threading.Event()
async def start_manager() -> None:
self._event_loop = asyncio.get_running_loop()
self._command_queue = asyncio.Queue()
self._setup_event.set()
await self._monitor_command_queue()
self._manager_thread = threading.Thread(
name=self.name,
target=lambda: asyncio.run(start_manager()),
daemon=True,
)
self._manager_thread.start()
atexit.register(partial(self.shutdown, wait=True))
self._setup_event.wait()
@property
def name(self) -> str:
"""The name of this service manager. Primarily intended for logging purposes."""
return self._name
@property
def logger(self) -> logging.Logger:
"""The logger used by this service manager."""
return self._logger
@classmethod
def current(cls) -> ServiceManager:
"""The service manager set in the current context (async task or thread).
A global default service manager will be automatically created on first access."""
global default_service_manager
current = current_service_manager.get(None)
if current is None:
with default_service_manager_lock:
if default_service_manager is None:
default_service_manager = cls()
current = default_service_manager
current_service_manager.set(current)
return current
def make_current(self) -> None:
"""Make this the current service manager."""
current_service_manager.set(self)
def run_task(
self,
coro: Coroutine,
*,
wait: bool = False,
wait_timeout: Optional[float] = None,
lock: Optional[asyncio.Lock] = None,
) -> None:
"""Run an async task in the service manager thread.
:param wait: If set, this function will block until the task is complete.
:param wait_timeout: If set, this function will not return until the task is complete or the
specified timeout has elapsed.
"""
if not isinstance(coro, Coroutine):
raise TypeError(f"Cannot schedule task for object of type {type(coro)}")
cmd = Command(coro=coro, lock=lock)
if wait or wait_timeout is not None:
cmd.done = threading.Event()
self._send_command(cmd)
if cmd.done is not None:
cmd.done.wait(timeout=wait_timeout)
def shutdown(
self, *, wait: bool = False, wait_timeout: Optional[float] = None
) -> None:
"""Shutdown the service manager thread.
After the shutdown process completes, any subsequent calls to the service manager will
produce an error.
:param wait: If set, this function will block until the shutdown process is complete.
:param wait_timeout: If set, this function will not return until the shutdown process is
complete or the specified timeout has elapsed.
"""
if self._shutdown_lock.acquire(blocking=False):
self._send_command(None)
if wait:
self._manager_thread.join(timeout=wait_timeout)
def _ensure_running(self) -> None:
self._setup_event.wait()
if not self._manager_thread.is_alive():
raise RuntimeError(f"ServiceManager {self.name} is not running")
def _send_command(self, command: Union[Command, None]) -> None:
self._ensure_running()
async def queue_command() -> None:
await self._command_queue.put(command)
self._pending_commands.sub()
self._pending_commands.add()
asyncio.run_coroutine_threadsafe(queue_command(), self._event_loop)
def _register(self, service: Service) -> None:
"""Register a service with the service manager. This is done by the service constructor."""
self._ensure_running()
with self._services_lock:
name_conflict: Optional[Service] = next(
(
existing
for name, existing in self._services.items()
if name == service.name
),
None,
)
if name_conflict is service:
raise RuntimeError(f"Attempt to re-register service: {service.name}")
elif name_conflict is not None:
raise RuntimeError(f"Duplicate service name: {service.name}")
self.logger.debug(f"Registering service: {service.name}")
self._services[service.name] = service
def _run_command(self, command: Command) -> None:
"""Execute a command and add it to the tasks set."""
def task_done(task: asyncio.Task) -> None:
exc = task.exception()
if exc:
self.logger.exception("Exception in service manager task", exc_info=exc)
self._tasks.discard(task)
if command.done is not None:
command.done.set()
async def task_harness() -> None:
if command.lock is not None:
async with command.lock:
await command.coro
else:
await command.coro
task = asyncio.create_task(task_harness())
task.add_done_callback(task_done)
self._tasks.add(task)
async def _monitor_command_queue(self) -> None:
"""The main function of the background thread."""
self.logger.info("Started service manager")
# Main command processing loop.
while (command := await self._command_queue.get()) is not None:
self._run_command(command)
# Send a stop command to all services. We don't have a status command yet, so we can just
# stop everything and be done with it.
with self._services_lock:
self.logger.debug(f"Stopping {len(self._services)} services")
for service in self._services.values():
service.stop()
# Wait for all commands to finish executing.
await self._shutdown()
self.logger.info("Exiting service manager")
async def _shutdown(self) -> None:
"""Ensure all commands have been queued & executed."""
while True:
command = None
try:
# Try and get a command from the queue.
command = self._command_queue.get_nowait()
except asyncio.QueueEmpty:
if self._pending_commands.value > 0:
# If there are pending commands to queue, await them.
command = await self._command_queue.get()
elif self._tasks:
# If there are still pending tasks, wait for them. These tasks might queue
# commands though, so we have to loop again.
await asyncio.wait(self._tasks)
else:
# Nothing is pending at this point, so we're done here.
break
# If we got a command, run it.
if command is not None:
self._run_command(command)
class AtomicCounter:
"""A lock-protected atomic counter."""
# Modern CPUs have atomics, but python doesn't seem to include them in the standard library.
# Besides, the performance penalty is negligible compared to, well, using python.
# So this will do just fine.
def __init__(self, initial: int = 0):
self._lock = threading.Lock()
self._value = initial
def add(self, value: int = 1) -> None:
with self._lock:
self._value += value
def sub(self, value: int = 1) -> None:
with self._lock:
self._value -= value
@property
def value(self) -> int:
with self._lock:
return self._value

View File

@@ -197,8 +197,8 @@ async def set_gpu_stats(
# intel QSV GPU # intel QSV GPU
intel_usage = get_intel_gpu_stats() intel_usage = get_intel_gpu_stats()
if intel_usage: if intel_usage is not None:
stats["intel-qsv"] = intel_usage stats["intel-qsv"] = intel_usage or {"gpu": "", "mem": ""}
else: else:
stats["intel-qsv"] = {"gpu": "", "mem": ""} stats["intel-qsv"] = {"gpu": "", "mem": ""}
hwaccel_errors.append(args) hwaccel_errors.append(args)
@@ -222,8 +222,8 @@ async def set_gpu_stats(
# intel VAAPI GPU # intel VAAPI GPU
intel_usage = get_intel_gpu_stats() intel_usage = get_intel_gpu_stats()
if intel_usage: if intel_usage is not None:
stats["intel-vaapi"] = intel_usage stats["intel-vaapi"] = intel_usage or {"gpu": "", "mem": ""}
else: else:
stats["intel-vaapi"] = {"gpu": "", "mem": ""} stats["intel-vaapi"] = {"gpu": "", "mem": ""}
hwaccel_errors.append(args) hwaccel_errors.append(args)

View File

@@ -20,7 +20,7 @@ def get_ort_providers(
["CPUExecutionProvider"], ["CPUExecutionProvider"],
[ [
{ {
"arena_extend_strategy": "kSameAsRequested", "enable_cpu_mem_arena": False,
} }
], ],
) )
@@ -53,7 +53,7 @@ def get_ort_providers(
providers.append(provider) providers.append(provider)
options.append( options.append(
{ {
"arena_extend_strategy": "kSameAsRequested", "enable_cpu_mem_arena": False,
} }
) )
else: else:
@@ -85,12 +85,8 @@ class ONNXModelRunner:
else: else:
# Use ONNXRuntime # Use ONNXRuntime
self.type = "ort" self.type = "ort"
options = ort.SessionOptions()
if device == "CPU":
options.enable_cpu_mem_arena = False
self.ort = ort.InferenceSession( self.ort = ort.InferenceSession(
model_path, model_path,
sess_options=options,
providers=providers, providers=providers,
provider_options=options, provider_options=options,
) )

View File

@@ -257,6 +257,40 @@ def get_amd_gpu_stats() -> dict[str, str]:
def get_intel_gpu_stats() -> dict[str, str]: def get_intel_gpu_stats() -> dict[str, str]:
"""Get stats using intel_gpu_top.""" """Get stats using intel_gpu_top."""
def get_stats_manually(output: str) -> dict[str, str]:
"""Find global stats via regex when json fails to parse."""
reading = "".join(output)
results: dict[str, str] = {}
# render is used for qsv
render = []
for result in re.findall(r'"Render/3D/0":{[a-z":\d.,%]+}', reading):
packet = json.loads(result[14:])
single = packet.get("busy", 0.0)
render.append(float(single))
if render:
render_avg = sum(render) / len(render)
else:
render_avg = 1
# video is used for vaapi
video = []
for result in re.findall(r'"Video/\d":{[a-z":\d.,%]+}', reading):
packet = json.loads(result[10:])
single = packet.get("busy", 0.0)
video.append(float(single))
if video:
video_avg = sum(video) / len(video)
else:
video_avg = 1
results["gpu"] = f"{round((video_avg + render_avg) / 2, 2)}%"
results["mem"] = "-%"
return results
intel_gpu_top_command = [ intel_gpu_top_command = [
"timeout", "timeout",
"0.5s", "0.5s",
@@ -279,7 +313,13 @@ def get_intel_gpu_stats() -> dict[str, str]:
logger.error(f"Unable to poll intel GPU stats: {p.stderr}") logger.error(f"Unable to poll intel GPU stats: {p.stderr}")
return None return None
else: else:
data = json.loads(f'[{"".join(p.stdout.split())}]') output = "".join(p.stdout.split())
try:
data = json.loads(f"[{output}]")
except json.JSONDecodeError:
return get_stats_manually(output)
results: dict[str, str] = {} results: dict[str, str] = {}
render = {"global": []} render = {"global": []}
video = {"global": []} video = {"global": []}
@@ -328,7 +368,7 @@ def get_intel_gpu_stats() -> dict[str, str]:
results["clients"] = {} results["clients"] = {}
for key in render.keys(): for key in render.keys():
if key == "global": if key == "global" or not render[key] or not video[key]:
continue continue
results["clients"][key] = ( results["clients"][key] = (

692
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,28 +16,28 @@
"dependencies": { "dependencies": {
"@cycjimmy/jsmpeg-player": "^6.1.1", "@cycjimmy/jsmpeg-player": "^6.1.1",
"@hookform/resolvers": "^3.9.0", "@hookform/resolvers": "^3.9.0",
"@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-aspect-ratio": "^1.1.0", "@radix-ui/react-aspect-ratio": "^1.1.0",
"@radix-ui/react-checkbox": "^1.1.1", "@radix-ui/react-checkbox": "^1.1.2",
"@radix-ui/react-context-menu": "^2.2.1", "@radix-ui/react-context-menu": "^2.2.2",
"@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-hover-card": "^1.1.1", "@radix-ui/react-hover-card": "^1.1.2",
"@radix-ui/react-label": "^2.1.0", "@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.1", "@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-radio-group": "^1.2.0", "@radix-ui/react-radio-group": "^1.2.1",
"@radix-ui/react-scroll-area": "^1.1.0", "@radix-ui/react-scroll-area": "^1.2.0",
"@radix-ui/react-select": "^2.1.1", "@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slider": "^1.2.0", "@radix-ui/react-slider": "^1.2.1",
"@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.0", "@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.3",
"apexcharts": "^3.52.0", "apexcharts": "^3.52.0",
"axios": "^1.7.3", "axios": "^1.7.7",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.0.0", "cmdk": "^1.0.0",
@@ -45,10 +45,10 @@
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"embla-carousel-react": "^8.2.0", "embla-carousel-react": "^8.2.0",
"framer-motion": "^11.5.4", "framer-motion": "^11.5.4",
"hls.js": "^1.5.14", "hls.js": "^1.5.17",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
"immer": "^10.1.1", "immer": "^10.1.1",
"konva": "^9.3.14", "konva": "^9.3.16",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lucide-react": "^0.407.0", "lucide-react": "^0.407.0",
"monaco-yaml": "^5.2.2", "monaco-yaml": "^5.2.2",
@@ -65,7 +65,7 @@
"react-konva": "^18.2.10", "react-konva": "^18.2.10",
"react-router-dom": "^6.26.0", "react-router-dom": "^6.26.0",
"react-swipeable": "^7.0.1", "react-swipeable": "^7.0.1",
"react-tracked": "^2.0.0", "react-tracked": "^2.0.1",
"react-transition-group": "^4.4.5", "react-transition-group": "^4.4.5",
"react-use-websocket": "^4.8.1", "react-use-websocket": "^4.8.1",
"react-zoom-pan-pinch": "3.4.4", "react-zoom-pan-pinch": "3.4.4",
@@ -83,9 +83,9 @@
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/forms": "^0.5.7", "@tailwindcss/forms": "^0.5.9",
"@testing-library/jest-dom": "^6.4.6", "@testing-library/jest-dom": "^6.6.2",
"@types/lodash": "^4.17.7", "@types/lodash": "^4.17.12",
"@types/node": "^20.14.10", "@types/node": "^20.14.10",
"@types/react": "^18.3.2", "@types/react": "^18.3.2",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
@@ -95,7 +95,7 @@
"@types/strftime": "^0.9.8", "@types/strftime": "^0.9.8",
"@typescript-eslint/eslint-plugin": "^7.5.0", "@typescript-eslint/eslint-plugin": "^7.5.0",
"@typescript-eslint/parser": "^7.5.0", "@typescript-eslint/parser": "^7.5.0",
"@vitejs/plugin-react-swc": "^3.6.0", "@vitejs/plugin-react-swc": "^3.7.1",
"@vitest/coverage-v8": "^2.0.5", "@vitest/coverage-v8": "^2.0.5",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"eslint": "^8.57.0", "eslint": "^8.57.0",
@@ -109,8 +109,8 @@
"jest-websocket-mock": "^2.5.0", "jest-websocket-mock": "^2.5.0",
"jsdom": "^24.1.1", "jsdom": "^24.1.1",
"msw": "^2.3.5", "msw": "^2.3.5",
"postcss": "^8.4.39", "postcss": "^8.4.47",
"prettier": "^3.3.2", "prettier": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.5", "prettier-plugin-tailwindcss": "^0.6.5",
"tailwindcss": "^3.4.9", "tailwindcss": "^3.4.9",
"typescript": "^5.5.4", "typescript": "^5.5.4",

View File

@@ -65,7 +65,10 @@ function useValue(): useValueReturn {
: "OFF"; : "OFF";
}); });
setWsState({ ...wsState, ...cameraStates }); setWsState((prevState) => ({
...prevState,
...cameraStates,
}));
setHasCameraState(true); setHasCameraState(true);
// we only want this to run initially when the config is loaded // we only want this to run initially when the config is loaded
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -77,7 +80,10 @@ function useValue(): useValueReturn {
const data: Update = JSON.parse(event.data); const data: Update = JSON.parse(event.data);
if (data) { if (data) {
setWsState({ ...wsState, [data.topic]: data.payload }); setWsState((prevState) => ({
...prevState,
[data.topic]: data.payload,
}));
} }
}, },
onOpen: () => { onOpen: () => {

View File

@@ -121,6 +121,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
variant="select" variant="select"
disabled={isLoading} disabled={isLoading}
className="flex flex-1" className="flex flex-1"
aria-label="Login"
> >
{isLoading && <ActivityIndicator className="mr-2 h-4 w-4" />} {isLoading && <ActivityIndicator className="mr-2 h-4 w-4" />}
Login Login

View File

@@ -4,17 +4,20 @@ import { toast } from "sonner";
import ActivityIndicator from "../indicators/activity-indicator"; import ActivityIndicator from "../indicators/activity-indicator";
import { FaDownload } from "react-icons/fa"; import { FaDownload } from "react-icons/fa";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { cn } from "@/lib/utils";
type DownloadVideoButtonProps = { type DownloadVideoButtonProps = {
source: string; source: string;
camera: string; camera: string;
startTime: number; startTime: number;
className?: string;
}; };
export function DownloadVideoButton({ export function DownloadVideoButton({
source, source,
camera, camera,
startTime, startTime,
className,
}: DownloadVideoButtonProps) { }: DownloadVideoButtonProps) {
const [isDownloading, setIsDownloading] = useState(false); const [isDownloading, setIsDownloading] = useState(false);
@@ -32,13 +35,6 @@ export function DownloadVideoButton({
}); });
}; };
const handleDownloadEnd = () => {
setIsDownloading(false);
toast.success("Download completed successfully.", {
position: "top-center",
});
};
return ( return (
<div className="flex justify-center"> <div className="flex justify-center">
<Button <Button
@@ -46,17 +42,15 @@ export function DownloadVideoButton({
disabled={isDownloading} disabled={isDownloading}
className="flex items-center gap-2" className="flex items-center gap-2"
size="sm" size="sm"
aria-label="Download Video"
> >
<a <a href={source} download={filename} onClick={handleDownloadStart}>
href={source}
download={filename}
onClick={handleDownloadStart}
onBlur={handleDownloadEnd}
>
{isDownloading ? ( {isDownloading ? (
<ActivityIndicator className="size-4" /> <ActivityIndicator className="size-4" />
) : ( ) : (
<FaDownload className="size-4 text-secondary-foreground" /> <FaDownload
className={cn("size-4 text-secondary-foreground", className)}
/>
)} )}
</a> </a>
</Button> </Button>

View File

@@ -55,7 +55,12 @@ export default function DebugCameraImage({
searchParams={searchParams} searchParams={searchParams}
cameraClasses="relative w-full h-full flex justify-center" cameraClasses="relative w-full h-full flex justify-center"
/> />
<Button onClick={handleToggleSettings} variant="link" size="sm"> <Button
onClick={handleToggleSettings}
variant="link"
size="sm"
aria-label="Settings"
>
<span className="h-5 w-5"> <span className="h-5 w-5">
<LuSettings /> <LuSettings />
</span>{" "} </span>{" "}

View File

@@ -121,6 +121,7 @@ export function AnimatedEventCard({
<Button <Button
className="absolute right-2 top-1 z-40 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500" className="absolute right-2 top-1 z-40 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
size="xs" size="xs"
aria-label="Mark as Reviewed"
onClick={async () => { onClick={async () => {
await axios.post(`reviews/viewed`, { ids: [event.id] }); await axios.post(`reviews/viewed`, { ids: [event.id] });
updateEvents(); updateEvents();

View File

@@ -113,6 +113,7 @@ export default function ExportCard({
/> />
<DialogFooter> <DialogFooter>
<Button <Button
aria-label="Save Export"
size="sm" size="sm"
variant="select" variant="select"
disabled={(editName?.update?.length ?? 0) == 0} disabled={(editName?.update?.length ?? 0) == 0}
@@ -206,6 +207,7 @@ export default function ExportCard({
{!exportedRecording.in_progress && ( {!exportedRecording.in_progress && (
<Button <Button
className="absolute left-1/2 top-1/2 h-20 w-20 -translate-x-1/2 -translate-y-1/2 cursor-pointer text-white hover:bg-transparent hover:text-white" className="absolute left-1/2 top-1/2 h-20 w-20 -translate-x-1/2 -translate-y-1/2 cursor-pointer text-white hover:bg-transparent hover:text-white"
aria-label="Play"
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
onSelect(exportedRecording); onSelect(exportedRecording);

View File

@@ -1,38 +1,10 @@
import { useCallback, useState } from "react";
import TimeAgo from "../dynamic/TimeAgo"; import TimeAgo from "../dynamic/TimeAgo";
import useSWR from "swr"; import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { useFormattedTimestamp } from "@/hooks/use-date-utils"; import { useFormattedTimestamp } from "@/hooks/use-date-utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import ActivityIndicator from "../indicators/activity-indicator";
import { SearchResult } from "@/types/search"; import { SearchResult } from "@/types/search";
import { import ActivityIndicator from "../indicators/activity-indicator";
DropdownMenu, import SearchResultActions from "../menu/SearchResultActions";
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "../ui/alert-dialog";
import { LuCamera, LuDownload, LuMoreVertical, LuTrash2 } from "react-icons/lu";
import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon";
import { FrigatePlusDialog } from "../overlay/dialog/FrigatePlusDialog";
import { Event } from "@/types/event";
import { FaArrowsRotate } from "react-icons/fa6";
import { baseUrl } from "@/api/baseUrl";
import axios from "axios";
import { toast } from "sonner";
import { MdImageSearch } from "react-icons/md";
import { isMobileOnly } from "react-device-detect";
import { buttonVariants } from "../ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type SearchThumbnailProps = { type SearchThumbnailProps = {
@@ -52,31 +24,7 @@ export default function SearchThumbnailFooter({
}: SearchThumbnailProps) { }: SearchThumbnailProps) {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
// interactions
const [showFrigatePlus, setShowFrigatePlus] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const handleDelete = useCallback(() => {
axios
.delete(`events/${searchResult.id}`)
.then((resp) => {
if (resp.status == 200) {
toast.success("Tracked object deleted successfully.", {
position: "top-center",
});
refreshResults();
}
})
.catch(() => {
toast.error("Failed to delete tracked object.", {
position: "top-center",
});
});
}, [searchResult, refreshResults]);
// date // date
const formattedDate = useFormattedTimestamp( const formattedDate = useFormattedTimestamp(
searchResult.start_time, searchResult.start_time,
config?.ui.time_format == "24hour" ? "%b %-d, %H:%M" : "%b %-d, %I:%M %p", config?.ui.time_format == "24hour" ? "%b %-d, %H:%M" : "%b %-d, %I:%M %p",
@@ -84,146 +32,31 @@ export default function SearchThumbnailFooter({
); );
return ( return (
<> <div
<AlertDialog className={cn(
open={deleteDialogOpen} "flex w-full flex-row items-center justify-between",
onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)} columns > 4 &&
> "items-start sm:flex-col sm:gap-2 lg:flex-row lg:items-center lg:gap-1",
<AlertDialogContent> )}
<AlertDialogHeader> >
<AlertDialogTitle>Confirm Delete</AlertDialogTitle> <div className="flex flex-col items-start text-xs text-primary-variant">
</AlertDialogHeader> {searchResult.end_time ? (
<AlertDialogDescription> <TimeAgo time={searchResult.start_time * 1000} dense />
Are you sure you want to delete this tracked object? ) : (
</AlertDialogDescription> <div>
<AlertDialogFooter> <ActivityIndicator size={14} />
<AlertDialogCancel>Cancel</AlertDialogCancel> </div>
<AlertDialogAction
className={buttonVariants({ variant: "destructive" })}
onClick={handleDelete}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<FrigatePlusDialog
upload={
showFrigatePlus ? (searchResult as unknown as Event) : undefined
}
onClose={() => setShowFrigatePlus(false)}
onEventUploaded={() => {
searchResult.plus_id = "submitted";
}}
/>
<div
className={cn(
"flex w-full flex-row items-center justify-between",
columns > 4 &&
"items-start sm:flex-col sm:gap-2 lg:flex-row lg:items-center lg:gap-1",
)} )}
> {formattedDate}
<div className="flex flex-col items-start text-xs text-primary-variant">
{searchResult.end_time ? (
<TimeAgo time={searchResult.start_time * 1000} dense />
) : (
<div>
<ActivityIndicator size={14} />
</div>
)}
{formattedDate}
</div>
<div className="flex flex-row items-center justify-end gap-6 md:gap-4">
{!isMobileOnly &&
config?.plus?.enabled &&
searchResult.has_snapshot &&
searchResult.end_time &&
!searchResult.plus_id && (
<Tooltip>
<TooltipTrigger>
<FrigatePlusIcon
className="size-5 cursor-pointer text-primary-variant hover:text-primary"
onClick={() => setShowFrigatePlus(true)}
/>
</TooltipTrigger>
<TooltipContent>Submit to Frigate+</TooltipContent>
</Tooltip>
)}
{config?.semantic_search?.enabled && (
<Tooltip>
<TooltipTrigger>
<MdImageSearch
className="size-5 cursor-pointer text-primary-variant hover:text-primary"
onClick={findSimilar}
/>
</TooltipTrigger>
<TooltipContent>Find similar</TooltipContent>
</Tooltip>
)}
<DropdownMenu>
<DropdownMenuTrigger>
<LuMoreVertical className="size-5 cursor-pointer text-primary-variant hover:text-primary" />
</DropdownMenuTrigger>
<DropdownMenuContent align={"end"}>
{searchResult.has_clip && (
<DropdownMenuItem>
<a
className="justify_start flex items-center"
href={`${baseUrl}api/events/${searchResult.id}/clip.mp4`}
download={`${searchResult.camera}_${searchResult.label}.mp4`}
>
<LuDownload className="mr-2 size-4" />
<span>Download video</span>
</a>
</DropdownMenuItem>
)}
{searchResult.has_snapshot && (
<DropdownMenuItem>
<a
className="justify_start flex items-center"
href={`${baseUrl}api/events/${searchResult.id}/snapshot.jpg`}
download={`${searchResult.camera}_${searchResult.label}.jpg`}
>
<LuCamera className="mr-2 size-4" />
<span>Download snapshot</span>
</a>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="cursor-pointer"
onClick={showObjectLifecycle}
>
<FaArrowsRotate className="mr-2 size-4" />
<span>View object lifecycle</span>
</DropdownMenuItem>
{isMobileOnly &&
config?.plus?.enabled &&
searchResult.has_snapshot &&
searchResult.end_time &&
!searchResult.plus_id && (
<DropdownMenuItem
className="cursor-pointer"
onClick={() => setShowFrigatePlus(true)}
>
<FrigatePlusIcon className="mr-2 size-4 cursor-pointer text-primary" />
<span>Submit to Frigate+</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="cursor-pointer"
onClick={() => setDeleteDialogOpen(true)}
>
<LuTrash2 className="mr-2 size-4" />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div> </div>
</> <div className="flex flex-row items-center justify-end gap-6 md:gap-4">
<SearchResultActions
searchResult={searchResult}
findSimilar={findSimilar}
refreshResults={refreshResults}
showObjectLifecycle={showObjectLifecycle}
/>
</div>
</div>
); );
} }

View File

@@ -36,6 +36,7 @@ export default function NewReviewData({
: "invisible", : "invisible",
"mx-auto mt-5 bg-gray-400 text-center text-white", "mx-auto mt-5 bg-gray-400 text-center text-white",
)} )}
aria-label="View new review items"
onClick={() => { onClick={() => {
pullLatestData(); pullLatestData();
if (contentRef.current) { if (contentRef.current) {

View File

@@ -34,6 +34,7 @@ export default function CalendarFilterButton({
const trigger = ( const trigger = (
<Button <Button
className="flex items-center gap-2" className="flex items-center gap-2"
aria-label="Select a date to filter by"
variant={day == undefined ? "default" : "select"} variant={day == undefined ? "default" : "select"}
size="sm" size="sm"
> >
@@ -57,6 +58,7 @@ export default function CalendarFilterButton({
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<div className="flex items-center justify-center p-2"> <div className="flex items-center justify-center p-2">
<Button <Button
aria-label="Reset"
onClick={() => { onClick={() => {
updateSelectedDay(undefined); updateSelectedDay(undefined);
}} }}
@@ -99,6 +101,7 @@ export function CalendarRangeFilterButton({
const trigger = ( const trigger = (
<Button <Button
className="flex items-center gap-2" className="flex items-center gap-2"
aria-label="Select a date to filter by"
variant={range == undefined ? "default" : "select"} variant={range == undefined ? "default" : "select"}
size="sm" size="sm"
> >

View File

@@ -141,6 +141,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
? "bg-blue-900 bg-opacity-60 text-selected focus:bg-blue-900 focus:bg-opacity-60" ? "bg-blue-900 bg-opacity-60 text-selected focus:bg-blue-900 focus:bg-opacity-60"
: "bg-secondary text-secondary-foreground focus:bg-secondary focus:text-secondary-foreground" : "bg-secondary text-secondary-foreground focus:bg-secondary focus:text-secondary-foreground"
} }
aria-label="All Cameras"
size="xs" size="xs"
onClick={() => (group ? setGroup("default", true) : null)} onClick={() => (group ? setGroup("default", true) : null)}
onMouseEnter={() => (isDesktop ? showTooltip("default") : null)} onMouseEnter={() => (isDesktop ? showTooltip("default") : null)}
@@ -165,6 +166,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
? "bg-blue-900 bg-opacity-60 text-selected focus:bg-blue-900 focus:bg-opacity-60" ? "bg-blue-900 bg-opacity-60 text-selected focus:bg-blue-900 focus:bg-opacity-60"
: "bg-secondary text-secondary-foreground" : "bg-secondary text-secondary-foreground"
} }
aria-label="Camera Group"
size="xs" size="xs"
onClick={() => setGroup(name, group != "default")} onClick={() => setGroup(name, group != "default")}
onMouseEnter={() => (isDesktop ? showTooltip(name) : null)} onMouseEnter={() => (isDesktop ? showTooltip(name) : null)}
@@ -191,6 +193,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
<Button <Button
className="bg-secondary text-muted-foreground" className="bg-secondary text-muted-foreground"
aria-label="Add camera group"
size="xs" size="xs"
onClick={() => setAddGroup(true)} onClick={() => setAddGroup(true)}
> >
@@ -355,6 +358,7 @@ function NewGroupDialog({
"size-6 rounded-md bg-secondary-foreground p-1 text-background", "size-6 rounded-md bg-secondary-foreground p-1 text-background",
isMobile && "text-secondary-foreground", isMobile && "text-secondary-foreground",
)} )}
aria-label="Add camera group"
onClick={() => { onClick={() => {
setEditState("add"); setEditState("add");
}} }}
@@ -536,10 +540,16 @@ export function CameraGroupRow({
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuPortal> <DropdownMenuPortal>
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuItem onClick={onEditGroup}> <DropdownMenuItem
aria-label="Edit group"
onClick={onEditGroup}
>
Edit Edit
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)}> <DropdownMenuItem
aria-label="Delete group"
onClick={() => setDeleteDialogOpen(true)}
>
Delete Delete
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
@@ -793,13 +803,19 @@ export function CameraGroupEdit({
<Separator className="my-2 flex bg-secondary" /> <Separator className="my-2 flex bg-secondary" />
<div className="flex flex-row gap-2 py-5 md:pb-0"> <div className="flex flex-row gap-2 py-5 md:pb-0">
<Button type="button" className="flex flex-1" onClick={onCancel}> <Button
type="button"
className="flex flex-1"
aria-label="Cancel"
onClick={onCancel}
>
Cancel Cancel
</Button> </Button>
<Button <Button
variant="select" variant="select"
disabled={isLoading} disabled={isLoading}
className="flex flex-1" className="flex flex-1"
aria-label="Save"
type="submit" type="submit"
> >
{isLoading ? ( {isLoading ? (

View File

@@ -55,6 +55,7 @@ export function CamerasFilterButton({
const trigger = ( const trigger = (
<Button <Button
className="flex items-center gap-2 capitalize" className="flex items-center gap-2 capitalize"
aria-label="Cameras Filter"
variant={selectedCameras?.length == undefined ? "default" : "select"} variant={selectedCameras?.length == undefined ? "default" : "select"}
size="sm" size="sm"
> >
@@ -202,6 +203,7 @@ export function CamerasFilterContent({
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<div className="flex items-center justify-evenly p-2"> <div className="flex items-center justify-evenly p-2">
<Button <Button
aria-label="Apply"
variant="select" variant="select"
disabled={currentCameras?.length === 0} disabled={currentCameras?.length === 0}
onClick={() => { onClick={() => {
@@ -212,6 +214,7 @@ export function CamerasFilterContent({
Apply Apply
</Button> </Button>
<Button <Button
aria-label="Reset"
onClick={() => { onClick={() => {
setCurrentCameras(undefined); setCurrentCameras(undefined);
updateCameraFilter(undefined); updateCameraFilter(undefined);

View File

@@ -17,7 +17,11 @@ export function LogLevelFilterButton({
updateLabelFilter, updateLabelFilter,
}: LogLevelFilterButtonProps) { }: LogLevelFilterButtonProps) {
const trigger = ( const trigger = (
<Button size="sm" className="flex items-center gap-2"> <Button
size="sm"
className="flex items-center gap-2"
aria-label="Filter log level"
>
<FaFilter className="text-secondary-foreground" /> <FaFilter className="text-secondary-foreground" />
<div className="hidden text-primary md:block">Filter</div> <div className="hidden text-primary md:block">Filter</div>
</Button> </Button>

View File

@@ -104,6 +104,7 @@ export default function ReviewActionGroup({
{selectedReviews.length == 1 && ( {selectedReviews.length == 1 && (
<Button <Button
className="flex items-center gap-2 p-2" className="flex items-center gap-2 p-2"
aria-label="Export"
size="sm" size="sm"
onClick={() => { onClick={() => {
onExport(selectedReviews[0]); onExport(selectedReviews[0]);
@@ -116,6 +117,7 @@ export default function ReviewActionGroup({
)} )}
<Button <Button
className="flex items-center gap-2 p-2" className="flex items-center gap-2 p-2"
aria-label="Mark as reviewed"
size="sm" size="sm"
onClick={onMarkAsReviewed} onClick={onMarkAsReviewed}
> >
@@ -124,6 +126,7 @@ export default function ReviewActionGroup({
</Button> </Button>
<Button <Button
className="flex items-center gap-2 p-2" className="flex items-center gap-2 p-2"
aria-label="Delete"
size="sm" size="sm"
onClick={handleDelete} onClick={handleDelete}
> >

View File

@@ -278,6 +278,7 @@ function ShowReviewFilter({
<Button <Button
className="block duration-0 md:hidden" className="block duration-0 md:hidden"
aria-label="Show reviewed"
variant={showReviewedSwitch ? "select" : "default"} variant={showReviewedSwitch ? "select" : "default"}
size="sm" size="sm"
onClick={() => onClick={() =>
@@ -338,6 +339,7 @@ function GeneralFilterButton({
selectedLabels?.length || selectedZones?.length ? "select" : "default" selectedLabels?.length || selectedZones?.length ? "select" : "default"
} }
className="flex items-center gap-2 capitalize" className="flex items-center gap-2 capitalize"
aria-label="Filter"
> >
<FaFilter <FaFilter
className={`${selectedLabels?.length || selectedZones?.length ? "text-selected-foreground" : "text-secondary-foreground"}`} className={`${selectedLabels?.length || selectedZones?.length ? "text-selected-foreground" : "text-secondary-foreground"}`}
@@ -538,6 +540,7 @@ export function GeneralFilterContent({
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<div className="flex items-center justify-evenly p-2"> <div className="flex items-center justify-evenly p-2">
<Button <Button
aria-label="Apply"
variant="select" variant="select"
onClick={() => { onClick={() => {
if (selectedLabels != currentLabels) { if (selectedLabels != currentLabels) {
@@ -554,6 +557,7 @@ export function GeneralFilterContent({
Apply Apply
</Button> </Button>
<Button <Button
aria-label="Reset"
onClick={() => { onClick={() => {
setCurrentLabels(undefined); setCurrentLabels(undefined);
setCurrentZones?.(undefined); setCurrentZones?.(undefined);
@@ -601,6 +605,7 @@ function ShowMotionOnlyButton({
<Button <Button
size="sm" size="sm"
className="duration-0" className="duration-0"
aria-label="Show Motion Only"
variant={motionOnlyButton ? "select" : "default"} variant={motionOnlyButton ? "select" : "default"}
onClick={() => setMotionOnlyButton(!motionOnlyButton)} onClick={() => setMotionOnlyButton(!motionOnlyButton)}
> >

View File

@@ -227,6 +227,7 @@ function GeneralFilterButton({
size="sm" size="sm"
variant={selectedLabels?.length ? "select" : "default"} variant={selectedLabels?.length ? "select" : "default"}
className="flex items-center gap-2 capitalize" className="flex items-center gap-2 capitalize"
aria-label="Labels"
> >
<MdLabel <MdLabel
className={`${selectedLabels?.length ? "text-selected-foreground" : "text-secondary-foreground"}`} className={`${selectedLabels?.length ? "text-selected-foreground" : "text-secondary-foreground"}`}
@@ -336,6 +337,7 @@ export function GeneralFilterContent({
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<div className="flex items-center justify-evenly p-2"> <div className="flex items-center justify-evenly p-2">
<Button <Button
aria-label="Apply"
variant="select" variant="select"
onClick={() => { onClick={() => {
if (selectedLabels != currentLabels) { if (selectedLabels != currentLabels) {
@@ -348,6 +350,7 @@ export function GeneralFilterContent({
Apply Apply
</Button> </Button>
<Button <Button
aria-label="Reset"
onClick={() => { onClick={() => {
setCurrentLabels(undefined); setCurrentLabels(undefined);
updateLabelFilter(undefined); updateLabelFilter(undefined);

View File

@@ -21,6 +21,7 @@ export function ZoneMaskFilterButton({
size="sm" size="sm"
variant={selectedZoneMask?.length ? "select" : "default"} variant={selectedZoneMask?.length ? "select" : "default"}
className="flex items-center gap-2 capitalize" className="flex items-center gap-2 capitalize"
aria-label="Filter by zone mask"
> >
<FaFilter <FaFilter
className={`${selectedZoneMask?.length ? "text-selected-foreground" : "text-secondary-foreground"}`} className={`${selectedZoneMask?.length ? "text-selected-foreground" : "text-secondary-foreground"}`}

View File

@@ -216,7 +216,7 @@ export function CombinedStorageGraph({
</Popover> </Popover>
)} )}
</TableCell> </TableCell>
<TableCell>{getUnitSize(item.usage)}</TableCell> <TableCell>{getUnitSize(item.usage ?? 0)}</TableCell>
<TableCell>{item.data[0].toFixed(2)}%</TableCell> <TableCell>{item.data[0].toFixed(2)}%</TableCell>
<TableCell> <TableCell>
{item.name === "Unused" {item.name === "Unused"

View File

@@ -66,7 +66,10 @@ export default function IconPicker({
> >
<PopoverTrigger asChild> <PopoverTrigger asChild>
{!selectedIcon?.name || !selectedIcon?.Icon ? ( {!selectedIcon?.name || !selectedIcon?.Icon ? (
<Button className="mt-2 w-full text-muted-foreground"> <Button
className="mt-2 w-full text-muted-foreground"
aria-label="Select an icon"
>
Select an icon Select an icon
</Button> </Button>
) : ( ) : (

View File

@@ -59,11 +59,14 @@ export function SaveSearchDialog({
placeholder="Enter a name for your search" placeholder="Enter a name for your search"
/> />
<DialogFooter> <DialogFooter>
<Button onClick={onClose}>Cancel</Button> <Button aria-label="Cancel" onClick={onClose}>
Cancel
</Button>
<Button <Button
onClick={handleSave} onClick={handleSave}
variant="select" variant="select"
className="mb-2 md:mb-0" className="mb-2 md:mb-0"
aria-label="Save this search"
> >
Save Save
</Button> </Button>

View File

@@ -72,6 +72,7 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
className={ className={
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm" isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
} }
aria-label="Log out"
> >
<a className="flex" href={logoutUrl}> <a className="flex" href={logoutUrl}>
<LuLogOut className="mr-2 size-4" /> <LuLogOut className="mr-2 size-4" />

View File

@@ -176,6 +176,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
? "cursor-pointer" ? "cursor-pointer"
: "flex items-center p-2 text-sm" : "flex items-center p-2 text-sm"
} }
aria-label="Log out"
> >
<a className="flex" href={logoutUrl}> <a className="flex" href={logoutUrl}>
<LuLogOut className="mr-2 size-4" /> <LuLogOut className="mr-2 size-4" />
@@ -194,6 +195,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
? "cursor-pointer" ? "cursor-pointer"
: "flex w-full items-center p-2 text-sm" : "flex w-full items-center p-2 text-sm"
} }
aria-label="System metrics"
> >
<LuActivity className="mr-2 size-4" /> <LuActivity className="mr-2 size-4" />
<span>System metrics</span> <span>System metrics</span>
@@ -206,6 +208,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
? "cursor-pointer" ? "cursor-pointer"
: "flex w-full items-center p-2 text-sm" : "flex w-full items-center p-2 text-sm"
} }
aria-label="System logs"
> >
<LuList className="mr-2 size-4" /> <LuList className="mr-2 size-4" />
<span>System logs</span> <span>System logs</span>
@@ -224,6 +227,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
? "cursor-pointer" ? "cursor-pointer"
: "flex w-full items-center p-2 text-sm" : "flex w-full items-center p-2 text-sm"
} }
aria-label="Settings"
> >
<LuSettings className="mr-2 size-4" /> <LuSettings className="mr-2 size-4" />
<span>Settings</span> <span>Settings</span>
@@ -236,6 +240,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
? "cursor-pointer" ? "cursor-pointer"
: "flex w-full items-center p-2 text-sm" : "flex w-full items-center p-2 text-sm"
} }
aria-label="Configuration editor"
> >
<LuPenSquare className="mr-2 size-4" /> <LuPenSquare className="mr-2 size-4" />
<span>Configuration editor</span> <span>Configuration editor</span>
@@ -269,6 +274,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
? "cursor-pointer" ? "cursor-pointer"
: "flex items-center p-2 text-sm" : "flex items-center p-2 text-sm"
} }
aria-label="Light mode"
onClick={() => setTheme("light")} onClick={() => setTheme("light")}
> >
{theme === "light" ? ( {theme === "light" ? (
@@ -286,6 +292,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
? "cursor-pointer" ? "cursor-pointer"
: "flex items-center p-2 text-sm" : "flex items-center p-2 text-sm"
} }
aria-label="Dark mode"
onClick={() => setTheme("dark")} onClick={() => setTheme("dark")}
> >
{theme === "dark" ? ( {theme === "dark" ? (
@@ -303,6 +310,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
? "cursor-pointer" ? "cursor-pointer"
: "flex items-center p-2 text-sm" : "flex items-center p-2 text-sm"
} }
aria-label="Use the system settings for light or dark mode"
onClick={() => setTheme("system")} onClick={() => setTheme("system")}
> >
{theme === "system" ? ( {theme === "system" ? (
@@ -343,6 +351,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
? "cursor-pointer" ? "cursor-pointer"
: "flex items-center p-2 text-sm" : "flex items-center p-2 text-sm"
} }
aria-label={`Color scheme - ${scheme}`}
onClick={() => setColorScheme(scheme)} onClick={() => setColorScheme(scheme)}
> >
{scheme === colorScheme ? ( {scheme === colorScheme ? (
@@ -370,6 +379,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
className={ className={
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm" isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
} }
aria-label="Frigate documentation"
> >
<LuLifeBuoy className="mr-2 size-4" /> <LuLifeBuoy className="mr-2 size-4" />
<span>Documentation</span> <span>Documentation</span>
@@ -383,6 +393,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
className={ className={
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm" isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
} }
aria-label="Frigate Github"
> >
<LuGithub className="mr-2 size-4" /> <LuGithub className="mr-2 size-4" />
<span>GitHub</span> <span>GitHub</span>
@@ -393,6 +404,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
className={ className={
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm" isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
} }
aria-label="Restart Frigate"
onClick={() => setRestartDialogOpen(true)} onClick={() => setRestartDialogOpen(true)}
> >
<LuRotateCw className="mr-2 size-4" /> <LuRotateCw className="mr-2 size-4" />
@@ -446,7 +458,12 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
<p>This page will reload in {countdown} seconds.</p> <p>This page will reload in {countdown} seconds.</p>
</SheetDescription> </SheetDescription>
</SheetHeader> </SheetHeader>
<Button size="lg" className="mt-5" onClick={handleForceReload}> <Button
size="lg"
className="mt-5"
aria-label="Force reload now"
onClick={handleForceReload}
>
Force Reload Now Force Reload Now
</Button> </Button>
</div> </div>

View File

@@ -0,0 +1,230 @@
import { useState, ReactNode } from "react";
import { SearchResult } from "@/types/search";
import { FrigateConfig } from "@/types/frigateConfig";
import { baseUrl } from "@/api/baseUrl";
import { toast } from "sonner";
import axios from "axios";
import { LuCamera, LuDownload, LuMoreVertical, LuTrash2 } from "react-icons/lu";
import { FaArrowsRotate } from "react-icons/fa6";
import { MdImageSearch } from "react-icons/md";
import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon";
import { isMobileOnly } from "react-device-detect";
import { buttonVariants } from "@/components/ui/button";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { FrigatePlusDialog } from "@/components/overlay/dialog/FrigatePlusDialog";
import useSWR from "swr";
import { Event } from "@/types/event";
type SearchResultActionsProps = {
searchResult: SearchResult;
findSimilar: () => void;
refreshResults: () => void;
showObjectLifecycle: () => void;
isContextMenu?: boolean;
children?: ReactNode;
};
export default function SearchResultActions({
searchResult,
findSimilar,
refreshResults,
showObjectLifecycle,
isContextMenu = false,
children,
}: SearchResultActionsProps) {
const { data: config } = useSWR<FrigateConfig>("config");
const [showFrigatePlus, setShowFrigatePlus] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const handleDelete = () => {
axios
.delete(`events/${searchResult.id}`)
.then((resp) => {
if (resp.status == 200) {
toast.success("Tracked object deleted successfully.", {
position: "top-center",
});
refreshResults();
}
})
.catch(() => {
toast.error("Failed to delete tracked object.", {
position: "top-center",
});
});
};
const MenuItem = isContextMenu ? ContextMenuItem : DropdownMenuItem;
const menuItems = (
<>
{searchResult.has_clip && (
<MenuItem aria-label="Download video">
<a
className="flex items-center"
href={`${baseUrl}api/events/${searchResult.id}/clip.mp4`}
download={`${searchResult.camera}_${searchResult.label}.mp4`}
>
<LuDownload className="mr-2 size-4" />
<span>Download video</span>
</a>
</MenuItem>
)}
{searchResult.has_snapshot && (
<MenuItem aria-label="Download snapshot">
<a
className="flex items-center"
href={`${baseUrl}api/events/${searchResult.id}/snapshot.jpg`}
download={`${searchResult.camera}_${searchResult.label}.jpg`}
>
<LuCamera className="mr-2 size-4" />
<span>Download snapshot</span>
</a>
</MenuItem>
)}
<MenuItem
aria-label="Show the object lifecycle"
onClick={showObjectLifecycle}
>
<FaArrowsRotate className="mr-2 size-4" />
<span>View object lifecycle</span>
</MenuItem>
{config?.semantic_search?.enabled && isContextMenu && (
<MenuItem
aria-label="Find similar tracked objects"
onClick={findSimilar}
>
<MdImageSearch className="mr-2 size-4" />
<span>Find similar</span>
</MenuItem>
)}
{isMobileOnly &&
config?.plus?.enabled &&
searchResult.has_snapshot &&
searchResult.end_time &&
!searchResult.plus_id && (
<MenuItem
aria-label="Submit to Frigate Plus"
onClick={() => setShowFrigatePlus(true)}
>
<FrigatePlusIcon className="mr-2 size-4 cursor-pointer text-primary" />
<span>Submit to Frigate+</span>
</MenuItem>
)}
<MenuItem
aria-label="Delete this tracked object"
onClick={() => setDeleteDialogOpen(true)}
>
<LuTrash2 className="mr-2 size-4" />
<span>Delete</span>
</MenuItem>
</>
);
return (
<>
<AlertDialog
open={deleteDialogOpen}
onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Delete</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
Are you sure you want to delete this tracked object?
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className={buttonVariants({ variant: "destructive" })}
onClick={handleDelete}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<FrigatePlusDialog
upload={
showFrigatePlus ? (searchResult as unknown as Event) : undefined
}
onClose={() => setShowFrigatePlus(false)}
onEventUploaded={() => {
searchResult.plus_id = "submitted";
}}
/>
{isContextMenu ? (
<ContextMenu>
<ContextMenuTrigger>{children}</ContextMenuTrigger>
<ContextMenuContent>{menuItems}</ContextMenuContent>
</ContextMenu>
) : (
<>
{config?.semantic_search?.enabled && (
<Tooltip>
<TooltipTrigger>
<MdImageSearch
className="size-5 cursor-pointer text-primary-variant hover:text-primary"
onClick={findSimilar}
/>
</TooltipTrigger>
<TooltipContent>Find similar</TooltipContent>
</Tooltip>
)}
{!isMobileOnly &&
config?.plus?.enabled &&
searchResult.has_snapshot &&
searchResult.end_time &&
!searchResult.plus_id && (
<Tooltip>
<TooltipTrigger>
<FrigatePlusIcon
className="size-5 cursor-pointer text-primary-variant hover:text-primary"
onClick={() => setShowFrigatePlus(true)}
/>
</TooltipTrigger>
<TooltipContent>Submit to Frigate+</TooltipContent>
</Tooltip>
)}
<DropdownMenu>
<DropdownMenuTrigger>
<LuMoreVertical className="size-5 cursor-pointer text-primary-variant hover:text-primary" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">{menuItems}</DropdownMenuContent>
</DropdownMenu>
</>
)}
</>
);
}

View File

@@ -25,7 +25,13 @@ export function MobilePage({
const [uncontrolledOpen, setUncontrolledOpen] = useState(false); const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
const open = controlledOpen ?? uncontrolledOpen; const open = controlledOpen ?? uncontrolledOpen;
const setOpen = onOpenChange ?? setUncontrolledOpen; const setOpen = (value: boolean) => {
if (onOpenChange) {
onOpenChange(value);
} else {
setUncontrolledOpen(value);
}
};
return ( return (
<MobilePageContext.Provider value={{ open, onOpenChange: setOpen }}> <MobilePageContext.Provider value={{ open, onOpenChange: setOpen }}>
@@ -154,6 +160,7 @@ export function MobilePageHeader({
> >
<Button <Button
className="absolute left-0 rounded-lg" className="absolute left-0 rounded-lg"
aria-label="Go back"
size="sm" size="sm"
onClick={handleClose} onClick={handleClose}
> >

View File

@@ -3,7 +3,7 @@ import { IoIosWarning } from "react-icons/io";
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import useSWR from "swr"; import useSWR from "swr";
import { FrigateStats } from "@/types/stats"; import { FrigateStats } from "@/types/stats";
import { useFrigateStats } from "@/api/ws"; import { useEmbeddingsReindexProgress, useFrigateStats } from "@/api/ws";
import { useContext, useEffect, useMemo } from "react"; import { useContext, useEffect, useMemo } from "react";
import useStats from "@/hooks/use-stats"; import useStats from "@/hooks/use-stats";
import GeneralSettings from "../menu/GeneralSettings"; import GeneralSettings from "../menu/GeneralSettings";
@@ -74,6 +74,23 @@ function StatusAlertNav({ className }: StatusAlertNavProps) {
}); });
}, [potentialProblems, addMessage, clearMessages]); }, [potentialProblems, addMessage, clearMessages]);
const { payload: reindexState } = useEmbeddingsReindexProgress();
useEffect(() => {
if (reindexState) {
if (reindexState.status == "indexing") {
clearMessages("embeddings-reindex");
addMessage(
"embeddings-reindex",
`Reindexing embeddings (${Math.floor((reindexState.processed_objects / reindexState.total_objects) * 100)}% complete)`,
);
}
if (reindexState.status === "completed") {
clearMessages("embeddings-reindex");
}
}
}, [reindexState, addMessage, clearMessages]);
if (!messages || Object.keys(messages).length === 0) { if (!messages || Object.keys(messages).length === 0) {
return; return;
} }

View File

@@ -167,7 +167,11 @@ export default function CameraInfoDialog({
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="select" onClick={() => onCopyFfprobe()}> <Button
variant="select"
aria-label="Copy"
onClick={() => onCopyFfprobe()}
>
Copy Copy
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@@ -98,7 +98,11 @@ export default function CreateUserDialog({
)} )}
/> />
<DialogFooter className="mt-4"> <DialogFooter className="mt-4">
<Button variant="select" disabled={isLoading}> <Button
variant="select"
aria-label="Create user"
disabled={isLoading}
>
{isLoading && <ActivityIndicator className="mr-2 h-4 w-4" />} {isLoading && <ActivityIndicator className="mr-2 h-4 w-4" />}
Create User Create User
</Button> </Button>

View File

@@ -27,6 +27,7 @@ export default function DeleteUserDialog({
<DialogFooter> <DialogFooter>
<Button <Button
className="flex items-center gap-1" className="flex items-center gap-1"
aria-label="Confirm delete"
variant="destructive" variant="destructive"
size="sm" size="sm"
onClick={onDelete} onClick={onDelete}

View File

@@ -142,6 +142,7 @@ export default function ExportDialog({
<Trigger asChild> <Trigger asChild>
<Button <Button
className="flex items-center gap-2" className="flex items-center gap-2"
aria-label="Export"
size="sm" size="sm"
onClick={() => { onClick={() => {
const now = new Date(latestTime * 1000); const now = new Date(latestTime * 1000);
@@ -307,6 +308,7 @@ export function ExportContent({
</div> </div>
<Button <Button
className={isDesktop ? "" : "w-full"} className={isDesktop ? "" : "w-full"}
aria-label="Select or export"
variant="select" variant="select"
size="sm" size="sm"
onClick={() => { onClick={() => {
@@ -420,6 +422,7 @@ function CustomTimeSelector({
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
className={`text-primary ${isDesktop ? "" : "text-xs"}`} className={`text-primary ${isDesktop ? "" : "text-xs"}`}
aria-label="Start time"
variant={startOpen ? "select" : "default"} variant={startOpen ? "select" : "default"}
size="sm" size="sm"
onClick={() => { onClick={() => {
@@ -485,6 +488,7 @@ function CustomTimeSelector({
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
className={`text-primary ${isDesktop ? "" : "text-xs"}`} className={`text-primary ${isDesktop ? "" : "text-xs"}`}
aria-label="End time"
variant={endOpen ? "select" : "default"} variant={endOpen ? "select" : "default"}
size="sm" size="sm"
onClick={() => { onClick={() => {

View File

@@ -59,8 +59,17 @@ export default function GPUInfoDialog({
<ActivityIndicator /> <ActivityIndicator />
)} )}
<DialogFooter> <DialogFooter>
<Button onClick={() => setShowGpuInfo(false)}>Close</Button> <Button
<Button variant="select" onClick={() => onCopyInfo()}> aria-label="Close GPU info"
onClick={() => setShowGpuInfo(false)}
>
Close
</Button>
<Button
aria-label="Copy GPU info"
variant="select"
onClick={() => onCopyInfo()}
>
Copy Copy
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -88,8 +97,17 @@ export default function GPUInfoDialog({
<ActivityIndicator /> <ActivityIndicator />
)} )}
<DialogFooter> <DialogFooter>
<Button onClick={() => setShowGpuInfo(false)}>Close</Button> <Button
<Button variant="select" onClick={() => onCopyInfo()}> aria-label="Close GPU info"
onClick={() => setShowGpuInfo(false)}
>
Close
</Button>
<Button
aria-label="Copy GPU info"
variant="select"
onClick={() => onCopyInfo()}
>
Copy Copy
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@@ -23,7 +23,11 @@ export default function MobileCameraDrawer({
return ( return (
<Drawer open={cameraDrawer} onOpenChange={setCameraDrawer}> <Drawer open={cameraDrawer} onOpenChange={setCameraDrawer}>
<DrawerTrigger asChild> <DrawerTrigger asChild>
<Button className="rounded-lg capitalize" size="sm"> <Button
className="rounded-lg capitalize"
aria-label="Cameras"
size="sm"
>
<FaVideo className="text-secondary-foreground" /> <FaVideo className="text-secondary-foreground" />
</Button> </Button>
</DrawerTrigger> </DrawerTrigger>

View File

@@ -132,6 +132,7 @@ export default function MobileReviewSettingsDrawer({
{features.includes("export") && ( {features.includes("export") && (
<Button <Button
className="flex w-full items-center justify-center gap-2" className="flex w-full items-center justify-center gap-2"
aria-label="Export"
onClick={() => { onClick={() => {
setDrawerMode("export"); setDrawerMode("export");
setMode("select"); setMode("select");
@@ -144,6 +145,7 @@ export default function MobileReviewSettingsDrawer({
{features.includes("calendar") && ( {features.includes("calendar") && (
<Button <Button
className="flex w-full items-center justify-center gap-2" className="flex w-full items-center justify-center gap-2"
aria-label="Calendar"
variant={filter?.after ? "select" : "default"} variant={filter?.after ? "select" : "default"}
onClick={() => setDrawerMode("calendar")} onClick={() => setDrawerMode("calendar")}
> >
@@ -156,6 +158,7 @@ export default function MobileReviewSettingsDrawer({
{features.includes("filter") && ( {features.includes("filter") && (
<Button <Button
className="flex w-full items-center justify-center gap-2" className="flex w-full items-center justify-center gap-2"
aria-label="Filter"
variant={filter?.labels || filter?.zones ? "select" : "default"} variant={filter?.labels || filter?.zones ? "select" : "default"}
onClick={() => setDrawerMode("filter")} onClick={() => setDrawerMode("filter")}
> >
@@ -226,6 +229,7 @@ export default function MobileReviewSettingsDrawer({
<SelectSeparator /> <SelectSeparator />
<div className="flex items-center justify-center p-2"> <div className="flex items-center justify-center p-2">
<Button <Button
aria-label="Reset"
onClick={() => { onClick={() => {
onUpdateFilter({ onUpdateFilter({
...filter, ...filter,
@@ -306,6 +310,7 @@ export default function MobileReviewSettingsDrawer({
<DrawerTrigger asChild> <DrawerTrigger asChild>
<Button <Button
className="rounded-lg capitalize" className="rounded-lg capitalize"
aria-label="Filters"
variant={ variant={
filter?.labels || filter?.after || filter?.zones filter?.labels || filter?.after || filter?.zones
? "select" ? "select"

View File

@@ -22,7 +22,11 @@ export default function MobileTimelineDrawer({
return ( return (
<Drawer open={drawer} onOpenChange={setDrawer}> <Drawer open={drawer} onOpenChange={setDrawer}>
<DrawerTrigger asChild> <DrawerTrigger asChild>
<Button className="rounded-lg capitalize" size="sm"> <Button
className="rounded-lg capitalize"
aria-label="Select timeline or events list"
size="sm"
>
<FaFlag className="text-secondary-foreground" /> <FaFlag className="text-secondary-foreground" />
</Button> </Button>
</DrawerTrigger> </DrawerTrigger>

View File

@@ -28,6 +28,7 @@ export default function SaveExportOverlay({
> >
<Button <Button
className="flex items-center gap-1 text-primary" className="flex items-center gap-1 text-primary"
aria-label="Cancel"
size="sm" size="sm"
onClick={onCancel} onClick={onCancel}
> >
@@ -36,6 +37,7 @@ export default function SaveExportOverlay({
</Button> </Button>
<Button <Button
className="flex items-center gap-1" className="flex items-center gap-1"
aria-label="Preview export"
size="sm" size="sm"
onClick={onPreview} onClick={onPreview}
> >
@@ -44,6 +46,7 @@ export default function SaveExportOverlay({
</Button> </Button>
<Button <Button
className="flex items-center gap-1" className="flex items-center gap-1"
aria-label="Save export"
variant="select" variant="select"
size="sm" size="sm"
onClick={onSave} onClick={onSave}

View File

@@ -36,6 +36,7 @@ export default function SetPasswordDialog({
<DialogFooter> <DialogFooter>
<Button <Button
className="flex items-center gap-1" className="flex items-center gap-1"
aria-label="Save Password"
variant="select" variant="select"
size="sm" size="sm"
onClick={() => { onClick={() => {

View File

@@ -207,12 +207,14 @@ export function AnnotationSettingsPane({
<div className="flex flex-row gap-2 pt-5"> <div className="flex flex-row gap-2 pt-5">
<Button <Button
className="flex flex-1" className="flex flex-1"
aria-label="Apply"
onClick={form.handleSubmit(onApply)} onClick={form.handleSubmit(onApply)}
> >
Apply Apply
</Button> </Button>
<Button <Button
variant="select" variant="select"
aria-label="Save"
disabled={isLoading} disabled={isLoading}
className="flex flex-1" className="flex flex-1"
type="submit" type="submit"

View File

@@ -242,6 +242,7 @@ export default function ObjectLifecycle({
<div className={cn("flex items-center gap-2")}> <div className={cn("flex items-center gap-2")}>
<Button <Button
className="mb-2 mt-3 flex items-center gap-2.5 rounded-lg md:mt-0" className="mb-2 mt-3 flex items-center gap-2.5 rounded-lg md:mt-0"
aria-label="Go back"
size="sm" size="sm"
onClick={() => setPane("overview")} onClick={() => setPane("overview")}
> >
@@ -346,6 +347,7 @@ export default function ObjectLifecycle({
<Button <Button
variant={showControls ? "select" : "default"} variant={showControls ? "select" : "default"}
className="size-7 p-1.5" className="size-7 p-1.5"
aria-label="Adjust annotation settings"
> >
<LuSettings <LuSettings
className="size-5" className="size-5"

View File

@@ -13,7 +13,7 @@ import { getIconForLabel } from "@/utils/iconUtil";
import { useApiHost } from "@/api"; import { useApiHost } from "@/api";
import { ReviewDetailPaneType, ReviewSegment } from "@/types/review"; import { ReviewDetailPaneType, ReviewSegment } from "@/types/review";
import { Event } from "@/types/event"; import { Event } from "@/types/event";
import { useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { FrigatePlusDialog } from "../dialog/FrigatePlusDialog"; import { FrigatePlusDialog } from "../dialog/FrigatePlusDialog";
import ObjectLifecycle from "./ObjectLifecycle"; import ObjectLifecycle from "./ObjectLifecycle";
@@ -91,6 +91,22 @@ export default function ReviewDetailDialog({
review != undefined, review != undefined,
); );
const handleOpenChange = useCallback(
(open: boolean) => {
setIsOpen(open);
if (!open) {
// short timeout to allow the mobile page animation
// to complete before updating the state
setTimeout(() => {
setReview(undefined);
setSelectedEvent(undefined);
setPane("overview");
}, 300);
}
},
[setReview, setIsOpen],
);
useEffect(() => { useEffect(() => {
setIsOpen(review != undefined); setIsOpen(review != undefined);
// we know that these deps are correct // we know that these deps are correct
@@ -109,16 +125,7 @@ export default function ReviewDetailDialog({
return ( return (
<> <>
<Overlay <Overlay open={isOpen ?? false} onOpenChange={handleOpenChange}>
open={isOpen ?? false}
onOpenChange={(open) => {
if (!open) {
setReview(undefined);
setSelectedEvent(undefined);
setPane("overview");
}
}}
>
<FrigatePlusDialog <FrigatePlusDialog
upload={upload} upload={upload}
onClose={() => setUpload(undefined)} onClose={() => setUpload(undefined)}
@@ -140,7 +147,7 @@ export default function ReviewDetailDialog({
> >
<span tabIndex={0} className="sr-only" /> <span tabIndex={0} className="sr-only" />
{pane == "overview" && ( {pane == "overview" && (
<Header className="justify-center" onClose={() => setIsOpen(false)}> <Header className="justify-center">
<Title>Review Item Details</Title> <Title>Review Item Details</Title>
<Description className="sr-only">Review item details</Description> <Description className="sr-only">Review item details</Description>
<div <div
@@ -153,6 +160,7 @@ export default function ReviewDetailDialog({
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger>
<Button <Button
aria-label="Share this review item"
size="sm" size="sm"
onClick={() => onClick={() =>
shareOrCopy(`${baseUrl}review?id=${review.id}`) shareOrCopy(`${baseUrl}review?id=${review.id}`)

View File

@@ -27,6 +27,7 @@ import ActivityIndicator from "@/components/indicators/activity-indicator";
import { import {
FaCheckCircle, FaCheckCircle,
FaChevronDown, FaChevronDown,
FaDownload,
FaHistory, FaHistory,
FaImage, FaImage,
FaRegListAlt, FaRegListAlt,
@@ -68,6 +69,7 @@ import {
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import { LuInfo } from "react-icons/lu"; import { LuInfo } from "react-icons/lu";
import { TooltipPortal } from "@radix-ui/react-tooltip";
const SEARCH_TABS = [ const SEARCH_TABS = [
"details", "details",
@@ -107,6 +109,20 @@ export default function SearchDetailDialog({
const [isOpen, setIsOpen] = useState(search != undefined); const [isOpen, setIsOpen] = useState(search != undefined);
const handleOpenChange = useCallback(
(open: boolean) => {
setIsOpen(open);
if (!open) {
// short timeout to allow the mobile page animation
// to complete before updating the state
setTimeout(() => {
setSearch(undefined);
}, 300);
}
},
[setSearch],
);
useEffect(() => { useEffect(() => {
if (search) { if (search) {
setIsOpen(search != undefined); setIsOpen(search != undefined);
@@ -156,14 +172,7 @@ export default function SearchDetailDialog({
const Description = isDesktop ? DialogDescription : MobilePageDescription; const Description = isDesktop ? DialogDescription : MobilePageDescription;
return ( return (
<Overlay <Overlay open={isOpen} onOpenChange={handleOpenChange}>
open={isOpen}
onOpenChange={() => {
if (search) {
setSearch(undefined);
}
}}
>
<Content <Content
className={cn( className={cn(
"scrollbar-container overflow-y-auto", "scrollbar-container overflow-y-auto",
@@ -172,7 +181,7 @@ export default function SearchDetailDialog({
isMobile && "px-4", isMobile && "px-4",
)} )}
> >
<Header onClose={() => setIsOpen(false)}> <Header>
<Title>Tracked Object Details</Title> <Title>Tracked Object Details</Title>
<Description className="sr-only">Tracked object details</Description> <Description className="sr-only">Tracked object details</Description>
</Header> </Header>
@@ -285,7 +294,7 @@ function ObjectDetailsTab({
return 0; return 0;
} }
const value = search.data.top_score; const value = search.data.top_score ?? search.top_score ?? 0;
return Math.round(value * 100); return Math.round(value * 100);
}, [search]); }, [search]);
@@ -296,7 +305,7 @@ function ObjectDetailsTab({
} }
if (search.sub_label) { if (search.sub_label) {
return Math.round((search.data?.top_score ?? 0) * 100); return Math.round((search.data?.sub_label_score ?? 0) * 100);
} else { } else {
return undefined; return undefined;
} }
@@ -321,6 +330,22 @@ function ObjectDetailsTab({
(key.includes("events") || (key.includes("events") ||
key.includes("events/search") || key.includes("events/search") ||
key.includes("events/explore")), key.includes("events/explore")),
(currentData: SearchResult[][] | SearchResult[] | undefined) => {
if (!currentData) return currentData;
// optimistic update
return currentData
.flat()
.map((event) =>
event.id === search.id
? { ...event, data: { ...event.data, description: desc } }
: event,
);
},
{
optimisticData: true,
rollbackOnError: true,
revalidate: false,
},
); );
}) })
.catch(() => { .catch(() => {
@@ -350,9 +375,9 @@ function ObjectDetailsTab({
); );
} }
}) })
.catch(() => { .catch((error) => {
toast.error( toast.error(
`Failed to call ${capitalizeAll(config?.genai.provider.replaceAll("_", " ") ?? "Generative AI")} for a new description`, `Failed to call ${capitalizeAll(config?.genai.provider.replaceAll("_", " ") ?? "Generative AI")} for a new description: ${error.response.data.message}`,
{ {
position: "top-center", position: "top-center",
}, },
@@ -424,6 +449,7 @@ function ObjectDetailsTab({
/> />
{config?.semantic_search.enabled && ( {config?.semantic_search.enabled && (
<Button <Button
aria-label="Find similar tracked objects"
onClick={() => { onClick={() => {
setSearch(undefined); setSearch(undefined);
@@ -450,6 +476,7 @@ function ObjectDetailsTab({
<div className="flex items-center"> <div className="flex items-center">
<Button <Button
className="rounded-r-none border-r-0" className="rounded-r-none border-r-0"
aria-label="Regenerate tracked object description"
onClick={() => regenerateDescription("thumbnails")} onClick={() => regenerateDescription("thumbnails")}
> >
Regenerate Regenerate
@@ -457,19 +484,24 @@ function ObjectDetailsTab({
{search.has_snapshot && ( {search.has_snapshot && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button className="rounded-l-none border-l-0 px-2"> <Button
className="rounded-l-none border-l-0 px-2"
aria-label="Expand regeneration menu"
>
<FaChevronDown className="size-3" /> <FaChevronDown className="size-3" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuItem <DropdownMenuItem
className="cursor-pointer" className="cursor-pointer"
aria-label="Regenerate from snapshot"
onClick={() => regenerateDescription("snapshot")} onClick={() => regenerateDescription("snapshot")}
> >
Regenerate from Snapshot Regenerate from Snapshot
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
className="cursor-pointer" className="cursor-pointer"
aria-label="Regenerate from thumbnails"
onClick={() => regenerateDescription("thumbnails")} onClick={() => regenerateDescription("thumbnails")}
> >
Regenerate from Thumbnails Regenerate from Thumbnails
@@ -479,7 +511,11 @@ function ObjectDetailsTab({
)} )}
</div> </div>
)} )}
<Button variant="select" onClick={updateDescription}> <Button
variant="select"
aria-label="Save"
onClick={updateDescription}
>
Save Save
</Button> </Button>
</div> </div>
@@ -550,16 +586,39 @@ function ObjectSnapshotTab({
}} }}
> >
{search?.id && ( {search?.id && (
<img <div className="relative mx-auto">
ref={imgRef} <img
className={`mx-auto max-h-[60dvh] bg-black object-contain`} ref={imgRef}
src={`${baseUrl}api/events/${search?.id}/snapshot.jpg`} className={`mx-auto max-h-[60dvh] bg-black object-contain`}
alt={`${search?.label}`} src={`${baseUrl}api/events/${search?.id}/snapshot.jpg`}
loading={isSafari ? "eager" : "lazy"} alt={`${search?.label}`}
onLoad={() => { loading={isSafari ? "eager" : "lazy"}
onImgLoad(); onLoad={() => {
}} onImgLoad();
/> }}
/>
<div
className={cn(
"absolute right-1 top-1 flex items-center gap-2",
)}
>
<Tooltip>
<TooltipTrigger asChild>
<a
download
href={`${baseUrl}api/events/${search?.id}/snapshot.jpg`}
>
<Chip className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500">
<FaDownload className="size-4 text-white" />
</Chip>
</a>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>Download</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>
</div>
)} )}
</TransformComponent> </TransformComponent>
{search.plus_id !== "not_enabled" && search.end_time && ( {search.plus_id !== "not_enabled" && search.end_time && (
@@ -585,6 +644,7 @@ function ObjectSnapshotTab({
<> <>
<Button <Button
className="bg-success" className="bg-success"
aria-label="Confirm this label for Frigate Plus"
onClick={() => { onClick={() => {
setState("uploading"); setState("uploading");
onSubmitToPlus(false); onSubmitToPlus(false);
@@ -594,6 +654,7 @@ function ObjectSnapshotTab({
</Button> </Button>
<Button <Button
className="text-white" className="text-white"
aria-label="Do not confirm this label for Frigate Plus"
variant="destructive" variant="destructive"
onClick={() => { onClick={() => {
setState("uploading"); setState("uploading");
@@ -640,7 +701,7 @@ export function VideoTab({ search }: VideoTabProps) {
{reviewItem && ( {reviewItem && (
<div <div
className={cn( className={cn(
"absolute top-2 z-10 flex items-center", "absolute top-2 z-10 flex items-center gap-2",
isIOS ? "right-8" : "right-2", isIOS ? "right-8" : "right-2",
)} )}
> >
@@ -660,7 +721,24 @@ export function VideoTab({ search }: VideoTabProps) {
<FaHistory className="size-4 text-white" /> <FaHistory className="size-4 text-white" />
</Chip> </Chip>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="left">View in History</TooltipContent> <TooltipPortal>
<TooltipContent>View in History</TooltipContent>
</TooltipPortal>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<a
download
href={`${baseUrl}api/${search.camera}/start/${search.start_time}/end/${endTime}/clip.mp4`}
>
<Chip className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500">
<FaDownload className="size-4 text-white" />
</Chip>
</a>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>Download</TooltipContent>
</TooltipPortal>
</Tooltip> </Tooltip>
</div> </div>
)} )}

View File

@@ -131,9 +131,14 @@ export function FrigatePlusDialog({
<DialogFooter className="flex flex-row justify-end gap-2"> <DialogFooter className="flex flex-row justify-end gap-2">
{state == "reviewing" && ( {state == "reviewing" && (
<> <>
{dialog && <Button onClick={onClose}>Cancel</Button>} {dialog && (
<Button aria-label="Cancel" onClick={onClose}>
Cancel
</Button>
)}
<Button <Button
className="bg-success" className="bg-success"
aria-label="Confirm this label for Frigate Plus"
onClick={() => { onClick={() => {
setState("uploading"); setState("uploading");
onSubmitToPlus(false); onSubmitToPlus(false);
@@ -143,6 +148,7 @@ export function FrigatePlusDialog({
</Button> </Button>
<Button <Button
className="text-white" className="text-white"
aria-label="Do not confirm this label for Frigate Plus"
variant="destructive" variant="destructive"
onClick={() => { onClick={() => {
setState("uploading"); setState("uploading");

View File

@@ -76,6 +76,7 @@ export default function SearchFilterDialog({
const trigger = ( const trigger = (
<Button <Button
className="flex items-center gap-2" className="flex items-center gap-2"
aria-label="More Filters"
size="sm" size="sm"
variant={moreFiltersSelected ? "select" : "default"} variant={moreFiltersSelected ? "select" : "default"}
> >
@@ -141,6 +142,7 @@ export default function SearchFilterDialog({
<div className="flex items-center justify-evenly p-2"> <div className="flex items-center justify-evenly p-2">
<Button <Button
variant="select" variant="select"
aria-label="Apply"
onClick={() => { onClick={() => {
if (currentFilter != filter) { if (currentFilter != filter) {
onUpdateFilter(currentFilter); onUpdateFilter(currentFilter);
@@ -152,6 +154,7 @@ export default function SearchFilterDialog({
Apply Apply
</Button> </Button>
<Button <Button
aria-label="Reset filters to default values"
onClick={() => { onClick={() => {
setCurrentFilter((prevFilter) => ({ setCurrentFilter((prevFilter) => ({
...prevFilter, ...prevFilter,
@@ -256,6 +259,7 @@ function TimeRangeFilterContent({
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
className={`text-primary ${isDesktop ? "" : "text-xs"} `} className={`text-primary ${isDesktop ? "" : "text-xs"} `}
aria-label="Select Start Time"
variant={startOpen ? "select" : "default"} variant={startOpen ? "select" : "default"}
size="sm" size="sm"
onClick={() => { onClick={() => {
@@ -293,6 +297,7 @@ function TimeRangeFilterContent({
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
className={`text-primary ${isDesktop ? "" : "text-xs"}`} className={`text-primary ${isDesktop ? "" : "text-xs"}`}
aria-label="Select End Time"
variant={endOpen ? "select" : "default"} variant={endOpen ? "select" : "default"}
size="sm" size="sm"
onClick={() => { onClick={() => {

View File

@@ -190,6 +190,7 @@ export default function HlsVideoPlayer({
minScale={1.0} minScale={1.0}
wheel={{ smoothStep: 0.005 }} wheel={{ smoothStep: 0.005 }}
onZoom={(zoom) => setZoomScale(zoom.state.scale)} onZoom={(zoom) => setZoomScale(zoom.state.scale)}
disabled={!frigateControls}
> >
{frigateControls && ( {frigateControls && (
<VideoControls <VideoControls

View File

@@ -308,11 +308,16 @@ export default function MotionMaskEditPane({
/> />
<div className="flex flex-1 flex-col justify-end"> <div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5"> <div className="flex flex-row gap-2 pt-5">
<Button className="flex flex-1" onClick={onCancel}> <Button
className="flex flex-1"
aria-label="Cancel"
onClick={onCancel}
>
Cancel Cancel
</Button> </Button>
<Button <Button
variant="select" variant="select"
aria-label="Save"
disabled={isLoading} disabled={isLoading}
className="flex flex-1" className="flex flex-1"
type="submit" type="submit"

View File

@@ -335,13 +335,18 @@ export default function ObjectMaskEditPane({
</div> </div>
<div className="flex flex-1 flex-col justify-end"> <div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5"> <div className="flex flex-row gap-2 pt-5">
<Button className="flex flex-1" onClick={onCancel}> <Button
className="flex flex-1"
aria-label="Cancel"
onClick={onCancel}
>
Cancel Cancel
</Button> </Button>
<Button <Button
variant="select" variant="select"
disabled={isLoading} disabled={isLoading}
className="flex flex-1" className="flex flex-1"
aria-label="Save"
type="submit" type="submit"
> >
{isLoading ? ( {isLoading ? (

View File

@@ -74,6 +74,7 @@ export default function PolygonEditControls({
<Button <Button
variant="default" variant="default"
className="size-6 rounded-md p-1" className="size-6 rounded-md p-1"
aria-label="Remove last point"
disabled={!polygons[activePolygonIndex].points.length} disabled={!polygons[activePolygonIndex].points.length}
onClick={undo} onClick={undo}
> >
@@ -87,6 +88,7 @@ export default function PolygonEditControls({
<Button <Button
variant="default" variant="default"
className="size-6 rounded-md p-1" className="size-6 rounded-md p-1"
aria-label="Clear all points"
disabled={!polygons[activePolygonIndex].points.length} disabled={!polygons[activePolygonIndex].points.length}
onClick={reset} onClick={reset}
> >

View File

@@ -276,6 +276,7 @@ export default function PolygonItem({
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuItem <DropdownMenuItem
aria-label="Edit"
onClick={() => { onClick={() => {
setActivePolygonIndex(index); setActivePolygonIndex(index);
setEditPane(polygon.type); setEditPane(polygon.type);
@@ -283,10 +284,14 @@ export default function PolygonItem({
> >
Edit Edit
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCopyCoordinates(index)}> <DropdownMenuItem
aria-label="Copy"
onClick={() => handleCopyCoordinates(index)}
>
Copy Copy
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
aria-label="Delete"
disabled={isLoading} disabled={isLoading}
onClick={() => setDeleteDialogOpen(true)} onClick={() => setDeleteDialogOpen(true)}
> >

View File

@@ -44,7 +44,11 @@ export default function SearchSettings({
]); ]);
const trigger = ( const trigger = (
<Button className="flex items-center gap-2" size="sm"> <Button
className="flex items-center gap-2"
aria-label="Search Settings"
size="sm"
>
<FaCog className="text-secondary-foreground" /> <FaCog className="text-secondary-foreground" />
Settings Settings
</Button> </Button>

View File

@@ -466,13 +466,18 @@ export default function ZoneEditPane({
)} )}
/> />
<div className="flex flex-row gap-2 pt-5"> <div className="flex flex-row gap-2 pt-5">
<Button className="flex flex-1" onClick={onCancel}> <Button
className="flex flex-1"
aria-label="Cancel"
onClick={onCancel}
>
Cancel Cancel
</Button> </Button>
<Button <Button
variant="select" variant="select"
disabled={isLoading} disabled={isLoading}
className="flex flex-1" className="flex flex-1"
aria-label="Save"
type="submit" type="submit"
> >
{isLoading ? ( {isLoading ? (

View File

@@ -283,6 +283,7 @@ export function DateRangePicker({
}): JSX.Element => ( }): JSX.Element => (
<Button <Button
className={cn(isSelected && "pointer-events-none text-primary")} className={cn(isSelected && "pointer-events-none text-primary")}
aria-label={label}
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
setPreset(preset); setPreset(preset);
@@ -417,6 +418,7 @@ export function DateRangePicker({
<div className="mx-auto flex w-64 items-center justify-evenly gap-2 py-2"> <div className="mx-auto flex w-64 items-center justify-evenly gap-2 py-2">
<Button <Button
variant="select" variant="select"
aria-label="Apply"
onClick={() => { onClick={() => {
setIsOpen(false); setIsOpen(false);
if ( if (
@@ -436,6 +438,7 @@ export function DateRangePicker({
onReset?.(); onReset?.();
}} }}
variant="ghost" variant="ghost"
aria-label="Reset"
> >
Reset Reset
</Button> </Button>

View File

@@ -1,43 +1,43 @@
import * as React from "react" import * as React from "react";
import useEmblaCarousel, { import useEmblaCarousel, {
type UseEmblaCarouselType, type UseEmblaCarouselType,
} from "embla-carousel-react" } from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react" import { ArrowLeft, ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button";
type CarouselApi = UseEmblaCarouselType[1] type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel> type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0] type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1] type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = { type CarouselProps = {
opts?: CarouselOptions opts?: CarouselOptions;
plugins?: CarouselPlugin plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical" orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void setApi?: (api: CarouselApi) => void;
} };
type CarouselContextProps = { type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0] carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1] api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void scrollPrev: () => void;
scrollNext: () => void scrollNext: () => void;
canScrollPrev: boolean canScrollPrev: boolean;
canScrollNext: boolean canScrollNext: boolean;
} & CarouselProps } & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null) const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() { function useCarousel() {
const context = React.useContext(CarouselContext) const context = React.useContext(CarouselContext);
if (!context) { if (!context) {
throw new Error("useCarousel must be used within a <Carousel />") throw new Error("useCarousel must be used within a <Carousel />");
} }
return context return context;
} }
const Carousel = React.forwardRef< const Carousel = React.forwardRef<
@@ -54,69 +54,69 @@ const Carousel = React.forwardRef<
children, children,
...props ...props
}, },
ref ref,
) => { ) => {
const [carouselRef, api] = useEmblaCarousel( const [carouselRef, api] = useEmblaCarousel(
{ {
...opts, ...opts,
axis: orientation === "horizontal" ? "x" : "y", axis: orientation === "horizontal" ? "x" : "y",
}, },
plugins plugins,
) );
const [canScrollPrev, setCanScrollPrev] = React.useState(false) const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false) const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => { const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) { if (!api) {
return return;
} }
setCanScrollPrev(api.canScrollPrev()) setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext()) setCanScrollNext(api.canScrollNext());
}, []) }, []);
const scrollPrev = React.useCallback(() => { const scrollPrev = React.useCallback(() => {
api?.scrollPrev() api?.scrollPrev();
}, [api]) }, [api]);
const scrollNext = React.useCallback(() => { const scrollNext = React.useCallback(() => {
api?.scrollNext() api?.scrollNext();
}, [api]) }, [api]);
const handleKeyDown = React.useCallback( const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => { (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") { if (event.key === "ArrowLeft") {
event.preventDefault() event.preventDefault();
scrollPrev() scrollPrev();
} else if (event.key === "ArrowRight") { } else if (event.key === "ArrowRight") {
event.preventDefault() event.preventDefault();
scrollNext() scrollNext();
} }
}, },
[scrollPrev, scrollNext] [scrollPrev, scrollNext],
) );
React.useEffect(() => { React.useEffect(() => {
if (!api || !setApi) { if (!api || !setApi) {
return return;
} }
setApi(api) setApi(api);
}, [api, setApi]) }, [api, setApi]);
React.useEffect(() => { React.useEffect(() => {
if (!api) { if (!api) {
return return;
} }
onSelect(api) onSelect(api);
api.on("reInit", onSelect) api.on("reInit", onSelect);
api.on("select", onSelect) api.on("select", onSelect);
return () => { return () => {
api?.off("select", onSelect) api?.off("select", onSelect);
} };
}, [api, onSelect]) }, [api, onSelect]);
return ( return (
<CarouselContext.Provider <CarouselContext.Provider
@@ -143,16 +143,16 @@ const Carousel = React.forwardRef<
{children} {children}
</div> </div>
</CarouselContext.Provider> </CarouselContext.Provider>
) );
} },
) );
Carousel.displayName = "Carousel" Carousel.displayName = "Carousel";
const CarouselContent = React.forwardRef< const CarouselContent = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => { >(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel() const { carouselRef, orientation } = useCarousel();
return ( return (
<div ref={carouselRef} className="overflow-hidden"> <div ref={carouselRef} className="overflow-hidden">
@@ -161,20 +161,20 @@ const CarouselContent = React.forwardRef<
className={cn( className={cn(
"flex", "flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className className,
)} )}
{...props} {...props}
/> />
</div> </div>
) );
}) });
CarouselContent.displayName = "CarouselContent" CarouselContent.displayName = "CarouselContent";
const CarouselItem = React.forwardRef< const CarouselItem = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => { >(({ className, ...props }, ref) => {
const { orientation } = useCarousel() const { orientation } = useCarousel();
return ( return (
<div <div
@@ -184,19 +184,19 @@ const CarouselItem = React.forwardRef<
className={cn( className={cn(
"min-w-0 shrink-0 grow-0 basis-full", "min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4", orientation === "horizontal" ? "pl-4" : "pt-4",
className className,
)} )}
{...props} {...props}
/> />
) );
}) });
CarouselItem.displayName = "CarouselItem" CarouselItem.displayName = "CarouselItem";
const CarouselPrevious = React.forwardRef< const CarouselPrevious = React.forwardRef<
HTMLButtonElement, HTMLButtonElement,
React.ComponentProps<typeof Button> React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => { >(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel() const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return ( return (
<Button <Button
@@ -204,12 +204,13 @@ const CarouselPrevious = React.forwardRef<
variant={variant} variant={variant}
size={size} size={size}
className={cn( className={cn(
"absolute h-8 w-8 rounded-full", "absolute h-8 w-8 rounded-full",
orientation === "horizontal" orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2" ? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90", : "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className className,
)} )}
aria-label="Previous slide"
disabled={!canScrollPrev} disabled={!canScrollPrev}
onClick={scrollPrev} onClick={scrollPrev}
{...props} {...props}
@@ -217,15 +218,15 @@ const CarouselPrevious = React.forwardRef<
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span> <span className="sr-only">Previous slide</span>
</Button> </Button>
) );
}) });
CarouselPrevious.displayName = "CarouselPrevious" CarouselPrevious.displayName = "CarouselPrevious";
const CarouselNext = React.forwardRef< const CarouselNext = React.forwardRef<
HTMLButtonElement, HTMLButtonElement,
React.ComponentProps<typeof Button> React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => { >(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel() const { orientation, scrollNext, canScrollNext } = useCarousel();
return ( return (
<Button <Button
@@ -237,8 +238,9 @@ const CarouselNext = React.forwardRef<
orientation === "horizontal" orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2" ? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90", : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className className,
)} )}
aria-label="Next slide"
disabled={!canScrollNext} disabled={!canScrollNext}
onClick={scrollNext} onClick={scrollNext}
{...props} {...props}
@@ -246,9 +248,9 @@ const CarouselNext = React.forwardRef<
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span> <span className="sr-only">Next slide</span>
</Button> </Button>
) );
}) });
CarouselNext.displayName = "CarouselNext" CarouselNext.displayName = "CarouselNext";
export { export {
type CarouselApi, type CarouselApi,
@@ -257,4 +259,4 @@ export {
CarouselItem, CarouselItem,
CarouselPrevious, CarouselPrevious,
CarouselNext, CarouselNext,
} };

View File

@@ -192,6 +192,7 @@ function ConfigEditor() {
<Button <Button
size="sm" size="sm"
className="flex items-center gap-2" className="flex items-center gap-2"
aria-label="Copy config"
onClick={() => handleCopyConfig()} onClick={() => handleCopyConfig()}
> >
<LuCopy className="text-secondary-foreground" /> <LuCopy className="text-secondary-foreground" />
@@ -200,6 +201,7 @@ function ConfigEditor() {
<Button <Button
size="sm" size="sm"
className="flex items-center gap-2" className="flex items-center gap-2"
aria-label="Save and restart"
onClick={() => onHandleSaveConfig("restart")} onClick={() => onHandleSaveConfig("restart")}
> >
<div className="relative size-5"> <div className="relative size-5">
@@ -211,6 +213,7 @@ function ConfigEditor() {
<Button <Button
size="sm" size="sm"
className="flex items-center gap-2" className="flex items-center gap-2"
aria-label="Save only without restarting"
onClick={() => onHandleSaveConfig("saveonly")} onClick={() => onHandleSaveConfig("saveonly")}
> >
<LuSave className="text-secondary-foreground" /> <LuSave className="text-secondary-foreground" />

View File

@@ -407,10 +407,6 @@ export default function Events() {
review.severity == "detection" review.severity == "detection"
? item.reviewed_detection + 1 ? item.reviewed_detection + 1
: item.reviewed_detection, : item.reviewed_detection,
reviewed_motion:
review.severity == "significant_motion"
? item.reviewed_motion + 1
: item.reviewed_motion,
}, },
}; };
}, },

View File

@@ -18,6 +18,7 @@ import { isMobileOnly } from "react-device-detect";
import { LuCheck, LuExternalLink, LuX } from "react-icons/lu"; import { LuCheck, LuExternalLink, LuX } from "react-icons/lu";
import { TbExclamationCircle } from "react-icons/tb"; import { TbExclamationCircle } from "react-icons/tb";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { toast } from "sonner";
import useSWR from "swr"; import useSWR from "swr";
import useSWRInfinite from "swr/infinite"; import useSWRInfinite from "swr/infinite";
@@ -177,9 +178,21 @@ export default function Explore() {
const { data, size, setSize, isValidating, mutate } = useSWRInfinite< const { data, size, setSize, isValidating, mutate } = useSWRInfinite<
SearchResult[] SearchResult[]
>(getKey, { >(getKey, {
revalidateFirstPage: false, revalidateFirstPage: true,
revalidateOnFocus: true, revalidateOnFocus: true,
revalidateAll: false, revalidateAll: false,
onError: (error) => {
toast.error(
`Error fetching tracked objects: ${error.response.data.message}`,
{
position: "top-center",
},
);
if (error.response.status === 404) {
// reset all filters if 404
setSearchFilter({});
}
},
}); });
const searchResults = useMemo( const searchResults = useMemo(

View File

@@ -125,6 +125,7 @@ function Exports() {
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<Button <Button
className="text-white" className="text-white"
aria-label="Delete Export"
variant="destructive" variant="destructive"
onClick={() => onHandleDelete()} onClick={() => onHandleDelete()}
> >

View File

@@ -339,6 +339,7 @@ function Logs() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
className="flex items-center justify-between gap-2" className="flex items-center justify-between gap-2"
aria-label="Copy logs to clipboard"
size="sm" size="sm"
onClick={handleCopyLogs} onClick={handleCopyLogs}
> >
@@ -349,6 +350,7 @@ function Logs() {
</Button> </Button>
<Button <Button
className="flex items-center justify-between gap-2" className="flex items-center justify-between gap-2"
aria-label="Download logs"
size="sm" size="sm"
onClick={handleDownloadLogs} onClick={handleDownloadLogs}
> >
@@ -365,6 +367,7 @@ function Logs() {
{initialScroll && !endVisible && ( {initialScroll && !endVisible && (
<Button <Button
className="absolute bottom-8 left-[50%] z-20 flex -translate-x-[50%] items-center gap-1 rounded-md p-2" className="absolute bottom-8 left-[50%] z-20 flex -translate-x-[50%] items-center gap-1 rounded-md p-2"
aria-label="Jump to bottom of logs"
onClick={() => onClick={() =>
contentRef.current?.scrollTo({ contentRef.current?.scrollTo({
top: contentRef.current?.scrollHeight, top: contentRef.current?.scrollHeight,

View File

@@ -252,6 +252,7 @@ function CameraSelectButton({
const trigger = ( const trigger = (
<Button <Button
className="flex items-center gap-2 bg-selected capitalize hover:bg-selected" className="flex items-center gap-2 bg-selected capitalize hover:bg-selected"
aria-label="Select a camera"
size="sm" size="sm"
> >
<FaVideo className="text-background dark:text-primary" /> <FaVideo className="text-background dark:text-primary" />

View File

@@ -42,10 +42,8 @@ type ReviewSummaryDay = {
day: string; day: string;
reviewed_alert: number; reviewed_alert: number;
reviewed_detection: number; reviewed_detection: number;
reviewed_motion: number;
total_alert: number; total_alert: number;
total_detection: number; total_detection: number;
total_motion: number;
}; };
export type ReviewSummary = { export type ReviewSummary = {

View File

@@ -117,13 +117,11 @@ export default function EventView({
return { return {
alert: summary.total_alert ?? 0, alert: summary.total_alert ?? 0,
detection: summary.total_detection ?? 0, detection: summary.total_detection ?? 0,
significant_motion: summary.total_motion ?? 0,
}; };
} else { } else {
return { return {
alert: summary.total_alert - summary.reviewed_alert, alert: summary.total_alert - summary.reviewed_alert,
detection: summary.total_detection - summary.reviewed_detection, detection: summary.total_detection - summary.reviewed_detection,
significant_motion: summary.total_motion - summary.reviewed_motion,
}; };
} }
}, [filter, showReviewed, reviewSummary]); }, [filter, showReviewed, reviewSummary]);
@@ -737,6 +735,7 @@ function DetectionReview({
<div className="col-span-full flex items-center justify-center"> <div className="col-span-full flex items-center justify-center">
<Button <Button
className="text-white" className="text-white"
aria-label="Mark these items as reviewed"
variant="select" variant="select"
onClick={() => { onClick={() => {
setSelectedReviews([]); setSelectedReviews([]);

View File

@@ -18,15 +18,22 @@ import ActivityIndicator from "@/components/indicators/activity-indicator";
import { useEventUpdate } from "@/api/ws"; import { useEventUpdate } from "@/api/ws";
import { isEqual } from "lodash"; import { isEqual } from "lodash";
import TimeAgo from "@/components/dynamic/TimeAgo"; import TimeAgo from "@/components/dynamic/TimeAgo";
import SearchResultActions from "@/components/menu/SearchResultActions";
import { SearchTab } from "@/components/overlay/detail/SearchDetailDialog";
import { FrigateConfig } from "@/types/frigateConfig";
type ExploreViewProps = { type ExploreViewProps = {
searchDetail: SearchResult | undefined; searchDetail: SearchResult | undefined;
setSearchDetail: (search: SearchResult | undefined) => void; setSearchDetail: (search: SearchResult | undefined) => void;
setSimilaritySearch: (search: SearchResult) => void;
onSelectSearch: (item: SearchResult, index: number, page?: SearchTab) => void;
}; };
export default function ExploreView({ export default function ExploreView({
searchDetail, searchDetail,
setSearchDetail, setSearchDetail,
setSimilaritySearch,
onSelectSearch,
}: ExploreViewProps) { }: ExploreViewProps) {
// title // title
@@ -102,6 +109,9 @@ export default function ExploreView({
isValidating={isValidating} isValidating={isValidating}
objectType={label} objectType={label}
setSearchDetail={setSearchDetail} setSearchDetail={setSearchDetail}
mutate={mutate}
setSimilaritySearch={setSimilaritySearch}
onSelectSearch={onSelectSearch}
/> />
))} ))}
</div> </div>
@@ -113,6 +123,9 @@ type ThumbnailRowType = {
searchResults?: SearchResult[]; searchResults?: SearchResult[];
isValidating: boolean; isValidating: boolean;
setSearchDetail: (search: SearchResult | undefined) => void; setSearchDetail: (search: SearchResult | undefined) => void;
mutate: () => void;
setSimilaritySearch: (search: SearchResult) => void;
onSelectSearch: (item: SearchResult, index: number, page?: SearchTab) => void;
}; };
function ThumbnailRow({ function ThumbnailRow({
@@ -120,6 +133,9 @@ function ThumbnailRow({
searchResults, searchResults,
isValidating, isValidating,
setSearchDetail, setSearchDetail,
mutate,
setSimilaritySearch,
onSelectSearch,
}: ThumbnailRowType) { }: ThumbnailRowType) {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -155,6 +171,9 @@ function ThumbnailRow({
<ExploreThumbnailImage <ExploreThumbnailImage
event={event} event={event}
setSearchDetail={setSearchDetail} setSearchDetail={setSearchDetail}
mutate={mutate}
setSimilaritySearch={setSimilaritySearch}
onSelectSearch={onSelectSearch}
/> />
</div> </div>
))} ))}
@@ -184,54 +203,78 @@ function ThumbnailRow({
type ExploreThumbnailImageProps = { type ExploreThumbnailImageProps = {
event: SearchResult; event: SearchResult;
setSearchDetail: (search: SearchResult | undefined) => void; setSearchDetail: (search: SearchResult | undefined) => void;
mutate: () => void;
setSimilaritySearch: (search: SearchResult) => void;
onSelectSearch: (item: SearchResult, index: number, page?: SearchTab) => void;
}; };
function ExploreThumbnailImage({ function ExploreThumbnailImage({
event, event,
setSearchDetail, setSearchDetail,
mutate,
setSimilaritySearch,
onSelectSearch,
}: ExploreThumbnailImageProps) { }: ExploreThumbnailImageProps) {
const apiHost = useApiHost(); const apiHost = useApiHost();
const { data: config } = useSWR<FrigateConfig>("config");
const [imgRef, imgLoaded, onImgLoad] = useImageLoaded(); const [imgRef, imgLoaded, onImgLoad] = useImageLoaded();
return ( const handleFindSimilar = () => {
<> if (config?.semantic_search.enabled) {
<ImageLoadingIndicator setSimilaritySearch(event);
className="absolute inset-0" }
imgLoaded={imgLoaded} };
/>
<img const handleShowObjectLifecycle = () => {
ref={imgRef} onSelectSearch(event, 0, "object lifecycle");
className={cn( };
"absolute h-full w-full cursor-pointer rounded-lg object-cover transition-all duration-300 ease-in-out lg:rounded-2xl",
)} return (
style={ <SearchResultActions
isIOS searchResult={event}
? { findSimilar={handleFindSimilar}
WebkitUserSelect: "none", refreshResults={mutate}
WebkitTouchCallout: "none", showObjectLifecycle={handleShowObjectLifecycle}
} isContextMenu={true}
: undefined >
} <div className="relative size-full">
loading={isSafari ? "eager" : "lazy"} <ImageLoadingIndicator
draggable={false} className="absolute inset-0"
src={`${apiHost}api/events/${event.id}/thumbnail.jpg`} imgLoaded={imgLoaded}
onClick={() => setSearchDetail(event)} />
onLoad={() => { <img
onImgLoad(); ref={imgRef}
}} className={cn(
/> "absolute size-full cursor-pointer rounded-lg object-cover transition-all duration-300 ease-in-out lg:rounded-2xl",
{isDesktop && ( !imgLoaded && "invisible",
<div className="absolute bottom-1 right-1 z-10 rounded-lg bg-black/50 px-2 py-1 text-xs text-white">
{event.end_time ? (
<TimeAgo time={event.start_time * 1000} dense />
) : (
<div>
<ActivityIndicator size={10} />
</div>
)} )}
</div> style={
)} isIOS
</> ? {
WebkitUserSelect: "none",
WebkitTouchCallout: "none",
}
: undefined
}
loading={isSafari ? "eager" : "lazy"}
draggable={false}
src={`${apiHost}api/events/${event.id}/thumbnail.jpg`}
onClick={() => setSearchDetail(event)}
onLoad={onImgLoad}
alt={`${event.label} thumbnail`}
/>
{isDesktop && (
<div className="absolute bottom-1 right-1 z-10 rounded-lg bg-black/50 px-2 py-1 text-xs text-white">
{event.end_time ? (
<TimeAgo time={event.start_time * 1000} dense />
) : (
<div>
<ActivityIndicator size={10} />
</div>
)}
</div>
)}
</div>
</SearchResultActions>
); );
} }

View File

@@ -144,6 +144,7 @@ export default function LiveBirdseyeView({
{!fullscreen ? ( {!fullscreen ? (
<Button <Button
className={`flex items-center gap-2 rounded-lg ${isMobile ? "ml-2" : "ml-0"}`} className={`flex items-center gap-2 rounded-lg ${isMobile ? "ml-2" : "ml-0"}`}
aria-label="Go Back"
size={isMobile ? "icon" : "sm"} size={isMobile ? "icon" : "sm"}
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
> >

View File

@@ -352,6 +352,7 @@ export default function LiveCameraView({
> >
<Button <Button
className={`flex items-center gap-2.5 rounded-lg`} className={`flex items-center gap-2.5 rounded-lg`}
aria-label="Go back"
size="sm" size="sm"
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
> >
@@ -360,6 +361,7 @@ export default function LiveCameraView({
</Button> </Button>
<Button <Button
className="flex items-center gap-2.5 rounded-lg" className="flex items-center gap-2.5 rounded-lg"
aria-label="Show historical footage"
size="sm" size="sm"
onClick={() => { onClick={() => {
navigate("review", { navigate("review", {
@@ -388,6 +390,7 @@ export default function LiveCameraView({
{fullscreen && ( {fullscreen && (
<Button <Button
className="bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 text-primary" className="bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 text-primary"
aria-label="Go back"
size="sm" size="sm"
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
> >
@@ -603,6 +606,7 @@ function PtzControlPanel({
{ptz?.features?.includes("pt") && ( {ptz?.features?.includes("pt") && (
<> <>
<Button <Button
aria-label="Move PTZ camera to the left"
onMouseDown={(e) => { onMouseDown={(e) => {
e.preventDefault(); e.preventDefault();
sendPtz("MOVE_LEFT"); sendPtz("MOVE_LEFT");
@@ -617,6 +621,7 @@ function PtzControlPanel({
<FaAngleLeft /> <FaAngleLeft />
</Button> </Button>
<Button <Button
aria-label="Move PTZ camera up"
onMouseDown={(e) => { onMouseDown={(e) => {
e.preventDefault(); e.preventDefault();
sendPtz("MOVE_UP"); sendPtz("MOVE_UP");
@@ -631,6 +636,7 @@ function PtzControlPanel({
<FaAngleUp /> <FaAngleUp />
</Button> </Button>
<Button <Button
aria-label="Move PTZ camera down"
onMouseDown={(e) => { onMouseDown={(e) => {
e.preventDefault(); e.preventDefault();
sendPtz("MOVE_DOWN"); sendPtz("MOVE_DOWN");
@@ -645,6 +651,7 @@ function PtzControlPanel({
<FaAngleDown /> <FaAngleDown />
</Button> </Button>
<Button <Button
aria-label="Move PTZ camera to the right"
onMouseDown={(e) => { onMouseDown={(e) => {
e.preventDefault(); e.preventDefault();
sendPtz("MOVE_RIGHT"); sendPtz("MOVE_RIGHT");
@@ -663,6 +670,7 @@ function PtzControlPanel({
{ptz?.features?.includes("zoom") && ( {ptz?.features?.includes("zoom") && (
<> <>
<Button <Button
aria-label="Zoom PTZ camera in"
onMouseDown={(e) => { onMouseDown={(e) => {
e.preventDefault(); e.preventDefault();
sendPtz("ZOOM_IN"); sendPtz("ZOOM_IN");
@@ -677,6 +685,7 @@ function PtzControlPanel({
<MdZoomIn /> <MdZoomIn />
</Button> </Button>
<Button <Button
aria-label="Zoom PTZ camera out"
onMouseDown={(e) => { onMouseDown={(e) => {
e.preventDefault(); e.preventDefault();
sendPtz("ZOOM_OUT"); sendPtz("ZOOM_OUT");
@@ -696,6 +705,7 @@ function PtzControlPanel({
<> <>
<Button <Button
className={`${clickOverlay ? "text-selected" : "text-primary"}`} className={`${clickOverlay ? "text-selected" : "text-primary"}`}
aria-label="Click in the frame to center the PTZ camera"
onClick={() => setClickOverlay(!clickOverlay)} onClick={() => setClickOverlay(!clickOverlay)}
> >
<TbViewfinder /> <TbViewfinder />
@@ -705,7 +715,7 @@ function PtzControlPanel({
{(ptz?.presets?.length ?? 0) > 0 && ( {(ptz?.presets?.length ?? 0) > 0 && (
<DropdownMenu modal={!isDesktop}> <DropdownMenu modal={!isDesktop}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button> <Button aria-label="PTZ camera presets">
<BsThreeDotsVertical /> <BsThreeDotsVertical />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -717,6 +727,7 @@ function PtzControlPanel({
return ( return (
<DropdownMenuItem <DropdownMenuItem
key={preset} key={preset}
aria-label={preset}
className="cursor-pointer" className="cursor-pointer"
onSelect={() => sendPtz(`preset_${preset}`)} onSelect={() => sendPtz(`preset_${preset}`)}
> >

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