Compare commits

..

117 Commits

Author SHA1 Message Date
Paul Armstrong
5043040530 fix(web): ensure tooltips and menus don't cause scrollbar reflow 2021-02-25 06:34:36 -06:00
Paul Armstrong
3c60aeeef9 fix(web): set events api limit to 25 2021-02-25 06:34:36 -06:00
Blake Blackshear
0344d61b26 use gevent sleep to prevent mjpeg from blocking 2021-02-25 06:34:36 -06:00
Blake Blackshear
0e8467782b version tick 2021-02-25 06:34:36 -06:00
Paul Armstrong
423ea26266 Add paularmstrong to funding.yml 2021-02-24 20:58:44 -06:00
Paul Armstrong
2f3339ba85 docs: add contributing docs 2021-02-23 07:37:19 -06:00
Blake Blackshear
9433b50785 add stalebot 2021-02-22 07:20:39 -06:00
Blake Blackshear
1e7b53dc0e clarify h264 in docs 2021-02-22 07:20:32 -06:00
Blake Blackshear
bc94748f2a clips not playable 2021-02-22 07:10:56 -06:00
Blake Blackshear
2395f93ed1 Update bug_report.md 2021-02-22 06:43:21 -06:00
Blake Blackshear
d771726c2a version tick 2021-02-21 09:32:45 -06:00
Blake Blackshear
b2a2fe898c ensure base url works for websockets 2021-02-21 09:32:45 -06:00
Blake Blackshear
31d408a746 dynamic ws/wss selection 2021-02-20 08:20:17 -06:00
Blake Blackshear
4a74f295e7 docs updates 2021-02-20 08:20:17 -06:00
Paul Armstrong
b6ba6459fb feat(web): detect, clips, snapshots toggles 2021-02-20 08:20:17 -06:00
Paul Armstrong
e399790442 feat(web): mqtt for stats 2021-02-20 08:20:17 -06:00
Blake Blackshear
20c65b9a31 fix link and clarify audio encoding (fixes #800) 2021-02-20 08:20:17 -06:00
Blake Blackshear
1a7853a47e subscribe in the connect callback (fixes #814) 2021-02-20 08:20:17 -06:00
Blake Blackshear
683c3a4c90 update wheels again 2021-02-20 08:20:17 -06:00
Blake Blackshear
4a8d998afe unpin numpy 2021-02-20 08:20:17 -06:00
Paul Armstrong
fe59d90c51 web(test): routes/Events 2021-02-20 08:20:17 -06:00
Paul Armstrong
f87813805a test(web): RelativeModal 2021-02-20 08:20:17 -06:00
Paul Armstrong
a7e5b9978f test(web): Select 2021-02-20 08:20:17 -06:00
Paul Armstrong
0a3959af86 test(web): TextField 2021-02-20 08:20:17 -06:00
Paul Armstrong
9ba6054140 test(web): Sidebar 2021-02-20 08:20:17 -06:00
Paul Armstrong
3348f04889 test(web): App 2021-02-20 08:20:17 -06:00
Paul Armstrong
c12aec7c8f test(web): routes/Event 2021-02-20 08:20:17 -06:00
Paul Armstrong
05f66b8f24 test(web): routes/Debug 2021-02-20 08:20:17 -06:00
Paul Armstrong
d8b80f0fe9 test(web): routes/Cameras 2021-02-20 08:20:17 -06:00
Paul Armstrong
7314572d97 feat(web): allow CameraImage to stretch 2021-02-20 08:20:17 -06:00
Paul Armstrong
52a29ed00a test(web): routes/Camera 2021-02-20 08:20:17 -06:00
Paul Armstrong
5eaf8a5448 test(web): Switch (and add label back in) 2021-02-20 08:20:17 -06:00
Paul Armstrong
f70fb12c3d test(web): NavigationDrawer 2021-02-20 08:20:17 -06:00
Paul Armstrong
ece6c1203c test(web): Menu, MenuItem 2021-02-20 08:20:17 -06:00
Paul Armstrong
9c7e3177a2 test(web): Link 2021-02-20 08:20:17 -06:00
Paul Armstrong
058c0affaf test(web): Heading 2021-02-20 08:20:17 -06:00
Paul Armstrong
5ee7146884 test(web): Card 2021-02-20 08:20:17 -06:00
Paul Armstrong
1aa9a7a093 test(web): CameraImage (basic)
Testing Image and Canvas calls requires a lot of heavy dependencies, so this skips that part of the tests
2021-02-20 08:20:17 -06:00
Paul Armstrong
a202c44a0f test(web): Button 2021-02-20 08:20:17 -06:00
Paul Armstrong
85776cc7d0 test(web): fix switch case indent lint 2021-02-20 08:20:17 -06:00
Paul Armstrong
6d133ef724 test(web): api/index.jsx 2021-02-20 08:20:17 -06:00
Paul Armstrong
53288d361c test(web): AutoUpdatingCameraImage 2021-02-20 08:20:17 -06:00
Paul Armstrong
e729bd52aa refactor(web): Split AppBar and add tests 2021-02-20 08:20:17 -06:00
Paul Armstrong
ddb6127519 test(web): add ActivityIndicator test 2021-02-20 08:20:17 -06:00
Blake Blackshear
50b42eb6fe add gevent to prebuilt wheels 2021-02-20 08:20:17 -06:00
Blake Blackshear
b6572b7272 add some error handling to mqtt relay 2021-02-20 08:20:17 -06:00
Blake Blackshear
57ced2c284 constrain websockets to frigate topics 2021-02-20 08:20:17 -06:00
Blake Blackshear
26a3491466 revise log messages 2021-02-20 08:20:17 -06:00
Blake Blackshear
eed8463832 relay messages from sockets to mqtt 2021-02-20 08:20:17 -06:00
Blake Blackshear
718b4f3fd7 relay mqtt to clients 2021-02-20 08:20:17 -06:00
Blake Blackshear
22461d1728 simple echo websocket working 2021-02-20 08:20:17 -06:00
Blake Blackshear
69cab1e6bb update docker commands to avoid privileged mode 2021-02-20 08:20:17 -06:00
Blake Blackshear
a661fddaf3 fix cache cleanup for large full disks 2021-02-20 08:20:17 -06:00
Blake Blackshear
1b85e561b9 only save the event to the database if a snapshot or clip exists 2021-02-20 08:20:17 -06:00
Paul Armstrong
a803ab8577 test(web): add unit test framework 2021-02-20 08:20:17 -06:00
Paul Armstrong
daa759cc55 test(web): add eslint and PR lint validation 2021-02-20 08:20:17 -06:00
Blake Blackshear
513a099c24 better error handling (fixes #739) 2021-02-20 08:20:17 -06:00
Blake Blackshear
e299e73a68 ignore detections that don't overlap with motion 2021-02-20 08:20:17 -06:00
Blake Blackshear
9550ac7422 fix intersection calculation 2021-02-20 08:20:17 -06:00
Patrick Decat
07bd376649 fix(web): fix CameraMap.jsx import of api after move to routes/ 2021-02-20 08:20:17 -06:00
Patrick Decat
ec6a1ed9d1 Add nginx sub_filter to fix resources from /dist with HA ingress 2021-02-20 08:20:17 -06:00
Paul Armstrong
7aee28d080 refactor(web): async routing 2021-02-20 08:20:17 -06:00
Paul Armstrong
24ec13e36d fix(web): svgs may need explicit height/width in rare cases on linux 2021-02-20 08:20:17 -06:00
Paul Armstrong
d2e7c360b9 fix(web): build fixes after rebase 2021-02-20 08:20:17 -06:00
Paul Armstrong
f00628f4e5 refactor(web): menu positioning 2021-02-20 08:20:17 -06:00
Paul Armstrong
19bd5ace7d perf(web): memoize icon components 2021-02-20 08:20:17 -06:00
Paul Armstrong
3e2506136c fix(web): debug tables scrollable on small width screens 2021-02-20 08:20:17 -06:00
Paul Armstrong
4e03acc944 fix(web): ensure drawer can slide in/out and not just appear 2021-02-20 08:20:17 -06:00
Paul Armstrong
188eb6b9ea fix(web): relative modal height, top position, and z-indexing 2021-02-20 08:20:17 -06:00
Paul Armstrong
c89e1a5735 fix(web): remove cards from event page 2021-02-20 08:20:17 -06:00
Paul Armstrong
e50cc59f0d refactor(web): datatables 2021-02-20 08:20:17 -06:00
Paul Armstrong
96f87caff0 refactor(web): camera view + bugfixes 2021-02-20 08:20:17 -06:00
Paul Armstrong
b422a83b57 fix(web): ensure relative modal respects scrollY 2021-02-20 08:20:17 -06:00
Paul Armstrong
15ae3bee55 refactor(web): update shadows for material specs 2021-02-20 08:20:17 -06:00
Paul Armstrong
0cac2fec2a feat(web): add button types 2021-02-20 08:20:17 -06:00
Paul Armstrong
5965da88c3 fix(web): dark mode for portals 2021-02-20 08:20:17 -06:00
Paul Armstrong
ba0338e9d5 refactor(web): NavigationBar (sidebar) styles 2021-02-20 08:20:17 -06:00
Paul Armstrong
ff62338359 feat(web): icons and better menu handling for dark mode 2021-02-20 08:20:17 -06:00
Paul Armstrong
9867f4eeee fix(web): ensure relative modals have proper padding 2021-02-20 08:20:17 -06:00
Paul Armstrong
ba278dfc3d refactor(web): add 3xl breakpoint 2021-02-20 08:20:17 -06:00
Paul Armstrong
063030bcf3 fix(web): make app bar and sidebar fully responsive 2021-02-20 08:20:17 -06:00
Paul Armstrong
276ce8710c feat(web): persist darkmode preference 2021-02-20 08:20:17 -06:00
Paul Armstrong
5ed7a17f46 refactor(web): styles and styleguide 2021-02-20 08:20:17 -06:00
Blake Blackshear
01c3b4fa6e try and ensure database closes cleanly 2021-02-20 08:20:17 -06:00
Blake Blackshear
165ca8fbc7 purge duplicate events during cleanup 2021-02-20 08:20:17 -06:00
Blake Blackshear
ce90ae343c add global object mask 2021-02-20 08:20:17 -06:00
Paul Armstrong
a99f360a64 refactor(web): use snowpack-plugin-hash 2021-02-20 08:20:17 -06:00
Blake Blackshear
d51e9446ff add camera level ffmpeg params 2021-02-20 08:20:17 -06:00
Blake Blackshear
d3524ee46f adjust jpg quality in other locations too 2021-02-20 08:20:17 -06:00
Blake Blackshear
121ea37825 allow defining required zones for snapshots/clips/mqtt 2021-02-20 08:20:17 -06:00
Blake Blackshear
9592d95599 proactively clean up cache when above 90% use 2021-02-20 08:20:17 -06:00
Blake Blackshear
d6faa18adb increase default max_disappeared to 5x FPS 2021-02-20 08:20:17 -06:00
Blake Blackshear
1cbe6f77ee only run detection on objects that intersect with motion 2021-02-20 08:20:17 -06:00
Blake Blackshear
4f5d4e36b7 add disk usage to stats 2021-02-20 08:20:17 -06:00
Paul Armstrong
163025c1f2 fix(app): reduce JPEG quality to drastically improve size 2021-02-20 08:20:17 -06:00
Paul Armstrong
880178d62e refactor(web): render CameraImage to a canvas 2021-02-20 08:20:17 -06:00
Blake Blackshear
d285ff7e54 increment version 2021-02-20 08:20:17 -06:00
jaburges
54671fc522 Added the base urls for image and video 2021-02-10 20:59:00 -06:00
jaburges
53e3e6545d Added Unraid and M2 coral edge tpu
Recommended hardware docs
2021-02-10 20:58:11 -06:00
Blake Blackshear
91cb49c4a3 Update issue templates 2021-02-10 06:51:52 -06:00
Blake Blackshear
c065cb48f2 fix notification examples 2021-02-03 06:56:14 -06:00
Blake Blackshear
d376f6b1d2 increment version 2021-01-31 06:20:59 -06:00
Paul Armstrong
45526a7652 feat(web): activity indicator while loading 2021-01-31 06:18:35 -06:00
Paul Armstrong
cc7929932b feat(nginx): enable gzip compression and cache control for static files 2021-01-31 06:18:35 -06:00
Paul Armstrong
e6516235fa feat(web): auto-paginate events page 2021-01-31 06:18:35 -06:00
Blake Blackshear
40d5a9f890 change default log level 2021-01-31 06:18:35 -06:00
Blake Blackshear
ee3e744cc6 tail last 100 lines of ffmpeg logs and dump when failure detected 2021-01-31 06:18:35 -06:00
Blake Blackshear
b55bd1e027 add param to reduce response sizes by excluding thumbnails in api response 2021-01-31 06:18:35 -06:00
Jeff Billimek
9a96df0319 update docs to reflect new chart home
Signed-off-by: Jeff Billimek <jeff@billimek.com>
2021-01-30 22:15:20 -06:00
Blake Blackshear
e9b1618364 add note about protection mode for tmpfs fixes #658 2021-01-29 06:42:55 -06:00
Blake Blackshear
6dc6ed1e94 more detailed reolink args 2021-01-29 06:40:32 -06:00
Blake Blackshear
1943a49274 add audio info to docs 2021-01-29 06:35:54 -06:00
Paul Armstrong
a8c00edc94 fix(web): reduce transferred/unused assets on html load 2021-01-29 06:27:32 -06:00
Blake Blackshear
faa8abb2b9 docs updates 2021-01-28 08:21:04 -06:00
Blake Blackshear
f6cd2fc68e clarifying addon docs 2021-01-28 07:45:09 -06:00
Paul Armstrong
6482000d6b fix(web): image loading for firefox 2021-01-28 07:05:45 -06:00
Justin Goette
dcf7209706 Fix camera.md links 2021-01-28 06:43:43 -06:00
127 changed files with 13174 additions and 5495 deletions

4
.github/FUNDING.yml vendored
View File

@@ -1 +1,3 @@
github: blakeblackshear
github:
- blakeblackshear
- paularmstrong

View File

@@ -1,6 +1,6 @@
---
name: Bug report or Support request
about: ''
about: Bug report or Support request
title: ''
labels: ''
assignees: ''
@@ -11,7 +11,7 @@ assignees: ''
A clear and concise description of what your issue is.
**Version of frigate**
Output from `/version`
Output from `/api/version`
**Config file**
Include your full config file wrapped in triple back ticks.
@@ -26,7 +26,7 @@ Include relevant log output here
**Frigate stats**
```json
Output from frigate's /stats endpoint
Output from frigate's /api/stats endpoint
```
**FFprobe from your camera**

17
.github/stale.yml vendored Normal file
View File

@@ -0,0 +1,17 @@
# Number of days of inactivity before an issue becomes stale
daysUntilStale: 30
# Number of days of inactivity before a stale issue is closed
daysUntilClose: 3
# Issues with these labels will never be considered stale
exemptLabels:
- pinned
- security
# Label to use when marking an issue as stale
staleLabel: stale
# Comment to post when marking an issue as stale. Set to `false` to disable
markComment: >
This issue has been automatically marked as stale because it has not had
recent activity. It will be closed if no further activity occurs. Thank you
for your contributions.
# Comment to post when closing a stale issue. Set to `false` to disable
closeComment: false

46
.github/workflows/pull_request.yml vendored Normal file
View File

@@ -0,0 +1,46 @@
name: On pull request
on: pull_request
jobs:
web_lint:
name: Web - Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- uses: actions/setup-node@master
with:
node-version: 14.x
- run: npm install
working-directory: ./web
- name: Lint
run: npm run lint:cmd
working-directory: ./web
web_build:
name: Web - Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- uses: actions/setup-node@master
with:
node-version: 14.x
- run: npm install
working-directory: ./web
- name: Build
run: npm run build
working-directory: ./web
web_test:
name: Web - Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- uses: actions/setup-node@master
with:
node-version: 14.x
- run: npm install
working-directory: ./web
- name: Test
run: npm run test
working-directory: ./web

1
.gitignore vendored
View File

@@ -9,3 +9,4 @@ models
frigate/version.py
web/build
web/node_modules
web/coverage

View File

@@ -3,55 +3,55 @@ default_target: amd64_frigate
COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1)
version:
echo "VERSION='0.8.0-$(COMMIT_HASH)'" > frigate/version.py
echo "VERSION='0.8.4-$(COMMIT_HASH)'" > frigate/version.py
web:
docker build --tag frigate-web --file docker/Dockerfile.web web/
amd64_wheels:
docker build --tag blakeblackshear/frigate-wheels:1.0.1-amd64 --file docker/Dockerfile.wheels .
docker build --tag blakeblackshear/frigate-wheels:1.0.3-amd64 --file docker/Dockerfile.wheels .
amd64_ffmpeg:
docker build --tag blakeblackshear/frigate-ffmpeg:1.1.0-amd64 --file docker/Dockerfile.ffmpeg.amd64 .
amd64_frigate: version web
docker build --tag frigate-base --build-arg ARCH=amd64 --build-arg FFMPEG_VERSION=1.1.0 --build-arg WHEELS_VERSION=1.0.1 --file docker/Dockerfile.base .
docker build --tag frigate-base --build-arg ARCH=amd64 --build-arg FFMPEG_VERSION=1.1.0 --build-arg WHEELS_VERSION=1.0.3 --file docker/Dockerfile.base .
docker build --tag frigate --file docker/Dockerfile.amd64 .
amd64_all: amd64_wheels amd64_ffmpeg amd64_frigate
amd64nvidia_wheels:
docker build --tag blakeblackshear/frigate-wheels:1.0.1-amd64nvidia --file docker/Dockerfile.wheels .
docker build --tag blakeblackshear/frigate-wheels:1.0.3-amd64nvidia --file docker/Dockerfile.wheels .
amd64nvidia_ffmpeg:
docker build --tag blakeblackshear/frigate-ffmpeg:1.0.0-amd64nvidia --file docker/Dockerfile.ffmpeg.amd64nvidia .
amd64nvidia_frigate: version web
docker build --tag frigate-base --build-arg ARCH=amd64nvidia --build-arg FFMPEG_VERSION=1.0.0 --build-arg WHEELS_VERSION=1.0.1 --file docker/Dockerfile.base .
docker build --tag frigate-base --build-arg ARCH=amd64nvidia --build-arg FFMPEG_VERSION=1.0.0 --build-arg WHEELS_VERSION=1.0.3 --file docker/Dockerfile.base .
docker build --tag frigate --file docker/Dockerfile.amd64nvidia .
amd64nvidia_all: amd64nvidia_wheels amd64nvidia_ffmpeg amd64nvidia_frigate
aarch64_wheels:
docker build --tag blakeblackshear/frigate-wheels:1.0.1-aarch64 --file docker/Dockerfile.wheels .
docker build --tag blakeblackshear/frigate-wheels:1.0.3-aarch64 --file docker/Dockerfile.wheels .
aarch64_ffmpeg:
docker build --tag blakeblackshear/frigate-ffmpeg:1.0.0-aarch64 --file docker/Dockerfile.ffmpeg.aarch64 .
aarch64_frigate: version web
docker build --tag frigate-base --build-arg ARCH=aarch64 --build-arg FFMPEG_VERSION=1.0.0 --build-arg WHEELS_VERSION=1.0.1 --file docker/Dockerfile.base .
docker build --tag frigate-base --build-arg ARCH=aarch64 --build-arg FFMPEG_VERSION=1.0.0 --build-arg WHEELS_VERSION=1.0.3 --file docker/Dockerfile.base .
docker build --tag frigate --file docker/Dockerfile.aarch64 .
armv7_all: armv7_wheels armv7_ffmpeg armv7_frigate
armv7_wheels:
docker build --tag blakeblackshear/frigate-wheels:1.0.1-armv7 --file docker/Dockerfile.wheels .
docker build --tag blakeblackshear/frigate-wheels:1.0.3-armv7 --file docker/Dockerfile.wheels .
armv7_ffmpeg:
docker build --tag blakeblackshear/frigate-ffmpeg:1.0.0-armv7 --file docker/Dockerfile.ffmpeg.armv7 .
armv7_frigate: version web
docker build --tag frigate-base --build-arg ARCH=armv7 --build-arg FFMPEG_VERSION=1.0.0 --build-arg WHEELS_VERSION=1.0.1 --file docker/Dockerfile.base .
docker build --tag frigate-base --build-arg ARCH=armv7 --build-arg FFMPEG_VERSION=1.0.0 --build-arg WHEELS_VERSION=1.0.3 --file docker/Dockerfile.base .
docker build --tag frigate --file docker/Dockerfile.armv7 .
armv7_all: armv7_wheels armv7_ffmpeg armv7_frigate

View File

@@ -22,6 +22,9 @@ Use of a [Google Coral Accelerator](https://coral.ai/products/) is optional, but
View the documentation at https://blakeblackshear.github.io/frigate
## Donations
If you would like to make a donation to support development, please use [Github Sponsors](https://github.com/sponsors/blakeblackshear).
## Screenshots
Integration into HomeAssistant
<div>

View File

@@ -34,7 +34,10 @@ RUN apt-get -qq update \
RUN pip3 install \
peewee_migrate \
zeroconf \
voluptuous
voluptuous\
Flask-Sockets \
gevent \
gevent-websocket
COPY nginx/nginx.conf /etc/nginx/nginx.conf

View File

@@ -24,8 +24,7 @@ RUN pip3 install scikit-build
RUN pip3 wheel --wheel-dir=/wheels \
opencv-python-headless \
# pinning due to issue in 1.19.5 https://github.com/numpy/numpy/issues/18131
numpy==1.19.4 \
numpy \
imutils \
scipy \
psutil \
@@ -35,7 +34,8 @@ RUN pip3 wheel --wheel-dir=/wheels \
matplotlib \
click \
setproctitle \
peewee
peewee \
gevent
FROM scratch

View File

@@ -2,32 +2,4 @@
This website is built using [Docusaurus 2](https://v2.docusaurus.io/), a modern static website generator.
## Installation
```console
yarn install
```
## Local Development
```console
yarn start
```
This command starts a local development server and open up a browser window. Most changes are reflected live without having to restart the server.
## Build
```console
yarn build
```
This command generates static content into the `build` directory and can be served using any static contents hosting service.
## Deployment
```console
GIT_USER=<Your GitHub username> USE_SSH=true yarn deploy
```
If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch.
For installation and contributing instructions, please follow the [Contributing Docs](https://blakeblackshear.github.io/frigate/contributing).

View File

@@ -41,8 +41,8 @@ Global object detection settings. These may also be defined at the camera level.
```yaml
detect:
# Optional: Number of frames without a detection before frigate considers an object to be gone. (default: double the frame rate)
max_disappeared: 10
# Optional: Number of frames without a detection before frigate considers an object to be gone. (default: 5x the frame rate)
max_disappeared: 25
```
### `logger`

View File

@@ -108,6 +108,10 @@ objects:
track:
- person
- car
# Optional: mask to prevent all object types from being detected in certain areas (default: no mask)
# Checks based on the bottom center of the bounding box of the object.
# NOTE: This mask is COMBINED with the object type specific mask below
mask: 0,0,1000,0,1000,200,0,200
filters:
person:
min_area: 5000
@@ -123,6 +127,8 @@ objects:
Frigate can save video clips without any CPU overhead for encoding by simply copying the stream directly with FFmpeg. It leverages FFmpeg's segment functionality to maintain a cache of video for each camera. The cache files are written to disk at `/tmp/cache` and do not introduce memory overhead. When an object is being tracked, it will extend the cache to ensure it can assemble a clip when the event ends. Once the event ends, it again uses FFmpeg to assemble a clip by combining the video clips without any encoding by the CPU. Assembled clips are are saved to `/media/frigate/clips`. Clips are retained according to the retention settings defined on the config for each object type.
These clips will not be playable in the web UI or in HomeAssistant's media browser unless your camera sends video as h264.
:::caution
Previous versions of frigate included `-vsync drop` in input parameters. This is not compatible with FFmpeg's segment feature and must be removed from your input parameters if you have overrides set.
:::
@@ -139,6 +145,8 @@ clips:
# Optional: Objects to save clips for. (default: all tracked objects)
objects:
- person
# Optional: Restrict clips to objects that entered any of the listed zones (default: no required zones)
required_zones: []
# Optional: Camera override for retention settings (default: global values)
retain:
# Required: Default retention days (default: shown below)
@@ -166,6 +174,8 @@ snapshots:
crop: False
# Optional: height to resize the snapshot to (default: original size)
height: 175
# Optional: Restrict snapshots to objects that entered any of the listed zones (default: no required zones)
required_zones: []
# Optional: Camera override for retention settings (default: global values)
retain:
# Required: Default retention days (default: shown below)
@@ -225,7 +235,6 @@ cameras:
hwaccel_args:
# Optional: stream specific input args (default: inherit)
input_args:
# Optional: camera specific global args (default: inherit)
global_args:
# Optional: camera specific hwaccel args (default: inherit)
@@ -276,8 +285,8 @@ cameras:
# Optional: enables detection for the camera (default: True)
# This value can be set via MQTT and will be updated in startup based on retained value
enabled: True
# Optional: Number of frames without a detection before frigate considers an object to be gone. (default: double the frame rate)
max_disappeared: 10
# Optional: Number of frames without a detection before frigate considers an object to be gone. (default: 5x the frame rate)
max_disappeared: 25
# Optional: save clips configuration
clips:
@@ -291,6 +300,8 @@ cameras:
# Optional: Objects to save clips for. (default: all tracked objects)
objects:
- person
# Optional: Restrict clips to objects that entered any of the listed zones (default: no required zones)
required_zones: []
# Optional: Camera override for retention settings (default: global values)
retain:
# Required: Default retention days (default: shown below)
@@ -324,6 +335,8 @@ cameras:
crop: False
# Optional: height to resize the snapshot to (default: original size)
height: 175
# Optional: Restrict snapshots to objects that entered any of the listed zones (default: no required zones)
required_zones: []
# Optional: Camera override for retention settings (default: global values)
retain:
# Required: Default retention days (default: shown below)
@@ -346,12 +359,18 @@ cameras:
crop: True
# Optional: height to resize the snapshot to (default: shown below)
height: 270
# Optional: Restrict mqtt messages to objects that entered any of the listed zones (default: no required zones)
required_zones: []
# Optional: Camera level object filters config.
objects:
track:
- person
- car
# Optional: mask to prevent all object types from being detected in certain areas (default: no mask)
# Checks based on the bottom center of the bounding box of the object.
# NOTE: This mask is COMBINED with the object type specific mask below
mask: 0,0,1000,0,1000,200,0,200
filters:
person:
min_area: 5000
@@ -386,6 +405,29 @@ ffmpeg:
- '1'
```
### Reolink 410/520 (possibly others)
Several users have reported success with the rtmp video from Reolink cameras.
```yaml
ffmpeg:
input_args:
- -avoid_negative_ts
- make_zero
- -fflags
- nobuffer
- -flags
- low_delay
- -strict
- experimental
- -fflags
- +genpts+discardcorrupt
- -rw_timeout
- '5000000'
- -use_wallclock_as_timestamps
- '1'
```
### Blue Iris RTSP Cameras
You will need to remove `nobuffer` flag for Blue Iris RTSP cameras

View File

@@ -3,7 +3,7 @@ id: index
title: Configuration
---
HassOS users can manage their configuration directly in the addon Configuration tab. For other installations, the default location for the config file is `/config/config.yml`. This can be overridden with the `CONFIG_FILE` environment variable. Camera specific ffmpeg parameters are documented [here](/configuration/cameras.md).
HassOS users can manage their configuration directly in the addon Configuration tab. For other installations, the default location for the config file is `/config/config.yml`. This can be overridden with the `CONFIG_FILE` environment variable. Camera specific ffmpeg parameters are documented [here](cameras.md).
It is recommended to start with a minimal configuration and add to it:
@@ -51,7 +51,7 @@ mqtt:
## `cameras`
Each of your cameras must be configured. The following is the minimum required to register a camera in Frigate. Check the [camera configuration page](cameras) for a complete list of options.
Each of your cameras must be configured. The following is the minimum required to register a camera in Frigate. Check the [camera configuration page](cameras.md) for a complete list of options.
```yaml
cameras:
@@ -80,7 +80,8 @@ clips:
max_seconds: 300
# Optional: size of tmpfs mount to create for cache files (default: not set)
# mount -t tmpfs -o size={tmpfs_cache_size} tmpfs /tmp/cache
# Notice: If you have mounted a tmpfs volume through docker, this value should not be set in your config
# NOTICE: Addon users must have Protection mode disabled for the addon when using this setting.
# Also, if you have mounted a tmpfs volume through docker, this value should not be set in your config.
tmpfs_cache_size: 256m
# Optional: Retention settings for clips (default: shown below)
retain:
@@ -96,7 +97,7 @@ clips:
```yaml
ffmpeg:
# Optional: global ffmpeg args (default: shown below)
global_args: -hide_banner -loglevel fatal
global_args: -hide_banner -loglevel warning
# Optional: global hwaccel args (default: shown below)
# NOTE: See hardware acceleration docs for your specific device
hwaccel_args: []

131
docs/docs/contributing.md Normal file
View File

@@ -0,0 +1,131 @@
---
id: contributing
title: Contributing
---
## Getting the source
### Core, Web, Docker, and Documentation
This repository holds the main Frigate application and all of its dependencies.
Fork [blakeblackshear/frigate](https://github.com/blakeblackshear/frigate.git) to your own GitHub profile, then clone the forked repo to your local machine.
From here, follow the guides for:
- [Core](#core)
- [Web Interface](#web-interface)
- [Documentation](#documentation)
### Frigate Home Assistant Addon
This repository holds the Home Assistant Addon, for use with Home Assistant OS and compatible installations. It is the piece that allows you to run Frigate from your Home Assistant Supervisor tab.
Fork [blakeblackshear/frigate-hass-addons](https://github.com/blakeblackshear/frigate-hass-addons) to your own Github profile, then clone the forked repo to your local machine.
### Frigate Home Assistant Integration
This repository holds the custom integration that allows your Home Assistant installation to automatically create entities for your Frigate instance, whether you run that with the [addon](#frigate-home-assistant-addon) or in a separate Docker instance.
Fork [blakeblackshear/frigate-hass-integration](https://github.com/blakeblackshear/frigate-hass-integration) to your own GitHub profile, then clone the forked repo to your local machine.
## Core
### Prerequisites
- [Frigate source code](#frigate-core-web-and-docs)
- GNU make
- Docker
## Web Interface
### Prerequisites
- [Frigate source code](#frigate-core-web-and-docs)
- All [core](#core) prerequisites _or_ another running Frigate instance locally available
- Node.js 14
### Making changes
#### 1. Set up a Frigate instance
The Web UI requires an instance of Frigate to interact with for all of its data. You can either run an instance locally (recommended) or attach to a separate instance accessible on your network.
To run the local instance, follow the [core](#core) development instructions.
If you won't be making any changes to the Frigate HTTP API, you can attach the web development server to any Frigate instance on your network. Skip this step and go to [3a](#3a-run-the-development-server-against-a-non-local-instance).
#### 2. Install dependencies
```console
cd web && npm install
```
#### 3. Run the development server
```console
cd web && npm run start
```
#### 3a. Run the development server against a non-local instance
To run the development server against a non-local instance, you will need to provide an environment variable, `SNOWPACK_PUBLIC_API_HOST` that tells the web application how to connect to the Frigate API:
```console
cd web && SNOWPACK_PUBLIC_API_HOST=http://<ip-address-to-your-frigate-instance>:5000 npm run start
```
#### 4. Making changes
The Web UI is built using [Snowpack](https://www.snowpack.dev/), [Preact](https://preactjs.com), and [Tailwind CSS](https://tailwindcss.com).
Light guidelines and advice:
- Avoid adding more dependencies. The web UI intends to be lightweight and fast to load.
- Do not make large sweeping changes. [Open a discussion on GitHub](https://github.com/blakeblackshear/frigate/discussions/new) for any large or architectural ideas.
- Ensure `lint` passes. This command will ensure basic conformance to styles, applying as many automatic fixes as possible, including Prettier formatting.
```console
npm run lint
```
- Add to unit tests and ensure they pass. As much as possible, you should strive to _increase_ test coverage whenever making changes. This will help ensure features do not accidentally become broken in the future.
```console
npm run test
```
- Test in different browsers. Firefox, Chrome, and Safari all have different quirks that make them unique targets to interact with.
## Documentation
### Prerequisites
- [Frigate source code](#frigate-core-web-and-docs)
- Node.js 14
### Making changes
#### 1. Installation
```console
npm run install
```
#### 2. Local Development
```console
npm run start
```
This command starts a local development server and open up a browser window. Most changes are reflected live without having to restart the server.
The docs are built using [Docusaurus v2](https://v2.docusaurus.io). Please refer to the Docusaurus docs for more information on how to modify Frigate's documentation.
#### 3. Build (optional)
```console
npm run build
```
This command generates static content into the `build` directory and can be served using any static contents hosting service.

View File

@@ -18,3 +18,12 @@ Cameras that output H.264 video and AAC audio will offer the most compatibility
| Raspberry Pi 3B (32bit) | 60ms | Can handle a small number of cameras, but the detection speeds are slow due to USB 2.0. |
| Raspberry Pi 4 (32bit) | 15-20ms | Can handle a small number of cameras. The 2GB version runs fine. |
| Raspberry Pi 4 (64bit) | 10-15ms | Can handle a small number of cameras. The 2GB version runs fine. |
## Unraid
Many people have powerful enough NAS devices or home servers to also run docker. There is a Unraid Community App.
To install make sure you have the [community app plugin here](https://forums.unraid.net/topic/38582-plug-in-community-applications/). Then search for "Frigate" in the apps section within Unraid - you can see the online store [here](https://unraid.net/community/apps?q=frigate#r)
| Name | Inference Speed | Notes |
| ----------------------- | --------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| [M2 Coral Edge TPU](http://coral.ai) | 6.2ms | Little complicated to get installed, as needs drivers on the host OS, [info here](https://forums.unraid.net/topic/98064-support-blakeblackshear-frigate/?do=findComment&comment=945776) |

View File

@@ -17,6 +17,7 @@ HassOS users can install via the addon repository. Frigate requires an MQTT serv
1. Add https://github.com/blakeblackshear/frigate-hass-addons
1. Setup your configuration in the `Configuration` tab
1. Start the addon container
1. If you are using hardware acceleration for ffmpeg, you will need to disable "Protection mode"
## Docker
@@ -32,19 +33,20 @@ Make sure you choose the right image for your architecture:
It is recommended to run with docker-compose:
```yaml
version: '3.6'
version: '3.9'
services:
frigate:
container_name: frigate
privileged: true # this may not be necessary for all setups
restart: unless-stopped
privileged: true
image: blakeblackshear/frigate:0.8.0-beta2-amd64
volumes:
image: blakeblackshear/frigate:<specify_version_tag>
devices:
- /dev/bus/usb:/dev/bus/usb
- /dev/dri/renderD128 # for intel hwaccel, needs to be updated for your hardware
volumes:
- /etc/localtime:/etc/localtime:ro
- <path_to_config>:/config
- <path_to_directory_for_clips>:/media/frigate/clips
- <path_to_directory_for_recordings>:/media/frigate/recordings
- <path_to_config_file>:/config/config.yml:ro
- <path_to_directory_for_media>:/media/frigate
- type: tmpfs # Optional: 1GB of memory, reduces SSD/SD Card wear
target: /tmp/cache
tmpfs:
@@ -59,30 +61,42 @@ services:
If you can't use docker compose, you can run the container with something similar to this:
```bash
docker run --rm \
--name frigate \
--privileged \
--mount type=tmpfs,target=/tmp/cache,tmpfs-size=1000000000 \
-v /dev/bus/usb:/dev/bus/usb \
-v <path_to_directory_for_clips>:/media/frigate/clips \
-v <path_to_directory_for_recordings>:/media/frigate/recordings \
-v <path_to_config>:/config:ro \
-v /etc/localtime:/etc/localtime:ro \
-e FRIGATE_RTSP_PASSWORD='password' \
-p 5000:5000 \
-p 1935:1935 \
blakeblackshear/frigate:0.8.0-beta2-amd64
docker run -d \
--name frigate \
--restart=unless-stopped \
--mount type=tmpfs,target=/tmp/cache,tmpfs-size=1000000000 \
--device /dev/bus/usb:/dev/bus/usb \
--device /dev/dri/renderD128
-v <path_to_directory_for_media>:/media/frigate \
-v <path_to_config_file>:/config/config.yml:ro \
-v /etc/localtime:/etc/localtime:ro \
-e FRIGATE_RTSP_PASSWORD='password' \
-p 5000:5000 \
-p 1935:1935 \
blakeblackshear/frigate:<specify_version_tag>
```
### Calculating shm-size
The default shm-size of 64m is fine for setups with 3 or less 1080p cameras. If frigate is exiting with "Bus error" messages, it could be because you have too many high resolution cameras and you need to specify a higher shm size.
You can calculate the necessary shm-size for each camera with the following formula:
```
(width * height * 1.5 * 7 + 270480)/1048576 = <shm size in mb>
```
The shm size cannot be set per container for HomeAssistant Addons. You must set `default-shm-size` in `/etc/docker/daemon.json` to increase the default shm size. This will increase the shm size for all of your docker containers. This may or may not cause issues with your setup. https://docs.docker.com/engine/reference/commandline/dockerd/#daemon-configuration-file
## Kubernetes
Use the [helm chart](https://github.com/k8s-at-home/charts/tree/master/charts/frigate).
Use the [helm chart](https://github.com/blakeblackshear/blakeshome-charts/tree/master/charts/frigate).
## Virtualization
For ideal performance, Frigate needs access to underlying hardware for the Coral and GPU devices for ffmpeg decoding. Running Frigate in a VM on top of Proxmox, ESXi, Virtualbox, etc. is not recommended. The virtualization layer typically introduces a sizable amount of overhead for communication with Coral devices.
## Proxmox
### Proxmox
Some people have had success running Frigate in LXC directly with the following config:
@@ -104,12 +118,6 @@ lxc.cgroup.devices.allow: a
lxc.cap.drop:
```
### Calculating shm-size
### ESX
For details on running Frigate under ESX, see details [here](https://github.com/blakeblackshear/frigate/issues/305).
The default shm-size of 64m is fine for setups with 3 or less 1080p cameras. If frigate is exiting with "Bus error" messages, it could be because you have too many high resolution cameras and you need to specify a higher shm size.
You can calculate the necessary shm-size for each camera with the following formula:
```
(width * height * 1.5 * 7 + 270480)/1048576 = <shm size in mb>
```

View File

@@ -1,26 +1,28 @@
---
id: troubleshooting
title: Troubleshooting
title: Troubleshooting and FAQ
---
### How can I get sound or audio in my clips and recordings?
By default, Frigate removes audio from clips and recordings to reduce the likelihood of failing for invalid data. If you would like to include audio, you need to override the output args to remove `-an` for where you want to include audio. The recommended audio codec is `aac`. Not all audio codecs are supported by RTMP, so you may need to re-encode your audio with `-c:a aac`. The default ffmpeg args are shown [here](/frigate/configuration/index#ffmpeg).
### My mjpeg stream or snapshots look green and crazy
This almost always means that the width/height defined for your camera are not correct. Double check the resolution with vlc or another player. Also make sure you don't have the width and height values backwards.
![mismatched-resolution](/img/mismatched-resolution.jpg)
## "[mov,mp4,m4a,3gp,3g2,mj2 @ 0x5639eeb6e140] moov atom not found"
### I have clips and snapshots in my clips folder, but I can't view them in the Web UI.
This is usually caused one of two things:
- The permissions on the parent folder don't have execute and nginx returns a 403 error you can see in the browser logs
- In this case, try mounting a volume to `/media/frigate` inside the container instead of `/media/frigate/clips`.
- Your cameras do not send h264 encoded video and the mp4 files are not playable in the browser
### "[mov,mp4,m4a,3gp,3g2,mj2 @ 0x5639eeb6e140] moov atom not found"
These messages in the logs are expected in certain situations. Frigate checks the integrity of the video cache before assembling clips. Occasionally these cached files will be invalid and cleaned up automatically.
## "ffmpeg didnt return a frame. something is wrong"
Turn on logging for the ffmpeg process by overriding the global_args and setting the log level to `info` (the default is `fatal`). Note that all ffmpeg logs show up in the Frigate logs as `ERROR` level. This does not mean they are actually errors.
```yaml
ffmpeg:
global_args: -hide_banner -loglevel info
```
## "On connect called"
### "On connect called"
If you see repeated "On connect called" messages in your config, check for another instance of frigate. This happens when multiple frigate containers are trying to connect to mqtt with the same client_id.

View File

@@ -118,7 +118,34 @@ Sample response:
"service": {
/* Uptime in seconds */
"uptime": 10,
"version": "0.8.0-8883709"
"version": "0.8.0-8883709",
/* Storage data in MB for important locations */
"storage": {
"/media/frigate/clips": {
"total": 1000,
"used": 700,
"free": 300,
"mnt_type": "ext4",
},
"/media/frigate/recordings": {
"total": 1000,
"used": 700,
"free": 300,
"mnt_type": "ext4",
},
"/tmp/cache": {
"total": 256,
"used": 100,
"free": 156,
"mnt_type": "tmpfs",
},
"/dev/shm": {
"total": 256,
"used": 100,
"free": 156,
"mnt_type": "tmpfs",
},
}
}
}
```
@@ -135,16 +162,17 @@ Version info
Events from the database. Accepts the following query string parameters:
| param | Type | Description |
| -------------- | ---- | --------------------------------------------- |
| `before` | int | Epoch time |
| `after` | int | Epoch time |
| `camera` | str | Camera name |
| `label` | str | Label name |
| `zone` | str | Zone name |
| `limit` | int | Limit the number of events returned |
| `has_snapshot` | int | Filter to events that have snapshots (0 or 1) |
| `has_clip` | int | Filter to events that have clips (0 or 1) |
| param | Type | Description |
| -------------------- | ---- | --------------------------------------------- |
| `before` | int | Epoch time |
| `after` | int | Epoch time |
| `camera` | str | Camera name |
| `label` | str | Label name |
| `zone` | str | Zone name |
| `limit` | int | Limit the number of events returned |
| `has_snapshot` | int | Filter to events that have snapshots (0 or 1) |
| `has_clip` | int | Filter to events that have clips (0 or 1) |
| `include_thumbnails` | int | Include thumbnails in the response (0 or 1) |
### `/api/events/summary`
@@ -159,16 +187,17 @@ Returns data for a single event.
Returns a thumbnail for the event id optimized for notifications. Works while the event is in progress and after completion. Passing `?format=android` will convert the thumbnail to 2:1 aspect ratio.
### `/api/events/<id>/snapshot.jpg`
Returns the snapshot image for the event id. Works while the event is in progress and after completion.
Accepts the following query string parameters, but they are only applied when an event is in progress. After the event is completed, the saved snapshot is returned from disk without modification:
|param|Type|Description|
|----|-----|--|
|`h`|int|Height in pixels|
|`bbox`|int|Show bounding boxes for detected objects (0 or 1)|
|`timestamp`|int|Print the timestamp in the upper left (0 or 1)|
|`crop`|int|Crop the snapshot to the (0 or 1)|
| param | Type | Description |
| ----------- | ---- | ------------------------------------------------- |
| `h` | int | Height in pixels |
| `bbox` | int | Show bounding boxes for detected objects (0 or 1) |
| `timestamp` | int | Print the timestamp in the upper left (0 or 1) |
| `crop` | int | Crop the snapshot to the (0 or 1) |
### `/clips/<camera>-<id>.mp4`

View File

@@ -28,8 +28,19 @@ The best way to integrate with HomeAssistant is to use the [official integration
### Notifications
Frigate publishes event information in the form of a change feed via MQTT. This allows lots of customization for notifications to meet your needs. Event changes are published with `before` and `after` information as shown [here](#frigateevents).
Note that some people may not want to expose frigate to the web, so you can leverage the HA API that frigate custom_integration ties into (which is exposed to the web, and thus can be used for mobile notifications etc):
Here is a simple example of a notification automation of events which will update the existing notification for each change. This means the image you see in the notification will update as frigate finds a "better" image.
To load an image taken by frigate from HomeAssistants API see below:
```
https://HA_URL/api/frigate/notifications/<event-id>/thumbnail.jpg
```
To load a video clip taken by frigate from HomeAssistants API :
```
https://HA_URL/api/frigate/notifications/<event-id>/<camera>/clip.mp4
```
Here is a simple example of a notification automation of events which will update the existing notification for each change. This means the image you see in the notification will update as frigate finds a "better" image.
```yaml
automation:
@@ -46,13 +57,14 @@ automation:
tag: '{{trigger.payload_json["after"]["id"]}}'
```
```yaml
automation:
- alias: When a person enters a zone named yard
trigger:
platform: mqtt
topic: frigate/events
conditions:
condition:
- "{{ trigger.payload_json['after']['label'] == 'person' }}"
- "{{ 'yard' in trigger.payload_json['after']['entered_zones'] }}"
action:
@@ -69,7 +81,7 @@ automation:
trigger:
platform: mqtt
topic: frigate/events
conditions:
condition:
- "{{ trigger.payload_json['after']['label'] == 'person' }}"
- "{{ 'yard' in trigger.payload_json['before']['current_zones'] }}"
- "{{ not 'yard' in trigger.payload_json['after']['current_zones'] }}"
@@ -87,7 +99,7 @@ automation:
trigger:
platform: mqtt
topic: frigate/events
conditions:
condition:
- "{{ trigger.payload_json['after']['label'] == 'dog' }}"
- "{{ trigger.payload_json['after']['camera'] == 'front' }}"
- "{{ trigger.payload_json['after']['top_score'] > 0.98 }}"

View File

@@ -10,5 +10,6 @@ module.exports = {
'configuration/advanced',
],
Usage: ['usage/home-assistant', 'usage/web', 'usage/api', 'usage/mqtt'],
Development: ['contributing'],
},
};

View File

@@ -8,6 +8,8 @@ import sys
import signal
import yaml
from gevent import pywsgi
from geventwebsocket.handler import WebSocketHandler
from peewee_migrate import Router
from playhouse.sqlite_ext import SqliteExtDatabase
from playhouse.sqliteq import SqliteQueueDatabase
@@ -106,8 +108,8 @@ class FrigateApp():
for log, level in self.config.logger.logs.items():
logging.getLogger(log).setLevel(level)
if not 'werkzeug' in self.config.logger.logs:
logging.getLogger('werkzeug').setLevel('ERROR')
if not 'geventwebsocket.handler' in self.config.logger.logs:
logging.getLogger('geventwebsocket.handler').setLevel('ERROR')
def init_queues(self):
# Queues for clip processing
@@ -135,7 +137,7 @@ class FrigateApp():
self.stats_tracking = stats_init(self.camera_metrics, self.detectors)
def init_web_server(self):
self.flask_app = create_app(self.config, self.db, self.stats_tracking, self.detected_frames_processor)
self.flask_app = create_app(self.config, self.db, self.stats_tracking, self.detected_frames_processor, self.mqtt_client)
def init_mqtt(self):
self.mqtt_client = create_mqtt_client(self.config, self.camera_metrics)
@@ -239,7 +241,9 @@ class FrigateApp():
signal.signal(signal.SIGTERM, receiveSignal)
self.flask_app.run(host='127.0.0.1', port=5001, debug=False)
server = pywsgi.WSGIServer(('127.0.0.1', 5001), self.flask_app, handler_class=WebSocketHandler)
server.serve_forever()
self.stop()
def stop(self):
@@ -252,6 +256,7 @@ class FrigateApp():
self.recording_maintainer.join()
self.stats_emitter.join()
self.frigate_watchdog.join()
self.db.stop()
for detector in self.detectors.values():
detector.stop()

View File

@@ -63,7 +63,7 @@ CLIPS_SCHEMA = vol.Schema(
}
)
FFMPEG_GLOBAL_ARGS_DEFAULT = ['-hide_banner','-loglevel','fatal']
FFMPEG_GLOBAL_ARGS_DEFAULT = ['-hide_banner','-loglevel','warning']
FFMPEG_INPUT_ARGS_DEFAULT = ['-avoid_negative_ts', 'make_zero',
'-fflags', '+genpts+discardcorrupt',
'-rtsp_transport', 'tcp',
@@ -131,6 +131,7 @@ def filters_for_all_tracked_objects(object_config):
OBJECTS_SCHEMA = vol.Schema(vol.All(filters_for_all_tracked_objects,
{
'track': [str],
'mask': vol.Any(str, [str]),
vol.Optional('filters', default = {}): FILTER_SCHEMA.extend(
{
str: {
@@ -164,6 +165,9 @@ CAMERA_FFMPEG_SCHEMA = vol.Schema(
'input_args': vol.Any(str, [str]),
}], vol.Msg(each_role_used_once, msg="Each input role may only be used once"),
vol.Msg(detect_is_required, msg="The detect role is required")),
'global_args': vol.Any(str, [str]),
'hwaccel_args': vol.Any(str, [str]),
'input_args': vol.Any(str, [str]),
'output_args': {
vol.Optional('detect', default=DETECT_FFMPEG_OUTPUT_ARGS_DEFAULT): vol.Any(str, [str]),
vol.Optional('record', default=RECORD_FFMPEG_OUTPUT_ARGS_DEFAULT): vol.Any(str, [str]),
@@ -198,6 +202,7 @@ CAMERAS_SCHEMA = vol.Schema(vol.All(
vol.Optional('enabled', default=False): bool,
vol.Optional('pre_capture', default=5): int,
vol.Optional('post_capture', default=5): int,
vol.Optional('required_zones', default=[]): [str],
'objects': [str],
vol.Optional('retain', default={}): RETAIN_SCHEMA,
},
@@ -213,6 +218,7 @@ CAMERAS_SCHEMA = vol.Schema(vol.All(
vol.Optional('timestamp', default=False): bool,
vol.Optional('bounding_box', default=False): bool,
vol.Optional('crop', default=False): bool,
vol.Optional('required_zones', default=[]): [str],
'height': int,
vol.Optional('retain', default={}): RETAIN_SCHEMA,
},
@@ -221,7 +227,8 @@ CAMERAS_SCHEMA = vol.Schema(vol.All(
vol.Optional('timestamp', default=True): bool,
vol.Optional('bounding_box', default=True): bool,
vol.Optional('crop', default=True): bool,
vol.Optional('height', default=270): int
vol.Optional('height', default=270): int,
vol.Optional('required_zones', default=[]): [str],
},
vol.Optional('objects', default={}): OBJECTS_SCHEMA,
vol.Optional('motion', default={}): MOTION_SCHEMA,
@@ -389,12 +396,12 @@ class MqttConfig():
}
class CameraInput():
def __init__(self, global_config, ffmpeg_input):
def __init__(self, camera_config, global_config, ffmpeg_input):
self._path = ffmpeg_input['path']
self._roles = ffmpeg_input['roles']
self._global_args = ffmpeg_input.get('global_args', global_config['global_args'])
self._hwaccel_args = ffmpeg_input.get('hwaccel_args', global_config['hwaccel_args'])
self._input_args = ffmpeg_input.get('input_args', global_config['input_args'])
self._global_args = ffmpeg_input.get('global_args', camera_config.get('global_args', global_config['global_args']))
self._hwaccel_args = ffmpeg_input.get('hwaccel_args', camera_config.get('hwaccel_args', global_config['hwaccel_args']))
self._input_args = ffmpeg_input.get('input_args', camera_config.get('input_args', global_config['input_args']))
@property
def path(self):
@@ -418,7 +425,7 @@ class CameraInput():
class CameraFfmpegConfig():
def __init__(self, global_config, config):
self._inputs = [CameraInput(global_config, i) for i in config['inputs']]
self._inputs = [CameraInput(config, global_config, i) for i in config['inputs']]
self._output_args = config.get('output_args', global_config['output_args'])
@property
@@ -506,12 +513,25 @@ class RecordConfig():
}
class FilterConfig():
def __init__(self, global_config, config, frame_shape=None):
def __init__(self, global_config, config, global_mask=None, frame_shape=None):
self._min_area = config.get('min_area', global_config.get('min_area', 0))
self._max_area = config.get('max_area', global_config.get('max_area', 24000000))
self._threshold = config.get('threshold', global_config.get('threshold', 0.7))
self._min_score = config.get('min_score', global_config.get('min_score', 0.5))
self._raw_mask = config.get('mask')
self._raw_mask = []
if global_mask:
if isinstance(global_mask, list):
self._raw_mask += global_mask
elif isinstance(global_mask, str):
self._raw_mask += [global_mask]
mask = config.get('mask')
if mask:
if isinstance(mask, list):
self._raw_mask += mask
elif isinstance(mask, str):
self._raw_mask += [mask]
self._mask = create_mask(frame_shape, self._raw_mask) if self._raw_mask else None
@property
@@ -546,7 +566,8 @@ class FilterConfig():
class ObjectConfig():
def __init__(self, global_config, config, frame_shape):
self._track = config.get('track', global_config.get('track', DEFAULT_TRACKED_OBJECTS))
self._filters = { name: FilterConfig(global_config.get('filters').get(name, {}), config.get('filters').get(name, {}), frame_shape) for name in self._track }
self._raw_mask = config.get('mask')
self._filters = { name: FilterConfig(global_config['filters'].get(name, {}), config['filters'].get(name, {}), self._raw_mask, frame_shape) for name in self._track }
@property
def track(self):
@@ -559,6 +580,7 @@ class ObjectConfig():
def to_dict(self):
return {
'track': self.track,
'mask': self._raw_mask,
'filters': { k: f.to_dict() for k, f in self.filters.items() }
}
@@ -570,6 +592,7 @@ class CameraSnapshotsConfig():
self._crop = config['crop']
self._height = config.get('height')
self._retain = RetainConfig(global_config['snapshots']['retain'], config['retain'])
self._required_zones = config['required_zones']
@property
def enabled(self):
@@ -594,6 +617,10 @@ class CameraSnapshotsConfig():
@property
def retain(self):
return self._retain
@property
def required_zones(self):
return self._required_zones
def to_dict(self):
return {
@@ -602,7 +629,8 @@ class CameraSnapshotsConfig():
'bounding_box': self.bounding_box,
'crop': self.crop,
'height': self.height,
'retain': self.retain.to_dict()
'retain': self.retain.to_dict(),
'required_zones': self.required_zones
}
class CameraMqttConfig():
@@ -612,6 +640,7 @@ class CameraMqttConfig():
self._bounding_box = config['bounding_box']
self._crop = config['crop']
self._height = config.get('height')
self._required_zones = config['required_zones']
@property
def enabled(self):
@@ -633,13 +662,18 @@ class CameraMqttConfig():
def height(self):
return self._height
@property
def required_zones(self):
return self._required_zones
def to_dict(self):
return {
'enabled': self.enabled,
'timestamp': self.timestamp,
'bounding_box': self.bounding_box,
'crop': self.crop,
'height': self.height
'height': self.height,
'required_zones': self.required_zones
}
class CameraClipsConfig():
@@ -649,6 +683,7 @@ class CameraClipsConfig():
self._post_capture = config['post_capture']
self._objects = config.get('objects')
self._retain = RetainConfig(global_config['clips']['retain'], config['retain'])
self._required_zones = config['required_zones']
@property
def enabled(self):
@@ -670,13 +705,18 @@ class CameraClipsConfig():
def retain(self):
return self._retain
@property
def required_zones(self):
return self._required_zones
def to_dict(self):
return {
'enabled': self.enabled,
'pre_capture': self.pre_capture,
'post_capture': self.post_capture,
'objects': self.objects,
'retain': self.retain.to_dict()
'retain': self.retain.to_dict(),
'required_zones': self.required_zones
}
class CameraRtmpConfig():
@@ -746,7 +786,7 @@ class MotionConfig():
class DetectConfig():
def __init__(self, global_config, config, camera_fps):
self._enabled = config['enabled']
self._max_disappeared = config.get('max_disappeared', global_config.get('max_disappeared', camera_fps*2))
self._max_disappeared = config.get('max_disappeared', global_config.get('max_disappeared', camera_fps*5))
@property
def enabled(self):

View File

@@ -10,6 +10,7 @@ from collections import defaultdict
from pathlib import Path
import psutil
import shutil
from frigate.config import FrigateConfig
from frigate.const import RECORD_DIR, CLIPS_DIR, CACHE_DIR
@@ -30,6 +31,18 @@ class EventProcessor(threading.Thread):
self.event_processed_queue = event_processed_queue
self.events_in_process = {}
self.stop_event = stop_event
def should_create_clip(self, camera, event_data):
if event_data['false_positive']:
return False
# if there are required zones and there is no overlap
required_zones = self.config.cameras[camera].clips.required_zones
if len(required_zones) > 0 and not set(event_data['entered_zones']) & set(required_zones):
logger.debug(f"Not creating clip for {event_data['id']} because it did not enter required zones")
return False
return True
def refresh_cache(self):
cached_files = os.listdir(CACHE_DIR)
@@ -97,6 +110,18 @@ class EventProcessor(threading.Thread):
del self.cached_clips[f]
logger.debug(f"Cleaning up cached file {f}")
os.remove(os.path.join(CACHE_DIR,f))
# if we are still using more than 90% of the cache, proactively cleanup
cache_usage = shutil.disk_usage("/tmp/cache")
if cache_usage.used/cache_usage.total > .9 and cache_usage.free < 200000000 and len(self.cached_clips) > 0:
logger.warning("More than 90% of the cache is used.")
logger.warning("Consider increasing space available at /tmp/cache or reducing max_seconds in your clips config.")
logger.warning("Proactively cleaning up the cache...")
while cache_usage.used/cache_usage.total > .9:
oldest_clip = min(self.cached_clips.values(), key=lambda x:x['start_time'])
del self.cached_clips[oldest_clip['path']]
os.remove(os.path.join(CACHE_DIR,oldest_clip['path']))
cache_usage = shutil.disk_usage("/tmp/cache")
def create_clip(self, camera, event_data, pre_capture, post_capture):
# get all clips from the camera with the event sorted
@@ -180,11 +205,12 @@ class EventProcessor(threading.Thread):
if event_type == 'end':
clips_config = self.config.cameras[camera].clips
if not event_data['false_positive']:
clip_created = False
clip_created = False
if self.should_create_clip(camera, event_data):
if clips_config.enabled and (clips_config.objects is None or event_data['label'] in clips_config.objects):
clip_created = self.create_clip(camera, event_data, clips_config.pre_capture, clips_config.post_capture)
if clip_created or event_data['has_snapshot']:
Event.create(
id=event_data['id'],
label=event_data['label'],
@@ -286,6 +312,38 @@ class EventCleanup(threading.Thread):
Event.label == l.label)
)
update_query.execute()
def purge_duplicates(self):
duplicate_query = """with grouped_events as (
select id,
label,
camera,
has_snapshot,
has_clip,
row_number() over (
partition by label, camera, round(start_time/5,0)*5
order by end_time-start_time desc
) as copy_number
from event
)
select distinct id, camera, has_snapshot, has_clip from grouped_events
where copy_number > 1;"""
duplicate_events = Event.raw(duplicate_query)
for event in duplicate_events:
logger.debug(f"Removing duplicate: {event.id}")
media_name = f"{event.camera}-{event.id}"
if event.has_snapshot:
media = Path(f"{os.path.join(CLIPS_DIR, media_name)}.jpg")
media.unlink(missing_ok=True)
if event.has_clip:
media = Path(f"{os.path.join(CLIPS_DIR, media_name)}.mp4")
media.unlink(missing_ok=True)
(Event.delete()
.where( Event.id << [event.id for event in duplicate_events] )
.execute())
def run(self):
counter = 0
@@ -294,15 +352,16 @@ class EventCleanup(threading.Thread):
logger.info(f"Exiting event cleanup...")
break
# only expire events every 10 minutes, but check for stop events every 10 seconds
# only expire events every 5 minutes, but check for stop events every 10 seconds
time.sleep(10)
counter = counter + 1
if counter < 60:
if counter < 30:
continue
counter = 0
self.expire('clips')
self.expire('snapshots')
self.purge_duplicates()
# drop events from db where has_clip and has_snapshot are false
delete_query = (

View File

@@ -1,14 +1,17 @@
import base64
import datetime
import json
import logging
import os
import time
from functools import reduce
import cv2
import gevent
import numpy as np
from flask import (Blueprint, Flask, Response, current_app, jsonify,
make_response, request)
from flask_sockets import Sockets
from peewee import SqliteDatabase, operator, fn, DoesNotExist
from playhouse.shortcuts import model_to_dict
@@ -21,9 +24,65 @@ from frigate.version import VERSION
logger = logging.getLogger(__name__)
bp = Blueprint('frigate', __name__)
ws = Blueprint('ws', __name__)
def create_app(frigate_config, database: SqliteDatabase, stats_tracking, detected_frames_processor):
class MqttBackend():
"""Interface for registering and updating WebSocket clients."""
def __init__(self, mqtt_client, topic_prefix):
self.clients = list()
self.mqtt_client = mqtt_client
self.topic_prefix = topic_prefix
def register(self, client):
"""Register a WebSocket connection for Mqtt updates."""
self.clients.append(client)
def publish(self, message):
try:
json_message = json.loads(message)
json_message = {
'topic': f"{self.topic_prefix}/{json_message['topic']}",
'payload': json_message['payload'],
'retain': json_message.get('retain', False)
}
except:
logger.warning("Unable to parse websocket message as valid json.")
return
logger.debug(f"Publishing mqtt message from websockets at {json_message['topic']}.")
self.mqtt_client.publish(json_message['topic'], json_message['payload'], retain=json_message['retain'])
def run(self):
def send(client, userdata, message):
"""Sends mqtt messages to clients."""
try:
logger.debug(f"Received mqtt message on {message.topic}.")
ws_message = json.dumps({
'topic': message.topic.replace(f"{self.topic_prefix}/",""),
'payload': message.payload.decode()
})
except:
# if the payload can't be decoded don't relay to clients
logger.debug(f"MQTT payload for {message.topic} wasn't text. Skipping...")
return
for client in self.clients:
try:
client.send(ws_message)
except:
logger.debug("Removing websocket client due to a closed connection.")
self.clients.remove(client)
self.mqtt_client.message_callback_add(f"{self.topic_prefix}/#", send)
def start(self):
"""Maintains mqtt subscription in the background."""
gevent.spawn(self.run)
def create_app(frigate_config, database: SqliteDatabase, stats_tracking, detected_frames_processor, mqtt_client):
app = Flask(__name__)
sockets = Sockets(app)
@app.before_request
def _db_connect():
@@ -39,6 +98,10 @@ def create_app(frigate_config, database: SqliteDatabase, stats_tracking, detecte
app.detected_frames_processor = detected_frames_processor
app.register_blueprint(bp)
sockets.register_blueprint(ws)
app.mqtt_backend = MqttBackend(mqtt_client, frigate_config.mqtt.topic_prefix)
app.mqtt_backend.start()
return app
@@ -55,7 +118,7 @@ def events_summary():
if not has_clip is None:
clauses.append((Event.has_clip == has_clip))
if not has_snapshot is None:
clauses.append((Event.has_snapshot == has_snapshot))
@@ -115,7 +178,7 @@ def event_thumbnail(id):
jpg_as_np = np.frombuffer(thumbnail_bytes, dtype=np.uint8)
img = cv2.imdecode(jpg_as_np, flags=1)
thumbnail = cv2.copyMakeBorder(img, 0, 0, int(img.shape[1]*0.5), int(img.shape[1]*0.5), cv2.BORDER_CONSTANT, (0,0,0))
ret, jpg = cv2.imencode('.jpg', thumbnail)
ret, jpg = cv2.imencode('.jpg', thumbnail, [int(cv2.IMWRITE_JPEG_QUALITY), 70])
thumbnail_bytes = jpg.tobytes()
response = make_response(thumbnail_bytes)
@@ -160,12 +223,14 @@ def events():
camera = request.args.get('camera')
label = request.args.get('label')
zone = request.args.get('zone')
after = request.args.get('after', type=int)
before = request.args.get('before', type=int)
after = request.args.get('after', type=float)
before = request.args.get('before', type=float)
has_clip = request.args.get('has_clip', type=int)
has_snapshot = request.args.get('has_snapshot', type=int)
include_thumbnails = request.args.get('include_thumbnails', default=1, type=int)
clauses = []
excluded_fields = []
if camera:
clauses.append((Event.camera == camera))
@@ -184,10 +249,13 @@ def events():
if not has_clip is None:
clauses.append((Event.has_clip == has_clip))
if not has_snapshot is None:
clauses.append((Event.has_snapshot == has_snapshot))
if not include_thumbnails:
excluded_fields.append(Event.thumbnail)
if len(clauses) == 0:
clauses.append((1 == 1))
@@ -196,7 +264,7 @@ def events():
.order_by(Event.start_time.desc())
.limit(limit))
return jsonify([model_to_dict(e) for e in events])
return jsonify([model_to_dict(e, exclude=excluded_fields) for e in events])
@bp.route('/config')
def config():
@@ -231,7 +299,7 @@ def best(camera_name, label):
width = int(height*best_frame.shape[1]/best_frame.shape[0])
best_frame = cv2.resize(best_frame, dsize=(width, height), interpolation=cv2.INTER_AREA)
ret, jpg = cv2.imencode('.jpg', best_frame)
ret, jpg = cv2.imencode('.jpg', best_frame, [int(cv2.IMWRITE_JPEG_QUALITY), 70])
response = make_response(jpg.tobytes())
response.headers['Content-Type'] = 'image/jpg'
return response
@@ -278,7 +346,7 @@ def latest_frame(camera_name):
frame = cv2.resize(frame, dsize=(width, height), interpolation=cv2.INTER_AREA)
ret, jpg = cv2.imencode('.jpg', frame)
ret, jpg = cv2.imencode('.jpg', frame, [int(cv2.IMWRITE_JPEG_QUALITY), 70])
response = make_response(jpg.tobytes())
response.headers['Content-Type'] = 'image/jpg'
return response
@@ -288,7 +356,7 @@ def latest_frame(camera_name):
def imagestream(detected_frames_processor, camera_name, fps, height, draw_options):
while True:
# max out at specified FPS
time.sleep(1/fps)
gevent.sleep(1/fps)
frame = detected_frames_processor.get_current_frame(camera_name, draw_options)
if frame is None:
frame = np.zeros((height,int(height*16/9),3), np.uint8)
@@ -296,6 +364,18 @@ def imagestream(detected_frames_processor, camera_name, fps, height, draw_option
width = int(height*frame.shape[1]/frame.shape[0])
frame = cv2.resize(frame, dsize=(width, height), interpolation=cv2.INTER_LINEAR)
ret, jpg = cv2.imencode('.jpg', frame)
ret, jpg = cv2.imencode('.jpg', frame, [int(cv2.IMWRITE_JPEG_QUALITY), 70])
yield (b'--frame\r\n'
b'Content-Type: image/jpeg\r\n\r\n' + jpg.tobytes() + b'\r\n\r\n')
@ws.route('/ws')
def echo_socket(socket):
current_app.mqtt_backend.register(socket)
while not socket.closed:
# Sleep to prevent *constant* context-switches.
gevent.sleep(0.1)
message = socket.receive()
if message:
current_app.mqtt_backend.publish(message)

View File

@@ -7,6 +7,7 @@ import queue
import multiprocessing as mp
from logging import handlers
from setproctitle import setproctitle
from collections import deque
def listener_configurer():
@@ -54,6 +55,7 @@ class LogPipe(threading.Thread):
self.daemon = False
self.logger = logging.getLogger(log_name)
self.level = level
self.deque = deque(maxlen=100)
self.fdRead, self.fdWrite = os.pipe()
self.pipeReader = os.fdopen(self.fdRead)
self.start()
@@ -67,9 +69,13 @@ class LogPipe(threading.Thread):
"""Run the thread, logging everything.
"""
for line in iter(self.pipeReader.readline, ''):
self.logger.log(self.level, line.strip('\n'))
self.deque.append(line.strip('\n'))
self.pipeReader.close()
def dump(self):
while len(self.deque) > 0:
self.logger.log(self.level, self.deque.popleft())
def close(self):
"""Close the write end of the pipe.

View File

@@ -91,6 +91,7 @@ def create_mqtt_client(config: FrigateConfig, camera_metrics):
logger.error("Unable to connect to MQTT: Connection refused. Error code: " + str(rc))
logger.info("MQTT connected")
client.subscribe(f"{mqtt_config.topic_prefix}/#")
client.publish(mqtt_config.topic_prefix+'/available', 'online', retain=True)
client = mqtt.Client(client_id=mqtt_config.client_id)
@@ -118,8 +119,6 @@ def create_mqtt_client(config: FrigateConfig, camera_metrics):
client.publish(f"{mqtt_config.topic_prefix}/{name}/snapshots/state", 'ON' if config.cameras[name].snapshots.enabled else 'OFF', retain=True)
client.publish(f"{mqtt_config.topic_prefix}/{name}/detect/state", 'ON' if config.cameras[name].detect.enabled else 'OFF', retain=True)
client.subscribe(f"{mqtt_config.topic_prefix}/+/clips/set")
client.subscribe(f"{mqtt_config.topic_prefix}/+/snapshots/set")
client.subscribe(f"{mqtt_config.topic_prefix}/+/detect/set")
client.subscribe(f"{mqtt_config.topic_prefix}/#")
return client

View File

@@ -217,7 +217,7 @@ class TrackedObject():
cv2.putText(best_frame, time_to_show, (5, best_frame.shape[0]-7), cv2.FONT_HERSHEY_SIMPLEX,
fontScale=font_scale, color=(255, 255, 255), thickness=2)
ret, jpg = cv2.imencode('.jpg', best_frame)
ret, jpg = cv2.imencode('.jpg', best_frame, [int(cv2.IMWRITE_JPEG_QUALITY), 70])
if ret:
return jpg.tobytes()
else:
@@ -454,28 +454,35 @@ class TrackedObjectProcessor(threading.Thread):
message = { 'before': obj.previous, 'after': obj.to_dict(), 'type': 'end' }
self.client.publish(f"{self.topic_prefix}/events", json.dumps(message), retain=False)
# write snapshot to disk if enabled
if snapshot_config.enabled:
if snapshot_config.enabled and self.should_save_snapshot(camera, obj):
jpg_bytes = obj.get_jpg_bytes(
timestamp=snapshot_config.timestamp,
bounding_box=snapshot_config.bounding_box,
crop=snapshot_config.crop,
height=snapshot_config.height
)
with open(os.path.join(CLIPS_DIR, f"{camera}-{obj.obj_data['id']}.jpg"), 'wb') as j:
j.write(jpg_bytes)
event_data['has_snapshot'] = True
if jpg_bytes is None:
logger.warning(f"Unable to save snapshot for {obj.obj_data['id']}.")
else:
with open(os.path.join(CLIPS_DIR, f"{camera}-{obj.obj_data['id']}.jpg"), 'wb') as j:
j.write(jpg_bytes)
event_data['has_snapshot'] = True
self.event_queue.put(('end', camera, event_data))
def snapshot(camera, obj: TrackedObject, current_frame_time):
mqtt_config = self.config.cameras[camera].mqtt
if mqtt_config.enabled:
if mqtt_config.enabled and self.should_mqtt_snapshot(camera, obj):
jpg_bytes = obj.get_jpg_bytes(
timestamp=mqtt_config.timestamp,
bounding_box=mqtt_config.bounding_box,
crop=mqtt_config.crop,
height=mqtt_config.height
)
self.client.publish(f"{self.topic_prefix}/{camera}/{obj.obj_data['label']}/snapshot", jpg_bytes, retain=True)
if jpg_bytes is None:
logger.warning(f"Unable to send mqtt snapshot for {obj.obj_data['id']}.")
else:
self.client.publish(f"{self.topic_prefix}/{camera}/{obj.obj_data['label']}/snapshot", jpg_bytes, retain=True)
def object_status(camera, object_name, status):
self.client.publish(f"{self.topic_prefix}/{camera}/{object_name}", status, retain=False)
@@ -499,6 +506,24 @@ class TrackedObjectProcessor(threading.Thread):
# }
self.zone_data = defaultdict(lambda: defaultdict(lambda: {}))
def should_save_snapshot(self, camera, obj: TrackedObject):
# if there are required zones and there is no overlap
required_zones = self.config.cameras[camera].snapshots.required_zones
if len(required_zones) > 0 and not obj.entered_zones & set(required_zones):
logger.debug(f"Not creating snapshot for {obj.obj_data['id']} because it did not enter required zones")
return False
return True
def should_mqtt_snapshot(self, camera, obj: TrackedObject):
# if there are required zones and there is no overlap
required_zones = self.config.cameras[camera].mqtt.required_zones
if len(required_zones) > 0 and not obj.entered_zones & set(required_zones):
logger.debug(f"Not sending mqtt for {obj.obj_data['id']} because it did not enter required zones")
return False
return True
def get_best(self, camera, label):
# TODO: need a lock here
camera_state = self.camera_states[camera]

View File

@@ -2,8 +2,11 @@ import json
import logging
import threading
import time
import psutil
import shutil
from frigate.config import FrigateConfig
from frigate.const import RECORD_DIR, CLIPS_DIR, CACHE_DIR
from frigate.version import VERSION
logger = logging.getLogger(__name__)
@@ -16,6 +19,15 @@ def stats_init(camera_metrics, detectors):
}
return stats_tracking
def get_fs_type(path):
bestMatch = ""
fsType = ""
for part in psutil.disk_partitions(all=True):
if path.startswith(part.mountpoint) and len(bestMatch) < len(part.mountpoint):
fsType = part.fstype
bestMatch = part.mountpoint
return fsType
def stats_snapshot(stats_tracking):
camera_metrics = stats_tracking['camera_metrics']
stats = {}
@@ -44,9 +56,19 @@ def stats_snapshot(stats_tracking):
stats['service'] = {
'uptime': (int(time.time()) - stats_tracking['started']),
'version': VERSION
'version': VERSION,
'storage': {}
}
for path in [RECORD_DIR, CLIPS_DIR, CACHE_DIR, "/dev/shm"]:
storage_stats = shutil.disk_usage(path)
stats['service']['storage'][path] = {
'total': round(storage_stats.total/1000000, 1),
'used': round(storage_stats.used/1000000, 1),
'free': round(storage_stats.free/1000000, 1),
'mount_type': get_fs_type(path)
}
return stats
class StatsEmitter(threading.Thread):

View File

@@ -160,7 +160,40 @@ class TestConfig(TestCase):
assert('dog' in frigate_config.cameras['back'].objects.filters)
assert(frigate_config.cameras['back'].objects.filters['dog'].threshold == 0.7)
def test_ffmpeg_params(self):
def test_global_object_mask(self):
config = {
'mqtt': {
'host': 'mqtt'
},
'objects': {
'track': ['person', 'dog']
},
'cameras': {
'back': {
'ffmpeg': {
'inputs': [
{ 'path': 'rtsp://10.0.0.1:554/video', 'roles': ['detect'] }
]
},
'height': 1080,
'width': 1920,
'objects': {
'mask': '0,0,1,1,0,1',
'filters': {
'dog': {
'mask': '1,1,1,1,1,1'
}
}
}
}
}
}
frigate_config = FrigateConfig(config=config)
assert('dog' in frigate_config.cameras['back'].objects.filters)
assert(len(frigate_config.cameras['back'].objects.filters['dog']._raw_mask) == 2)
assert(len(frigate_config.cameras['back'].objects.filters['person']._raw_mask) == 1)
def test_ffmpeg_params_global(self):
config = {
'ffmpeg': {
'input_args': ['-re']
@@ -190,6 +223,64 @@ class TestConfig(TestCase):
}
frigate_config = FrigateConfig(config=config)
assert('-re' in frigate_config.cameras['back'].ffmpeg_cmds[0]['cmd'])
def test_ffmpeg_params_camera(self):
config = {
'mqtt': {
'host': 'mqtt'
},
'cameras': {
'back': {
'ffmpeg': {
'inputs': [
{ 'path': 'rtsp://10.0.0.1:554/video', 'roles': ['detect'] }
],
'input_args': ['-re']
},
'height': 1080,
'width': 1920,
'objects': {
'track': ['person', 'dog'],
'filters': {
'dog': {
'threshold': 0.7
}
}
}
}
}
}
frigate_config = FrigateConfig(config=config)
assert('-re' in frigate_config.cameras['back'].ffmpeg_cmds[0]['cmd'])
def test_ffmpeg_params_input(self):
config = {
'mqtt': {
'host': 'mqtt'
},
'cameras': {
'back': {
'ffmpeg': {
'inputs': [
{ 'path': 'rtsp://10.0.0.1:554/video', 'roles': ['detect'], 'input_args': ['-re'] }
]
},
'height': 1080,
'width': 1920,
'objects': {
'track': ['person', 'dog'],
'filters': {
'dog': {
'threshold': 0.7
}
}
}
}
}
}
frigate_config = FrigateConfig(config=config)
assert('-re' in frigate_config.cameras['back'].ffmpeg_cmds[0]['cmd'])
def test_inherit_clips_retention(self):
config = {

View File

@@ -181,6 +181,7 @@ class CameraWatchdog(threading.Thread):
now = datetime.datetime.now().timestamp()
if not self.capture_thread.is_alive():
self.logpipe.dump()
self.start_ffmpeg_detect()
elif now - self.capture_thread.current_frame.value > 20:
self.logger.info(f"No frames received from {self.camera_name} in 20 seconds. Exiting ffmpeg...")
@@ -197,6 +198,7 @@ class CameraWatchdog(threading.Thread):
poll = p['process'].poll()
if poll == None:
continue
p['logpipe'].dump()
p['process'] = start_or_restart_ffmpeg(p['cmd'], self.logger, p['logpipe'], ffmpeg_process=p['process'])
# wait a bit before checking again
@@ -279,6 +281,13 @@ def reduce_boxes(boxes):
reduced_boxes = cv2.groupRectangles([list(b) for b in itertools.chain(boxes, boxes)], 1, 0.2)[0]
return [tuple(b) for b in reduced_boxes]
# modified from https://stackoverflow.com/a/40795835
def intersects_any(box_a, boxes):
for box in boxes:
if box_a[2] < box[0] or box_a[0] > box[2] or box_a[1] > box[3] or box_a[3] < box[1]:
continue
return True
def detect(object_detector, frame, model_shape, region, objects_to_track, object_filters):
tensor_input = create_tensor_input(frame, model_shape, region)
@@ -348,7 +357,8 @@ def process_frames(camera_name: str, frame_queue: mp.Queue, frame_shape, model_s
# look for motion
motion_boxes = motion_detector.detect(frame)
tracked_object_boxes = [obj['box'] for obj in object_tracker.tracked_objects.values()]
# only get the tracked object boxes that intersect with motion
tracked_object_boxes = [obj['box'] for obj in object_tracker.tracked_objects.values() if intersects_any(obj['box'], motion_boxes)]
# combine motion boxes with known locations of existing objects
combined_boxes = reduce_boxes(motion_boxes + tracked_object_boxes)
@@ -414,8 +424,12 @@ def process_frames(camera_name: str, frame_queue: mp.Queue, frame_shape, model_s
if refining:
refine_count += 1
# Limit to the detections overlapping with motion areas
# to avoid picking up stationary background objects
detections_with_motion = [d for d in detections if intersects_any(d[2], motion_boxes)]
# now that we have refined our detections, we need to track objects
object_tracker.match_and_update(frame_time, detections)
object_tracker.match_and_update(frame_time, detections_with_motion)
# add to the queue if not full
if(detected_objects_queue.full()):

View File

@@ -31,8 +31,8 @@ class FrigateWatchdog(threading.Thread):
detection_start = detector.detection_start.value
if (detection_start > 0.0 and
now - detection_start > 10):
logger.info("Detection appears to be stuck. Restarting detection process")
logger.info("Detection appears to be stuck. Restarting detection process...")
detector.start_or_restart()
elif not detector.detect_process.is_alive():
logger.info("Detection appears to have stopped. Restarting frigate")
logger.info("Detection appears to have stopped. Exiting frigate...")
os.kill(os.getpid(), signal.SIGTERM)

View File

@@ -23,6 +23,12 @@ http {
keepalive_timeout 65;
gzip on;
gzip_comp_level 6;
gzip_types text/plain text/css application/json application/x-javascript application/javascript text/javascript image/svg+xml image/x-icon image/bmp image/png image/gif image/jpeg image/jpg;
gzip_proxied no-cache no-store private expired auth;
gzip_vary on;
upstream frigate_api {
server localhost:5001;
keepalive 1024;
@@ -96,8 +102,17 @@ http {
root /media/frigate;
}
location /ws {
proxy_pass http://frigate_api/ws;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
}
location /api/ {
add_header 'Access-Control-Allow-Origin' '*';
add_header Cache-Control "no-store";
proxy_pass http://frigate_api/;
proxy_pass_request_headers on;
proxy_set_header Host $host;
@@ -105,13 +120,24 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
add_header Cache-Control "no-cache";
location ~* \.(?:js|css|svg|ico|png)$ {
access_log off;
expires 1y;
add_header Cache-Control "public";
}
sub_filter 'href="/' 'href="$http_x_ingress_path/';
sub_filter 'url(/' 'url($http_x_ingress_path/';
sub_filter '"/dist/' '"$http_x_ingress_path/dist/';
sub_filter '"/js/' '"$http_x_ingress_path/js/';
sub_filter '<body>' '<body><script>window.baseUrl="$http_x_ingress_path";</script>';
sub_filter_types text/css application/javascript;
sub_filter_once off;
root /opt/frigate/web;
try_files $uri $uri/ /index.html;
}

2
web/.eslintignore Normal file
View File

@@ -0,0 +1,2 @@
build/*
node_modules/*

140
web/.eslintrc.js Normal file
View File

@@ -0,0 +1,140 @@
module.exports = {
parser: '@babel/eslint-parser',
parserOptions: {
sourceType: 'module',
ecmaFeatures: {
experimentalObjectRestSpread: true,
jsx: true,
},
},
extends: [
'prettier',
'preact',
'plugin:import/react',
'plugin:testing-library/recommended',
'plugin:jest/recommended',
],
plugins: ['import', 'testing-library', 'jest'],
env: {
es6: true,
node: true,
browser: true,
},
rules: {
'constructor-super': 'error',
'default-case': ['error', { commentPattern: '^no default$' }],
'handle-callback-err': ['error', '^(err|error)$'],
'new-cap': ['error', { newIsCap: true, capIsNew: false }],
'no-alert': 'error',
'no-array-constructor': 'error',
'no-caller': 'error',
'no-case-declarations': 'error',
'no-class-assign': 'error',
'no-cond-assign': 'error',
'no-console': 'error',
'no-const-assign': 'error',
'no-control-regex': 'error',
'no-debugger': 'error',
'no-delete-var': 'error',
'no-dupe-args': 'error',
'no-dupe-class-members': 'error',
'no-dupe-keys': 'error',
'no-duplicate-case': 'error',
'no-duplicate-imports': 'error',
'no-empty-character-class': 'error',
'no-empty-pattern': 'error',
'no-eval': 'error',
'no-ex-assign': 'error',
'no-extend-native': 'error',
'no-extra-bind': 'error',
'no-extra-boolean-cast': 'error',
'no-fallthrough': 'error',
'no-floating-decimal': 'error',
'no-func-assign': 'error',
'no-implied-eval': 'error',
'no-inner-declarations': ['error', 'functions'],
'no-invalid-regexp': 'error',
'no-irregular-whitespace': 'error',
'no-iterator': 'error',
'no-label-var': 'error',
'no-labels': ['error', { allowLoop: false, allowSwitch: false }],
'no-lone-blocks': 'error',
'no-loop-func': 'error',
'no-multi-str': 'error',
'no-native-reassign': 'error',
'no-negated-in-lhs': 'error',
'no-new': 'error',
'no-new-func': 'error',
'no-new-object': 'error',
'no-new-require': 'error',
'no-new-symbol': 'error',
'no-new-wrappers': 'error',
'no-obj-calls': 'error',
'no-octal': 'error',
'no-octal-escape': 'error',
'no-path-concat': 'error',
'no-proto': 'error',
'no-redeclare': 'error',
'no-regex-spaces': 'error',
'no-return-assign': ['error', 'except-parens'],
'no-script-url': 'error',
'no-self-assign': 'error',
'no-self-compare': 'error',
'no-sequences': 'error',
'no-shadow-restricted-names': 'error',
'no-sparse-arrays': 'error',
'no-this-before-super': 'error',
'no-throw-literal': 'error',
'no-trailing-spaces': 'error',
'no-undef': 'error',
'no-undef-init': 'error',
'no-unexpected-multiline': 'error',
'no-unmodified-loop-condition': 'error',
'no-unneeded-ternary': ['error', { defaultAssignment: false }],
'no-unreachable': 'error',
'no-unsafe-finally': 'error',
'no-unused-vars': ['error', { vars: 'all', args: 'none', ignoreRestSiblings: true }],
'no-useless-call': 'error',
'no-useless-computed-key': 'error',
'no-useless-concat': 'error',
'no-useless-constructor': 'error',
'no-useless-escape': 'error',
'no-var': 'error',
'no-with': 'error',
'prefer-const': 'error',
'prefer-rest-params': 'error',
'use-isnan': 'error',
'valid-typeof': 'error',
camelcase: 'off',
eqeqeq: ['error', 'allow-null'],
indent: ['error', 2, { SwitchCase: 1 }],
quotes: ['error', 'single', 'avoid-escape'],
radix: 'error',
yoda: ['error', 'never'],
'import/no-unresolved': 'error',
'react-hooks/exhaustive-deps': 'error',
'jest/consistent-test-it': ['error', { fn: 'test' }],
'jest/no-test-prefixes': 'error',
'jest/no-restricted-matchers': [
'error',
{ toMatchSnapshot: 'Use `toMatchInlineSnapshot()` and ensure you only snapshot very small elements' },
],
'jest/valid-describe': 'error',
'jest/valid-expect-in-promise': 'error',
},
settings: {
'import/resolver': {
node: {
extensions: ['.js', '.jsx'],
},
},
},
};

View File

@@ -1,8 +1,3 @@
# Frigate Web UI
## Development
1. Build the docker images in the root of the repository `make amd64_all` (or appropriate for your system)
2. Create a config file in `config/`
3. Run the container: `docker run --rm --name frigate --privileged -v $PWD/config:/config:ro -v /etc/localtime:/etc/localtime:ro -p 5000:5000 frigate`
4. Run the dev ui: `cd web && npm run start`
For installation and contributing instructions, please follow the [Contributing Docs](https://blakeblackshear.github.io/frigate/contributing).

4
web/babel.config.js Normal file
View File

@@ -0,0 +1,4 @@
module.exports = {
presets: ['@babel/preset-env'],
plugins: [['@babel/plugin-transform-react-jsx', { pragma: 'h' }]],
};

18
web/config/setupTests.js Normal file
View File

@@ -0,0 +1,18 @@
import 'regenerator-runtime/runtime';
import '@testing-library/jest-dom/extend-expect';
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query) => ({
matches: false,
media: query,
onchange: null,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
}),
});
window.fetch = () => Promise.resolve();
jest.mock('../src/env');

9
web/jest.config.js Normal file
View File

@@ -0,0 +1,9 @@
module.exports = {
moduleFileExtensions: ['js', 'jsx'],
name: 'react-component-benchmark',
resetMocks: true,
roots: ['<rootDir>'],
setupFilesAfterEnv: ['<rootDir>/config/setupTests.js'],
testEnvironment: 'jsdom',
timers: 'fake',
};

12294
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,22 +3,44 @@
"private": true,
"scripts": {
"start": "cross-env SNOWPACK_PUBLIC_API_HOST=http://localhost:5000 snowpack dev",
"start:custom": "snowpack dev",
"prebuild": "rimraf build",
"build": "snowpack build"
"build": "cross-env NODE_ENV=production SNOWPACK_MODE=production SNOWPACK_PUBLIC_API_HOST='' snowpack build",
"lint": "npm run lint:cmd -- --fix",
"lint:cmd": "eslint ./ --ext .jsx,.js",
"test": "jest"
},
"dependencies": {
"idb-keyval": "^5.0.2",
"immer": "^8.0.1",
"preact": "^10.5.9",
"preact-async-route": "^2.2.1",
"preact-router": "^3.2.1"
},
"devDependencies": {
"@babel/eslint-parser": "^7.12.13",
"@babel/plugin-transform-react-jsx": "^7.12.13",
"@babel/preset-env": "^7.12.13",
"@prefresh/snowpack": "^3.0.1",
"@snowpack/plugin-optimize": "^0.2.13",
"@snowpack/plugin-postcss": "^1.1.0",
"@snowpack/plugin-webpack": "^2.3.0",
"@testing-library/jest-dom": "^5.11.9",
"@testing-library/preact": "^2.0.1",
"@testing-library/user-event": "^12.7.1",
"autoprefixer": "^10.2.1",
"cross-env": "^7.0.3",
"eslint": "^7.19.0",
"eslint-config-preact": "^1.1.3",
"eslint-config-prettier": "^7.2.0",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-jest": "^24.1.3",
"eslint-plugin-testing-library": "^3.10.1",
"jest": "^26.6.3",
"postcss": "^8.2.2",
"postcss-cli": "^8.3.1",
"preact": "^10.5.9",
"preact-router": "^3.2.1",
"prettier": "^2.2.1",
"rimraf": "^3.0.2",
"snowpack": "^3.0.0",
"snowpack": "^3.0.11",
"snowpack-plugin-hash": "^0.14.2",
"tailwindcss": "^2.0.2"
}
}

View File

@@ -1,8 +1,3 @@
'use strict';
module.exports = {
plugins: [
require('tailwindcss'),
require('autoprefixer'),
],
plugins: [require('tailwindcss'), require('autoprefixer')],
};

5
web/prettier.config.js Normal file
View File

@@ -0,0 +1,5 @@
module.exports = {
printWidth: 120,
singleQuote: true,
useTabs: false,
};

View File

@@ -14,7 +14,9 @@
<meta name="theme-color" content="#ff0000" />
</head>
<body>
<div id="root"></div>
<div id="root" class="z-0"></div>
<div id="menus" class="z-0"></div>
<div id="tooltips" class="z-0"></div>
<noscript>You need to enable JavaScript to run this app.</noscript>
<script type="module" src="/dist/index.js"></script>
</body>

View File

@@ -1,31 +1,19 @@
'use strict';
module.exports = {
mount: {
public: { url: '/', static: true },
src: { url: '/dist' },
},
plugins: [
'@snowpack/plugin-postcss',
'@prefresh/snowpack',
[
'@snowpack/plugin-optimize',
{
preloadModules: true,
},
],
[
'@snowpack/plugin-webpack',
{
sourceMap: true,
},
],
],
plugins: ['@snowpack/plugin-postcss', '@prefresh/snowpack', 'snowpack-plugin-hash'],
routes: [{ match: 'routes', src: '.*', dest: '/index.html' }],
optimize: {
bundle: false,
minify: true,
treeshake: true,
},
packageOptions: {
sourcemap: false,
},
buildOptions: {
sourcemap: true,
sourcemap: false,
},
};

View File

@@ -1,42 +1,43 @@
import * as Routes from './routes';
import { h } from 'preact';
import Camera from './Camera';
import CameraMap from './CameraMap';
import Cameras from './Cameras';
import Debug from './Debug';
import Event from './Event';
import Events from './Events';
import ActivityIndicator from './components/ActivityIndicator';
import AsyncRoute from 'preact-async-route';
import AppBar from './AppBar';
import Cameras from './routes/Cameras';
import { Router } from 'preact-router';
import Sidebar from './Sidebar';
import { ApiHost, Config } from './context';
import { useContext, useEffect, useState } from 'preact/hooks';
import { DarkModeProvider, DrawerProvider } from './context';
import { FetchStatus, useConfig } from './api';
export default function App() {
const apiHost = useContext(ApiHost);
const [config, setConfig] = useState(null);
useEffect(async () => {
const response = await fetch(`${apiHost}/api/config`);
const data = response.ok ? await response.json() : {};
setConfig(data);
}, []);
return !config ? (
<div />
) : (
<Config.Provider value={config}>
<div className="md:flex flex-col md:flex-row md:min-h-screen w-full bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-white">
<Sidebar />
<div className="flex-auto p-4 lg:pl-8 lg:pr-8 min-w-0">
<Router>
<CameraMap path="/cameras/:camera/editor" />
<Camera path="/cameras/:camera" />
<Event path="/events/:eventId" />
<Events path="/events" />
<Debug path="/debug" />
<Cameras default path="/" />
</Router>
const { status } = useConfig();
return (
<DarkModeProvider>
<DrawerProvider>
<div data-testid="app" className="w-full">
<AppBar />
{status !== FetchStatus.LOADED ? (
<div className="flex flex-grow-1 min-h-screen justify-center items-center">
<ActivityIndicator />
</div>
) : (
<div className="flex flex-row min-h-screen w-full bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
<Sidebar />
<div className="w-full flex-auto p-2 mt-24 px-4 min-w-0">
<Router>
<AsyncRoute path="/cameras/:camera/editor" getComponent={Routes.getCameraMap} />
<AsyncRoute path="/cameras/:camera" getComponent={Routes.getCamera} />
<AsyncRoute path="/events/:eventId" getComponent={Routes.getEvent} />
<AsyncRoute path="/events" getComponent={Routes.getEvents} />
<AsyncRoute path="/debug" getComponent={Routes.getDebug} />
<AsyncRoute path="/styleguide" getComponent={Routes.getStyleGuide} />
<Cameras default path="/" />
</Router>
</div>
</div>
)}
</div>
</div>
</Config.Provider>
</DrawerProvider>
</DarkModeProvider>
);
}

46
web/src/AppBar.jsx Normal file
View File

@@ -0,0 +1,46 @@
import { h, Fragment } from 'preact';
import BaseAppBar from './components/AppBar';
import LinkedLogo from './components/LinkedLogo';
import Menu, { MenuItem, MenuSeparator } from './components/Menu';
import AutoAwesomeIcon from './icons/AutoAwesome';
import LightModeIcon from './icons/LightMode';
import DarkModeIcon from './icons/DarkMode';
import { useDarkMode } from './context';
import { useCallback, useRef, useState } from 'preact/hooks';
export default function AppBar() {
const [showMoreMenu, setShowMoreMenu] = useState(false);
const { setDarkMode } = useDarkMode();
const handleSelectDarkMode = useCallback(
(value, label) => {
setDarkMode(value);
setShowMoreMenu(false);
},
[setDarkMode, setShowMoreMenu]
);
const moreRef = useRef(null);
const handleShowMenu = useCallback(() => {
setShowMoreMenu(true);
}, [setShowMoreMenu]);
const handleDismissMoreMenu = useCallback(() => {
setShowMoreMenu(false);
}, [setShowMoreMenu]);
return (
<Fragment>
<BaseAppBar title={LinkedLogo} overflowRef={moreRef} onOverflowClick={handleShowMenu} />
{showMoreMenu ? (
<Menu onDismiss={handleDismissMoreMenu} relativeTo={moreRef}>
<MenuItem icon={AutoAwesomeIcon} label="Auto dark mode" value="media" onSelect={handleSelectDarkMode} />
<MenuSeparator />
<MenuItem icon={LightModeIcon} label="Light" value="light" onSelect={handleSelectDarkMode} />
<MenuItem icon={DarkModeIcon} label="Dark" value="dark" onSelect={handleSelectDarkMode} />
</Menu>
) : null}
</Fragment>
);
}

View File

@@ -1,68 +0,0 @@
import { h } from 'preact';
import AutoUpdatingCameraImage from './components/AutoUpdatingCameraImage';
import Box from './components/Box';
import Heading from './components/Heading';
import Link from './components/Link';
import Switch from './components/Switch';
import { route } from 'preact-router';
import { useCallback, useContext } from 'preact/hooks';
import { ApiHost, Config } from './context';
export default function Camera({ camera, url }) {
const config = useContext(Config);
const apiHost = useContext(ApiHost);
if (!(camera in config.cameras)) {
return <div>{`No camera named ${camera}`}</div>;
}
const cameraConfig = config.cameras[camera];
const { pathname, searchParams } = new URL(`${window.location.protocol}//${window.location.host}${url}`);
const searchParamsString = searchParams.toString();
const handleSetOption = useCallback(
(id, value) => {
searchParams.set(id, value ? 1 : 0);
route(`${pathname}?${searchParams.toString()}`, true);
},
[searchParams]
);
function getBoolean(id) {
return Boolean(parseInt(searchParams.get(id), 10));
}
return (
<div className="space-y-4">
<Heading size="2xl">{camera}</Heading>
<Box>
<AutoUpdatingCameraImage camera={camera} searchParams={searchParamsString} />
</Box>
<Box className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 p-4">
<Switch checked={getBoolean('bbox')} id="bbox" label="Bounding box" onChange={handleSetOption} />
<Switch checked={getBoolean('timestamp')} id="timestamp" label="Timestamp" onChange={handleSetOption} />
<Switch checked={getBoolean('zones')} id="zones" label="Zones" onChange={handleSetOption} />
<Switch checked={getBoolean('mask')} id="mask" label="Masks" onChange={handleSetOption} />
<Switch checked={getBoolean('motion')} id="motion" label="Motion boxes" onChange={handleSetOption} />
<Switch checked={getBoolean('regions')} id="regions" label="Regions" onChange={handleSetOption} />
<Link href={`/cameras/${camera}/editor`}>Mask & Zone creator</Link>
</Box>
<div className="space-y-4">
<Heading size="sm">Tracked objects</Heading>
<div className="grid grid-cols-3 md:grid-cols-4 gap-4">
{cameraConfig.objects.track.map((objectType) => {
return (
<Box key={objectType} hover href={`/events?camera=${camera}&label=${objectType}`}>
<Heading size="sm">{objectType}</Heading>
<img src={`${apiHost}/api/${camera}/${objectType}/best.jpg?crop=1&h=150`} />
</Box>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -1,38 +0,0 @@
import { h } from 'preact';
import Box from './components/Box';
import CameraImage from './components/CameraImage';
import Events from './Events';
import Heading from './components/Heading';
import { route } from 'preact-router';
import { useContext } from 'preact/hooks';
import { ApiHost, Config } from './context';
export default function Cameras() {
const config = useContext(Config);
if (!config.cameras) {
return <p>loading</p>;
}
return (
<div className="grid lg:grid-cols-2 md:grid-cols-1 gap-4">
{Object.keys(config.cameras).map((camera) => (
<Camera name={camera} />
))}
</div>
);
}
function Camera({ name }) {
const href = `/cameras/${name}`;
return (
<Box
className="bg-white dark:bg-gray-700 shadow-lg rounded-lg p-4 hover:bg-gray-300 hover:dark:bg-gray-500 dark:hover:text-gray-900 dark:hover:text-gray-900"
href={href}
>
<Heading size="base">{name}</Heading>
<CameraImage camera={name} />
</Box>
);
}

View File

@@ -1,113 +0,0 @@
import { h } from 'preact';
import Box from './components/Box';
import Button from './components/Button';
import Heading from './components/Heading';
import Link from './components/Link';
import { ApiHost, Config } from './context';
import { Table, Tbody, Thead, Tr, Th, Td } from './components/Table';
import { useCallback, useContext, useEffect, useState } from 'preact/hooks';
export default function Debug() {
const apiHost = useContext(ApiHost);
const config = useContext(Config);
const [stats, setStats] = useState({});
const [timeoutId, setTimeoutId] = useState(null);
const fetchStats = useCallback(async () => {
const statsResponse = await fetch(`${apiHost}/api/stats`);
const stats = statsResponse.ok ? await statsResponse.json() : {};
setStats(stats);
setTimeoutId(setTimeout(fetchStats, 1000));
}, [setStats]);
useEffect(() => {
fetchStats();
}, []);
useEffect(() => {
return () => {
clearTimeout(timeoutId);
};
}, [timeoutId]);
const { detectors, detection_fps, service, ...cameras } = stats;
if (!service) {
return 'loading…';
}
const detectorNames = Object.keys(detectors);
const detectorDataKeys = Object.keys(detectors[detectorNames[0]]);
const cameraNames = Object.keys(cameras);
const cameraDataKeys = Object.keys(cameras[cameraNames[0]]);
const handleCopyConfig = useCallback(async () => {
await window.navigator.clipboard.writeText(JSON.stringify(config, null, 2));
}, [config]);
return (
<div class="space-y-4">
<Heading>
Debug <span className="text-sm">{service.version}</span>
</Heading>
<Box>
<Table className="w-full">
<Thead>
<Tr>
<Th>detector</Th>
{detectorDataKeys.map((name) => (
<Th>{name.replace('_', ' ')}</Th>
))}
</Tr>
</Thead>
<Tbody>
{detectorNames.map((detector, i) => (
<Tr index={i}>
<Td>{detector}</Td>
{detectorDataKeys.map((name) => (
<Td key={`${name}-${detector}`}>{detectors[detector][name]}</Td>
))}
</Tr>
))}
</Tbody>
</Table>
</Box>
<Box>
<Table className="w-full">
<Thead>
<Tr>
<Th>camera</Th>
{cameraDataKeys.map((name) => (
<Th>{name.replace('_', ' ')}</Th>
))}
</Tr>
</Thead>
<Tbody>
{cameraNames.map((camera, i) => (
<Tr index={i}>
<Td>
<Link href={`/cameras/${camera}`}>{camera}</Link>
</Td>
{cameraDataKeys.map((name) => (
<Td key={`${name}-${camera}`}>{cameras[camera][name]}</Td>
))}
</Tr>
))}
</Tbody>
</Table>
</Box>
<Box className="relative">
<Heading size="sm">Config</Heading>
<Button className="absolute top-4 right-8" onClick={handleCopyConfig}>
Copy to Clipboard
</Button>
<pre className="overflow-auto font-mono text-gray-900 dark:text-gray-100 rounded bg-gray-100 dark:bg-gray-800 p-2 max-h-96">
{JSON.stringify(config, null, 2)}
</pre>
</Box>
</div>
);
}

View File

@@ -1,90 +0,0 @@
import { h, Fragment } from 'preact';
import { ApiHost } from './context';
import Box from './components/Box';
import Heading from './components/Heading';
import Link from './components/Link';
import { Table, Thead, Tbody, Tfoot, Th, Tr, Td } from './components/Table';
import { useContext, useEffect, useState } from 'preact/hooks';
export default function Event({ eventId }) {
const apiHost = useContext(ApiHost);
const [data, setData] = useState(null);
useEffect(async () => {
const response = await fetch(`${apiHost}/api/events/${eventId}`);
const data = response.ok ? await response.json() : null;
setData(data);
}, [apiHost, eventId]);
if (!data) {
return (
<div>
<Heading>{eventId}</Heading>
<p>loading</p>
</div>
);
}
const startime = new Date(data.start_time * 1000);
const endtime = new Date(data.end_time * 1000);
return (
<div className="space-y-4">
<Heading>
{data.camera} {data.label} <span className="text-sm">{startime.toLocaleString()}</span>
</Heading>
<Box>
{data.has_clip ? (
<Fragment>
<Heading size="sm">Clip</Heading>
<video className="w-100" src={`${apiHost}/clips/${data.camera}-${eventId}.mp4`} controls />
</Fragment>
) : (
<p>No clip available</p>
)}
</Box>
<Box>
<Heading size="sm">{data.has_snapshot ? 'Best image' : 'Thumbnail'}</Heading>
<img
src={
data.has_snapshot
? `${apiHost}/clips/${data.camera}-${eventId}.jpg`
: `data:image/jpeg;base64,${data.thumbnail}`
}
alt={`${data.label} at ${(data.top_score * 100).toFixed(1)}% confidence`}
/>
</Box>
<Table>
<Thead>
<Th>Key</Th>
<Th>Value</Th>
</Thead>
<Tbody>
<Tr>
<Td>Camera</Td>
<Td>
<Link href={`/cameras/${data.camera}`}>{data.camera}</Link>
</Td>
</Tr>
<Tr index={1}>
<Td>Timeframe</Td>
<Td>
{startime.toLocaleString()} {endtime.toLocaleString()}
</Td>
</Tr>
<Tr>
<Td>Score</Td>
<Td>{(data.top_score * 100).toFixed(2)}%</Td>
</Tr>
<Tr index={1}>
<Td>Zones</Td>
<Td>{data.zones.join(', ')}</Td>
</Tr>
</Tbody>
</Table>
</div>
);
}

View File

@@ -1,136 +0,0 @@
import { h } from 'preact';
import { ApiHost } from './context';
import Box from './components/Box';
import Heading from './components/Heading';
import Link from './components/Link';
import { route } from 'preact-router';
import { Table, Thead, Tbody, Tfoot, Th, Tr, Td } from './components/Table';
import { useCallback, useContext, useEffect, useState } from 'preact/hooks';
export default function Events({ url } = {}) {
const apiHost = useContext(ApiHost);
const [events, setEvents] = useState([]);
const { pathname, searchParams } = new URL(`${window.location.protocol}//${window.location.host}${url || '/events'}`);
const searchParamsString = searchParams.toString();
useEffect(async () => {
const response = await fetch(`${apiHost}/api/events?${searchParamsString}`);
const data = response.ok ? await response.json() : {};
setEvents(data);
}, [searchParamsString]);
const searchKeys = Array.from(searchParams.keys());
return (
<div className="space-y-4 w-full">
<Heading>Events</Heading>
{searchKeys.length ? (
<Box>
<Heading size="sm">Filters</Heading>
<div className="flex flex-wrap space-x-2">
{searchKeys.map((filterKey) => (
<UnFilterable
name={`${filterKey}: ${searchParams.get(filterKey)}`}
paramName={filterKey}
pathname={pathname}
searchParams={searchParamsString}
/>
))}
</div>
</Box>
) : null}
<Box className="min-w-0 overflow-auto">
<Table className="w-full">
<Thead>
<Tr>
<Th></Th>
<Th>Camera</Th>
<Th>Label</Th>
<Th>Score</Th>
<Th>Zones</Th>
<Th>Date</Th>
<Th>Start</Th>
<Th>End</Th>
</Tr>
</Thead>
<Tbody>
{events.map(
(
{ camera, id, label, start_time: startTime, end_time: endTime, thumbnail, top_score: score, zones },
i
) => {
const start = new Date(parseInt(startTime * 1000, 10));
const end = new Date(parseInt(endTime * 1000, 10));
return (
<Tr key={id} index={i}>
<Td>
<a href={`/events/${id}`}>
<img className="w-32 max-w-none" src={`data:image/jpeg;base64,${thumbnail}`} />
</a>
</Td>
<Td>
<Filterable
pathname={pathname}
searchParams={searchParamsString}
paramName="camera"
name={camera}
/>
</Td>
<Td>
<Filterable
pathname={pathname}
searchParams={searchParamsString}
paramName="label"
name={label}
/>
</Td>
<Td>{(score * 100).toFixed(2)}%</Td>
<Td>
<ul>
{zones.map((zone) => (
<li>
<Filterable
pathname={pathname}
searchParams={searchParamsString}
paramName="zone"
name={zone}
/>
</li>
))}
</ul>
</Td>
<Td>{start.toLocaleDateString()}</Td>
<Td>{start.toLocaleTimeString()}</Td>
<Td>{end.toLocaleTimeString()}</Td>
</Tr>
);
}
)}
</Tbody>
</Table>
</Box>
</div>
);
}
function Filterable({ pathname, searchParams, paramName, name }) {
const params = new URLSearchParams(searchParams);
params.set(paramName, name);
return <Link href={`${pathname}?${params.toString()}`}>{name}</Link>;
}
function UnFilterable({ pathname, searchParams, paramName, name }) {
const params = new URLSearchParams(searchParams);
params.delete(paramName);
return (
<a
className="bg-gray-700 text-white px-3 py-1 rounded-md hover:bg-gray-300 hover:text-gray-900 dark:bg-gray-300 dark:text-gray-900 dark:hover:bg-gray-700 dark:hover:text-white"
href={`${pathname}?${params.toString()}`}
>
{name}
</a>
);
}

View File

@@ -1,87 +1,52 @@
import { h } from 'preact';
import Link from './components/Link';
import { Link as RouterLink } from 'preact-router/match';
import { useCallback, useState } from 'preact/hooks';
function HamburgerIcon() {
return (
<svg fill="currentColor" viewBox="0 0 20 20" className="w-6 h-6">
<path
fill-rule="evenodd"
d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM9 15a1 1 0 011-1h6a1 1 0 110 2h-6a1 1 0 01-1-1z"
clip-rule="evenodd"
></path>
</svg>
);
}
function CloseIcon() {
return (
<svg fill="currentColor" viewBox="0 0 20 20" className="w-6 h-6">
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
></path>
</svg>
);
}
function NavLink({ className = '', href, text }) {
const external = href.startsWith('http');
const El = external ? Link : RouterLink;
const props = external ? { rel: 'noopener nofollow', target: '_blank' } : {};
return (
<El
activeClassName="bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 dark:focus:bg-gray-600 dark:focus:text-white dark:hover:text-white dark:text-gray-200"
className={`block px-4 py-2 mt-2 text-sm font-semibold text-gray-900 bg-transparent rounded-lg dark:bg-transparent dark:hover:bg-gray-600 dark:focus:bg-gray-600 dark:focus:text-white dark:hover:text-white dark:text-gray-200 hover:text-gray-900 focus:text-gray-900 hover:bg-gray-200 focus:bg-gray-200 focus:outline-none focus:shadow-outline self-end ${className}`}
href={href}
{...props}
>
{text}
</El>
);
}
import { h, Fragment } from 'preact';
import LinkedLogo from './components/LinkedLogo';
import { Match } from 'preact-router/match';
import { memo } from 'preact/compat';
import { ENV } from './env';
import { useConfig } from './api';
import { useMemo } from 'preact/hooks';
import NavigationDrawer, { Destination, Separator } from './components/NavigationDrawer';
export default function Sidebar() {
const [open, setOpen] = useState(false);
const handleToggle = useCallback(() => {
setOpen(!open);
}, [open, setOpen]);
const { data: config } = useConfig();
const cameras = useMemo(() => Object.keys(config.cameras), [config]);
return (
<div className="flex flex-col w-full md:w-64 text-gray-700 bg-white dark:text-gray-200 dark:bg-gray-700 flex-shrink-0">
<div className="flex-shrink-0 px-8 py-4 flex flex-row items-center justify-between">
<a
href="#"
className="text-lg font-semibold tracking-widest text-gray-900 uppercase rounded-lg dark:text-white focus:outline-none focus:shadow-outline"
>
Frigate
</a>
<button
className="rounded-lg md:hidden rounded-lg focus:outline-none focus:shadow-outline"
onClick={handleToggle}
>
{open ? <CloseIcon /> : <HamburgerIcon />}
</button>
</div>
<nav
className={`flex-col flex-grow md:block overflow-hidden px-4 pb-4 md:pb-0 md:overflow-y-auto ${
!open ? 'md:h-0 hidden' : ''
}`}
>
<NavLink href="/" text="Cameras" />
<NavLink href="/events" text="Events" />
<NavLink href="/debug" text="Debug" />
<hr className="border-solid border-gray-500 mt-2" />
<NavLink
className="self-end"
href="https://blakeblackshear.github.io/frigate"
text="Documentation"
/>
<NavLink className="self-end" href="https://github.com/blakeblackshear/frigate" text="GitHub" />
</nav>
</div>
<NavigationDrawer header={<Header />}>
<Destination href="/" text="Cameras" />
<Match path="/cameras/:camera/:other?">
{({ matches }) =>
matches ? (
<Fragment>
<Separator />
{cameras.map((camera) => (
<Destination href={`/cameras/${camera}`} text={camera} />
))}
<Separator />
</Fragment>
) : null
}
</Match>
<Destination href="/events" text="Events" />
<Destination href="/debug" text="Debug" />
<Separator />
<div className="flex flex-grow" />
{ENV !== 'production' ? (
<Fragment>
<Destination href="/styleguide" text="Style Guide" />
<Separator />
</Fragment>
) : null}
<Destination className="self-end" href="https://blakeblackshear.github.io/frigate" text="Documentation" />
<Destination className="self-end" href="https://github.com/blakeblackshear/frigate" text="GitHub" />
</NavigationDrawer>
);
}
const Header = memo(() => {
return (
<div className="text-gray-500">
<LinkedLogo />
</div>
);
});

2
web/src/__mocks__/env.js Normal file
View File

@@ -0,0 +1,2 @@
export const ENV = 'test';
export const API_HOST = 'http://base-url.local:5000';

View File

@@ -0,0 +1,27 @@
import { h } from 'preact';
import * as Api from '../api';
import * as IDB from 'idb-keyval';
import * as PreactRouter from 'preact-router';
import App from '../App';
import { render, screen } from '@testing-library/preact';
describe('App', () => {
let mockUseConfig;
beforeEach(() => {
jest.spyOn(IDB, 'get').mockImplementation(() => Promise.resolve(undefined));
jest.spyOn(IDB, 'set').mockImplementation(() => Promise.resolve(true));
mockUseConfig = jest.spyOn(Api, 'useConfig').mockImplementation(() => ({
data: { cameras: { front: { name: 'front', objects: { track: ['taco', 'cat', 'dog'] } } } },
}));
jest.spyOn(Api, 'useApiHost').mockImplementation(() => 'http://base-url.local:5000');
jest.spyOn(PreactRouter, 'Router').mockImplementation(() => <div data-testid="router" />);
});
test('shows a loading indicator while loading', async () => {
mockUseConfig.mockReturnValue({ status: 'loading' });
render(<App />);
await screen.findByTestId('app');
expect(screen.queryByLabelText('Loading…')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,53 @@
import { h } from 'preact';
import * as Context from '../context';
import AppBar from '../AppBar';
import { fireEvent, render, screen } from '@testing-library/preact';
describe('AppBar', () => {
beforeEach(() => {
jest.spyOn(Context, 'useDarkMode').mockImplementation(() => ({
setDarkMode: jest.fn(),
}));
jest.spyOn(Context, 'DarkModeProvider').mockImplementation(({ children }) => {
return <div>{children}</div>;
});
});
test('shows a menu on overflow click', async () => {
render(
<Context.DarkModeProvider>
<Context.DrawerProvider>
<AppBar />
</Context.DrawerProvider>
</Context.DarkModeProvider>
);
const overflowButton = await screen.findByLabelText('More options');
fireEvent.click(overflowButton);
const menu = await screen.findByRole('listbox');
expect(menu).toBeInTheDocument();
});
test('sets dark mode on MenuItem select', async () => {
const setDarkModeSpy = jest.fn();
jest.spyOn(Context, 'useDarkMode').mockImplementation(() => ({
setDarkMode: setDarkModeSpy,
}));
render(
<Context.DarkModeProvider>
<Context.DrawerProvider>
<AppBar />
</Context.DrawerProvider>
</Context.DarkModeProvider>
);
const overflowButton = await screen.findByLabelText('More options');
fireEvent.click(overflowButton);
await screen.findByRole('listbox');
fireEvent.click(screen.getByText('Light'));
expect(setDarkModeSpy).toHaveBeenCalledWith('light');
});
});

View File

@@ -0,0 +1,33 @@
import { h } from 'preact';
import * as Api from '../api';
import * as Context from '../context';
import Sidebar from '../Sidebar';
import { render, screen } from '@testing-library/preact';
describe('Sidebar', () => {
beforeEach(() => {
jest.spyOn(Api, 'useConfig').mockImplementation(() => ({
data: {
cameras: {
front: { name: 'front', objects: { track: ['taco', 'cat', 'dog'] } },
side: { name: 'side', objects: { track: ['taco', 'cat', 'dog'] } },
},
},
}));
jest.spyOn(Context, 'useDrawer').mockImplementation(() => ({ showDrawer: true, setShowDrawer: () => {} }));
});
test('does not render cameras by default', async () => {
render(<Sidebar />);
expect(screen.queryByRole('link', { name: 'front' })).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'side' })).not.toBeInTheDocument();
});
test('render cameras if in camera route', async () => {
window.history.replaceState({}, 'Cameras', '/cameras/front');
render(<Sidebar />);
expect(screen.queryByRole('link', { name: 'front' })).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'side' })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,121 @@
import { h } from 'preact';
import * as Mqtt from '../mqtt';
import { ApiProvider, useFetch, useApiHost } from '..';
import { render, screen } from '@testing-library/preact';
describe('useApiHost', () => {
beforeEach(() => {
jest.spyOn(Mqtt, 'MqttProvider').mockImplementation(({ children }) => children);
});
test('is set from the baseUrl', async () => {
function Test() {
const apiHost = useApiHost();
return <div>{apiHost}</div>;
}
render(
<ApiProvider>
<Test />
</ApiProvider>
);
expect(screen.queryByText('http://base-url.local:5000')).toBeInTheDocument();
});
});
function Test() {
const { data, status } = useFetch('/api/tacos');
return (
<div>
<span>{data ? data.returnData : ''}</span>
<span>{status}</span>
</div>
);
}
describe('useFetch', () => {
let fetchSpy;
beforeEach(() => {
jest.spyOn(Mqtt, 'MqttProvider').mockImplementation(({ children }) => children);
fetchSpy = jest.spyOn(window, 'fetch').mockImplementation(async (url, options) => {
if (url.endsWith('/api/config')) {
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
}
return new Promise((resolve) => {
setTimeout(() => {
resolve({ ok: true, json: () => Promise.resolve({ returnData: 'yep' }) });
}, 1);
});
});
});
test('loads data', async () => {
render(
<ApiProvider>
<Test />
</ApiProvider>
);
expect(screen.queryByText('loading')).toBeInTheDocument();
expect(screen.queryByText('yep')).not.toBeInTheDocument();
jest.runAllTimers();
await screen.findByText('loaded');
expect(fetchSpy).toHaveBeenCalledWith('http://base-url.local:5000/api/tacos');
expect(screen.queryByText('loaded')).toBeInTheDocument();
expect(screen.queryByText('yep')).toBeInTheDocument();
});
test('sets error if response is not okay', async () => {
jest.spyOn(window, 'fetch').mockImplementation((url) => {
if (url.includes('/config')) {
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
}
return new Promise((resolve) => {
setTimeout(() => {
resolve({ ok: false });
}, 1);
});
});
render(
<ApiProvider>
<Test />
</ApiProvider>
);
expect(screen.queryByText('loading')).toBeInTheDocument();
jest.runAllTimers();
await screen.findByText('error');
});
test('does not re-fetch if the query has already been made', async () => {
const { rerender } = render(
<ApiProvider>
<Test key={0} />
</ApiProvider>
);
expect(screen.queryByText('loading')).toBeInTheDocument();
expect(screen.queryByText('yep')).not.toBeInTheDocument();
jest.runAllTimers();
await screen.findByText('loaded');
expect(fetchSpy).toHaveBeenCalledWith('http://base-url.local:5000/api/tacos');
rerender(
<ApiProvider>
<Test key={1} />
</ApiProvider>
);
expect(screen.queryByText('loaded')).toBeInTheDocument();
expect(screen.queryByText('yep')).toBeInTheDocument();
jest.runAllTimers();
// once for /api/config, once for /api/tacos
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,135 @@
import { h } from 'preact';
import { Mqtt, MqttProvider, useMqtt } from '../mqtt';
import { useCallback, useContext } from 'preact/hooks';
import { fireEvent, render, screen } from '@testing-library/preact';
function Test() {
const { state } = useContext(Mqtt);
return state.__connected ? (
<div data-testid="data">
{Object.keys(state).map((key) => (
<div data-testid={key}>{JSON.stringify(state[key])}</div>
))}
</div>
) : null;
}
const TEST_URL = 'ws://test-foo:1234/ws';
describe('MqttProvider', () => {
let createWebsocket, wsClient;
beforeEach(() => {
wsClient = {
close: jest.fn(),
send: jest.fn(),
};
createWebsocket = jest.fn((url) => {
wsClient.args = [url];
return new Proxy(
{},
{
get(target, prop, receiver) {
return wsClient[prop];
},
set(target, prop, value) {
wsClient[prop] = typeof value === 'function' ? jest.fn(value) : value;
if (prop === 'onopen') {
wsClient[prop]();
}
return true;
},
}
);
});
});
test('connects to the mqtt server', async () => {
render(
<MqttProvider config={mockConfig} createWebsocket={createWebsocket} mqttUrl={TEST_URL}>
<Test />
</MqttProvider>
);
await screen.findByTestId('data');
expect(wsClient.args).toEqual([TEST_URL]);
expect(screen.getByTestId('__connected')).toHaveTextContent('true');
});
test('receives data through useMqtt', async () => {
function Test() {
const {
value: { payload, retain },
connected,
} = useMqtt('tacos');
return connected ? (
<div>
<div data-testid="payload">{JSON.stringify(payload)}</div>
<div data-testid="retain">{JSON.stringify(retain)}</div>
</div>
) : null;
}
const { rerender } = render(
<MqttProvider config={mockConfig} createWebsocket={createWebsocket} mqttUrl={TEST_URL}>
<Test />
</MqttProvider>
);
await screen.findByTestId('payload');
wsClient.onmessage({
data: JSON.stringify({ topic: 'tacos', payload: JSON.stringify({ yes: true }), retain: false }),
});
rerender(
<MqttProvider config={mockConfig} createWebsocket={createWebsocket} mqttUrl={TEST_URL}>
<Test />
</MqttProvider>
);
expect(screen.getByTestId('payload')).toHaveTextContent('{"yes":true}');
expect(screen.getByTestId('retain')).toHaveTextContent('false');
});
test('can send values through useMqtt', async () => {
function Test() {
const { send, connected } = useMqtt('tacos');
const handleClick = useCallback(() => {
send({ yes: true });
}, [send]);
return connected ? <button onClick={handleClick}>click me</button> : null;
}
render(
<MqttProvider config={mockConfig} createWebsocket={createWebsocket} mqttUrl={TEST_URL}>
<Test />
</MqttProvider>
);
await screen.findByRole('button');
fireEvent.click(screen.getByRole('button'));
await expect(wsClient.send).toHaveBeenCalledWith(
JSON.stringify({ topic: 'tacos', payload: JSON.stringify({ yes: true }) })
);
});
test('prefills the clips/detect/snapshots state from config', async () => {
jest.spyOn(Date, 'now').mockReturnValue(123456);
const config = {
cameras: {
front: { name: 'front', detect: { enabled: true }, clips: { enabled: false }, snapshots: { enabled: true } },
side: { name: 'side', detect: { enabled: false }, clips: { enabled: false }, snapshots: { enabled: false } },
},
};
render(
<MqttProvider config={config} createWebsocket={createWebsocket} mqttUrl={TEST_URL}>
<Test />
</MqttProvider>
);
await screen.findByTestId('data');
expect(screen.getByTestId('front/detect/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"ON"}');
expect(screen.getByTestId('front/clips/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF"}');
expect(screen.getByTestId('front/snapshots/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"ON"}');
expect(screen.getByTestId('side/detect/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF"}');
expect(screen.getByTestId('side/clips/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF"}');
expect(screen.getByTestId('side/snapshots/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF"}');
});
});
const mockConfig = {
cameras: {},
};

2
web/src/api/baseUrl.js Normal file
View File

@@ -0,0 +1,2 @@
import { API_HOST } from '../env';
export const baseUrl = API_HOST || `${window.location.protocol}//${window.location.host}${window.baseUrl || ''}`;

121
web/src/api/index.jsx Normal file
View File

@@ -0,0 +1,121 @@
import { baseUrl } from './baseUrl';
import { h, createContext } from 'preact';
import { MqttProvider } from './mqtt';
import produce from 'immer';
import { useContext, useEffect, useReducer } from 'preact/hooks';
export const FetchStatus = {
NONE: 'none',
LOADING: 'loading',
LOADED: 'loaded',
ERROR: 'error',
};
const initialState = Object.freeze({
host: baseUrl,
queries: {},
});
const Api = createContext(initialState);
function reducer(state, { type, payload, meta }) {
switch (type) {
case 'REQUEST': {
const { url, fetchId } = payload;
const data = state.queries[url]?.data || null;
return produce(state, (draftState) => {
draftState.queries[url] = { status: FetchStatus.LOADING, data, fetchId };
});
}
case 'RESPONSE': {
const { url, ok, data, fetchId } = payload;
return produce(state, (draftState) => {
draftState.queries[url] = { status: ok ? FetchStatus.LOADED : FetchStatus.ERROR, data, fetchId };
});
}
default:
return state;
}
}
export function ApiProvider({ children }) {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<Api.Provider value={{ state, dispatch }}>
<MqttWithConfig>{children}</MqttWithConfig>
</Api.Provider>
);
}
function MqttWithConfig({ children }) {
const { data, status } = useConfig();
return status === FetchStatus.LOADED ? <MqttProvider config={data}>{children}</MqttProvider> : children;
}
function shouldFetch(state, url, fetchId = null) {
if ((fetchId && url in state.queries && state.queries[url].fetchId !== fetchId) || !(url in state.queries)) {
return true;
}
const { status } = state.queries[url];
return status !== FetchStatus.LOADING && status !== FetchStatus.LOADED;
}
export function useFetch(url, fetchId) {
const { state, dispatch } = useContext(Api);
useEffect(() => {
if (!shouldFetch(state, url, fetchId)) {
return;
}
async function fetchData() {
await dispatch({ type: 'REQUEST', payload: { url, fetchId } });
const response = await fetch(`${state.host}${url}`);
try {
const data = await response.json();
await dispatch({ type: 'RESPONSE', payload: { url, ok: response.ok, data, fetchId } });
} catch (e) {
await dispatch({ type: 'RESPONSE', payload: { url, ok: false, data: null, fetchId } });
}
}
fetchData();
}, [url, fetchId, state, dispatch]);
if (!(url in state.queries)) {
return { data: null, status: FetchStatus.NONE };
}
const data = state.queries[url].data || null;
const status = state.queries[url].status;
return { data, status };
}
export function useApiHost() {
const { state } = useContext(Api);
return state.host;
}
export function useEvents(searchParams, fetchId) {
const url = `/api/events${searchParams ? `?${searchParams.toString()}` : ''}`;
return useFetch(url, fetchId);
}
export function useEvent(eventId, fetchId) {
const url = `/api/events/${eventId}`;
return useFetch(url, fetchId);
}
export function useConfig(searchParams, fetchId) {
const url = `/api/config${searchParams ? `?${searchParams.toString()}` : ''}`;
return useFetch(url, fetchId);
}
export function useStats(searchParams, fetchId) {
const url = `/api/stats${searchParams ? `?${searchParams.toString()}` : ''}`;
return useFetch(url, fetchId);
}

120
web/src/api/mqtt.jsx Normal file
View File

@@ -0,0 +1,120 @@
import { h, createContext } from 'preact';
import { baseUrl } from './baseUrl';
import produce from 'immer';
import { useCallback, useContext, useEffect, useRef, useReducer } from 'preact/hooks';
const initialState = Object.freeze({ __connected: false });
export const Mqtt = createContext({ state: initialState, connection: null });
const defaultCreateWebsocket = (url) => new WebSocket(url);
function reducer(state, { topic, payload, retain }) {
switch (topic) {
case '__CLIENT_CONNECTED':
return produce(state, (draftState) => {
draftState.__connected = true;
});
default:
return produce(state, (draftState) => {
let parsedPayload = payload;
try {
parsedPayload = payload && JSON.parse(payload);
} catch (e) {}
draftState[topic] = {
lastUpdate: Date.now(),
payload: parsedPayload,
retain,
};
});
}
}
export function MqttProvider({
config,
children,
createWebsocket = defaultCreateWebsocket,
mqttUrl = `${baseUrl.replace(/^http/, 'ws')}/ws`,
}) {
const [state, dispatch] = useReducer(reducer, initialState);
const wsRef = useRef();
useEffect(() => {
Object.keys(config.cameras).forEach((camera) => {
const { name, clips, detect, snapshots } = config.cameras[camera];
dispatch({ topic: `${name}/clips/state`, payload: clips.enabled ? 'ON' : 'OFF' });
dispatch({ topic: `${name}/detect/state`, payload: detect.enabled ? 'ON' : 'OFF' });
dispatch({ topic: `${name}/snapshots/state`, payload: snapshots.enabled ? 'ON' : 'OFF' });
});
}, [config]);
useEffect(
() => {
const ws = createWebsocket(mqttUrl);
ws.onopen = () => {
dispatch({ topic: '__CLIENT_CONNECTED' });
};
ws.onmessage = (event) => {
dispatch(JSON.parse(event.data));
};
wsRef.current = ws;
return () => {
ws.close(3000, 'Provider destroyed');
};
},
// Forces reconnecting
[state.__reconnectAttempts, mqttUrl] // eslint-disable-line react-hooks/exhaustive-deps
);
return <Mqtt.Provider value={{ state, ws: wsRef.current }}>{children}</Mqtt.Provider>;
}
export function useMqtt(watchTopic, publishTopic) {
const { state, ws } = useContext(Mqtt);
const value = state[watchTopic] || { payload: null };
const send = useCallback(
(payload) => {
ws.send(
JSON.stringify({
topic: publishTopic || watchTopic,
payload: typeof payload !== 'string' ? JSON.stringify(payload) : payload,
})
);
},
[ws, watchTopic, publishTopic]
);
return { value, send, connected: state.__connected };
}
export function useDetectState(camera) {
const {
value: { payload },
send,
connected,
} = useMqtt(`${camera}/detect/state`, `${camera}/detect/set`);
return { payload, send, connected };
}
export function useClipsState(camera) {
const {
value: { payload },
send,
connected,
} = useMqtt(`${camera}/clips/state`, `${camera}/clips/set`);
return { payload, send, connected };
}
export function useSnapshotsState(camera) {
const {
value: { payload },
send,
connected,
} = useMqtt(`${camera}/snapshots/state`, `${camera}/snapshots/set`);
return { payload, send, connected };
}

View File

@@ -0,0 +1,15 @@
import { h } from 'preact';
const sizes = {
sm: 'h-4 w-4 border-2 border-t-2',
md: 'h-8 w-8 border-4 border-t-4',
lg: 'h-16 w-16 border-8 border-t-8',
};
export default function ActivityIndicator({ size = 'md' }) {
return (
<div className="w-full flex items-center justify-center" aria-label="Loading…">
<div className={`activityindicator ease-in rounded-full border-gray-200 text-blue-500 ${sizes[size]}`} />
</div>
);
}

View File

@@ -0,0 +1,68 @@
import { h } from 'preact';
import Button from './Button';
import MenuIcon from '../icons/Menu';
import MoreIcon from '../icons/More';
import { useDrawer } from '../context';
import { useLayoutEffect, useCallback, useState } from 'preact/hooks';
// We would typically preserve these in component state
// But need to avoid too many re-renders
let lastScrollY = window.scrollY;
export default function AppBar({ title: Title, overflowRef, onOverflowClick }) {
const [show, setShow] = useState(true);
const [atZero, setAtZero] = useState(window.scrollY === 0);
const { setShowDrawer } = useDrawer();
const scrollListener = useCallback(() => {
const scrollY = window.scrollY;
window.requestAnimationFrame(() => {
setShow(scrollY <= 0 || lastScrollY > scrollY);
setAtZero(scrollY === 0);
lastScrollY = scrollY;
});
}, [setShow]);
useLayoutEffect(() => {
document.addEventListener('scroll', scrollListener);
return () => {
document.removeEventListener('scroll', scrollListener);
};
}, [scrollListener]);
const handleShowDrawer = useCallback(() => {
setShowDrawer(true);
}, [setShowDrawer]);
return (
<div
className={`w-full border-b border-gray-200 dark:border-gray-700 flex items-center align-middle p-4 space-x-2 fixed left-0 right-0 z-10 bg-white dark:bg-gray-900 transform transition-all duration-200 ${
!show ? '-translate-y-full' : 'translate-y-0'
} ${!atZero ? 'shadow-sm' : ''}`}
data-testid="appbar"
>
<div className="lg:hidden">
<Button color="black" className="rounded-full w-12 h-12" onClick={handleShowDrawer} type="text">
<MenuIcon className="w-10 h-10" />
</Button>
</div>
<Title />
<div className="flex-grow-1 flex justify-end w-full">
{overflowRef && onOverflowClick ? (
<div className="w-auto" ref={overflowRef}>
<Button
aria-label="More options"
color="black"
className="rounded-full w-12 h-12"
onClick={onOverflowClick}
type="text"
>
<MoreIcon className="w-10 h-10" />
</Button>
</div>
) : null}
</div>
</div>
);
}

View File

@@ -1,11 +1,10 @@
import { h } from 'preact';
import CameraImage from './CameraImage';
import { ApiHost, Config } from '../context';
import { useCallback, useState } from 'preact/hooks';
const MIN_LOAD_TIMEOUT_MS = 200;
export default function AutoUpdatingCameraImage({ camera, searchParams, showFps = true }) {
export default function AutoUpdatingCameraImage({ camera, searchParams = '', showFps = true }) {
const [key, setKey] = useState(Date.now());
const [fps, setFps] = useState(0);
@@ -18,7 +17,7 @@ export default function AutoUpdatingCameraImage({ camera, searchParams, showFps
},
loadTime > MIN_LOAD_TIMEOUT_MS ? 1 : MIN_LOAD_TIMEOUT_MS
);
}, [key, searchParams, setFps]);
}, [key, setFps]);
return (
<div>

View File

@@ -1,16 +0,0 @@
import { h } from 'preact';
export default function Box({ children, className = '', hover = false, href, ...props }) {
const Element = href ? 'a' : 'div';
return (
<Element
className={`bg-white dark:bg-gray-700 shadow-lg rounded-lg p-4 ${
hover ? 'hover:bg-gray-300 hover:dark:bg-gray-500 dark:hover:text-gray-900 dark:hover:text-gray-900' : ''
} ${className}`}
href={href}
{...props}
>
{children}
</Element>
);
}

View File

@@ -1,23 +1,105 @@
import { h } from 'preact';
import { h, Fragment } from 'preact';
import Tooltip from './Tooltip';
import { useCallback, useRef, useState } from 'preact/hooks';
const noop = () => {};
const BUTTON_COLORS = {
blue: { normal: 'bg-blue-500', hover: 'hover:bg-blue-400' },
red: { normal: 'bg-red-500', hover: 'hover:bg-red-400' },
green: { normal: 'bg-green-500', hover: 'hover:bg-green-400' },
const ButtonColors = {
blue: {
contained: 'bg-blue-500 focus:bg-blue-400 active:bg-blue-600 ring-blue-300',
outlined:
'text-blue-500 border-2 border-blue-500 hover:bg-blue-500 hover:bg-opacity-20 focus:bg-blue-500 focus:bg-opacity-40 active:bg-blue-500 active:bg-opacity-40',
text:
'text-blue-500 hover:bg-blue-500 hover:bg-opacity-20 focus:bg-blue-500 focus:bg-opacity-40 active:bg-blue-500 active:bg-opacity-40',
},
red: {
contained: 'bg-red-500 focus:bg-red-400 active:bg-red-600 ring-red-300',
outlined:
'text-red-500 border-2 border-red-500 hover:bg-red-500 hover:bg-opacity-20 focus:bg-red-500 focus:bg-opacity-40 active:bg-red-500 active:bg-opacity-40',
text:
'text-red-500 hover:bg-red-500 hover:bg-opacity-20 focus:bg-red-500 focus:bg-opacity-40 active:bg-red-500 active:bg-opacity-40',
},
green: {
contained: 'bg-green-500 focus:bg-green-400 active:bg-green-600 ring-green-300',
outlined:
'text-green-500 border-2 border-green-500 hover:bg-green-500 hover:bg-opacity-20 focus:bg-green-500 focus:bg-opacity-40 active:bg-green-500 active:bg-opacity-40',
text:
'text-green-500 hover:bg-green-500 hover:bg-opacity-20 focus:bg-green-500 focus:bg-opacity-40 active:bg-green-500 active:bg-opacity-40',
},
gray: {
contained: 'bg-gray-500 focus:bg-gray-400 active:bg-gray-600 ring-gray-300',
outlined:
'text-gray-500 border-2 border-gray-500 hover:bg-gray-500 hover:bg-opacity-20 focus:bg-gray-500 focus:bg-opacity-40 active:bg-gray-500 active:bg-opacity-40',
text:
'text-gray-500 hover:bg-gray-500 hover:bg-opacity-20 focus:bg-gray-500 focus:bg-opacity-40 active:bg-gray-500 active:bg-opacity-40',
},
disabled: {
contained: 'bg-gray-400',
outlined:
'text-gray-500 border-2 border-gray-500 hover:bg-gray-500 hover:bg-opacity-20 focus:bg-gray-500 focus:bg-opacity-40 active:bg-gray-500 active:bg-opacity-40',
text:
'text-gray-500 hover:bg-gray-500 hover:bg-opacity-20 focus:bg-gray-500 focus:bg-opacity-40 active:bg-gray-500 active:bg-opacity-40',
},
black: {
contained: '',
outlined: '',
text: 'text-black dark:text-white',
},
};
export default function Button({ children, className, color = 'blue', onClick, size, ...attrs }) {
const ButtonTypes = {
contained: 'text-white shadow focus:shadow-xl hover:shadow-md',
outlined: '',
text: 'transition-opacity',
};
export default function Button({
children,
className = '',
color = 'blue',
disabled = false,
href,
size,
type = 'contained',
...attrs
}) {
const [hovered, setHovered] = useState(false);
const ref = useRef();
let classes = `whitespace-nowrap flex items-center space-x-1 ${className} ${ButtonTypes[type]} ${
ButtonColors[disabled ? 'disabled' : color][type]
} font-sans inline-flex font-bold uppercase text-xs px-2 py-2 rounded outline-none focus:outline-none ring-opacity-50 transition-shadow transition-colors ${
disabled ? 'cursor-not-allowed' : 'focus:ring-2 cursor-pointer'
}`;
if (disabled) {
classes = classes.replace(/(?:focus|active|hover):[^ ]+/g, '');
}
const handleMousenter = useCallback((event) => {
setHovered(true);
}, []);
const handleMouseleave = useCallback((event) => {
setHovered(false);
}, []);
const Element = href ? 'a' : 'div';
return (
<div
role="button"
tabindex="0"
className={`rounded ${BUTTON_COLORS[color].normal} text-white pl-4 pr-4 pt-2 pb-2 font-bold shadow ${BUTTON_COLORS[color].hover} hover:shadow-lg cursor-pointer ${className}`}
onClick={onClick || noop}
{...attrs}
>
{children}
</div>
<Fragment>
<Element
role="button"
aria-disabled={disabled ? 'true' : 'false'}
tabindex="0"
className={classes}
href={href}
ref={ref}
onmouseenter={handleMousenter}
onmouseleave={handleMouseleave}
{...attrs}
>
{children}
</Element>
{hovered && attrs['aria-label'] ? <Tooltip text={attrs['aria-label']} relativeTo={ref} /> : null}
</Fragment>
);
}

View File

@@ -1,60 +1,54 @@
import { h } from 'preact';
import { ApiHost, Config } from '../context';
import { useCallback, useEffect, useContext, useMemo, useRef, useState } from 'preact/hooks';
import ActivityIndicator from './ActivityIndicator';
import { useApiHost, useConfig } from '../api';
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { useResizeObserver } from '../hooks';
export default function CameraImage({ camera, onload, searchParams = '' }) {
const config = useContext(Config);
const apiHost = useContext(ApiHost);
const [availableWidth, setAvailableWidth] = useState(0);
const [loadedSrc, setLoadedSrc] = useState(null);
export default function CameraImage({ camera, onload, searchParams = '', stretch = false }) {
const { data: config } = useConfig();
const apiHost = useApiHost();
const [hasLoaded, setHasLoaded] = useState(false);
const containerRef = useRef(null);
const canvasRef = useRef(null);
const [{ width: availableWidth }] = useResizeObserver(containerRef);
const { name, width, height } = config.cameras[camera];
const aspectRatio = width / height;
const resizeObserver = useMemo(() => {
return new ResizeObserver((entries) => {
window.requestAnimationFrame(() => {
if (Array.isArray(entries) && entries.length) {
setAvailableWidth(entries[0].contentRect.width);
}
});
});
}, [setAvailableWidth, width]);
const scaledHeight = useMemo(() => {
const scaledHeight = Math.floor(availableWidth / aspectRatio);
return stretch ? scaledHeight : Math.min(scaledHeight, height);
}, [availableWidth, aspectRatio, height, stretch]);
const scaledWidth = useMemo(() => Math.ceil(scaledHeight * aspectRatio), [scaledHeight, aspectRatio]);
useEffect(() => {
if (!containerRef.current) {
return;
}
resizeObserver.observe(containerRef.current);
}, [resizeObserver, containerRef.current]);
const scaledHeight = useMemo(() => Math.min(Math.ceil(availableWidth / aspectRatio), height), [
availableWidth,
aspectRatio,
height,
]);
const img = useMemo(() => new Image(), [camera]);
const img = useMemo(() => new Image(), []);
img.onload = useCallback(
(event) => {
const src = event.path[0].currentSrc;
setLoadedSrc(src);
setHasLoaded(true);
if (canvasRef.current) {
const ctx = canvasRef.current.getContext('2d');
ctx.drawImage(img, 0, 0, scaledWidth, scaledHeight);
}
onload && onload(event);
},
[searchParams, onload]
[img, scaledHeight, scaledWidth, setHasLoaded, onload, canvasRef]
);
useEffect(() => {
if (!scaledHeight) {
if (scaledHeight === 0 || !canvasRef.current) {
return;
}
img.src = `${apiHost}/api/${name}/latest.jpg?h=${scaledHeight}${searchParams ? `&${searchParams}` : ''}`;
}, [apiHost, name, img, searchParams, scaledHeight]);
}, [apiHost, canvasRef, name, img, searchParams, scaledHeight]);
return (
<div ref={containerRef}>
{loadedSrc ? <img width={scaledHeight * aspectRatio} height={scaledHeight} src={loadedSrc} alt={name} /> : null}
<div className="relative w-full" ref={containerRef}>
<canvas data-testid="cameraimage-canvas" height={scaledHeight} ref={canvasRef} width={scaledWidth} />
{!hasLoaded ? (
<div className="absolute inset-0 flex justify-center" style={`height: ${scaledHeight}px`}>
<ActivityIndicator />
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,52 @@
import { h } from 'preact';
import Button from './Button';
import Heading from './Heading';
export default function Box({
buttons = [],
className = '',
content,
elevated = true,
header,
href,
icons = [],
media = null,
...props
}) {
const Element = href ? 'a' : 'div';
const typeClasses = elevated
? 'shadow-md hover:shadow-lg transition-shadow'
: 'border border-gray-200 dark:border-gray-700';
return (
<div className={`bg-white dark:bg-gray-800 rounded-lg overflow-hidden ${typeClasses} ${className}`}>
{media || header ? (
<Element href={href} {...props}>
{media}
<div className="p-4 pb-2">{header ? <Heading size="base">{header}</Heading> : null}</div>
</Element>
) : null}
{buttons.length || content || icons.length ? (
<div className="px-4 pb-2">
{content || null}
{buttons.length ? (
<div className="flex space-x-4 -ml-2">
{buttons.map(({ name, href }) => (
<Button key={name} href={href} type="text">
{name}
</Button>
))}
<div class="flex-grow" />
{icons.map(({ name, icon: Icon, ...props }) => (
<Button aria-label={name} className="rounded-full" key={name} type="text" {...props}>
<Icon className="w-6" />
</Button>
))}
</div>
) : null}
</div>
) : null}
</div>
);
}

View File

@@ -1,9 +1,16 @@
import { h } from 'preact';
import { Link as RouterLink } from 'preact-router/match';
export default function Link({ className, children, href, ...props }) {
export default function Link({
activeClassName = '',
className = 'text-blue-500 hover:underline',
children,
href,
...props
}) {
return (
<a className={`text-blue-500 dark:text-blue-400 hover:underline ${className}`} href={href} {...props}>
<RouterLink activeClassName={activeClassName} className={className} href={href} {...props}>
{children}
</a>
</RouterLink>
);
}

View File

@@ -0,0 +1,16 @@
import { h } from 'preact';
import Heading from './Heading';
import Logo from './Logo';
export default function LinkedLogo() {
return (
<Heading size="lg">
<a className="transition-colors flex items-center space-x-4 dark:text-white hover:text-blue-500" href="/">
<div className="w-10">
<Logo />
</div>
Frigate
</a>
</Heading>
);
}

View File

@@ -0,0 +1,9 @@
import { h } from 'preact';
export default function Logo() {
return (
<svg viewBox="0 0 512 512" className="fill-current">
<path d="M130 446.5C131.6 459.3 145 468 137 470C129 472 94 406.5 86 378.5C78 350.5 73.5 319 75.5 301C77.4999 283 181 255 181 247.5C181 240 147.5 247 146 241C144.5 235 171.3 238.6 178.5 229C189.75 214 204 216.5 213 208.5C222 200.5 233 170 235 157C237 144 215 129 209 119C203 109 222 102 268 83C314 64 460 22 462 27C464 32 414 53 379 66C344 79 287 104 287 111C287 118 290 123.5 288 139.5C286 155.5 285.76 162.971 282 173.5C279.5 180.5 277 197 282 212C286 224 299 233 305 235C310 235.333 323.8 235.8 339 235C358 234 385 236 385 241C385 246 344 243 344 250C344 257 386 249 385 256C384 263 350 260 332 260C317.6 260 296.333 259.333 287 256L285 263C281.667 263 274.7 265 267.5 265C258.5 265 258 268 241.5 268C225 268 230 267 215 266C200 265 144 308 134 322C124 336 130 370 130 385.5C130 399.428 128 430.5 130 446.5Z" />
</svg>
);
}

View File

@@ -0,0 +1,44 @@
import { h } from 'preact';
import RelativeModal from './RelativeModal';
import { useCallback } from 'preact/hooks';
export default function Menu({ className, children, onDismiss, relativeTo, widthRelative }) {
return relativeTo ? (
<RelativeModal
children={children}
className={`${className || ''} py-2`}
role="listbox"
onDismiss={onDismiss}
portalRootID="menus"
relativeTo={relativeTo}
widthRelative={widthRelative}
/>
) : null;
}
export function MenuItem({ focus, icon: Icon, label, onSelect, value }) {
const handleClick = useCallback(() => {
onSelect && onSelect(value, label);
}, [onSelect, value, label]);
return (
<div
className={`flex space-x-2 p-2 px-5 hover:bg-gray-200 dark:hover:bg-gray-800 dark:hover:text-white cursor-pointer ${
focus ? 'bg-gray-200 dark:bg-gray-800 dark:text-white' : ''
}`}
onClick={handleClick}
role="option"
>
{Icon ? (
<div className="w-6 h-6 self-center mr-4 text-gray-500 flex-shrink-0">
<Icon />
</div>
) : null}
<div className="whitespace-nowrap">{label}</div>
</div>
);
}
export function MenuSeparator() {
return <div className="border-b border-gray-200 dark:border-gray-800 my-2" />;
}

View File

@@ -0,0 +1,65 @@
import { h, Fragment } from 'preact';
import { Link } from 'preact-router/match';
import { useCallback } from 'preact/hooks';
import { useDrawer } from '../context';
export default function NavigationDrawer({ children, header }) {
const { showDrawer, setShowDrawer } = useDrawer();
const handleDismiss = useCallback(() => {
setShowDrawer(false);
}, [setShowDrawer]);
return (
<Fragment>
{showDrawer ? <div data-testid="scrim" key="scrim" className="fixed inset-0 z-20" onClick={handleDismiss} /> : ''}
<div
key="drawer"
data-testid="drawer"
className={`fixed left-0 top-0 bottom-0 lg:sticky max-h-screen flex flex-col w-64 text-gray-700 bg-white dark:text-gray-200 dark:bg-gray-900 flex-shrink-0 border-r border-gray-200 dark:border-gray-700 shadow lg:shadow-none z-20 lg:z-0 transform ${
!showDrawer ? '-translate-x-full lg:translate-x-0' : 'translate-x-0'
} transition-transform duration-300`}
onClick={handleDismiss}
>
{header ? (
<div className="flex-shrink-0 p-5 flex flex-row items-center justify-between border-b border-gray-200 dark:border-gray-700">
{header}
</div>
) : null}
<nav className="flex flex-col flex-grow overflow-hidden overflow-y-auto p-2 space-y-2">{children}</nav>
</div>
</Fragment>
);
}
export function Destination({ className = '', href, text, ...other }) {
const external = href.startsWith('http');
const props = external ? { rel: 'noopener nofollow', target: '_blank' } : {};
const { setShowDrawer } = useDrawer();
const handleDismiss = useCallback(() => {
setTimeout(() => {
setShowDrawer(false);
}, 250);
}, [setShowDrawer]);
const styleProps = {
[external
? 'className'
: 'class']: 'block p-2 text-sm font-semibold text-gray-900 rounded hover:bg-blue-500 dark:text-gray-200 hover:text-white dark:hover:text-white focus:outline-none ring-opacity-50 focus:ring-2 ring-blue-300',
};
const El = external ? 'a' : Link;
return (
<El activeClassName="bg-blue-500 bg-opacity-50 text-white" {...styleProps} href={href} {...props} {...other}>
<div onClick={handleDismiss}>{text}</div>
</El>
);
}
export function Separator() {
return <div className="border-b border-gray-200 dark:border-gray-700 -mx-2" />;
}

View File

@@ -0,0 +1,129 @@
import { h, Fragment } from 'preact';
import { createPortal } from 'preact/compat';
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
const WINDOW_PADDING = 20;
export default function RelativeModal({
className,
role = 'dialog',
children,
onDismiss,
portalRootID,
relativeTo,
widthRelative = false,
}) {
const [position, setPosition] = useState({ top: -9999, left: -9999 });
const [show, setShow] = useState(false);
const portalRoot = portalRootID && document.getElementById(portalRootID);
const ref = useRef(null);
const handleDismiss = useCallback(
(event) => {
onDismiss && onDismiss(event);
},
[onDismiss]
);
const handleKeydown = useCallback(
(event) => {
const focusable = ref.current.querySelectorAll('[tabindex]');
if (event.key === 'Tab' && focusable.length) {
if (event.shiftKey && document.activeElement === focusable[0]) {
focusable[focusable.length - 1].focus();
event.preventDefault();
} else if (document.activeElement === focusable[focusable.length - 1]) {
focusable[0].focus();
event.preventDefault();
}
return;
}
if (event.key === 'Escape') {
setShow(false);
handleDismiss();
return;
}
},
[ref, handleDismiss]
);
useLayoutEffect(() => {
if (ref && ref.current && relativeTo && relativeTo.current) {
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
const { width: menuWidth, height: menuHeight } = ref.current.getBoundingClientRect();
const {
x: relativeToX,
y: relativeToY,
width: relativeToWidth,
// height: relativeToHeight,
} = relativeTo.current.getBoundingClientRect();
const _width = widthRelative ? relativeToWidth : menuWidth;
const width = _width * 1.1;
const left = relativeToX + window.scrollX;
const top = relativeToY + window.scrollY;
let newTop = top;
let newLeft = left;
// too far right
if (newLeft + width + WINDOW_PADDING >= windowWidth - WINDOW_PADDING) {
newLeft = windowWidth - width - WINDOW_PADDING;
}
// too far left
else if (left < WINDOW_PADDING) {
newLeft = WINDOW_PADDING;
}
// too close to bottom
if (top + menuHeight > windowHeight - WINDOW_PADDING + window.scrollY) {
newTop = relativeToY - menuHeight;
}
if (top <= WINDOW_PADDING + window.scrollY) {
newTop = WINDOW_PADDING;
}
const maxHeight = windowHeight - WINDOW_PADDING * 2 > menuHeight ? null : windowHeight - WINDOW_PADDING * 2;
const newPosition = { left: newLeft, top: newTop, maxHeight };
if (widthRelative) {
newPosition.width = relativeToWidth;
}
setPosition(newPosition);
const focusable = ref.current.querySelector('[tabindex]');
focusable && focusable.focus();
}
}, [relativeTo, ref, widthRelative]);
useEffect(() => {
if (position.top >= 0) {
window.requestAnimationFrame(() => {
setShow(true);
});
} else {
setShow(false);
}
}, [show, position, ref]);
const menu = (
<Fragment>
<div data-testid="scrim" key="scrim" className="absolute inset-0 z-10" onClick={handleDismiss} />
<div
key="menu"
className={`z-10 bg-white dark:bg-gray-700 dark:text-white absolute shadow-lg rounded w-auto h-auto transition-transform transition-opacity duration-75 transform scale-90 opacity-0 overflow-x-hidden overflow-y-auto ${
show ? 'scale-100 opacity-100' : ''
} ${className}`}
onKeyDown={handleKeydown}
role={role}
ref={ref}
style={position}
>
{children}
</div>
</Fragment>
);
return portalRoot ? createPortal(menu, portalRoot) : menu;
}

View File

@@ -0,0 +1,109 @@
import { h, Fragment } from 'preact';
import ArrowDropdown from '../icons/ArrowDropdown';
import ArrowDropup from '../icons/ArrowDropup';
import Menu, { MenuItem } from './Menu';
import TextField from './TextField';
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
export default function Select({ label, onChange, options: inputOptions = [], selected: propSelected }) {
const options = useMemo(
() =>
typeof inputOptions[0] === 'string' ? inputOptions.map((opt) => ({ value: opt, label: opt })) : inputOptions,
[inputOptions]
);
const [showMenu, setShowMenu] = useState(false);
const [selected, setSelected] = useState(
Math.max(
options.findIndex(({ value }) => value === propSelected),
0
)
);
const [focused, setFocused] = useState(null);
const ref = useRef(null);
const handleSelect = useCallback(
(value, label) => {
setSelected(options.findIndex((opt) => opt.value === value));
onChange && onChange(value, label);
setShowMenu(false);
},
[onChange, options]
);
const handleClick = useCallback(() => {
setShowMenu(true);
}, [setShowMenu]);
const handleKeydown = useCallback(
(event) => {
switch (event.key) {
case 'Enter': {
if (!showMenu) {
setShowMenu(true);
setFocused(selected);
} else {
setSelected(focused);
onChange && onChange(options[focused].value, options[focused].label);
setShowMenu(false);
}
break;
}
case 'ArrowDown': {
const newIndex = focused + 1;
newIndex < options.length && setFocused(newIndex);
break;
}
case 'ArrowUp': {
const newIndex = focused - 1;
newIndex > -1 && setFocused(newIndex);
break;
}
// no default
}
},
[onChange, options, showMenu, setShowMenu, setFocused, focused, selected]
);
const handleDismiss = useCallback(() => {
setShowMenu(false);
}, [setShowMenu]);
// Reset the state if the prop value changes
useEffect(() => {
const selectedIndex = Math.max(
options.findIndex(({ value }) => value === propSelected),
0
);
if (propSelected && selectedIndex !== selected) {
setSelected(selectedIndex);
setFocused(selectedIndex);
}
// DO NOT include `selected`
}, [options, propSelected]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<Fragment>
<TextField
inputRef={ref}
label={label}
onchange={onChange}
onclick={handleClick}
onkeydown={handleKeydown}
readonly
trailingIcon={showMenu ? ArrowDropup : ArrowDropdown}
value={options[selected]?.label}
/>
{showMenu ? (
<Menu className="rounded-t-none" onDismiss={handleDismiss} relativeTo={ref} widthRelative>
{options.map(({ value, label }, i) => (
<MenuItem key={value} label={label} focus={focused === i} onSelect={handleSelect} value={value} />
))}
</Menu>
) : null}
</Fragment>
);
}

View File

@@ -1,26 +1,71 @@
import { h } from 'preact';
import { useCallback, useState } from 'preact/hooks';
export default function Switch({ checked, label, id, onChange }) {
const handleChange = useCallback(() => {
onChange(id, !checked);
}, [id, onChange, checked]);
export default function Switch({ checked, id, onChange, label, labelPosition = 'before' }) {
const [isFocused, setFocused] = useState(false);
const handleChange = useCallback(
(event) => {
if (onChange) {
onChange(id, !checked);
}
},
[id, onChange, checked]
);
const handleFocus = useCallback(() => {
onChange && setFocused(true);
}, [onChange, setFocused]);
const handleBlur = useCallback(() => {
onChange && setFocused(false);
}, [onChange, setFocused]);
return (
<label for={id} className="flex items-center cursor-pointer">
<div className="relative">
<input id={id} type="checkbox" className="hidden" onChange={handleChange} checked={checked} />
<label
htmlFor={id}
className={`flex items-center space-x-4 w-full ${onChange ? 'cursor-pointer' : 'cursor-not-allowed'}`}
>
{label && labelPosition === 'before' ? (
<div data-testid={`${id}-label`} className="inline-flex flex-grow">
{label}
</div>
) : null}
<div
onMouseOver={handleFocus}
onMouseOut={handleBlur}
className={`self-end w-8 h-5 relative ${!onChange ? 'opacity-60' : ''}`}
>
<div className="relative overflow-hidden">
<input
data-testid={`${id}-input`}
className="absolute left-48"
onBlur={handleBlur}
onFocus={handleFocus}
tabIndex="0"
id={id}
type="checkbox"
onChange={handleChange}
checked={checked}
/>
</div>
<div
className={`transition-colors toggle__line w-12 h-6 ${
!checked ? 'bg-gray-400' : 'bg-blue-400'
className={`w-8 h-3 absolute top-1 left-1 ${
!checked ? 'bg-gray-300' : 'bg-blue-300'
} rounded-full shadow-inner`}
/>
<div
className="transition-transform absolute w-6 h-6 bg-white rounded-full shadow-md inset-y-0 left-0"
className={`transition-all absolute w-5 h-5 rounded-full shadow-md inset-y-0 left-0 ring-opacity-30 ${
isFocused ? 'ring-4 ring-gray-500' : ''
} ${checked ? 'bg-blue-600' : 'bg-white'} ${isFocused && checked ? 'ring-blue-500' : ''}`}
style={checked ? 'transform: translateX(100%);' : 'transform: translateX(0%);'}
/>
</div>
<div className="ml-3 text-gray-700 font-medium dark:text-gray-200">{label}</div>
{label && labelPosition !== 'before' ? (
<div data-testid={`${id}-label`} class="inline-flex flex-grow">
{label}
</div>
) : null}
</label>
);
}

View File

@@ -6,26 +6,53 @@ export function Table({ children, className = '' }) {
);
}
export function Thead({ children, className = '' }) {
return <thead className={`${className}`}>{children}</thead>;
export function Thead({ children, className, ...attrs }) {
return (
<thead className={className} {...attrs}>
{children}
</thead>
);
}
export function Tbody({ children, className = '' }) {
return <tbody className={`${className}`}>{children}</tbody>;
export function Tbody({ children, className, ...attrs }) {
return (
<tbody className={className} {...attrs}>
{children}
</tbody>
);
}
export function Tfoot({ children, className = '' }) {
return <tfoot className={`${className}`}>{children}</tfoot>;
export function Tfoot({ children, className = '', ...attrs }) {
return (
<tfoot className={`${className}`} {...attrs}>
{children}
</tfoot>
);
}
export function Tr({ children, className = '', index }) {
return <tr className={`${index % 2 ? 'bg-gray-200 dark:bg-gray-700' : ''} ${className}`}>{children}</tr>;
export function Tr({ children, className = '', ...attrs }) {
return (
<tr
className={`border-b border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 ${className}`}
{...attrs}
>
{children}
</tr>
);
}
export function Th({ children, className = '' }) {
return <th className={`border-b-2 border-gray-400 p-4 text-left ${className}`}>{children}</th>;
export function Th({ children, className = '', colspan, ...attrs }) {
return (
<th className={`border-b border-gray-400 p-2 px-1 lg:p-4 text-left ${className}`} colSpan={colspan} {...attrs}>
{children}
</th>
);
}
export function Td({ children, className = '' }) {
return <td className={`p-4 ${className}`}>{children}</td>;
export function Td({ children, className = '', colspan, ...attrs }) {
return (
<td className={`p-2 px-1 lg:p-4 ${className}`} colSpan={colspan} {...attrs}>
{children}
</td>
);
}

View File

@@ -0,0 +1,102 @@
import { h } from 'preact';
import { useCallback, useEffect, useState } from 'preact/hooks';
export default function TextField({
helpText,
keyboardType = 'text',
inputRef,
label,
leadingIcon: LeadingIcon,
onBlur,
onChangeText,
onFocus,
readonly,
trailingIcon: TrailingIcon,
value: propValue = '',
...props
}) {
const [isFocused, setFocused] = useState(false);
const [value, setValue] = useState(propValue);
const handleFocus = useCallback(
(event) => {
setFocused(true);
onFocus && onFocus(event);
},
[onFocus]
);
const handleBlur = useCallback(
(event) => {
setFocused(false);
onBlur && onBlur(event);
},
[onBlur]
);
const handleChange = useCallback(
(event) => {
const { value } = event.target;
setValue(value);
onChangeText && onChangeText(value);
},
[onChangeText, setValue]
);
useEffect(() => {
if (propValue !== value) {
setValue(propValue);
}
// DO NOT include `value`
}, [propValue, setValue]); // eslint-disable-line react-hooks/exhaustive-deps
const labelMoved = isFocused || value !== '';
return (
<div className="w-full">
<div
className={`bg-gray-100 dark:bg-gray-700 rounded rounded-b-none border-gray-400 border-b p-1 pl-4 pr-3 ${
isFocused ? 'border-blue-500 dark:border-blue-500' : ''
}`}
ref={inputRef}
>
<label
className="flex space-x-2 items-center"
data-testid={`label-${label.toLowerCase().replace(/[^\w]+/g, '_')}`}
>
{LeadingIcon ? (
<div className="w-10 h-full">
<LeadingIcon />
</div>
) : null}
<div className="relative w-full">
<input
className="h-6 mt-6 w-full bg-transparent focus:outline-none focus:ring-0"
onBlur={handleBlur}
onFocus={handleFocus}
onInput={handleChange}
readOnly={readonly}
tabIndex="0"
type={keyboardType}
value={value}
{...props}
/>
<div
className={`absolute top-3 transition transform text-gray-600 dark:text-gray-400 ${
labelMoved ? 'text-xs -translate-y-2' : ''
} ${isFocused ? 'text-blue-500 dark:text-blue-500' : ''}`}
>
{label}
</div>
</div>
{TrailingIcon ? (
<div className="w-10 h-10">
<TrailingIcon />
</div>
) : null}
</label>
</div>
{helpText ? <div className="text-xs pl-3 pt-1">{helpText}</div> : null}
</div>
);
}

View File

@@ -0,0 +1,63 @@
import { h } from 'preact';
import { createPortal } from 'preact/compat';
import { useLayoutEffect, useRef, useState } from 'preact/hooks';
const TIP_SPACE = 20;
export default function Tooltip({ relativeTo, text }) {
const [position, setPosition] = useState({ top: -9999, left: -9999 });
const portalRoot = document.getElementById('tooltips');
const ref = useRef();
useLayoutEffect(() => {
if (ref && ref.current && relativeTo && relativeTo.current) {
const windowWidth = window.innerWidth;
const {
x: relativeToX,
y: relativeToY,
width: relativeToWidth,
height: relativeToHeight,
} = relativeTo.current.getBoundingClientRect();
const { width: _tipWidth, height: _tipHeight } = ref.current.getBoundingClientRect();
const tipWidth = _tipWidth * 1.1;
const tipHeight = _tipHeight * 1.1;
const left = relativeToX + Math.round(relativeToWidth / 2) + window.scrollX;
const top = relativeToY + Math.round(relativeToHeight / 2) + window.scrollY;
let newTop = top - TIP_SPACE - tipHeight;
let newLeft = left - Math.round(tipWidth / 2);
// too far right
if (newLeft + tipWidth + TIP_SPACE > windowWidth - window.scrollX) {
newLeft = left - tipWidth - TIP_SPACE;
newTop = top - Math.round(tipHeight / 2);
}
// too far left
else if (newLeft < TIP_SPACE + window.scrollX) {
newLeft = left + TIP_SPACE;
newTop = top - Math.round(tipHeight / 2);
}
// too close to top
else if (newTop <= TIP_SPACE + window.scrollY) {
newTop = top + tipHeight + TIP_SPACE;
}
setPosition({ left: newLeft, top: newTop });
}
}, [relativeTo, ref]);
const tooltip = (
<div
role="tooltip"
className={`shadow max-w-lg absolute pointer-events-none bg-gray-900 dark:bg-gray-200 bg-opacity-80 rounded px-2 py-1 transition-transform transition-opacity duration-75 transform scale-90 opacity-0 text-gray-100 dark:text-gray-900 text-sm ${
position.top >= 0 ? 'opacity-100 scale-100' : ''
}`}
ref={ref}
style={position}
>
{text}
</div>
);
return portalRoot ? createPortal(tooltip, portalRoot) : tooltip;
}

View File

@@ -0,0 +1,47 @@
import { h } from 'preact';
import ActivityIndicator from '../ActivityIndicator';
import { render, screen } from '@testing-library/preact';
describe('ActivityIndicator', () => {
test('renders an ActivityIndicator with default size md', async () => {
render(<ActivityIndicator />);
expect(screen.getByLabelText('Loading…')).toMatchInlineSnapshot(`
<div
aria-label="Loading…"
class="w-full flex items-center justify-center"
>
<div
class="activityindicator ease-in rounded-full border-gray-200 text-blue-500 h-8 w-8 border-4 border-t-4"
/>
</div>
`);
});
test('renders an ActivityIndicator with size sm', async () => {
render(<ActivityIndicator size="sm" />);
expect(screen.getByLabelText('Loading…')).toMatchInlineSnapshot(`
<div
aria-label="Loading…"
class="w-full flex items-center justify-center"
>
<div
class="activityindicator ease-in rounded-full border-gray-200 text-blue-500 h-4 w-4 border-2 border-t-2"
/>
</div>
`);
});
test('renders an ActivityIndicator with size lg', async () => {
render(<ActivityIndicator size="lg" />);
expect(screen.getByLabelText('Loading…')).toMatchInlineSnapshot(`
<div
aria-label="Loading…"
class="w-full flex items-center justify-center"
>
<div
class="activityindicator ease-in rounded-full border-gray-200 text-blue-500 h-16 w-16 border-8 border-t-8"
/>
</div>
`);
});
});

View File

@@ -0,0 +1,132 @@
import { h } from 'preact';
import { DrawerProvider } from '../../context';
import AppBar from '../AppBar';
import { fireEvent, render, screen } from '@testing-library/preact';
import { useRef } from 'preact/hooks';
function Title() {
return <div>I am the title</div>;
}
describe('AppBar', () => {
test('renders the title', async () => {
render(
<DrawerProvider>
<AppBar title={Title} />
</DrawerProvider>
);
expect(screen.getByText('I am the title')).toBeInTheDocument();
});
describe('overflow menu', () => {
test('is not rendered if a ref is not provided', async () => {
const handleOverflow = jest.fn();
render(
<DrawerProvider>
<AppBar title={Title} onOverflowClick={handleOverflow} />
</DrawerProvider>
);
expect(screen.queryByLabelText('More options')).not.toBeInTheDocument();
});
test('is not rendered if a click handler is not provided', async () => {
function Wrapper() {
const ref = useRef(null);
return <AppBar title={Title} overflowRef={ref} />;
}
render(
<DrawerProvider>
<Wrapper />
</DrawerProvider>
);
expect(screen.queryByLabelText('More options')).not.toBeInTheDocument();
});
test('is rendered with click handler and ref', async () => {
const handleOverflow = jest.fn();
function Wrapper() {
const ref = useRef(null);
return <AppBar title={Title} overflowRef={ref} onOverflowClick={handleOverflow} />;
}
render(
<DrawerProvider>
<Wrapper />
</DrawerProvider>
);
expect(screen.queryByLabelText('More options')).toBeInTheDocument();
});
test('calls the handler when clicked', async () => {
const handleOverflow = jest.fn();
function Wrapper() {
const ref = useRef(null);
return <AppBar title={Title} overflowRef={ref} onOverflowClick={handleOverflow} />;
}
render(
<DrawerProvider>
<Wrapper />
</DrawerProvider>
);
fireEvent.click(screen.queryByLabelText('More options'));
expect(handleOverflow).toHaveBeenCalled();
});
});
describe('scrolling', () => {
test('is visible initially', async () => {
render(
<DrawerProvider>
<AppBar title={Title} />
</DrawerProvider>
);
const classes = screen.getByTestId('appbar').classList;
expect(classes.contains('translate-y-0')).toBe(true);
expect(classes.contains('-translate-y-full')).toBe(false);
});
test('hides when scrolled downward', async () => {
jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => cb());
render(
<DrawerProvider>
<AppBar title={Title} />
</DrawerProvider>
);
window.scrollY = 300;
await fireEvent.scroll(document, { target: { scrollY: 300 } });
const classes = screen.getByTestId('appbar').classList;
expect(classes.contains('translate-y-0')).toBe(false);
expect(classes.contains('-translate-y-full')).toBe(true);
});
test('reappears when scrolled upward', async () => {
jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => cb());
render(
<DrawerProvider>
<AppBar title={Title} />
</DrawerProvider>
);
window.scrollY = 300;
await fireEvent.scroll(document, { target: { scrollY: 300 } });
window.scrollY = 280;
await fireEvent.scroll(document, { target: { scrollY: 280 } });
const classes = screen.getByTestId('appbar').classList;
expect(classes.contains('translate-y-0')).toBe(true);
expect(classes.contains('-translate-y-full')).toBe(false);
});
});
});

View File

@@ -0,0 +1,42 @@
import { h } from 'preact';
import AutoUpdatingCameraImage from '../AutoUpdatingCameraImage';
import { screen, render } from '@testing-library/preact';
let mockOnload;
jest.mock('../CameraImage', () => {
function CameraImage({ onload, searchParams }) {
mockOnload = () => {
onload();
};
return <div data-testid="camera-image">{searchParams}</div>;
}
return {
__esModule: true,
default: CameraImage,
};
});
describe('AutoUpdatingCameraImage', () => {
let dateNowSpy;
beforeEach(() => {
dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(0);
});
test('shows FPS by default', async () => {
render(<AutoUpdatingCameraImage camera="tacos" />);
expect(screen.queryByText('Displaying at 0fps')).toBeInTheDocument();
});
test('does not show FPS if turned off', async () => {
render(<AutoUpdatingCameraImage camera="tacos" showFps={false} />);
expect(screen.queryByText('Displaying at 0fps')).not.toBeInTheDocument();
});
test('on load, sets a new cache key to search params', async () => {
dateNowSpy.mockReturnValueOnce(100).mockReturnValueOnce(200).mockReturnValueOnce(300);
render(<AutoUpdatingCameraImage camera="tacos" searchParams="foo" />);
mockOnload();
jest.runAllTimers();
expect(screen.queryByText('cache=100&foo')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,36 @@
import { h } from 'preact';
import Button from '../Button';
import { render, screen } from '@testing-library/preact';
describe('Button', () => {
test('renders children', async () => {
render(
<Button>
<div>hello</div>
<div>hi</div>
</Button>
);
expect(screen.queryByText('hello')).toBeInTheDocument();
expect(screen.queryByText('hi')).toBeInTheDocument();
});
test('includes focus, active, and hover classes when enabled', async () => {
render(<Button>click me</Button>);
const classList = screen.queryByRole('button').classList;
expect(classList.contains('focus:outline-none')).toBe(true);
expect(classList.contains('focus:ring-2')).toBe(true);
expect(classList.contains('hover:shadow-md')).toBe(true);
expect(classList.contains('active:bg-blue-600')).toBe(true);
});
test('does not focus, active, and hover classes when enabled', async () => {
render(<Button disabled>click me</Button>);
const classList = screen.queryByRole('button').classList;
expect(classList.contains('focus:outline-none')).toBe(false);
expect(classList.contains('focus:ring-2')).toBe(false);
expect(classList.contains('hover:shadow-md')).toBe(false);
expect(classList.contains('active:bg-blue-600')).toBe(false);
});
});

View File

@@ -0,0 +1,40 @@
import { h } from 'preact';
import * as Api from '../../api';
import * as Hooks from '../../hooks';
import CameraImage from '../CameraImage';
import { render, screen } from '@testing-library/preact';
describe('CameraImage', () => {
beforeEach(() => {
jest.spyOn(Api, 'useConfig').mockImplementation(() => {
return { data: { cameras: { front: { name: 'front', width: 1280, height: 720 } } } };
});
jest.spyOn(Api, 'useApiHost').mockReturnValue('http://base-url.local:5000');
jest.spyOn(Hooks, 'useResizeObserver').mockImplementation(() => [{ width: 0 }]);
});
test('renders an activity indicator while loading', async () => {
render(<CameraImage camera="front" />);
expect(screen.queryByLabelText('Loading…')).toBeInTheDocument();
});
test('creates a scaled canvas using the available width & height, preserving camera aspect ratio', async () => {
jest.spyOn(Hooks, 'useResizeObserver').mockReturnValueOnce([{ width: 720 }]);
render(<CameraImage camera="front" />);
expect(screen.queryByLabelText('Loading…')).toBeInTheDocument();
const canvas = screen.queryByTestId('cameraimage-canvas');
expect(canvas).toHaveAttribute('height', '405');
expect(canvas).toHaveAttribute('width', '720');
});
test('allows camera image to stretch to available space', async () => {
jest.spyOn(Hooks, 'useResizeObserver').mockReturnValueOnce([{ width: 1400 }]);
render(<CameraImage camera="front" stretch />);
expect(screen.queryByLabelText('Loading…')).toBeInTheDocument();
const canvas = screen.queryByTestId('cameraimage-canvas');
expect(canvas).toHaveAttribute('height', '787');
expect(canvas).toHaveAttribute('width', '1400');
});
});

View File

@@ -0,0 +1,46 @@
import { h } from 'preact';
import Card from '../Card';
import { render, screen } from '@testing-library/preact';
describe('Card', () => {
test('renders a Card with media', async () => {
render(<Card media={<img src="tacos.jpg" alt="tacos" />} />);
expect(screen.queryByAltText('tacos')).toBeInTheDocument();
});
test('renders a Card with a link around media', async () => {
render(<Card href="/tacos" media={<img src="tacos.jpg" alt="tacos" />} />);
expect(screen.queryByAltText('tacos')).toBeInTheDocument();
expect(screen.getByAltText('tacos').closest('a')).toHaveAttribute('href', '/tacos');
});
test('renders a Card with a header', async () => {
render(<Card header="Tacos!" />);
expect(screen.queryByText('Tacos!')).toBeInTheDocument();
});
test('renders a Card with a linked header', async () => {
render(<Card href="/tacos" header="Tacos!" />);
expect(screen.queryByText('Tacos!')).toBeInTheDocument();
expect(screen.queryByText('Tacos!').closest('a')).toHaveAttribute('href', '/tacos');
});
test('renders content', async () => {
const content = <div data-testid="content">hello</div>;
render(<Card content={content} />);
expect(screen.queryByTestId('content')).toBeInTheDocument();
});
test('renders buttons', async () => {
const buttons = [
{ name: 'Tacos', href: '/tacos' },
{ name: 'Burritos', href: '/burritos' },
];
render(<Card buttons={buttons} />);
expect(screen.queryByText('Tacos')).toHaveAttribute('role', 'button');
expect(screen.queryByText('Tacos')).toHaveAttribute('href', '/tacos');
expect(screen.queryByText('Burritos')).toHaveAttribute('role', 'button');
expect(screen.queryByText('Burritos')).toHaveAttribute('href', '/burritos');
});
});

View File

@@ -0,0 +1,25 @@
import { h } from 'preact';
import Heading from '../Heading';
import { render, screen } from '@testing-library/preact';
describe('Heading', () => {
test('renders content with default size', async () => {
render(<Heading>Hello</Heading>);
expect(screen.queryByText('Hello')).toBeInTheDocument();
expect(screen.queryByText('Hello').classList.contains('text-2xl')).toBe(true);
});
test('renders with custom size', async () => {
render(<Heading size="lg">Hello</Heading>);
expect(screen.queryByText('Hello')).toBeInTheDocument();
expect(screen.queryByText('Hello').classList.contains('text-2xl')).toBe(false);
expect(screen.queryByText('Hello').classList.contains('text-lg')).toBe(true);
});
test('renders with custom className', async () => {
render(<Heading className="tacos">Hello</Heading>);
expect(screen.queryByText('Hello')).toBeInTheDocument();
expect(screen.queryByText('Hello').classList.contains('text-2xl')).toBe(true);
expect(screen.queryByText('Hello').classList.contains('tacos')).toBe(true);
});
});

View File

@@ -0,0 +1,17 @@
import { h } from 'preact';
import Link from '../Link';
import { render, screen } from '@testing-library/preact';
describe('Link', () => {
test('renders a link', async () => {
render(<Link href="/tacos">Hello</Link>);
expect(screen.queryByText('Hello')).toMatchInlineSnapshot(`
<a
class="text-blue-500 hover:underline"
href="/tacos"
>
Hello
</a>
`);
});
});

View File

@@ -0,0 +1,52 @@
import { h } from 'preact';
import Menu, { MenuItem } from '../Menu';
import { fireEvent, render, screen } from '@testing-library/preact';
import { useRef } from 'preact/hooks';
describe('Menu', () => {
test('renders a dialog', async () => {
function Test() {
const relativeRef = useRef();
return (
<div>
<div ref={relativeRef} />
<Menu relativeTo={relativeRef} />
</div>
);
}
render(<Test />);
expect(screen.queryByRole('listbox')).toBeInTheDocument();
});
});
describe('MenuItem', () => {
test('renders a menu item', async () => {
render(<MenuItem label="Tacos" />);
expect(screen.queryByRole('option')).toHaveTextContent('Tacos');
});
test('calls onSelect when clicked', async () => {
const handleSelect = jest.fn();
render(<MenuItem label="Tacos" onSelect={handleSelect} value="tacos-value" />);
fireEvent.click(screen.queryByRole('option'));
expect(handleSelect).toHaveBeenCalledWith('tacos-value', 'Tacos');
});
test('renders and icon when passed', async () => {
function Icon() {
return <div data-testid="icon" />;
}
render(<MenuItem icon={Icon} label="Tacos" />);
expect(screen.queryByTestId('icon')).toBeInTheDocument();
});
test('applies different styles when focused', async () => {
const { rerender } = render(<MenuItem label="Tacos" />);
const classes = Array.from(screen.queryByRole('option').classList);
rerender(<MenuItem label="Tacos" focus />);
const focusClasses = Array.from(screen.queryByRole('option').classList);
expect(focusClasses.length).toBeGreaterThan(classes.length);
});
});

View File

@@ -0,0 +1,61 @@
import { h } from 'preact';
import * as Context from '../../context';
import NavigationDrawer, { Destination } from '../NavigationDrawer';
import { fireEvent, render, screen } from '@testing-library/preact';
describe('NavigationDrawer', () => {
let useDrawer, setShowDrawer;
beforeEach(() => {
setShowDrawer = jest.fn();
useDrawer = jest.spyOn(Context, 'useDrawer').mockImplementation(() => ({ showDrawer: true, setShowDrawer }));
});
test('renders a navigation drawer', async () => {
render(
<NavigationDrawer>
<div data-testid="children">Hello</div>
</NavigationDrawer>
);
expect(screen.queryByTestId('children')).toHaveTextContent('Hello');
expect(screen.queryByTestId('drawer').classList.contains('translate-x-full')).toBe(false);
expect(screen.queryByTestId('drawer').classList.contains('translate-x-0')).toBe(true);
});
test('is dismissed when the scrim is clicked', async () => {
useDrawer
.mockReturnValueOnce({ showDrawer: true, setShowDrawer })
.mockReturnValueOnce({ showDrawer: false, setShowDrawer });
render(<NavigationDrawer />);
fireEvent.click(screen.queryByTestId('scrim'));
expect(setShowDrawer).toHaveBeenCalledWith(false);
});
test('is not visible when not set to show', async () => {
useDrawer.mockReturnValue({ showDrawer: false, setShowDrawer });
render(<NavigationDrawer />);
expect(screen.queryByTestId('scrim')).not.toBeInTheDocument();
expect(screen.queryByTestId('drawer').classList.contains('-translate-x-full')).toBe(true);
expect(screen.queryByTestId('drawer').classList.contains('translate-x-0')).toBe(false);
});
});
describe('Destination', () => {
let setShowDrawer;
beforeEach(() => {
setShowDrawer = jest.fn();
jest.spyOn(Context, 'useDrawer').mockImplementation(() => ({ showDrawer: true, setShowDrawer }));
});
test('dismisses the drawer moments after being clicked', async () => {
render(
<NavigationDrawer>
<Destination href="/tacos" text="Tacos" />
</NavigationDrawer>
);
fireEvent.click(screen.queryByText('Tacos'));
jest.runAllTimers();
expect(setShowDrawer).toHaveBeenCalledWith(false);
});
});

View File

@@ -0,0 +1,63 @@
import { h, createRef } from 'preact';
import RelativeModal from '../RelativeModal';
import userEvent from '@testing-library/user-event';
import { fireEvent, render, screen } from '@testing-library/preact';
describe('RelativeModal', () => {
test('keeps tab focus', async () => {
const ref = createRef();
render(
<div>
<label for="outside-input">outside</label>
<input id="outside-input" tabindex="0" />
<div ref={ref} />
<RelativeModal relativeTo={ref}>
<input data-testid="modal-input-0" tabindex="0" />
<input data-testid="modal-input-1" tabindex="0" />
</RelativeModal>
</div>
);
const inputs = screen.queryAllByTestId(/modal-input/);
expect(document.activeElement).toBe(inputs[0]);
userEvent.tab();
expect(document.activeElement).toBe(inputs[1]);
userEvent.tab();
expect(document.activeElement).toBe(inputs[0]);
});
test('pressing ESC dismisses', async () => {
const handleDismiss = jest.fn();
const ref = createRef();
render(
<div>
<div ref={ref} />
<RelativeModal onDismiss={handleDismiss} relativeTo={ref}>
<input data-testid="modal-input-0" tabindex="0" />
</RelativeModal>
</div>
);
const dialog = screen.queryByRole('dialog');
expect(dialog).toBeInTheDocument();
fireEvent.keyDown(document.activeElement, { key: 'Escape', code: 'Escape' });
expect(handleDismiss).toHaveBeenCalled();
});
test('clicking a scrim dismisses', async () => {
const handleDismiss = jest.fn();
const ref = createRef();
render(
<div>
<div ref={ref} />
<RelativeModal onDismiss={handleDismiss} relativeTo={ref}>
<input data-testid="modal-input-0" tabindex="0" />
</RelativeModal>
</div>
);
fireEvent.click(screen.queryByTestId('scrim'));
expect(handleDismiss).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,34 @@
import { h } from 'preact';
import Select from '../Select';
import { fireEvent, render, screen } from '@testing-library/preact';
describe('Select', () => {
test('on focus, shows a menu', async () => {
const handleChange = jest.fn();
render(<Select label="Tacos" onChange={handleChange} options={['tacos', 'burritos']} />);
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('textbox'));
expect(screen.queryByRole('listbox')).toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'tacos' })).toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'burritos' })).toBeInTheDocument();
fireEvent.click(screen.queryByRole('option', { name: 'burritos' }));
expect(handleChange).toHaveBeenCalledWith('burritos', 'burritos');
});
test('allows keyboard navigation', async () => {
const handleChange = jest.fn();
render(<Select label="Tacos" onChange={handleChange} options={['tacos', 'burritos']} />);
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
const input = screen.getByRole('textbox');
fireEvent.focus(input);
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
expect(screen.queryByRole('listbox')).toBeInTheDocument();
fireEvent.keyDown(input, { key: 'ArrowDown', code: 'ArrowDown' });
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
expect(handleChange).toHaveBeenCalledWith('burritos', 'burritos');
});
});

View File

@@ -0,0 +1,47 @@
import { h } from 'preact';
import Switch from '../Switch';
import { fireEvent, render, screen } from '@testing-library/preact';
describe('Switch', () => {
test('renders a hidden checkbox', async () => {
render(
<div>
<Switch id="unchecked-switch" />
<Switch id="checked-switch" checked={true} />
</div>
);
const unchecked = screen.queryByTestId('unchecked-switch-input');
expect(unchecked).toHaveAttribute('type', 'checkbox');
expect(unchecked).not.toBeChecked();
const checked = screen.queryByTestId('checked-switch-input');
expect(checked).toHaveAttribute('type', 'checkbox');
expect(checked).toBeChecked();
});
test('calls onChange callback when checked/unchecked', async () => {
const handleChange = jest.fn();
const { rerender } = render(<Switch id="check" onChange={handleChange} />);
fireEvent.change(screen.queryByTestId('check-input'), { checked: true });
expect(handleChange).toHaveBeenCalledWith('check', true);
rerender(<Switch id="check" onChange={handleChange} checked />);
fireEvent.change(screen.queryByTestId('check-input'), { checked: false });
expect(handleChange).toHaveBeenCalledWith('check', false);
});
test('renders a label before', async () => {
render(<Switch id="check" label="This is the label" />);
const items = screen.queryAllByTestId(/check-.+/);
expect(items[0]).toHaveTextContent('This is the label');
expect(items[1]).toHaveAttribute('data-testid', 'check-input');
});
test('renders a label after', async () => {
render(<Switch id="check" label="This is the label" labelPosition="after" />);
const items = screen.queryAllByTestId(/check-.+/);
expect(items[0]).toHaveAttribute('data-testid', 'check-input');
expect(items[1]).toHaveTextContent('This is the label');
});
});

View File

@@ -0,0 +1,73 @@
import { h } from 'preact';
import TextField from '../TextField';
import { fireEvent, render, screen } from '@testing-library/preact';
describe('TextField', () => {
test('can render a leading icon', async () => {
render(<TextField label="Tacos" leadingIcon={FakeLeadingIcon} />);
expect(screen.getByTestId('icon-leading')).toBeInTheDocument();
});
test('can render a trailing icon', async () => {
render(<TextField label="Tacos" trailingIcon={FakeTrailingIcon} />);
expect(screen.getByTestId('icon-trailing')).toBeInTheDocument();
});
test('can renders icons in correct positions', async () => {
render(<TextField label="Tacos" leadingIcon={FakeLeadingIcon} trailingIcon={FakeTrailingIcon} />);
const icons = screen.queryAllByTestId(/icon-.+/);
expect(icons[0]).toHaveAttribute('data-testid', 'icon-leading');
expect(icons[1]).toHaveAttribute('data-testid', 'icon-trailing');
});
test('focuses and blurs', async () => {
const handleFocus = jest.fn();
const handleBlur = jest.fn();
render(<TextField label="Tacos" onFocus={handleFocus} onBlur={handleBlur} />);
fireEvent.focus(screen.getByRole('textbox'));
expect(handleFocus).toHaveBeenCalled();
expect(screen.getByText('Tacos').classList.contains('-translate-y-2')).toBe(true);
fireEvent.blur(screen.getByRole('textbox'));
expect(handleBlur).toHaveBeenCalled();
expect(screen.getByText('Tacos').classList.contains('-translate-y-2')).toBe(false);
});
test('onChange updates the value', async () => {
const handleChangeText = jest.fn();
render(<TextField label="Tacos" onChangeText={handleChangeText} />);
const input = screen.getByRole('textbox');
fireEvent.input(input, { target: { value: 'i like tacos' } });
expect(handleChangeText).toHaveBeenCalledWith('i like tacos');
expect(input.value).toEqual('i like tacos');
});
test('still updates the value if an original value was given', async () => {
render(<TextField label="Tacos" value="no, burritos" />);
const input = screen.getByRole('textbox');
fireEvent.input(input, { target: { value: 'i like tacos' } });
expect(input.value).toEqual('i like tacos');
});
test('changes the value if the prop value changes', async () => {
const { rerender } = render(<TextField key="test" label="Tacos" value="no, burritos" />);
const input = screen.getByRole('textbox');
fireEvent.input(input, { target: { value: 'i like tacos' } });
expect(input.value).toEqual('i like tacos');
rerender(<TextField key="test" label="Tacos" value="no, really, burritos" />);
expect(input.value).toEqual('no, really, burritos');
});
});
function FakeLeadingIcon() {
return <div data-testid="icon-leading" />;
}
function FakeTrailingIcon() {
return <div data-testid="icon-trailing" />;
}

View File

@@ -0,0 +1,115 @@
import { h, createRef } from 'preact';
import Tooltip from '../Tooltip';
import { render, screen } from '@testing-library/preact';
describe('Tooltip', () => {
test('renders in a relative position', async () => {
jest
.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
// relativeTo
.mockReturnValueOnce({
x: 100,
y: 100,
width: 50,
height: 10,
})
// tooltip
.mockReturnValueOnce({ width: 40, height: 15 });
const ref = createRef();
render(
<div>
<div ref={ref} />
<Tooltip relativeTo={ref} text="hello" />
</div>
);
const tooltip = await screen.findByRole('tooltip');
const style = window.getComputedStyle(tooltip);
expect(style.left).toEqual('103px');
expect(style.top).toEqual('68.5px');
});
test('if too far right, renders to the left', async () => {
window.innerWidth = 1024;
jest
.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
// relativeTo
.mockReturnValueOnce({
x: 1000,
y: 100,
width: 24,
height: 10,
})
// tooltip
.mockReturnValueOnce({ width: 50, height: 15 });
const ref = createRef();
render(
<div>
<div ref={ref} />
<Tooltip relativeTo={ref} text="hello" />
</div>
);
const tooltip = await screen.findByRole('tooltip');
const style = window.getComputedStyle(tooltip);
expect(style.left).toEqual('937px');
expect(style.top).toEqual('97px');
});
test('if too far left, renders to the right', async () => {
jest
.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
// relativeTo
.mockReturnValueOnce({
x: 0,
y: 100,
width: 24,
height: 10,
})
// tooltip
.mockReturnValueOnce({ width: 50, height: 15 });
const ref = createRef();
render(
<div>
<div ref={ref} />
<Tooltip relativeTo={ref} text="hello" />
</div>
);
const tooltip = await screen.findByRole('tooltip');
const style = window.getComputedStyle(tooltip);
expect(style.left).toEqual('32px');
expect(style.top).toEqual('97px');
});
test('if too close to top, renders to the bottom', async () => {
window.scrollY = 90;
jest
.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
// relativeTo
.mockReturnValueOnce({
x: 100,
y: 100,
width: 24,
height: 10,
})
// tooltip
.mockReturnValueOnce({ width: 50, height: 15 });
const ref = createRef();
render(
<div>
<div ref={ref} />
<Tooltip relativeTo={ref} text="hello" />
</div>
);
const tooltip = await screen.findByRole('tooltip');
const style = window.getComputedStyle(tooltip);
expect(style.left).toEqual('84px');
expect(style.top).toEqual('158.5px');
});
});

View File

@@ -0,0 +1,217 @@
import { h } from 'preact';
import * as IDB from 'idb-keyval';
import { DarkModeProvider, useDarkMode, usePersistence } from '..';
import { fireEvent, render, screen } from '@testing-library/preact';
import { useCallback } from 'preact/hooks';
function DarkModeChecker() {
const { currentMode } = useDarkMode();
return <div data-testid={currentMode}>{currentMode}</div>;
}
describe('DarkMode', () => {
let MockIDB;
beforeEach(() => {
MockIDB = {
get: jest.spyOn(IDB, 'get').mockImplementation(() => Promise.resolve(undefined)),
set: jest.spyOn(IDB, 'set').mockImplementation(() => Promise.resolve(true)),
};
});
test('uses media by default', async () => {
render(
<DarkModeProvider>
<DarkModeChecker />
</DarkModeProvider>
);
const el = await screen.findByTestId('media');
expect(el).toBeInTheDocument();
});
test('uses the mode stored in idb - dark', async () => {
MockIDB.get.mockResolvedValue('dark');
render(
<DarkModeProvider>
<DarkModeChecker />
</DarkModeProvider>
);
const el = await screen.findByTestId('dark');
expect(el).toBeInTheDocument();
expect(document.body.classList.contains('dark')).toBe(true);
});
test('uses the mode stored in idb - light', async () => {
MockIDB.get.mockResolvedValue('light');
render(
<DarkModeProvider>
<DarkModeChecker />
</DarkModeProvider>
);
const el = await screen.findByTestId('light');
expect(el).toBeInTheDocument();
expect(document.body.classList.contains('dark')).toBe(false);
});
test('allows updating the mode', async () => {
MockIDB.get.mockResolvedValue('dark');
function Updater() {
const { setDarkMode } = useDarkMode();
const handleClick = useCallback(() => {
setDarkMode('light');
}, [setDarkMode]);
return <div onClick={handleClick}>click me</div>;
}
render(
<DarkModeProvider>
<DarkModeChecker />
<Updater />
</DarkModeProvider>
);
const dark = await screen.findByTestId('dark');
expect(dark).toBeInTheDocument();
expect(document.body.classList.contains('dark')).toBe(true);
const button = await screen.findByText('click me');
fireEvent.click(button);
const light = await screen.findByTestId('light');
expect(light).toBeInTheDocument();
expect(document.body.classList.contains('dark')).toBe(false);
});
test('when using media, matches on preference', async () => {
MockIDB.get.mockResolvedValue('media');
jest.spyOn(window, 'matchMedia').mockImplementation((query) => {
if (query === '(prefers-color-scheme: dark)') {
return { matches: true, addEventListener: jest.fn(), removeEventListener: jest.fn() };
}
throw new Error(`Unexpected query to matchMedia: ${query}`);
});
render(
<DarkModeProvider>
<DarkModeChecker />
</DarkModeProvider>
);
const el = await screen.findByTestId('dark');
expect(el).toBeInTheDocument();
expect(document.body.classList.contains('dark')).toBe(true);
});
});
describe('usePersistence', () => {
let MockIDB;
beforeEach(() => {
MockIDB = {
get: jest.spyOn(IDB, 'get').mockImplementation(() => Promise.resolve(undefined)),
set: jest.spyOn(IDB, 'set').mockImplementation(() => Promise.resolve(true)),
};
});
test('returns a defaultValue initially', async () => {
MockIDB.get.mockImplementationOnce(
() =>
new Promise((resolve) => {
setTimeout(() => {
resolve('foo');
}, 1);
})
);
function Component() {
const [value, , loaded] = usePersistence('tacos', 'my-default');
return (
<div>
<div data-testid="loaded">{loaded ? 'loaded' : 'not loaded'}</div>
<div data-testid="value">{value}</div>
</div>
);
}
render(<Component />);
expect(screen.getByTestId('loaded')).toMatchInlineSnapshot(`
<div
data-testid="loaded"
>
not loaded
</div>
`);
expect(screen.getByTestId('value')).toMatchInlineSnapshot(`
<div
data-testid="value"
>
my-default
</div>
`);
jest.runAllTimers();
});
test('updates with the previously-persisted value', async () => {
MockIDB.get.mockResolvedValue('are delicious');
function Component() {
const [value, , loaded] = usePersistence('tacos', 'my-default');
return (
<div>
<div data-testid="loaded">{loaded ? 'loaded' : 'not loaded'}</div>
<div data-testid="value">{value}</div>
</div>
);
}
render(<Component />);
await screen.findByText('loaded');
expect(screen.getByTestId('loaded')).toMatchInlineSnapshot(`
<div
data-testid="loaded"
>
loaded
</div>
`);
expect(screen.getByTestId('value')).toMatchInlineSnapshot(`
<div
data-testid="value"
>
are delicious
</div>
`);
});
test('can be updated manually', async () => {
MockIDB.get.mockResolvedValue('are delicious');
function Component() {
const [value, setValue] = usePersistence('tacos', 'my-default');
const handleClick = useCallback(() => {
setValue('super delicious');
}, [setValue]);
return (
<div>
<div onClick={handleClick}>click me</div>
<div data-testid="value">{value}</div>
</div>
);
}
render(<Component />);
const button = await screen.findByText('click me');
fireEvent.click(button);
expect(screen.getByTestId('value')).toMatchInlineSnapshot(`
<div
data-testid="value"
>
super delicious
</div>
`);
});
});

View File

@@ -1,5 +0,0 @@
import { createContext } from 'preact';
export const Config = createContext({});
export const ApiHost = createContext(import.meta.env.SNOWPACK_PUBLIC_API_HOST || window.baseUrl || '');

111
web/src/context/index.jsx Normal file
View File

@@ -0,0 +1,111 @@
import { h, createContext } from 'preact';
import { get as getData, set as setData } from 'idb-keyval';
import { useCallback, useContext, useEffect, useLayoutEffect, useState } from 'preact/hooks';
const DarkMode = createContext(null);
export function DarkModeProvider({ children }) {
const [persistedMode, setPersistedMode] = useState(null);
const [currentMode, setCurrentMode] = useState(persistedMode !== 'media' ? persistedMode : null);
const setDarkMode = useCallback(
(value) => {
setPersistedMode(value);
setData('darkmode', value);
setCurrentMode(value);
},
[setPersistedMode]
);
useEffect(() => {
async function load() {
const darkmode = await getData('darkmode');
setDarkMode(darkmode || 'media');
}
load();
}, [setDarkMode]);
const handleMediaMatch = useCallback(
({ matches }) => {
if (matches) {
setCurrentMode('dark');
} else {
setCurrentMode('light');
}
},
[setCurrentMode]
);
useEffect(() => {
if (persistedMode !== 'media') {
return;
}
const query = window.matchMedia('(prefers-color-scheme: dark)');
query.addEventListener('change', handleMediaMatch);
handleMediaMatch(query);
}, [persistedMode, handleMediaMatch]);
useLayoutEffect(() => {
if (currentMode === 'dark') {
document.body.classList.add('dark');
} else {
document.body.classList.remove('dark');
}
}, [currentMode]);
return !persistedMode ? null : (
<DarkMode.Provider value={{ currentMode, persistedMode, setDarkMode }}>{children}</DarkMode.Provider>
);
}
export function useDarkMode() {
return useContext(DarkMode);
}
const Drawer = createContext(null);
export function DrawerProvider({ children }) {
const [showDrawer, setShowDrawer] = useState(false);
return <Drawer.Provider value={{ showDrawer, setShowDrawer }}>{children}</Drawer.Provider>;
}
export function useDrawer() {
return useContext(Drawer);
}
export function usePersistence(key, defaultValue = undefined) {
const [value, setInternalValue] = useState(defaultValue);
const [loaded, setLoaded] = useState(false);
const setValue = useCallback(
(value) => {
setInternalValue(value);
async function update() {
await setData(key, value);
}
update();
},
[key]
);
useEffect(() => {
setLoaded(false);
setInternalValue(defaultValue);
async function load() {
const value = await getData(key);
if (typeof value !== 'undefined') {
setValue(value);
}
setLoaded(true);
}
load();
}, [key, defaultValue, setValue]);
return [value, setValue, loaded];
}

2
web/src/env.js Normal file
View File

@@ -0,0 +1,2 @@
export const ENV = import.meta.env.MODE;
export const API_HOST = import.meta.env.SNOWPACK_PUBLIC_API_HOST;

59
web/src/hooks/index.jsx Normal file
View File

@@ -0,0 +1,59 @@
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
export function useResizeObserver(...refs) {
const [dimensions, setDimensions] = useState(
new Array(refs.length).fill({ width: 0, height: 0, x: -Infinity, y: -Infinity })
);
const resizeObserver = useMemo(
() =>
new ResizeObserver((entries) => {
window.requestAnimationFrame(() => {
setDimensions(entries.map((entry) => entry.contentRect));
});
}),
[]
);
useEffect(() => {
refs.forEach((ref) => {
resizeObserver.observe(ref.current);
});
return () => {
refs.forEach((ref) => {
resizeObserver.unobserve(ref.current);
});
};
}, [refs, resizeObserver]);
return dimensions;
}
export function useIntersectionObserver() {
const [entry, setEntry] = useState({});
const [node, setNode] = useState(null);
const observer = useRef(null);
useEffect(() => {
if (observer.current) {
observer.current.disconnect();
}
observer.current = new IntersectionObserver((entries) => {
window.requestAnimationFrame(() => {
setEntry(entries[0]);
});
});
if (node) {
observer.current.observe(node);
}
return () => {
observer.current.disconnect();
};
}, [node]);
return [entry, setNode];
}

View File

@@ -0,0 +1,13 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function ArrowDropdown({ className = '' }) {
return (
<svg className={`fill-current ${className}`} viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none" />
<path d="M7 10l5 5 5-5z" />
</svg>
);
}
export default memo(ArrowDropdown);

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