Compare commits

..

16 Commits

Author SHA1 Message Date
Blake Blackshear
d376f6b1d2 increment version 2021-01-31 06:20:59 -06:00
Paul Armstrong
45526a7652 feat(web): activity indicator while loading 2021-01-31 06:18:35 -06:00
Paul Armstrong
cc7929932b feat(nginx): enable gzip compression and cache control for static files 2021-01-31 06:18:35 -06:00
Paul Armstrong
e6516235fa feat(web): auto-paginate events page 2021-01-31 06:18:35 -06:00
Blake Blackshear
40d5a9f890 change default log level 2021-01-31 06:18:35 -06:00
Blake Blackshear
ee3e744cc6 tail last 100 lines of ffmpeg logs and dump when failure detected 2021-01-31 06:18:35 -06:00
Blake Blackshear
b55bd1e027 add param to reduce response sizes by excluding thumbnails in api response 2021-01-31 06:18:35 -06:00
Jeff Billimek
9a96df0319 update docs to reflect new chart home
Signed-off-by: Jeff Billimek <jeff@billimek.com>
2021-01-30 22:15:20 -06:00
Blake Blackshear
e9b1618364 add note about protection mode for tmpfs fixes #658 2021-01-29 06:42:55 -06:00
Blake Blackshear
6dc6ed1e94 more detailed reolink args 2021-01-29 06:40:32 -06:00
Blake Blackshear
1943a49274 add audio info to docs 2021-01-29 06:35:54 -06:00
Paul Armstrong
a8c00edc94 fix(web): reduce transferred/unused assets on html load 2021-01-29 06:27:32 -06:00
Blake Blackshear
faa8abb2b9 docs updates 2021-01-28 08:21:04 -06:00
Blake Blackshear
f6cd2fc68e clarifying addon docs 2021-01-28 07:45:09 -06:00
Paul Armstrong
6482000d6b fix(web): image loading for firefox 2021-01-28 07:05:45 -06:00
Justin Goette
dcf7209706 Fix camera.md links 2021-01-28 06:43:43 -06:00
31 changed files with 648 additions and 446 deletions

View File

@@ -3,7 +3,7 @@ default_target: amd64_frigate
COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1) COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1)
version: version:
echo "VERSION='0.8.0-$(COMMIT_HASH)'" > frigate/version.py echo "VERSION='0.8.1-$(COMMIT_HASH)'" > frigate/version.py
web: web:
docker build --tag frigate-web --file docker/Dockerfile.web web/ docker build --tag frigate-web --file docker/Dockerfile.web web/

View File

