forked from Github/frigate
Compare commits
117 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5043040530 | ||
|
|
3c60aeeef9 | ||
|
|
0344d61b26 | ||
|
|
0e8467782b | ||
|
|
423ea26266 | ||
|
|
2f3339ba85 | ||
|
|
9433b50785 | ||
|
|
1e7b53dc0e | ||
|
|
bc94748f2a | ||
|
|
2395f93ed1 | ||
|
|
d771726c2a | ||
|
|
b2a2fe898c | ||
|
|
31d408a746 | ||
|
|
4a74f295e7 | ||
|
|
b6ba6459fb | ||
|
|
e399790442 | ||
|
|
20c65b9a31 | ||
|
|
1a7853a47e | ||
|
|
683c3a4c90 | ||
|
|
4a8d998afe | ||
|
|
fe59d90c51 | ||
|
|
f87813805a | ||
|
|
a7e5b9978f | ||
|
|
0a3959af86 | ||
|
|
9ba6054140 | ||
|
|
3348f04889 | ||
|
|
c12aec7c8f | ||
|
|
05f66b8f24 | ||
|
|
d8b80f0fe9 | ||
|
|
7314572d97 | ||
|
|
52a29ed00a | ||
|
|
5eaf8a5448 | ||
|
|
f70fb12c3d | ||
|
|
ece6c1203c | ||
|
|
9c7e3177a2 | ||
|
|
058c0affaf | ||
|
|
5ee7146884 | ||
|
|
1aa9a7a093 | ||
|
|
a202c44a0f | ||
|
|
85776cc7d0 | ||
|
|
6d133ef724 | ||
|
|
53288d361c | ||
|
|
e729bd52aa | ||
|
|
ddb6127519 | ||
|
|
50b42eb6fe | ||
|
|
b6572b7272 | ||
|
|
57ced2c284 | ||
|
|
26a3491466 | ||
|
|
eed8463832 | ||
|
|
718b4f3fd7 | ||
|
|
22461d1728 | ||
|
|
69cab1e6bb | ||
|
|
a661fddaf3 | ||
|
|
1b85e561b9 | ||
|
|
a803ab8577 | ||
|
|
daa759cc55 | ||
|
|
513a099c24 | ||
|
|
e299e73a68 | ||
|
|
9550ac7422 | ||
|
|
07bd376649 | ||
|
|
ec6a1ed9d1 | ||
|
|
7aee28d080 | ||
|
|
24ec13e36d | ||
|
|
d2e7c360b9 | ||
|
|
f00628f4e5 | ||
|
|
19bd5ace7d | ||
|
|
3e2506136c | ||
|
|
4e03acc944 | ||
|
|
188eb6b9ea | ||
|
|
c89e1a5735 | ||
|
|
e50cc59f0d | ||
|
|
96f87caff0 | ||
|
|
b422a83b57 | ||
|
|
15ae3bee55 | ||
|
|
0cac2fec2a | ||
|
|
5965da88c3 | ||
|
|
ba0338e9d5 | ||
|
|
ff62338359 | ||
|
|
9867f4eeee | ||
|
|
ba278dfc3d | ||
|
|
063030bcf3 | ||
|
|
276ce8710c | ||
|
|
5ed7a17f46 | ||
|
|
01c3b4fa6e | ||
|
|
165ca8fbc7 | ||
|
|
ce90ae343c | ||
|
|
a99f360a64 | ||
|
|
d51e9446ff | ||
|
|
d3524ee46f | ||
|
|
121ea37825 | ||
|
|
9592d95599 | ||
|
|
d6faa18adb | ||
|
|
1cbe6f77ee | ||
|
|
4f5d4e36b7 | ||
|
|
163025c1f2 | ||
|
|
880178d62e | ||
|
|
d285ff7e54 | ||
|
|
54671fc522 | ||
|
|
53e3e6545d | ||
|
|
91cb49c4a3 | ||
|
|
c065cb48f2 | ||
|
|
d376f6b1d2 | ||
|
|
45526a7652 | ||
|
|
cc7929932b | ||
|
|
e6516235fa | ||
|
|
40d5a9f890 | ||
|
|
ee3e744cc6 | ||
|
|
b55bd1e027 | ||
|
|
9a96df0319 | ||
|
|
e9b1618364 | ||
|
|
6dc6ed1e94 | ||
|
|
1943a49274 | ||
|
|
a8c00edc94 | ||
|
|
faa8abb2b9 | ||
|
|
f6cd2fc68e | ||
|
|
6482000d6b | ||
|
|
dcf7209706 |
4
.github/FUNDING.yml
vendored
4
.github/FUNDING.yml
vendored
@@ -1 +1,3 @@
|
||||
github: blakeblackshear
|
||||
github:
|
||||
- blakeblackshear
|
||||
- paularmstrong
|
||||
|
||||
6
.github/ISSUE_TEMPLATE/bug_report.md
vendored
6
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -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
17
.github/stale.yml
vendored
Normal 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
46
.github/workflows/pull_request.yml
vendored
Normal 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
1
.gitignore
vendored
@@ -9,3 +9,4 @@ models
|
||||
frigate/version.py
|
||||
web/build
|
||||
web/node_modules
|
||||
web/coverage
|
||||
|
||||
18
Makefile
18
Makefile
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
131
docs/docs/contributing.md
Normal 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.
|
||||
@@ -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) |
|
||||
|
||||
@@ -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>
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
## "[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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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 }}"
|
||||
|
||||
@@ -10,5 +10,6 @@ module.exports = {
|
||||
'configuration/advanced',
|
||||
],
|
||||
Usage: ['usage/home-assistant', 'usage/web', 'usage/api', 'usage/mqtt'],
|
||||
Development: ['contributing'],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
102
frigate/http.py
102
frigate/http.py
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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()):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
2
web/.eslintignore
Normal file
@@ -0,0 +1,2 @@
|
||||
build/*
|
||||
node_modules/*
|
||||
140
web/.eslintrc.js
Normal file
140
web/.eslintrc.js
Normal 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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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
4
web/babel.config.js
Normal 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
18
web/config/setupTests.js
Normal 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
9
web/jest.config.js
Normal 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
12294
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
5
web/prettier.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
printWidth: 120,
|
||||
singleQuote: true,
|
||||
useTabs: false,
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
46
web/src/AppBar.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
2
web/src/__mocks__/env.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export const ENV = 'test';
|
||||
export const API_HOST = 'http://base-url.local:5000';
|
||||
27
web/src/__tests__/App.test.jsx
Normal file
27
web/src/__tests__/App.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
53
web/src/__tests__/AppBar.test.jsx
Normal file
53
web/src/__tests__/AppBar.test.jsx
Normal 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');
|
||||
});
|
||||
});
|
||||
33
web/src/__tests__/Sidebar.test.jsx
Normal file
33
web/src/__tests__/Sidebar.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
121
web/src/api/__tests__/index.test.jsx
Normal file
121
web/src/api/__tests__/index.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
135
web/src/api/__tests__/mqtt.test.jsx
Normal file
135
web/src/api/__tests__/mqtt.test.jsx
Normal 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
2
web/src/api/baseUrl.js
Normal 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
121
web/src/api/index.jsx
Normal 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
120
web/src/api/mqtt.jsx
Normal 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 };
|
||||
}
|
||||
15
web/src/components/ActivityIndicator.jsx
Normal file
15
web/src/components/ActivityIndicator.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
web/src/components/AppBar.jsx
Normal file
68
web/src/components/AppBar.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
52
web/src/components/Card.jsx
Normal file
52
web/src/components/Card.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
16
web/src/components/LinkedLogo.jsx
Normal file
16
web/src/components/LinkedLogo.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
web/src/components/Logo.jsx
Normal file
9
web/src/components/Logo.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
web/src/components/Menu.jsx
Normal file
44
web/src/components/Menu.jsx
Normal 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" />;
|
||||
}
|
||||
65
web/src/components/NavigationDrawer.jsx
Normal file
65
web/src/components/NavigationDrawer.jsx
Normal 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" />;
|
||||
}
|
||||
129
web/src/components/RelativeModal.jsx
Normal file
129
web/src/components/RelativeModal.jsx
Normal 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;
|
||||
}
|
||||
109
web/src/components/Select.jsx
Normal file
109
web/src/components/Select.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
102
web/src/components/TextField.jsx
Normal file
102
web/src/components/TextField.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
web/src/components/Tooltip.jsx
Normal file
63
web/src/components/Tooltip.jsx
Normal 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;
|
||||
}
|
||||
47
web/src/components/__tests__/ActivityIndicator.test.jsx
Normal file
47
web/src/components/__tests__/ActivityIndicator.test.jsx
Normal 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>
|
||||
`);
|
||||
});
|
||||
});
|
||||
132
web/src/components/__tests__/AppBar.test.jsx
Normal file
132
web/src/components/__tests__/AppBar.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
36
web/src/components/__tests__/Button.test.jsx
Normal file
36
web/src/components/__tests__/Button.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
40
web/src/components/__tests__/CameraImage.test.jsx
Normal file
40
web/src/components/__tests__/CameraImage.test.jsx
Normal 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');
|
||||
});
|
||||
});
|
||||
46
web/src/components/__tests__/Card.test.jsx
Normal file
46
web/src/components/__tests__/Card.test.jsx
Normal 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');
|
||||
});
|
||||
});
|
||||
25
web/src/components/__tests__/Heading.test.jsx
Normal file
25
web/src/components/__tests__/Heading.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
17
web/src/components/__tests__/Link.test.jsx
Normal file
17
web/src/components/__tests__/Link.test.jsx
Normal 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>
|
||||
`);
|
||||
});
|
||||
});
|
||||
52
web/src/components/__tests__/Menu.test.jsx
Normal file
52
web/src/components/__tests__/Menu.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
61
web/src/components/__tests__/NavigationDrawer.test.jsx
Normal file
61
web/src/components/__tests__/NavigationDrawer.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
63
web/src/components/__tests__/RelativeModal.test.jsx
Normal file
63
web/src/components/__tests__/RelativeModal.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
34
web/src/components/__tests__/Select.test.jsx
Normal file
34
web/src/components/__tests__/Select.test.jsx
Normal 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');
|
||||
});
|
||||
});
|
||||
47
web/src/components/__tests__/Switch.test.jsx
Normal file
47
web/src/components/__tests__/Switch.test.jsx
Normal 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');
|
||||
});
|
||||
});
|
||||
73
web/src/components/__tests__/TextField.test.jsx
Normal file
73
web/src/components/__tests__/TextField.test.jsx
Normal 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" />;
|
||||
}
|
||||
115
web/src/components/__tests__/Toolltip.test.jsx
Normal file
115
web/src/components/__tests__/Toolltip.test.jsx
Normal 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');
|
||||
});
|
||||
});
|
||||
217
web/src/context/__tests__/index.test.jsx
Normal file
217
web/src/context/__tests__/index.test.jsx
Normal 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>
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -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
111
web/src/context/index.jsx
Normal 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
2
web/src/env.js
Normal 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
59
web/src/hooks/index.jsx
Normal 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];
|
||||
}
|
||||
13
web/src/icons/ArrowDropdown.jsx
Normal file
13
web/src/icons/ArrowDropdown.jsx
Normal 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
Reference in New Issue
Block a user