@@ -22,6 +22,9 @@ Use of a [Google Coral Accelerator](https://coral.ai/products/) is optional, but
View the documentation at https://blakeblackshear.github.io/frigate 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 ## Screenshots
Integration into HomeAssistant Integration into HomeAssistant
<div> <div>

View File

@@ -386,6 +386,29 @@ ffmpeg:
- '1' - '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 ### Blue Iris RTSP Cameras
You will need to remove `nobuffer` flag for Blue Iris RTSP cameras You will need to remove `nobuffer` flag for Blue Iris RTSP cameras

View File

@@ -3,7 +3,7 @@ id: index
title: Configuration 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: It is recommended to start with a minimal configuration and add to it:
@@ -51,7 +51,7 @@ mqtt:
## `cameras` ## `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 ```yaml
cameras: cameras:
@@ -80,7 +80,8 @@ clips:
max_seconds: 300 max_seconds: 300
# Optional: size of tmpfs mount to create for cache files (default: not set) # Optional: size of tmpfs mount to create for cache files (default: not set)
# mount -t tmpfs -o size={tmpfs_cache_size} tmpfs /tmp/cache # 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 tmpfs_cache_size: 256m
# Optional: Retention settings for clips (default: shown below) # Optional: Retention settings for clips (default: shown below)
retain: retain:
@@ -96,7 +97,7 @@ clips:
```yaml ```yaml
ffmpeg: ffmpeg:
# Optional: global ffmpeg args (default: shown below) # 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) # Optional: global hwaccel args (default: shown below)
# NOTE: See hardware acceleration docs for your specific device # NOTE: See hardware acceleration docs for your specific device
hwaccel_args: [] hwaccel_args: []

View File

@@ -17,6 +17,7 @@ HassOS users can install via the addon repository. Frigate requires an MQTT serv
1. Add https://github.com/blakeblackshear/frigate-hass-addons 1. Add https://github.com/blakeblackshear/frigate-hass-addons
1. Setup your configuration in the `Configuration` tab 1. Setup your configuration in the `Configuration` tab
1. Start the addon container 1. Start the addon container
1. If you are using hardware acceleration for ffmpeg, you will need to disable "Protection mode"
## Docker ## Docker
@@ -74,15 +75,27 @@ docker run --rm \
blakeblackshear/frigate:0.8.0-beta2-amd64 blakeblackshear/frigate:0.8.0-beta2-amd64
``` ```
### 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 ## 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 ## 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. 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: Some people have had success running Frigate in LXC directly with the following config:
@@ -104,12 +117,6 @@ lxc.cgroup.devices.allow: a
lxc.cap.drop: lxc.cap.drop:
``` ```
### Calculating shm-size ### ESX
For details on running Frigate under ESX, see details [here](https://github.com/blakeblackshear/frigate/issues/305).
The default shm-size of 64m is fine for setups with 3 or less 1080p cameras. If frigate is exiting with "Bus error" messages, it could be because you have too many high resolution cameras and you need to specify a higher shm size.
You can calculate the necessary shm-size for each camera with the following formula:
```
(width * height * 1.5 * 7 + 270480)/1048576 = <shm size in mb>
```

View File

@@ -1,8 +1,11 @@
--- ---
id: troubleshooting 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 default ffmpeg args are shown [here](configuration/index#ffmpeg).
### My mjpeg stream or snapshots look green and crazy ### 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. 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.
@@ -12,15 +15,6 @@ This almost always means that the width/height defined for your camera are not c
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. 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. If you see repeated "On connect called" messages in your config, check for another instance of frigate. This happens when multiple frigate containers are trying to connect to mqtt with the same client_id.

View File

@@ -135,16 +135,17 @@ Version info
Events from the database. Accepts the following query string parameters: Events from the database. Accepts the following query string parameters:
| param | Type | Description | | param | Type | Description |
| -------------- | ---- | --------------------------------------------- | | -------------------- | ---- | --------------------------------------------- |
| `before` | int | Epoch time | | `before` | int | Epoch time |
| `after` | int | Epoch time | | `after` | int | Epoch time |
| `camera` | str | Camera name | | `camera` | str | Camera name |
| `label` | str | Label name | | `label` | str | Label name |
| `zone` | str | Zone name | | `zone` | str | Zone name |
| `limit` | int | Limit the number of events returned | | `limit` | int | Limit the number of events returned |
| `has_snapshot` | int | Filter to events that have snapshots (0 or 1) | | `has_snapshot` | int | Filter to events that have snapshots (0 or 1) |
| `has_clip` | int | Filter to events that have clips (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` ### `/api/events/summary`
@@ -159,16 +160,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. 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` ### `/api/events/<id>/snapshot.jpg`
Returns the snapshot image for the event id. Works while the event is in progress and after completion. 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: 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| | param | Type | Description |
|----|-----|--| | ----------- | ---- | ------------------------------------------------- |
|`h`|int|Height in pixels| | `h` | int | Height in pixels |
|`bbox`|int|Show bounding boxes for detected objects (0 or 1)| | `bbox` | int | Show bounding boxes for detected objects (0 or 1) |
|`timestamp`|int|Print the timestamp in the upper left (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)| | `crop` | int | Crop the snapshot to the (0 or 1) |
### `/clips/<camera>-<id>.mp4` ### `/clips/<camera>-<id>.mp4`

View File

@@ -63,7 +63,7 @@ CLIPS_SCHEMA = vol.Schema(
} }
) )
FFMPEG_GLOBAL_ARGS_DEFAULT = ['-hide_banner','-loglevel','fatal'] FFMPEG_GLOBAL_ARGS_DEFAULT = ['-hide_banner','-loglevel','warning']
FFMPEG_INPUT_ARGS_DEFAULT = ['-avoid_negative_ts', 'make_zero', FFMPEG_INPUT_ARGS_DEFAULT = ['-avoid_negative_ts', 'make_zero',
'-fflags', '+genpts+discardcorrupt', '-fflags', '+genpts+discardcorrupt',
'-rtsp_transport', 'tcp', '-rtsp_transport', 'tcp',

View File

@@ -55,7 +55,7 @@ def events_summary():
if not has_clip is None: if not has_clip is None:
clauses.append((Event.has_clip == has_clip)) clauses.append((Event.has_clip == has_clip))
if not has_snapshot is None: if not has_snapshot is None:
clauses.append((Event.has_snapshot == has_snapshot)) clauses.append((Event.has_snapshot == has_snapshot))
@@ -160,12 +160,14 @@ def events():
camera = request.args.get('camera') camera = request.args.get('camera')
label = request.args.get('label') label = request.args.get('label')
zone = request.args.get('zone') zone = request.args.get('zone')
after = request.args.get('after', type=int) after = request.args.get('after', type=float)
before = request.args.get('before', type=int) before = request.args.get('before', type=float)
has_clip = request.args.get('has_clip', type=int) has_clip = request.args.get('has_clip', type=int)
has_snapshot = request.args.get('has_snapshot', type=int) has_snapshot = request.args.get('has_snapshot', type=int)
include_thumbnails = request.args.get('include_thumbnails', default=1, type=int)
clauses = [] clauses = []
excluded_fields = []
if camera: if camera:
clauses.append((Event.camera == camera)) clauses.append((Event.camera == camera))
@@ -184,10 +186,13 @@ def events():
if not has_clip is None: if not has_clip is None:
clauses.append((Event.has_clip == has_clip)) clauses.append((Event.has_clip == has_clip))
if not has_snapshot is None: if not has_snapshot is None:
clauses.append((Event.has_snapshot == has_snapshot)) clauses.append((Event.has_snapshot == has_snapshot))
if not include_thumbnails:
excluded_fields.append(Event.thumbnail)
if len(clauses) == 0: if len(clauses) == 0:
clauses.append((1 == 1)) clauses.append((1 == 1))
@@ -196,7 +201,7 @@ def events():
.order_by(Event.start_time.desc()) .order_by(Event.start_time.desc())
.limit(limit)) .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') @bp.route('/config')
def config(): def config():

View File

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

View File

@@ -181,6 +181,7 @@ class CameraWatchdog(threading.Thread):
now = datetime.datetime.now().timestamp() now = datetime.datetime.now().timestamp()
if not self.capture_thread.is_alive(): if not self.capture_thread.is_alive():
self.logpipe.dump()
self.start_ffmpeg_detect() self.start_ffmpeg_detect()
elif now - self.capture_thread.current_frame.value > 20: 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...") 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() poll = p['process'].poll()
if poll == None: if poll == None:
continue continue
p['logpipe'].dump()
p['process'] = start_or_restart_ffmpeg(p['cmd'], self.logger, p['logpipe'], ffmpeg_process=p['process']) p['process'] = start_or_restart_ffmpeg(p['cmd'], self.logger, p['logpipe'], ffmpeg_process=p['process'])
# wait a bit before checking again # wait a bit before checking again

View File

@@ -23,6 +23,12 @@ http {
keepalive_timeout 65; 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 { upstream frigate_api {
server localhost:5001; server localhost:5001;
keepalive 1024; keepalive 1024;
@@ -98,6 +104,7 @@ http {
location /api/ { location /api/ {
add_header 'Access-Control-Allow-Origin' '*'; add_header 'Access-Control-Allow-Origin' '*';
add_header Cache-Control "no-store";
proxy_pass http://frigate_api/; proxy_pass http://frigate_api/;
proxy_pass_request_headers on; proxy_pass_request_headers on;
proxy_set_header Host $host; proxy_set_header Host $host;
@@ -105,13 +112,23 @@ http {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
location / { 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 'href="/' 'href="$http_x_ingress_path/';
sub_filter 'url(/' 'url($http_x_ingress_path/'; sub_filter 'url(/' 'url($http_x_ingress_path/';
sub_filter '"/js/' '"$http_x_ingress_path/js/'; sub_filter '"/js/' '"$http_x_ingress_path/js/';
sub_filter '<body>' '<body><script>window.baseUrl="$http_x_ingress_path";</script>'; sub_filter '<body>' '<body><script>window.baseUrl="$http_x_ingress_path";</script>';
sub_filter_types text/css application/javascript; sub_filter_types text/css application/javascript;
sub_filter_once off; sub_filter_once off;
root /opt/frigate/web; root /opt/frigate/web;
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }

289
web/package-lock.json generated
View File

@@ -93,13 +93,6 @@
"@babel/helper-validator-option": "^7.12.1", "@babel/helper-validator-option": "^7.12.1",
"browserslist": "^4.14.5", "browserslist": "^4.14.5",
"semver": "^5.5.0" "semver": "^5.5.0"
},
"dependencies": {
"semver": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
"integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ=="
}
} }
}, },
"@babel/helper-create-class-features-plugin": { "@babel/helper-create-class-features-plugin": {
@@ -871,13 +864,6 @@
"@babel/types": "^7.12.11", "@babel/types": "^7.12.11",
"core-js-compat": "^3.8.0", "core-js-compat": "^3.8.0",
"semver": "^5.5.0" "semver": "^5.5.0"
},
"dependencies": {
"semver": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
"integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ=="
}
} }
}, },
"@babel/preset-modules": { "@babel/preset-modules": {
@@ -968,22 +954,12 @@
} }
}, },
"@npmcli/move-file": { "@npmcli/move-file": {
"version": "1.1.0", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.1.tgz",
"integrity": "sha512-Iv2iq0JuyYjKeFkSR4LPaCdDZwlGK9X2cP/01nJcp3yMJ1FjNd9vpiEYvLUgzBxKPg2SFmaOhizoQsPc0LWeOQ==", "integrity": "sha512-LtWTicuF2wp7PNTuyCwABx7nNG+DnzSE8gN0iWxkC6mpgm/iOPu0ZMTkXuCxmJxtWFsDxUaixM9COSNJEMUfuQ==",
"requires": { "requires": {
"mkdirp": "^1.0.4", "mkdirp": "^1.0.4",
"rimraf": "^2.7.1" "rimraf": "^3.0.2"
},
"dependencies": {
"rimraf": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
"integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
"requires": {
"glob": "^7.1.3"
}
}
} }
}, },
"@prefresh/babel-plugin": { "@prefresh/babel-plugin": {
@@ -1013,24 +989,6 @@
"resolved": "https://registry.npmjs.org/@prefresh/utils/-/utils-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@prefresh/utils/-/utils-1.1.1.tgz",
"integrity": "sha512-MUhT5m2XNN5NsZl4GnpuvlzLo6VSTa/+wBfBd3fiWUvHGhv0GF9hnA1pd//v0uJaKwUnVRQ1hYElxCV7DtYsCQ==" "integrity": "sha512-MUhT5m2XNN5NsZl4GnpuvlzLo6VSTa/+wBfBd3fiWUvHGhv0GF9hnA1pd//v0uJaKwUnVRQ1hYElxCV7DtYsCQ=="
}, },
"@snowpack/plugin-optimize": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/@snowpack/plugin-optimize/-/plugin-optimize-0.2.13.tgz",
"integrity": "sha512-DW0+iaSc80vWZKDIKbdR5pqDkNDsv3ZbH8eVB8yppK/JlBmOqmsUGdKlZ2eq8XDxE23/V5+gmA9RXLWcTMJ1eQ==",
"requires": {
"csso": "^4.1.0",
"es-module-lexer": "^0.3.25",
"esbuild": "^0.8.0",
"glob": "^7.1.6",
"html-minifier": "^4.0.0",
"hypertag": "^0.0.3",
"kleur": "^4.1.3",
"mkdirp": "^1.0.4",
"node-inject-html": "^0.0.5",
"object.fromentries": "^2.0.2",
"p-queue": "^6.6.1"
}
},
"@snowpack/plugin-postcss": { "@snowpack/plugin-postcss": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@snowpack/plugin-postcss/-/plugin-postcss-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@snowpack/plugin-postcss/-/plugin-postcss-1.1.0.tgz",
@@ -1061,14 +1019,14 @@
} }
}, },
"@types/json-schema": { "@types/json-schema": {
"version": "7.0.6", "version": "7.0.7",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.6.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.7.tgz",
"integrity": "sha512-3c+yGKvVP5Y9TYBEibGNR+kLtijnj7mYrXRg+WpFb2X9xm04g/DXYkfg4hmzJQosc9snFNUPkbYIhu+KAm6jJw==" "integrity": "sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA=="
}, },
"@types/node": { "@types/node": {
"version": "14.14.21", "version": "14.14.22",
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.21.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.22.tgz",
"integrity": "sha512-cHYfKsnwllYhjOzuC5q1VpguABBeecUp24yFluHpn/BQaVxB1CuQ1FSRZCzrPxrkIfWISXV2LbeoBthLWg0+0A==" "integrity": "sha512-g+f/qj/cNcqKkc3tFqlXOYjrmZA+jNBiDzbP3kH+B+otKFqAdPgVTGP1IeKRdMml/aE69as5S4FqtxAbl+LaMw=="
}, },
"@types/parse-json": { "@types/parse-json": {
"version": "4.0.0", "version": "4.0.0",
@@ -2104,16 +2062,16 @@
"integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=" "integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40="
}, },
"core-js": { "core-js": {
"version": "3.8.2", "version": "3.8.3",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.8.2.tgz", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.8.3.tgz",
"integrity": "sha512-FfApuSRgrR6G5s58casCBd9M2k+4ikuu4wbW6pJyYU7bd9zvFc9qf7vr5xmrZOhT9nn+8uwlH1oRR9jTnFoA3A==" "integrity": "sha512-KPYXeVZYemC2TkNEkX/01I+7yd+nX3KddKwZ1Ww7SKWdI2wQprSgLmrTddT8nw92AjEklTsPBoSdQBhbI1bQ6Q=="
}, },
"core-js-compat": { "core-js-compat": {
"version": "3.8.2", "version": "3.8.3",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.8.2.tgz", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.8.3.tgz",
"integrity": "sha512-LO8uL9lOIyRRrQmZxHZFl1RV+ZbcsAkFWTktn5SmH40WgLtSNYN4m4W2v9ONT147PxBY/XrRhrWq8TlvObyUjQ==", "integrity": "sha512-1sCb0wBXnBIL16pfFG1Gkvei6UzvKyTNYpiC41yrdjEv0UoJoq9E/abTMzyYJ6JpTkAj15dLjbqifIzEBDVvog==",
"requires": { "requires": {
"browserslist": "^4.16.0", "browserslist": "^4.16.1",
"semver": "7.0.0" "semver": "7.0.0"
}, },
"dependencies": { "dependencies": {
@@ -2824,9 +2782,9 @@
} }
}, },
"entities": { "entities": {
"version": "2.1.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-2.1.0.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
"integrity": "sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==" "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="
}, },
"errno": { "errno": {
"version": "0.1.8", "version": "0.1.8",
@@ -2863,11 +2821,6 @@
"string.prototype.trimstart": "^1.0.1" "string.prototype.trimstart": "^1.0.1"
} }
}, },
"es-module-lexer": {
"version": "0.3.26",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.3.26.tgz",
"integrity": "sha512-Va0Q/xqtrss45hWzP8CZJwzGSZJjDM5/MJRE3IXXnUCcVLElR9BRaE9F62BopysASyc4nM3uwhSW7FFB9nlWAA=="
},
"es-to-primitive": { "es-to-primitive": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz",
@@ -2944,11 +2897,6 @@
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==" "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="
}, },
"eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
},
"events": { "events": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.2.0.tgz", "resolved": "https://registry.npmjs.org/events/-/events-3.2.0.tgz",
@@ -3575,11 +3523,6 @@
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
"integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==" "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="
}, },
"hypertag": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/hypertag/-/hypertag-0.0.3.tgz",
"integrity": "sha512-Z/cAsLCihKj+QJGQt5X19L/YY8PW5l8/A9Ju7s4g1ty4kwDdSzGp2QdRoKb266kFglJw6+CSsR/zNpkrOEo54A=="
},
"iconv-lite": { "iconv-lite": {
"version": "0.4.24", "version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@@ -3631,6 +3574,11 @@
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz",
"integrity": "sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==" "integrity": "sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw=="
}, },
"immer": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/immer/-/immer-8.0.1.tgz",
"integrity": "sha512-aqXhGP7//Gui2+UrEtvxZxSquQVXTpZ7KDxfCcKAF3Vysvw0CViVaW9RZ1j1xlIYqaaaipBoqdqeibkc18PNvA=="
},
"import-cwd": { "import-cwd": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-3.0.0.tgz", "resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-3.0.0.tgz",
@@ -3928,12 +3876,9 @@
"integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==" "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA=="
}, },
"is-wsl": { "is-wsl": {
"version": "2.2.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz",
"integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", "integrity": "sha1-HxbkqiKwTRM2tmGIpmrzxgDDpm0="
"requires": {
"is-docker": "^2.0.0"
}
}, },
"isarray": { "isarray": {
"version": "1.0.0", "version": "1.0.0",
@@ -4102,11 +4047,6 @@
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==" "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="
}, },
"kleur": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.3.tgz",
"integrity": "sha512-H1tr8QP2PxFTNwAFM74Mui2b6ovcY9FoxJefgrwxY+OCJcq01k5nvhf4M/KnizzrJvLRap5STUy7dgDV35iUBw=="
},
"last-call-webpack-plugin": { "last-call-webpack-plugin": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/last-call-webpack-plugin/-/last-call-webpack-plugin-3.0.0.tgz", "resolved": "https://registry.npmjs.org/last-call-webpack-plugin/-/last-call-webpack-plugin-3.0.0.tgz",
@@ -4227,6 +4167,13 @@
"integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
"requires": { "requires": {
"semver": "^6.0.0" "semver": "^6.0.0"
},
"dependencies": {
"semver": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
"integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw=="
}
} }
}, },
"map-cache": { "map-cache": {
@@ -4320,23 +4267,15 @@
"integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==" "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="
}, },
"mini-css-extract-plugin": { "mini-css-extract-plugin": {
"version": "1.3.4", "version": "1.3.5",
"resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-1.3.4.tgz", "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-1.3.5.tgz",
"integrity": "sha512-dNjqyeogUd8ucUgw5sxm1ahvSfSUgef7smbmATRSbDm4EmNx5kQA6VdUEhEeCKSjX6CTYjb5vxgMUvRjqP3uHg==", "integrity": "sha512-tvmzcwqJJXau4OQE5vT72pRT18o2zF+tQJp8CWchqvfQnTlflkzS+dANYcRdyPRWUWRkfmeNTKltx0NZI/b5dQ==",
"requires": { "requires": {
"loader-utils": "^2.0.0", "loader-utils": "^2.0.0",
"schema-utils": "^3.0.0", "schema-utils": "^3.0.0",
"webpack-sources": "^1.1.0" "webpack-sources": "^1.1.0"
}, },
"dependencies": { "dependencies": {
"json5": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.1.3.tgz",
"integrity": "sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA==",
"requires": {
"minimist": "^1.2.5"
}
},
"loader-utils": { "loader-utils": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.0.tgz", "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.0.tgz",
@@ -4469,11 +4408,6 @@
"resolved": "https://registry.npmjs.org/modern-normalize/-/modern-normalize-1.0.0.tgz", "resolved": "https://registry.npmjs.org/modern-normalize/-/modern-normalize-1.0.0.tgz",
"integrity": "sha512-1lM+BMLGuDfsdwf3rsgBSrxJwAZHFIrQ8YR61xIqdHo0uNKI9M52wNpHSrliZATJp51On6JD0AfRxd4YGSU0lw==" "integrity": "sha512-1lM+BMLGuDfsdwf3rsgBSrxJwAZHFIrQ8YR61xIqdHo0uNKI9M52wNpHSrliZATJp51On6JD0AfRxd4YGSU0lw=="
}, },
"moo": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/moo/-/moo-0.5.1.tgz",
"integrity": "sha512-I1mnb5xn4fO80BH9BLcF0yLypy2UKl+Cb01Fu0hJRkJjlCRtxZMWkTdAtDd5ZqCOxtCkhmRwyI57vWT+1iZ67w=="
},
"move-concurrently": { "move-concurrently": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz", "resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz",
@@ -4560,14 +4494,6 @@
"lodash.toarray": "^4.4.0" "lodash.toarray": "^4.4.0"
} }
}, },
"node-inject-html": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/node-inject-html/-/node-inject-html-0.0.5.tgz",
"integrity": "sha512-YUjHKq6sjD5rru/lqxQ0BHLvgJP33T54/ehsxgi1sShUkA5cU+Kb/eZ8UnbjaZeKxueA5D75vwAmyc+B5Kof1Q==",
"requires": {
"moo": "^0.5.1"
}
},
"node-libs-browser": { "node-libs-browser": {
"version": "2.2.1", "version": "2.2.1",
"resolved": "https://registry.npmjs.org/node-libs-browser/-/node-libs-browser-2.2.1.tgz", "resolved": "https://registry.npmjs.org/node-libs-browser/-/node-libs-browser-2.2.1.tgz",
@@ -4729,17 +4655,6 @@
"has": "^1.0.3" "has": "^1.0.3"
} }
}, },
"object.fromentries": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.3.tgz",
"integrity": "sha512-IDUSMXs6LOSJBWE++L0lzIbSqHl9KDCfff2x/JSEIDtEUavUnyMYC2ZGay/04Zq4UT8lvd4xNhU4/YHKibAOlw==",
"requires": {
"call-bind": "^1.0.0",
"define-properties": "^1.1.3",
"es-abstract": "^1.18.0-next.1",
"has": "^1.0.3"
}
},
"object.getownpropertydescriptors": { "object.getownpropertydescriptors": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.1.tgz", "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.1.tgz",
@@ -4792,6 +4707,16 @@
"requires": { "requires": {
"is-docker": "^2.0.0", "is-docker": "^2.0.0",
"is-wsl": "^2.1.1" "is-wsl": "^2.1.1"
},
"dependencies": {
"is-wsl": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
"integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
"requires": {
"is-docker": "^2.0.0"
}
}
} }
}, },
"optimize-css-assets-webpack-plugin": { "optimize-css-assets-webpack-plugin": {
@@ -4821,11 +4746,6 @@
"resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz", "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz",
"integrity": "sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc=" "integrity": "sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc="
}, },
"p-finally": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
"integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4="
},
"p-limit": { "p-limit": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
@@ -4850,23 +4770,6 @@
"aggregate-error": "^3.0.0" "aggregate-error": "^3.0.0"
} }
}, },
"p-queue": {
"version": "6.6.2",
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz",
"integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==",
"requires": {
"eventemitter3": "^4.0.4",
"p-timeout": "^3.2.0"
}
},
"p-timeout": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz",
"integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==",
"requires": {
"p-finally": "^1.0.0"
}
},
"p-try": { "p-try": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
@@ -6671,9 +6574,9 @@
} }
}, },
"rollup": { "rollup": {
"version": "2.36.2", "version": "2.38.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.36.2.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.38.1.tgz",
"integrity": "sha512-qjjiuJKb+/8n0EZyQYVW+gFU4bNRBcZaXVzUgSVrGw0HlQBlK2aWyaOMMs1Ufic1jV69b9kW3u3i9B+hISDm3A==", "integrity": "sha512-q07T6vU/V1kqM8rGRRyCgEvIQcIAXoKIE5CpkYAlHhfiWM1Iuh4dIPWpIbqFngCK6lwAB2aYHiUVhIbSWHQWhw==",
"requires": { "requires": {
"fsevents": "~2.1.2" "fsevents": "~2.1.2"
}, },
@@ -6741,14 +6644,14 @@
} }
}, },
"semver": { "semver": {
"version": "6.3.0", "version": "5.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
"integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ=="
}, },
"serialize-javascript": { "serialize-javascript": {
"version": "4.0.0", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-5.0.1.tgz",
"integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==", "integrity": "sha512-SaaNal9imEO737H2c05Og0/8LUXG7EnsZyMa8MzkmuHoELfT6txuj0cMqRj6zfPKnmQ1yasR4PCJc8x+M4JSPA==",
"requires": { "requires": {
"randombytes": "^2.1.0" "randombytes": "^2.1.0"
} }
@@ -7013,9 +6916,9 @@
} }
}, },
"ssri": { "ssri": {
"version": "8.0.0", "version": "8.0.1",
"resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.0.tgz", "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz",
"integrity": "sha512-aq/pz989nxVYwn16Tsbj1TqFpD5LLrQxHf5zaHuieFV+R0Bbr4y8qUsOA45hXT/N4/9UNXTarBjnjVmjSOVaAA==", "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==",
"requires": { "requires": {
"minipass": "^3.1.1" "minipass": "^3.1.1"
} }
@@ -7330,11 +7233,6 @@
"source-map-support": "~0.5.19" "source-map-support": "~0.5.19"
}, },
"dependencies": { "dependencies": {
"commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="
},
"source-map": { "source-map": {
"version": "0.7.3", "version": "0.7.3",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz",
@@ -7375,14 +7273,6 @@
"ajv": "^6.12.5", "ajv": "^6.12.5",
"ajv-keywords": "^3.5.2" "ajv-keywords": "^3.5.2"
} }
},
"serialize-javascript": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-5.0.1.tgz",
"integrity": "sha512-SaaNal9imEO737H2c05Og0/8LUXG7EnsZyMa8MzkmuHoELfT6txuj0cMqRj6zfPKnmQ1yasR4PCJc8x+M4JSPA==",
"requires": {
"randombytes": "^2.1.0"
}
} }
} }
}, },
@@ -7924,26 +7814,6 @@
"binary-extensions": "^1.0.0" "binary-extensions": "^1.0.0"
} }
}, },
"is-number": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
"integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=",
"optional": true,
"requires": {
"kind-of": "^3.0.2"
},
"dependencies": {
"kind-of": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
"integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=",
"optional": true,
"requires": {
"is-buffer": "^1.1.5"
}
}
}
},
"micromatch": { "micromatch": {
"version": "3.1.10", "version": "3.1.10",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz",
@@ -8082,11 +7952,6 @@
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="
}, },
"commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="
},
"fill-range": { "fill-range": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz",
@@ -8126,29 +7991,6 @@
"locate-path": "^3.0.0" "locate-path": "^3.0.0"
} }
}, },
"is-number": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
"integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=",
"requires": {
"kind-of": "^3.0.2"
},
"dependencies": {
"kind-of": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
"integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=",
"requires": {
"is-buffer": "^1.1.5"
}
}
}
},
"is-wsl": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz",
"integrity": "sha1-HxbkqiKwTRM2tmGIpmrzxgDDpm0="
},
"locate-path": { "locate-path": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz",
@@ -8247,10 +8089,13 @@
"ajv-keywords": "^3.1.0" "ajv-keywords": "^3.1.0"
} }
}, },
"semver": { "serialize-javascript": {
"version": "5.7.1", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz",
"integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==" "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==",
"requires": {
"randombytes": "^2.1.0"
}
}, },
"ssri": { "ssri": {
"version": "6.0.1", "version": "6.0.1",

View File

@@ -8,17 +8,17 @@
}, },
"dependencies": { "dependencies": {
"@prefresh/snowpack": "^3.0.1", "@prefresh/snowpack": "^3.0.1",
"@snowpack/plugin-optimize": "^0.2.13",
"@snowpack/plugin-postcss": "^1.1.0", "@snowpack/plugin-postcss": "^1.1.0",
"@snowpack/plugin-webpack": "^2.3.0", "@snowpack/plugin-webpack": "^2.3.0",
"autoprefixer": "^10.2.1", "autoprefixer": "^10.2.1",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"immer": "^8.0.1",
"postcss": "^8.2.2", "postcss": "^8.2.2",
"postcss-cli": "^8.3.1", "postcss-cli": "^8.3.1",
"preact": "^10.5.9", "preact": "^10.5.9",
"preact-router": "^3.2.1", "preact-router": "^3.2.1",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"snowpack": "^3.0.0", "snowpack": "^3.0.11",
"tailwindcss": "^2.0.2" "tailwindcss": "^2.0.2"
} }
} }

View File

@@ -8,12 +8,6 @@ module.exports = {
plugins: [ plugins: [
'@snowpack/plugin-postcss', '@snowpack/plugin-postcss',
'@prefresh/snowpack', '@prefresh/snowpack',
[
'@snowpack/plugin-optimize',
{
preloadModules: true,
},
],
[ [
'@snowpack/plugin-webpack', '@snowpack/plugin-webpack',
{ {

View File

@@ -1,4 +1,5 @@
import { h } from 'preact'; import { h } from 'preact';
import ActivityIndicator from './components/ActivityIndicator';
import Camera from './Camera'; import Camera from './Camera';
import CameraMap from './CameraMap'; import CameraMap from './CameraMap';
import Cameras from './Cameras'; import Cameras from './Cameras';
@@ -7,36 +8,27 @@ import Event from './Event';
import Events from './Events'; import Events from './Events';
import { Router } from 'preact-router'; import { Router } from 'preact-router';
import Sidebar from './Sidebar'; import Sidebar from './Sidebar';
import { ApiHost, Config } from './context'; import Api, { FetchStatus, useConfig } from './api';
import { useContext, useEffect, useState } from 'preact/hooks';
export default function App() { export default function App() {
const apiHost = useContext(ApiHost); const { data, status } = useConfig();
const [config, setConfig] = useState(null); return status !== FetchStatus.LOADED ? (
<div className="flex flex-grow-1 min-h-screen justify-center items-center">
useEffect(async () => { <ActivityIndicator />
const response = await fetch(`${apiHost}/api/config`); </div>
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">
<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 />
<Sidebar /> <div className="flex-auto p-2 md:p-4 lg:pl-8 lg:pr-8 min-w-0">
<div className="flex-auto p-4 lg:pl-8 lg:pr-8 min-w-0"> <Router>
<Router> <CameraMap path="/cameras/:camera/editor" />
<CameraMap path="/cameras/:camera/editor" /> <Camera path="/cameras/:camera" />
<Camera path="/cameras/:camera" /> <Event path="/events/:eventId" />
<Event path="/events/:eventId" /> <Events path="/events" />
<Events path="/events" /> <Debug path="/debug" />
<Debug path="/debug" /> <Cameras default path="/" />
<Cameras default path="/" /> </Router>
</Router>
</div>
</div> </div>
</Config.Provider> </div>
); );
} }

View File

@@ -6,13 +6,13 @@ import Link from './components/Link';
import Switch from './components/Switch'; import Switch from './components/Switch';
import { route } from 'preact-router'; import { route } from 'preact-router';
import { useCallback, useContext } from 'preact/hooks'; import { useCallback, useContext } from 'preact/hooks';
import { ApiHost, Config } from './context'; import { useApiHost, useConfig } from './api';
export default function Camera({ camera, url }) { export default function Camera({ camera, url }) {
const config = useContext(Config); const { data: config } = useConfig();
const apiHost = useContext(ApiHost); const apiHost = useApiHost();
if (!(camera in config.cameras)) { if (!config) {
return <div>{`No camera named ${camera}`}</div>; return <div>{`No camera named ${camera}`}</div>;
} }

View File

@@ -5,11 +5,11 @@ import Heading from './components/Heading';
import Switch from './components/Switch'; import Switch from './components/Switch';
import { route } from 'preact-router'; import { route } from 'preact-router';
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { ApiHost, Config } from './context'; import { useApiHost, useConfig } from './api';
export default function CameraMasks({ camera, url }) { export default function CameraMasks({ camera, url }) {
const config = useContext(Config); const { data: config } = useConfig();
const apiHost = useContext(ApiHost); const apiHost = useApiHost();
const imageRef = useRef(null); const imageRef = useRef(null);
const [imageScale, setImageScale] = useState(1); const [imageScale, setImageScale] = useState(1);
const [snap, setSnap] = useState(true); const [snap, setSnap] = useState(true);

View File

@@ -1,16 +1,16 @@
import { h } from 'preact'; import { h } from 'preact';
import ActivityIndicator from './components/ActivityIndicator';
import Box from './components/Box'; import Box from './components/Box';
import CameraImage from './components/CameraImage'; import CameraImage from './components/CameraImage';
import Events from './Events'; import Events from './Events';
import Heading from './components/Heading'; import Heading from './components/Heading';
import { route } from 'preact-router'; import { route } from 'preact-router';
import { useContext } from 'preact/hooks'; import { useConfig } from './api';
import { ApiHost, Config } from './context';
export default function Cameras() { export default function Cameras() {
const config = useContext(Config); const { data: config, status } = useConfig();
if (!config.cameras) { if (!config) {
return <p>loading</p>; return <p>loading</p>;
} }

View File

@@ -1,27 +1,24 @@
import { h } from 'preact'; import { h } from 'preact';
import ActivityIndicator from './components/ActivityIndicator';
import Box from './components/Box'; import Box from './components/Box';
import Button from './components/Button'; import Button from './components/Button';
import Heading from './components/Heading'; import Heading from './components/Heading';
import Link from './components/Link'; import Link from './components/Link';
import { ApiHost, Config } from './context'; import { FetchStatus, useConfig, useStats } from './api';
import { Table, Tbody, Thead, Tr, Th, Td } from './components/Table'; import { Table, Tbody, Thead, Tr, Th, Td } from './components/Table';
import { useCallback, useContext, useEffect, useState } from 'preact/hooks'; import { useCallback, useEffect, useState } from 'preact/hooks';
export default function Debug() { export default function Debug() {
const apiHost = useContext(ApiHost); const config = useConfig();
const config = useContext(Config);
const [stats, setStats] = useState({});
const [timeoutId, setTimeoutId] = useState(null); const [timeoutId, setTimeoutId] = useState(null);
const fetchStats = useCallback(async () => { const forceUpdate = useCallback(async () => {
const statsResponse = await fetch(`${apiHost}/api/stats`); setTimeoutId(setTimeout(forceUpdate, 1000));
const stats = statsResponse.ok ? await statsResponse.json() : {}; }, []);
setStats(stats);
setTimeoutId(setTimeout(fetchStats, 1000));
}, [setStats]);
useEffect(() => { useEffect(() => {
fetchStats(); forceUpdate();
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -29,11 +26,13 @@ export default function Debug() {
clearTimeout(timeoutId); clearTimeout(timeoutId);
}; };
}, [timeoutId]); }, [timeoutId]);
const { data: stats, status } = useStats(null, timeoutId);
if (stats === null && (status === FetchStatus.LOADING || status === FetchStatus.NONE)) {
return <ActivityIndicator />;
}
const { detectors, detection_fps, service, ...cameras } = stats; const { detectors, detection_fps, service, ...cameras } = stats;
if (!service) {
return 'loading…';
}
const detectorNames = Object.keys(detectors); const detectorNames = Object.keys(detectors);
const detectorDataKeys = Object.keys(detectors[detectorNames[0]]); const detectorDataKeys = Object.keys(detectors[detectorNames[0]]);

View File

@@ -1,28 +1,17 @@
import { h, Fragment } from 'preact'; import { h, Fragment } from 'preact';
import { ApiHost } from './context'; import ActivityIndicator from './components/ActivityIndicator';
import Box from './components/Box'; import Box from './components/Box';
import Heading from './components/Heading'; import Heading from './components/Heading';
import Link from './components/Link'; import Link from './components/Link';
import { FetchStatus, useApiHost, useEvent } from './api';
import { Table, Thead, Tbody, Tfoot, Th, Tr, Td } from './components/Table'; import { Table, Thead, Tbody, Tfoot, Th, Tr, Td } from './components/Table';
import { useContext, useEffect, useState } from 'preact/hooks';
export default function Event({ eventId }) { export default function Event({ eventId }) {
const apiHost = useContext(ApiHost); const apiHost = useApiHost();
const [data, setData] = useState(null); const { data, status } = useEvent(eventId);
useEffect(async () => { if (status !== FetchStatus.LOADED) {
const response = await fetch(`${apiHost}/api/events/${eventId}`); return <ActivityIndicator />;
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 startime = new Date(data.start_time * 1000);
@@ -57,34 +46,36 @@ export default function Event({ eventId }) {
/> />
</Box> </Box>
<Table> <Box>
<Thead> <Table>
<Th>Key</Th> <Thead>
<Th>Value</Th> <Th>Key</Th>
</Thead> <Th>Value</Th>
<Tbody> </Thead>
<Tr> <Tbody>
<Td>Camera</Td> <Tr>
<Td> <Td>Camera</Td>
<Link href={`/cameras/${data.camera}`}>{data.camera}</Link> <Td>
</Td> <Link href={`/cameras/${data.camera}`}>{data.camera}</Link>
</Tr> </Td>
<Tr index={1}> </Tr>
<Td>Timeframe</Td> <Tr index={1}>
<Td> <Td>Timeframe</Td>
{startime.toLocaleString()} {endtime.toLocaleString()} <Td>
</Td> {startime.toLocaleString()} {endtime.toLocaleString()}
</Tr> </Td>
<Tr> </Tr>
<Td>Score</Td> <Tr>
<Td>{(data.top_score * 100).toFixed(2)}%</Td> <Td>Score</Td>
</Tr> <Td>{(data.top_score * 100).toFixed(2)}%</Td>
<Tr index={1}> </Tr>
<Td>Zones</Td> <Tr index={1}>
<Td>{data.zones.join(', ')}</Td> <Td>Zones</Td>
</Tr> <Td>{data.zones.join(', ')}</Td>
</Tbody> </Tr>
</Table> </Tbody>
</Table>
</Box>
</div> </div>
); );
} }

View File

@@ -1,49 +1,123 @@
import { h } from 'preact'; import { h } from 'preact';
import { ApiHost } from './context'; import ActivityIndicator from './components/ActivityIndicator';
import Box from './components/Box'; import Box from './components/Box';
import Heading from './components/Heading'; import Heading from './components/Heading';
import Link from './components/Link'; import Link from './components/Link';
import produce from 'immer';
import { route } from 'preact-router'; import { route } from 'preact-router';
import { FetchStatus, useApiHost, useConfig, useEvents } from './api';
import { Table, Thead, Tbody, Tfoot, Th, Tr, Td } from './components/Table'; import { Table, Thead, Tbody, Tfoot, Th, Tr, Td } from './components/Table';
import { useCallback, useContext, useEffect, useState } from 'preact/hooks'; import { useCallback, useContext, useEffect, useMemo, useRef, useReducer, useState } from 'preact/hooks';
export default function Events({ url } = {}) { const API_LIMIT = 25;
const apiHost = useContext(ApiHost);
const [events, setEvents] = useState([]);
const { pathname, searchParams } = new URL(`${window.location.protocol}//${window.location.host}${url || '/events'}`); const initialState = Object.freeze({ events: [], reachedEnd: false, searchStrings: {} });
const searchParamsString = searchParams.toString(); const reducer = (state = initialState, action) => {
switch (action.type) {
case 'APPEND_EVENTS': {
const {
meta: { searchString },
payload,
} = action;
return produce(state, (draftState) => {
draftState.searchStrings[searchString] = true;
draftState.events.push(...payload);
});
}
useEffect(async () => { case 'REACHED_END': {
const response = await fetch(`${apiHost}/api/events?${searchParamsString}`); const {
const data = response.ok ? await response.json() : {}; meta: { searchString },
setEvents(data); } = action;
}, [searchParamsString]); return produce(state, (draftState) => {
draftState.reachedEnd = true;
draftState.searchStrings[searchString] = true;
});
}
const searchKeys = Array.from(searchParams.keys()); case 'RESET':
return initialState;
default:
return state;
}
};
const defaultSearchString = `include_thumbnails=0&limit=${API_LIMIT}`;
function removeDefaultSearchKeys(searchParams) {
searchParams.delete('limit');
searchParams.delete('include_thumbnails');
searchParams.delete('before');
}
export default function Events({ path: pathname } = {}) {
const apiHost = useApiHost();
const [{ events, reachedEnd, searchStrings }, dispatch] = useReducer(reducer, initialState);
const { searchParams: initialSearchParams } = new URL(window.location);
const [searchString, setSearchString] = useState(`${defaultSearchString}&${initialSearchParams.toString()}`);
const { data, status } = useEvents(searchString);
useEffect(() => {
if (data && !(searchString in searchStrings)) {
dispatch({ type: 'APPEND_EVENTS', payload: data, meta: { searchString } });
}
if (Array.isArray(data) && data.length < API_LIMIT) {
dispatch({ type: 'REACHED_END', meta: { searchString } });
}
}, [data]);
const observer = useRef(
new IntersectionObserver((entries, observer) => {
window.requestAnimationFrame(() => {
if (entries.length === 0) {
return;
}
// under certain edge cases, a ref may be applied / in memory twice
// avoid fetching twice by grabbing the last observed entry only
const entry = entries[entries.length - 1];
if (entry.isIntersecting) {
const { startTime } = entry.target.dataset;
const { searchParams } = new URL(window.location);
searchParams.set('before', parseFloat(startTime) - 0.0001);
setSearchString(`${defaultSearchString}&${searchParams.toString()}`);
}
});
})
);
const lastCellRef = useCallback(
(node) => {
if (node !== null) {
observer.current.disconnect();
if (!reachedEnd) {
observer.current.observe(node);
}
}
},
[observer.current, reachedEnd]
);
const handleFilter = useCallback(
(searchParams) => {
dispatch({ type: 'RESET' });
removeDefaultSearchKeys(searchParams);
setSearchString(`${defaultSearchString}&${searchParams.toString()}`);
route(`${pathname}?${searchParams.toString()}`);
},
[pathname, setSearchString]
);
const searchParams = useMemo(() => new URLSearchParams(searchString), [searchString]);
return ( return (
<div className="space-y-4 w-full"> <div className="space-y-4 w-full">
<Heading>Events</Heading> <Heading>Events</Heading>
{searchKeys.length ? ( <Filters onChange={handleFilter} searchParams={searchParams} />
<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"> <Box className="min-w-0 overflow-auto">
<Table className="w-full"> <Table className="min-w-full table-fixed">
<Thead> <Thead>
<Tr> <Tr>
<Th></Th> <Th></Th>
@@ -64,25 +138,33 @@ export default function Events({ url } = {}) {
) => { ) => {
const start = new Date(parseInt(startTime * 1000, 10)); const start = new Date(parseInt(startTime * 1000, 10));
const end = new Date(parseInt(endTime * 1000, 10)); const end = new Date(parseInt(endTime * 1000, 10));
const ref = i === events.length - 1 ? lastCellRef : undefined;
return ( return (
<Tr key={id} index={i}> <Tr key={id} index={i}>
<Td> <Td className="w-40">
<a href={`/events/${id}`}> <a href={`/events/${id}`} ref={ref} data-start-time={startTime} data-reached-end={reachedEnd}>
<img className="w-32 max-w-none" src={`data:image/jpeg;base64,${thumbnail}`} /> <img
width="150"
height="150"
style="min-height: 48px; min-width: 48px;"
src={`${apiHost}/api/events/${id}/thumbnail.jpg`}
/>
</a> </a>
</Td> </Td>
<Td> <Td>
<Filterable <Filterable
onFilter={handleFilter}
pathname={pathname} pathname={pathname}
searchParams={searchParamsString} searchParams={searchParams}
paramName="camera" paramName="camera"
name={camera} name={camera}
/> />
</Td> </Td>
<Td> <Td>
<Filterable <Filterable
onFilter={handleFilter}
pathname={pathname} pathname={pathname}
searchParams={searchParamsString} searchParams={searchParams}
paramName="label" paramName="label"
name={label} name={label}
/> />
@@ -93,8 +175,9 @@ export default function Events({ url } = {}) {
{zones.map((zone) => ( {zones.map((zone) => (
<li> <li>
<Filterable <Filterable
onFilter={handleFilter}
pathname={pathname} pathname={pathname}
searchParams={searchParamsString} searchParams={searchString}
paramName="zone" paramName="zone"
name={zone} name={zone}
/> />
@@ -110,27 +193,108 @@ export default function Events({ url } = {}) {
} }
)} )}
</Tbody> </Tbody>
<Tfoot>
<Tr>
<Td className="text-center p-4" colspan="8">
{status === FetchStatus.LOADING ? <ActivityIndicator /> : reachedEnd ? 'No more events' : null}
</Td>
</Tr>
</Tfoot>
</Table> </Table>
</Box> </Box>
</div> </div>
); );
} }
function Filterable({ pathname, searchParams, paramName, name }) { function Filterable({ onFilter, pathname, searchParams, paramName, name }) {
const params = new URLSearchParams(searchParams); const href = useMemo(() => {
params.set(paramName, name); const params = new URLSearchParams(searchParams.toString());
return <Link href={`${pathname}?${params.toString()}`}>{name}</Link>; params.set(paramName, name);
} removeDefaultSearchKeys(params);
return `${pathname}?${params.toString()}`;
}, [searchParams]);
const handleClick = useCallback(
(event) => {
event.preventDefault();
route(href, true);
const params = new URLSearchParams(searchParams.toString());
params.set(paramName, name);
onFilter(params);
},
[href, searchParams]
);
function UnFilterable({ pathname, searchParams, paramName, name }) {
const params = new URLSearchParams(searchParams);
params.delete(paramName);
return ( return (
<a <Link href={href} onclick={handleClick}>
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} {name}
</a> </Link>
);
}
function Filters({ onChange, searchParams }) {
const { data } = useConfig();
const cameras = useMemo(() => Object.keys(data.cameras), [data]);
const zones = useMemo(
() =>
Object.values(data.cameras)
.reduce((memo, camera) => {
memo = memo.concat(Object.keys(camera.zones));
return memo;
}, [])
.filter((value, i, self) => self.indexOf(value) === i),
[data]
);
const labels = useMemo(() => {
return Object.values(data.cameras)
.reduce((memo, camera) => {
memo = memo.concat(camera.objects?.track || []);
return memo;
}, data.objects?.track || [])
.filter((value, i, self) => self.indexOf(value) === i);
}, [data]);
return (
<Box className="flex space-y-0 space-x-8 flex-wrap">
<Filter onChange={onChange} options={cameras} paramName="camera" searchParams={searchParams} />
<Filter onChange={onChange} options={zones} paramName="zone" searchParams={searchParams} />
<Filter onChange={onChange} options={labels} paramName="label" searchParams={searchParams} />
</Box>
);
}
function Filter({ onChange, searchParams, paramName, options }) {
const handleSelect = useCallback(
(event) => {
const newParams = new URLSearchParams(searchParams.toString());
const value = event.target.value;
if (value) {
newParams.set(paramName, event.target.value);
} else {
newParams.delete(paramName);
}
onChange(newParams);
},
[searchParams, paramName, onChange]
);
return (
<label>
<span className="block uppercase text-sm">{paramName}</span>
<select className="border-solid border border-gray-500 rounded dark:text-gray-900" onChange={handleSelect}>
<option>All</option>
{options.map((opt) => {
return (
<option value={opt} selected={searchParams.get(paramName) === opt}>
{opt}
</option>
);
})}
</select>
</label>
); );
} }

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

@@ -0,0 +1,108 @@
import { h, createContext } from 'preact';
import produce from 'immer';
import { useCallback, useContext, useEffect, useMemo, useRef, useReducer, useState } from 'preact/hooks';
export const ApiHost = createContext(import.meta.env.SNOWPACK_PUBLIC_API_HOST || window.baseUrl || '');
export const FetchStatus = {
NONE: 'none',
LOADING: 'loading',
LOADED: 'loaded',
ERROR: 'error',
};
const initialState = Object.freeze({
host: import.meta.env.SNOWPACK_PUBLIC_API_HOST || window.baseUrl || '',
queries: {},
});
export const Api = createContext(initialState);
export default Api;
function reducer(state, { type, payload, meta }) {
switch (type) {
case 'REQUEST': {
const { url, request } = payload;
const data = state.queries[url]?.data || null;
return produce(state, (draftState) => {
draftState.queries[url] = { status: FetchStatus.LOADING, data };
});
}
case 'RESPONSE': {
const { url, ok, data } = payload;
return produce(state, (draftState) => {
draftState.queries[url] = { status: ok ? FetchStatus.LOADED : FetchStatus.ERROR, data };
});
}
default:
return state;
}
}
export const ApiProvider = ({ children }) => {
const [state, dispatch] = useReducer(reducer, initialState);
return <Api.Provider value={{ state, dispatch }}>{children}</Api.Provider>;
};
function shouldFetch(state, url, forceRefetch = false) {
if (forceRefetch || !(url in state.queries)) {
return true;
}
const { status } = state.queries[url];
return status !== FetchStatus.LOADING && status !== FetchStatus.LOADED;
}
export function useFetch(url, forceRefetch) {
const { state, dispatch } = useContext(Api);
useEffect(() => {
if (!shouldFetch(state, url, forceRefetch)) {
return;
}
async function fetchConfig() {
await dispatch({ type: 'REQUEST', payload: { url } });
const response = await fetch(`${state.host}${url}`);
const data = await response.json();
await dispatch({ type: 'RESPONSE', payload: { url, ok: response.ok, data } });
}
fetchConfig();
}, [url, forceRefetch]);
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, dispatch } = useContext(Api);
return state.host;
}
export function useEvents(searchParams, forceRefetch) {
const url = `/api/events${searchParams ? `?${searchParams.toString()}` : ''}`;
return useFetch(url, forceRefetch);
}
export function useEvent(eventId, forceRefetch) {
const url = `/api/events/${eventId}`;
return useFetch(url, forceRefetch);
}
export function useConfig(searchParams, forceRefetch) {
const url = `/api/config${searchParams ? `?${searchParams.toString()}` : ''}`;
return useFetch(url, forceRefetch);
}
export function useStats(searchParams, forceRefetch) {
const url = `/api/stats${searchParams ? `?${searchParams.toString()}` : ''}`;
return useFetch(url, forceRefetch);
}

View File

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

View File

@@ -1,6 +1,5 @@
import { h } from 'preact'; import { h } from 'preact';
import CameraImage from './CameraImage'; import CameraImage from './CameraImage';
import { ApiHost, Config } from '../context';
import { useCallback, useState } from 'preact/hooks'; import { useCallback, useState } from 'preact/hooks';
const MIN_LOAD_TIMEOUT_MS = 200; const MIN_LOAD_TIMEOUT_MS = 200;

View File

@@ -4,7 +4,7 @@ export default function Box({ children, className = '', hover = false, href, ...
const Element = href ? 'a' : 'div'; const Element = href ? 'a' : 'div';
return ( return (
<Element <Element
className={`bg-white dark:bg-gray-700 shadow-lg rounded-lg p-4 ${ className={`bg-white dark:bg-gray-700 shadow-lg rounded-lg p-2 lg:p-4 ${
hover ? 'hover:bg-gray-300 hover:dark:bg-gray-500 dark:hover:text-gray-900 dark:hover:text-gray-900' : '' hover ? 'hover:bg-gray-300 hover:dark:bg-gray-500 dark:hover:text-gray-900 dark:hover:text-gray-900' : ''
} ${className}`} } ${className}`}
href={href} href={href}

View File

@@ -1,10 +1,11 @@
import { h } from 'preact'; import { h } from 'preact';
import { ApiHost, Config } from '../context'; import ActivityIndicator from './ActivityIndicator';
import { useApiHost, useConfig } from '../api';
import { useCallback, useEffect, useContext, useMemo, useRef, useState } from 'preact/hooks'; import { useCallback, useEffect, useContext, useMemo, useRef, useState } from 'preact/hooks';
export default function CameraImage({ camera, onload, searchParams = '' }) { export default function CameraImage({ camera, onload, searchParams = '' }) {
const config = useContext(Config); const { data: config } = useConfig();
const apiHost = useContext(ApiHost); const apiHost = useApiHost();
const [availableWidth, setAvailableWidth] = useState(0); const [availableWidth, setAvailableWidth] = useState(0);
const [loadedSrc, setLoadedSrc] = useState(null); const [loadedSrc, setLoadedSrc] = useState(null);
const containerRef = useRef(null); const containerRef = useRef(null);
@@ -38,7 +39,7 @@ export default function CameraImage({ camera, onload, searchParams = '' }) {
const img = useMemo(() => new Image(), [camera]); const img = useMemo(() => new Image(), [camera]);
img.onload = useCallback( img.onload = useCallback(
(event) => { (event) => {
const src = event.path[0].currentSrc; const src = event.srcElement.currentSrc;
setLoadedSrc(src); setLoadedSrc(src);
onload && onload(event); onload && onload(event);
}, },
@@ -54,7 +55,11 @@ export default function CameraImage({ camera, onload, searchParams = '' }) {
return ( return (
<div ref={containerRef}> <div ref={containerRef}>
{loadedSrc ? <img width={scaledHeight * aspectRatio} height={scaledHeight} src={loadedSrc} alt={name} /> : null} {loadedSrc ? (
<img width={scaledHeight * aspectRatio} height={scaledHeight} src={loadedSrc} alt={name} />
) : (
<ActivityIndicator />
)}
</div> </div>
); );
} }

View File

@@ -6,12 +6,12 @@ export function Table({ children, className = '' }) {
); );
} }
export function Thead({ children, className = '' }) { export function Thead({ children, className }) {
return <thead className={`${className}`}>{children}</thead>; return <thead className={className}>{children}</thead>;
} }
export function Tbody({ children, className = '' }) { export function Tbody({ children, className }) {
return <tbody className={`${className}`}>{children}</tbody>; return <tbody className={className}>{children}</tbody>;
} }
export function Tfoot({ children, className = '' }) { export function Tfoot({ children, className = '' }) {
@@ -19,13 +19,21 @@ export function Tfoot({ children, className = '' }) {
} }
export function Tr({ children, className = '', index }) { export function Tr({ children, className = '', index }) {
return <tr className={`${index % 2 ? 'bg-gray-200 dark:bg-gray-700' : ''} ${className}`}>{children}</tr>; return <tr className={`${index % 2 ? 'bg-gray-200 dark:bg-gray-600' : ''} ${className}`}>{children}</tr>;
} }
export function Th({ children, className = '' }) { export function Th({ children, className = '', colspan }) {
return <th className={`border-b-2 border-gray-400 p-4 text-left ${className}`}>{children}</th>; return (
<th className={`border-b-2 border-gray-400 p-1 md:p-2 text-left ${className}`} colspan={colspan}>
{children}
</th>
);
} }
export function Td({ children, className = '' }) { export function Td({ children, className = '', colspan }) {
return <td className={`p-4 ${className}`}>{children}</td>; return (
<td className={`p-1 md:p-2 ${className}`} colspan={colspan}>
{children}
</td>
);
} }

View File

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

View File

@@ -1,3 +1,27 @@
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
.activityindicator {
border-top-color: currentColor;
-webkit-animation: spinner 0.75s linear infinite;
animation: spinner 0.75s linear infinite;
}
@-webkit-keyframes spinner {
0% {
-webkit-transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
}
}
@keyframes spinner {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@@ -1,9 +1,12 @@
import App from './App'; import App from './App';
import { ApiProvider } from './api';
import { h, render } from 'preact'; import { h, render } from 'preact';
import 'preact/devtools'; import 'preact/devtools';
import './index.css'; import './index.css';
render( render(
<App />, <ApiProvider>
<App />
</ApiProvider>,
document.getElementById('root') document.getElementById('root')
); );