forked from Github/frigate
Compare commits
53 Commits
v0.14.0-be
...
v0.14.0-be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bccffe6670 | ||
|
|
8418b65f34 | ||
|
|
6e53c109b6 | ||
|
|
7b99bbfd28 | ||
|
|
8179278bfa | ||
|
|
758df09da3 | ||
|
|
a3d116e70e | ||
|
|
8c325801ef | ||
|
|
35946d332d | ||
|
|
142641b387 | ||
|
|
402c16e7df | ||
|
|
3e6b8c23bc | ||
|
|
1c5e7ebb48 | ||
|
|
9cb3e11df6 | ||
|
|
1c2e2a7b38 | ||
|
|
a763ae303d | ||
|
|
ec88752666 | ||
|
|
4135cabf58 | ||
|
|
9fc22efa2d | ||
|
|
37dd3fc25b | ||
|
|
9e8202874e | ||
|
|
9245c5cb56 | ||
|
|
f1c0422d5e | ||
|
|
3dd401f57a | ||
|
|
6dd9660ecd | ||
|
|
d5f6decd30 | ||
|
|
cf4517cbdb | ||
|
|
61f79afae9 | ||
|
|
5513addab8 | ||
|
|
d064e44571 | ||
|
|
c95758580f | ||
|
|
ced5ab203f | ||
|
|
4236580672 | ||
|
|
f7c3ddd380 | ||
|
|
8546d3d315 | ||
|
|
2fda383782 | ||
|
|
4165639308 | ||
|
|
6913cc6abc | ||
|
|
d64633889b | ||
|
|
7bed854ff7 | ||
|
|
c1330704cf | ||
|
|
5900a2a4ba | ||
|
|
bfeb7b8a96 | ||
|
|
a86e22e0fc | ||
|
|
c07f6999ca | ||
|
|
eca8c52f15 | ||
|
|
be147d218b | ||
|
|
7a9ee63bd3 | ||
|
|
c2eac10925 | ||
|
|
63d81bef45 | ||
|
|
3f171e7670 | ||
|
|
681c7367d7 | ||
|
|
adb043e7ae |
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -220,7 +220,7 @@ jobs:
|
||||
with:
|
||||
string: ${{ github.repository }}
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@e92390c5fb421da1463c202d546fed0ec5c39f20
|
||||
uses: docker/login-action@0d4c9c5ea7693da7b068278f7b52bda2a190a446
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
12
.github/workflows/release.yml
vendored
12
.github/workflows/release.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
with:
|
||||
string: ${{ github.repository }}
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@e92390c5fb421da1463c202d546fed0ec5c39f20
|
||||
uses: docker/login-action@0d4c9c5ea7693da7b068278f7b52bda2a190a446
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -24,14 +24,24 @@ jobs:
|
||||
- name: Create tag variables
|
||||
run: |
|
||||
BRANCH=$([[ "${{ github.ref_name }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] && echo "master" || echo "dev")
|
||||
echo "BRANCH=${BRANCH}" >> $GITHUB_ENV
|
||||
echo "BASE=ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}" >> $GITHUB_ENV
|
||||
echo "BUILD_TAG=${BRANCH}-${GITHUB_SHA::7}" >> $GITHUB_ENV
|
||||
echo "CLEAN_VERSION=$(echo ${GITHUB_REF##*/} | tr '[:upper:]' '[:lower:]' | sed 's/^[v]//')" >> $GITHUB_ENV
|
||||
- name: Tag and push the main image
|
||||
run: |
|
||||
VERSION_TAG=${BASE}:${CLEAN_VERSION}
|
||||
STABLE_TAG=${BASE}:stable
|
||||
PULL_TAG=${BASE}:${BUILD_TAG}
|
||||
docker run --rm -v $HOME/.docker/config.json:/config.json quay.io/skopeo/stable:latest copy --authfile /config.json --multi-arch all docker://${PULL_TAG} docker://${VERSION_TAG}
|
||||
for variant in standard-arm64 tensorrt tensorrt-jp4 tensorrt-jp5 rk; do
|
||||
docker run --rm -v $HOME/.docker/config.json:/config.json quay.io/skopeo/stable:latest copy --authfile /config.json --multi-arch all docker://${PULL_TAG}-${variant} docker://${VERSION_TAG}-${variant}
|
||||
done
|
||||
|
||||
# stable tag
|
||||
if [[ "${BRANCH}" == "master" ]]; then
|
||||
docker run --rm -v $HOME/.docker/config.json:/config.json quay.io/skopeo/stable:latest copy --authfile /config.json --multi-arch all docker://${PULL_TAG} docker://${STABLE_TAG}
|
||||
for variant in standard-arm64 tensorrt tensorrt-jp4 tensorrt-jp5 rk; do
|
||||
docker run --rm -v $HOME/.docker/config.json:/config.json quay.io/skopeo/stable:latest copy --authfile /config.json --multi-arch all docker://${PULL_TAG}-${variant} docker://${STABLE_TAG}-${variant}
|
||||
done
|
||||
fi
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -16,4 +16,5 @@ web/node_modules
|
||||
web/coverage
|
||||
core
|
||||
!/web/**/*.ts
|
||||
.idea/*
|
||||
.idea/*
|
||||
.ipynb_checkpoints
|
||||
@@ -41,7 +41,6 @@ ADD --link --chmod=755 "https://github.com/AlexxIT/go2rtc/releases/download/v1.9
|
||||
# OpenVino Support
|
||||
#
|
||||
# 1. Download and convert a model from Intel's Public Open Model Zoo
|
||||
# 2. Build libUSB without udev to handle NCS2 enumeration
|
||||
#
|
||||
####
|
||||
# Download and Convert OpenVino model
|
||||
@@ -57,38 +56,11 @@ RUN apt-get -qq update \
|
||||
&& pip install -r /requirements-ov.txt
|
||||
|
||||
# Get OpenVino Model
|
||||
RUN mkdir /models \
|
||||
&& cd /models && omz_downloader --name ssdlite_mobilenet_v2 \
|
||||
&& cd /models && omz_converter --name ssdlite_mobilenet_v2 --precision FP16
|
||||
|
||||
|
||||
# libUSB - No Udev
|
||||
FROM wget as libusb-build
|
||||
ARG TARGETARCH
|
||||
ARG DEBIAN_FRONTEND
|
||||
ENV CCACHE_DIR /root/.ccache
|
||||
ENV CCACHE_MAXSIZE 2G
|
||||
|
||||
# Build libUSB without udev. Needed for Openvino NCS2 support
|
||||
WORKDIR /opt
|
||||
RUN apt-get update && apt-get install -y unzip build-essential automake libtool ccache pkg-config
|
||||
RUN --mount=type=cache,target=/root/.ccache wget -q https://github.com/libusb/libusb/archive/v1.0.26.zip -O v1.0.26.zip && \
|
||||
unzip v1.0.26.zip && cd libusb-1.0.26 && \
|
||||
./bootstrap.sh && \
|
||||
./configure CC='ccache gcc' CCX='ccache g++' --disable-udev --enable-shared && \
|
||||
make -j $(nproc --all)
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends libusb-1.0-0-dev && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /opt/libusb-1.0.26/libusb
|
||||
RUN /bin/mkdir -p '/usr/local/lib' && \
|
||||
/bin/bash ../libtool --mode=install /usr/bin/install -c libusb-1.0.la '/usr/local/lib' && \
|
||||
/bin/mkdir -p '/usr/local/include/libusb-1.0' && \
|
||||
/usr/bin/install -c -m 644 libusb.h '/usr/local/include/libusb-1.0' && \
|
||||
/bin/mkdir -p '/usr/local/lib/pkgconfig' && \
|
||||
cd /opt/libusb-1.0.26/ && \
|
||||
/usr/bin/install -c -m 644 libusb-1.0.pc '/usr/local/lib/pkgconfig' && \
|
||||
ldconfig
|
||||
RUN --mount=type=bind,source=docker/main/build_ov_model.py,target=/build_ov_model.py \
|
||||
mkdir /models && cd /models \
|
||||
&& wget http://download.tensorflow.org/models/object_detection/ssdlite_mobilenet_v2_coco_2018_05_09.tar.gz \
|
||||
&& tar -xvf ssdlite_mobilenet_v2_coco_2018_05_09.tar.gz \
|
||||
&& python3 /build_ov_model.py
|
||||
|
||||
FROM wget AS models
|
||||
|
||||
@@ -97,7 +69,8 @@ RUN wget -qO edgetpu_model.tflite https://github.com/google-coral/test_data/raw/
|
||||
RUN wget -qO cpu_model.tflite https://github.com/google-coral/test_data/raw/release-frogfish/ssdlite_mobiledet_coco_qat_postprocess.tflite
|
||||
COPY labelmap.txt .
|
||||
# Copy OpenVino model
|
||||
COPY --from=ov-converter /models/public/ssdlite_mobilenet_v2/FP16 openvino-model
|
||||
COPY --from=ov-converter /models/ssdlite_mobilenet_v2.xml openvino-model/
|
||||
COPY --from=ov-converter /models/ssdlite_mobilenet_v2.bin openvino-model/
|
||||
RUN wget -q https://github.com/openvinotoolkit/open_model_zoo/raw/master/data/dataset_classes/coco_91cl_bkgr.txt -O openvino-model/coco_91cl_bkgr.txt && \
|
||||
sed -i 's/truck/car/g' openvino-model/coco_91cl_bkgr.txt
|
||||
# Get Audio Model and labels
|
||||
@@ -158,7 +131,6 @@ RUN pip3 wheel --wheel-dir=/wheels -r /requirements-wheels.txt
|
||||
FROM scratch AS deps-rootfs
|
||||
COPY --from=nginx /usr/local/nginx/ /usr/local/nginx/
|
||||
COPY --from=go2rtc /rootfs/ /
|
||||
COPY --from=libusb-build /usr/local/lib /usr/local/lib
|
||||
COPY --from=s6-overlay /rootfs/ /
|
||||
COPY --from=models /rootfs/ /
|
||||
COPY docker/main/rootfs/ /
|
||||
@@ -188,8 +160,6 @@ RUN --mount=type=bind,from=wheels,source=/wheels,target=/deps/wheels \
|
||||
|
||||
COPY --from=deps-rootfs / /
|
||||
|
||||
RUN ldconfig
|
||||
|
||||
EXPOSE 5000
|
||||
EXPOSE 8554
|
||||
EXPOSE 8555/tcp 8555/udp
|
||||
|
||||
11
docker/main/build_ov_model.py
Normal file
11
docker/main/build_ov_model.py
Normal file
@@ -0,0 +1,11 @@
|
||||
import openvino as ov
|
||||
from openvino.tools import mo
|
||||
|
||||
ov_model = mo.convert_model(
|
||||
"/models/ssdlite_mobilenet_v2_coco_2018_05_09/frozen_inference_graph.pb",
|
||||
compress_to_fp16=True,
|
||||
transformations_config="/usr/local/lib/python3.9/dist-packages/openvino/tools/mo/front/tf/ssd_v2_support.json",
|
||||
tensorflow_object_detection_api_pipeline_config="/models/ssdlite_mobilenet_v2_coco_2018_05_09/pipeline.config",
|
||||
reverse_input_channels=True,
|
||||
)
|
||||
ov.save_model(ov_model, "/models/ssdlite_mobilenet_v2.xml")
|
||||
@@ -1,5 +1,3 @@
|
||||
numpy
|
||||
# Openvino Library - Custom built with MYRIAD support
|
||||
openvino @ https://github.com/NateMeyer/openvino-wheels/releases/download/multi-arch_2022.3.1/openvino-2022.3.1-1-cp39-cp39-manylinux_2_31_x86_64.whl; platform_machine == 'x86_64'
|
||||
openvino @ https://github.com/NateMeyer/openvino-wheels/releases/download/multi-arch_2022.3.1/openvino-2022.3.1-1-cp39-cp39-linux_aarch64.whl; platform_machine == 'aarch64'
|
||||
openvino-dev[tensorflow2] @ https://github.com/NateMeyer/openvino-wheels/releases/download/multi-arch_2022.3.1/openvino_dev-2022.3.1-1-py3-none-any.whl
|
||||
tensorflow
|
||||
openvino-dev>=2024.0.0
|
||||
@@ -30,6 +30,4 @@ setproctitle == 1.3.*
|
||||
ws4py == 0.5.*
|
||||
unidecode == 1.3.*
|
||||
onnxruntime == 1.16.*
|
||||
# Openvino Library - Custom built with MYRIAD support
|
||||
openvino @ https://github.com/NateMeyer/openvino-wheels/releases/download/multi-arch_2022.3.1/openvino-2022.3.1-1-cp39-cp39-manylinux_2_31_x86_64.whl; platform_machine == 'x86_64'
|
||||
openvino @ https://github.com/NateMeyer/openvino-wheels/releases/download/multi-arch_2022.3.1/openvino-2022.3.1-1-cp39-cp39-linux_aarch64.whl; platform_machine == 'aarch64'
|
||||
openvino == 2024.1.*
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
certsync
|
||||
@@ -0,0 +1 @@
|
||||
certsync-pipeline
|
||||
4
docker/main/rootfs/etc/s6-overlay/s6-rc.d/certsync-log/run
Executable file
4
docker/main/rootfs/etc/s6-overlay/s6-rc.d/certsync-log/run
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/command/with-contenv bash
|
||||
# shellcheck shell=bash
|
||||
|
||||
exec logutil-service /dev/shm/logs/certsync
|
||||
@@ -0,0 +1 @@
|
||||
longrun
|
||||
30
docker/main/rootfs/etc/s6-overlay/s6-rc.d/certsync/finish
Executable file
30
docker/main/rootfs/etc/s6-overlay/s6-rc.d/certsync/finish
Executable file
@@ -0,0 +1,30 @@
|
||||
#!/command/with-contenv bash
|
||||
# shellcheck shell=bash
|
||||
# Take down the S6 supervision tree when the service fails
|
||||
|
||||
set -o errexit -o nounset -o pipefail
|
||||
|
||||
# Logs should be sent to stdout so that s6 can collect them
|
||||
|
||||
declare exit_code_container
|
||||
exit_code_container=$(cat /run/s6-linux-init-container-results/exitcode)
|
||||
readonly exit_code_container
|
||||
readonly exit_code_service="${1}"
|
||||
readonly exit_code_signal="${2}"
|
||||
readonly service="CERTSYNC"
|
||||
|
||||
echo "[INFO] Service ${service} exited with code ${exit_code_service} (by signal ${exit_code_signal})"
|
||||
|
||||
if [[ "${exit_code_service}" -eq 256 ]]; then
|
||||
if [[ "${exit_code_container}" -eq 0 ]]; then
|
||||
echo $((128 + exit_code_signal)) >/run/s6-linux-init-container-results/exitcode
|
||||
fi
|
||||
if [[ "${exit_code_signal}" -eq 15 ]]; then
|
||||
exec /run/s6/basedir/bin/halt
|
||||
fi
|
||||
elif [[ "${exit_code_service}" -ne 0 ]]; then
|
||||
if [[ "${exit_code_container}" -eq 0 ]]; then
|
||||
echo "${exit_code_service}" >/run/s6-linux-init-container-results/exitcode
|
||||
fi
|
||||
exec /run/s6/basedir/bin/halt
|
||||
fi
|
||||
@@ -0,0 +1 @@
|
||||
certsync-log
|
||||
53
docker/main/rootfs/etc/s6-overlay/s6-rc.d/certsync/run
Executable file
53
docker/main/rootfs/etc/s6-overlay/s6-rc.d/certsync/run
Executable file
@@ -0,0 +1,53 @@
|
||||
#!/command/with-contenv bash
|
||||
# shellcheck shell=bash
|
||||
# Start the CERTSYNC service
|
||||
|
||||
set -o errexit -o nounset -o pipefail
|
||||
|
||||
# Logs should be sent to stdout so that s6 can collect them
|
||||
|
||||
echo "[INFO] Starting certsync..."
|
||||
|
||||
lefile="/etc/letsencrypt/live/frigate/fullchain.pem"
|
||||
|
||||
|
||||
while true
|
||||
do
|
||||
|
||||
if [ ! -e $lefile ]
|
||||
then
|
||||
echo "[ERROR] TLS certificate does not exist: $lefile"
|
||||
fi
|
||||
|
||||
leprint=`openssl x509 -in $lefile -fingerprint -noout || echo 'failed'`
|
||||
|
||||
case "$leprint" in
|
||||
*Fingerprint*)
|
||||
;;
|
||||
*)
|
||||
echo "[ERROR] Missing fingerprint from $lefile"
|
||||
;;
|
||||
esac
|
||||
|
||||
liveprint=`echo | openssl s_client -showcerts -connect 127.0.0.1:443 2>&1 | openssl x509 -fingerprint | grep -i fingerprint || echo 'failed'`
|
||||
|
||||
case "$liveprint" in
|
||||
*Fingerprint*)
|
||||
;;
|
||||
*)
|
||||
echo "[ERROR] Missing fingerprint from current nginx TLS cert"
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ "$leprint" != "failed" && "$liveprint" != "failed" && "$leprint" != "$liveprint" ]]
|
||||
then
|
||||
echo "[INFO] Reloading nginx to refresh TLS certificate"
|
||||
echo "$lefile: $leprint"
|
||||
/usr/local/nginx/sbin/nginx -s reload
|
||||
fi
|
||||
|
||||
sleep 60
|
||||
|
||||
done
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1 @@
|
||||
30000
|
||||
1
docker/main/rootfs/etc/s6-overlay/s6-rc.d/certsync/type
Normal file
1
docker/main/rootfs/etc/s6-overlay/s6-rc.d/certsync/type
Normal file
@@ -0,0 +1 @@
|
||||
longrun
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
set -o errexit -o nounset -o pipefail
|
||||
|
||||
dirs=(/dev/shm/logs/frigate /dev/shm/logs/go2rtc /dev/shm/logs/nginx)
|
||||
dirs=(/dev/shm/logs/frigate /dev/shm/logs/go2rtc /dev/shm/logs/nginx /dev/shm/logs/certsync)
|
||||
|
||||
mkdir -p "${dirs[@]}"
|
||||
chown nobody:nogroup "${dirs[@]}"
|
||||
|
||||
5
docker/main/rootfs/etc/s6-overlay/s6-rc.d/nginx/data/check
Executable file
5
docker/main/rootfs/etc/s6-overlay/s6-rc.d/nginx/data/check
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
# Wait for PID file to exist.
|
||||
while ! test -f /run/nginx.pid; do sleep 1; done
|
||||
@@ -0,0 +1 @@
|
||||
3
|
||||
@@ -8,6 +8,36 @@ set -o errexit -o nounset -o pipefail
|
||||
|
||||
echo "[INFO] Starting NGINX..."
|
||||
|
||||
function set_worker_processes() {
|
||||
# Capture number of assigned CPUs to calculate worker processes
|
||||
local proc_count
|
||||
|
||||
if proc_count=$(nproc --all) && [[ $proc_count -gt 4 ]]; then
|
||||
proc_count=4;
|
||||
fi
|
||||
|
||||
# we need to catch any errors because sed will fail if user has bind mounted a custom nginx file
|
||||
sed -i "s/worker_processes auto;/worker_processes ${proc_count};/" /usr/local/nginx/conf/nginx.conf || true
|
||||
}
|
||||
|
||||
set_worker_processes
|
||||
|
||||
# ensure the directory for ACME challenges exists
|
||||
mkdir -p /etc/letsencrypt/www
|
||||
|
||||
# Create self signed certs if needed
|
||||
letsencrypt_path=/etc/letsencrypt/live/frigate
|
||||
mkdir -p $letsencrypt_path
|
||||
|
||||
if [ ! \( -f "$letsencrypt_path/privkey.pem" -a -f "$letsencrypt_path/fullchain.pem" \) ]; then
|
||||
echo "[INFO] No TLS certificate found. Generating a self signed certificate..."
|
||||
openssl req -new -newkey rsa:4096 -days 365 -nodes -x509 \
|
||||
-subj "/O=FRIGATE DEFAULT CERT/CN=*" \
|
||||
-keyout "$letsencrypt_path/privkey.pem" -out "$letsencrypt_path/fullchain.pem"
|
||||
fi
|
||||
|
||||
# Replace the bash process with the NGINX process, redirecting stderr to stdout
|
||||
exec 2>&1
|
||||
exec nginx
|
||||
exec \
|
||||
s6-notifyoncheck -t 30000 -n 1 \
|
||||
nginx
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
upstream go2rtc {
|
||||
server 127.0.0.1:1984;
|
||||
keepalive 1024;
|
||||
}
|
||||
@@ -56,9 +56,14 @@ http {
|
||||
keepalive 1024;
|
||||
}
|
||||
|
||||
upstream go2rtc {
|
||||
server 127.0.0.1:1984;
|
||||
keepalive 1024;
|
||||
include go2rtc_upstream.conf;
|
||||
|
||||
server {
|
||||
listen [::]:80 ipv6only=off default_server;
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
@@ -67,6 +72,8 @@ http {
|
||||
# intended for internal traffic, not protected by auth
|
||||
listen [::]:5000 ipv6only=off;
|
||||
|
||||
include tls.conf;
|
||||
|
||||
# vod settings
|
||||
vod_base_url '';
|
||||
vod_segments_base_url '';
|
||||
|
||||
24
docker/main/rootfs/usr/local/nginx/conf/tls.conf
Normal file
24
docker/main/rootfs/usr/local/nginx/conf/tls.conf
Normal file
@@ -0,0 +1,24 @@
|
||||
keepalive_timeout 70;
|
||||
listen [::]:443 ipv6only=off default_server ssl;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/frigate/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/frigate/privkey.pem;
|
||||
|
||||
# generated 2024-06-01, Mozilla Guideline v5.7, nginx 1.25.3, OpenSSL 1.1.1w, modern configuration, no OCSP
|
||||
# https://ssl-config.mozilla.org/#server=nginx&version=1.25.3&config=modern&openssl=1.1.1w&ocsp=false&guideline=5.7
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_cache shared:MozSSL:10m; # about 40000 sessions
|
||||
ssl_session_tickets off;
|
||||
|
||||
# modern configuration
|
||||
ssl_protocols TLSv1.3;
|
||||
ssl_prefer_server_ciphers off;
|
||||
|
||||
# HSTS (ngx_http_headers_module is required) (63072000 seconds)
|
||||
add_header Strict-Transport-Security "max-age=63072000" always;
|
||||
|
||||
# ACME challenge location
|
||||
location /.well-known/acme-challenge/ {
|
||||
default_type "text/plain";
|
||||
root /etc/letsencrypt/www;
|
||||
}
|
||||
@@ -52,6 +52,27 @@ auth:
|
||||
- 172.18.0.0/16 # <---- this is the subnet for the internal docker compose network
|
||||
```
|
||||
|
||||
#### JWT Token Secret
|
||||
|
||||
The JWT token secret needs to be kept secure. Anyone with this secret can generate valid JWT tokens to authenticate with Frigate. This should be a cryptographically random string of at least 64 characters.
|
||||
|
||||
You can generate a token using the Python secret library with the following command:
|
||||
|
||||
```shell
|
||||
python3 -c 'import secrets; print(secrets.token_hex(64))'
|
||||
```
|
||||
|
||||
Frigate looks for a JWT token secret in the following order:
|
||||
|
||||
1. An environment variable named `FRIGATE_JWT_SECRET`
|
||||
2. A docker secret named `FRIGATE_JWT_SECRET` in `/run/secrets/`
|
||||
3. A `jwt_secret` option from the Home Assistant Addon options
|
||||
4. A `.jwt_secret` file in the config directory
|
||||
|
||||
If no secret is found on startup, Frigate generates one and stores it in a `.jwt_secret` file in the config directory.
|
||||
|
||||
Changing the secret will invalidate current tokens.
|
||||
|
||||
### Proxy mode
|
||||
|
||||
Proxy mode is designed to complement common upstream authentication proxies such as Authelia, Authentik, oauth2_proxy, or traefik-forward-auth.
|
||||
|
||||
@@ -362,39 +362,11 @@ that NVDEC/NVDEC1 are in use.
|
||||
|
||||
## Rockchip platform
|
||||
|
||||
Hardware accelerated video de-/encoding is supported on all Rockchip SoCs using [Nyanmisaka's FFmpeg Fork](https://github.com/nyanmisaka/ffmpeg-rockchip) based on [Rockchip's mpp library](https://github.com/rockchip-linux/mpp).
|
||||
Hardware accelerated video de-/encoding is supported on all Rockchip SoCs using [Nyanmisaka's FFmpeg 6.1 Fork](https://github.com/nyanmisaka/ffmpeg-rockchip) based on [Rockchip's mpp library](https://github.com/rockchip-linux/mpp).
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Make sure that you use a linux distribution that comes with the rockchip BSP kernel 5.10 or 6.1 and rkvdec2 driver. To check, enter the following commands:
|
||||
|
||||
```
|
||||
$ uname -r
|
||||
5.10.xxx-rockchip # or 6.1.xxx; the -rockchip suffix is important
|
||||
$ ls /dev/dri
|
||||
by-path card0 card1 renderD128 renderD129 # should list renderD128
|
||||
```
|
||||
|
||||
I recommend [Joshua Riek's Ubuntu for Rockchip](https://github.com/Joshua-Riek/ubuntu-rockchip), if your board is supported.
|
||||
|
||||
### Setup
|
||||
|
||||
Follow Frigate's default installation instructions, but use a docker image with `-rk` suffix for example `ghcr.io/blakeblackshear/frigate:stable-rk`.
|
||||
|
||||
Next, you need to grant docker permissions to access your hardware:
|
||||
- During the configuration process, you should run docker in privileged mode to avoid any errors due to insufficient permissions. To do so, add `privileged: true` to your `docker-compose.yml` file or the `--privileged` flag to your docker run command.
|
||||
- After everything works, you should only grant necessary permissions to increase security. Add the lines below to your `docker-compose.yml` file or the following options to your docker run command: `--security-opt systempaths=unconfined --security-opt apparmor=unconfined --device /dev/dri:/dev/dri --device /dev/dma_heap:/dev/dma_heap --device /dev/rga:/dev/rga --device /dev/mpp_service:/dev/mpp_service`:
|
||||
|
||||
```yaml
|
||||
security_opt:
|
||||
- apparmor=unconfined
|
||||
- systempaths=unconfined
|
||||
devices:
|
||||
- /dev/dri:/dev/dri
|
||||
- /dev/dma_heap:/dev/dma_heap
|
||||
- /dev/rga:/dev/rga
|
||||
- /dev/mpp_service:/dev/mpp_service
|
||||
```
|
||||
Make sure to follow the [Rockchip specific installation instructions](/frigate/installation#rockchip-platform).
|
||||
|
||||
### Configuration
|
||||
|
||||
|
||||
@@ -313,39 +313,11 @@ Hardware accelerated object detection is supported on the following SoCs:
|
||||
- RK3576
|
||||
- RK3588
|
||||
|
||||
This implementation uses the [Rockchip's RKNN-Toolkit2](https://github.com/airockchip/rknn-toolkit2/) Currently, only [Yolo-NAS](https://github.com/Deci-AI/super-gradients/blob/master/YOLONAS.md) is supported as object detection model.
|
||||
This implementation uses the [Rockchip's RKNN-Toolkit2](https://github.com/airockchip/rknn-toolkit2/), version v2.0.0.beta0. Currently, only [Yolo-NAS](https://github.com/Deci-AI/super-gradients/blob/master/YOLONAS.md) is supported as object detection model.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Make sure that you use a linux distribution that comes with the rockchip BSP kernel 5.10 or 6.1 and rknpu driver. To check, enter the following commands:
|
||||
|
||||
```
|
||||
$ uname -r
|
||||
5.10.xxx-rockchip # or 6.1.xxx; the -rockchip suffix is important
|
||||
$ ls /dev/dri
|
||||
by-path card0 card1 renderD128 renderD129 # should list renderD129
|
||||
$ sudo cat /sys/kernel/debug/rknpu/version
|
||||
RKNPU driver: v0.9.2 # or later version
|
||||
```
|
||||
|
||||
I recommend [Joshua Riek's Ubuntu for Rockchip](https://github.com/Joshua-Riek/ubuntu-rockchip), if your board is supported.
|
||||
|
||||
### Setup
|
||||
|
||||
Follow Frigate's default installation instructions, but use a docker image with `-rk` suffix for example `ghcr.io/blakeblackshear/frigate:stable-rk`.
|
||||
|
||||
Next, you need to grant docker permissions to access your hardware:
|
||||
|
||||
- During the configuration process, you should run docker in privileged mode to avoid any errors due to insufficient permissions. To do so, add `privileged: true` to your `docker-compose.yml` file or the `--privileged` flag to your docker run command.
|
||||
- After everything works, you should only grant necessary permissions to increase security. Add the lines below to your `docker-compose.yml` file or the following options to your docker run command: `--security-opt systempaths=unconfined --security-opt apparmor=unconfined --device /dev/dri:/dev/dri`:
|
||||
|
||||
```yaml
|
||||
security_opt:
|
||||
- apparmor=unconfined
|
||||
- systempaths=unconfined
|
||||
devices:
|
||||
- /dev/dri:/dev/dri
|
||||
```
|
||||
Make sure to follow the [Rockchip specific installation instrucitions](/frigate/installation#rockchip-platform).
|
||||
|
||||
### Configuration
|
||||
|
||||
@@ -405,6 +377,5 @@ $ cat /sys/kernel/debug/rknpu/load
|
||||
|
||||
:::
|
||||
|
||||
- By default the rknn detector uses the yolonas_s model (`model: path: default-fp16-yolonas_s`). This model comes with the image, so no further steps than those mentioned above are necessary and no download happens.
|
||||
- The other choices are automatically downloaded and stored in the folder `config/model_cache/rknn_cache`. After upgrading Frigate, you should remove older models to free up space.
|
||||
- Finally, you can also provide your own `.rknn` model. You should not save your own models in the `rknn_cache` folder, store them directly in the `model_cache` folder or another subfolder. To convert a model to `.rknn` format see the `rknn-toolkit2` (requires a x86 machine). Note, that there is only post-processing for the supported models.
|
||||
- All models are automatically downloaded and stored in the folder `config/model_cache/rknn_cache`. After upgrading Frigate, you should remove older models to free up space.
|
||||
- You can also provide your own `.rknn` model. You should not save your own models in the `rknn_cache` folder, store them directly in the `model_cache` folder or another subfolder. To convert a model to `.rknn` format see the `rknn-toolkit2` (requires a x86 machine). Note, that there is only post-processing for the supported models.
|
||||
|
||||
@@ -645,8 +645,6 @@ cameras:
|
||||
|
||||
# Optional
|
||||
ui:
|
||||
# Optional: Set the default live mode for cameras in the UI (default: shown below)
|
||||
live_mode: mse
|
||||
# Optional: Set a timezone to use in the UI (default: use browser local time)
|
||||
# timezone: America/Denver
|
||||
# Optional: Set the time format used.
|
||||
|
||||
@@ -42,6 +42,20 @@ review:
|
||||
- dog
|
||||
```
|
||||
|
||||
## Excluding a camera from alerts or detections
|
||||
|
||||
To exclude a specific camera from alerts or detections, simply provide an empty list to the alerts or detections field _at the camera level_.
|
||||
|
||||
For example, to exclude objects on the camera _gatecamera_ from any detections, include this in your config:
|
||||
|
||||
```yaml
|
||||
cameras:
|
||||
gatecamera:
|
||||
review:
|
||||
detections:
|
||||
labels: []
|
||||
```
|
||||
|
||||
## Restricting review items to specific zones
|
||||
|
||||
By default a review item will be created if any `review -> alerts -> labels` and `review -> detections -> labels` are detected anywhere in the camera frame. You will likely want to configure review items to only be created when the object enters an area of interest, [see the zone docs for more information](./zones.md#restricting-alerts-and-detections-to-specific-zones)
|
||||
|
||||
34
docs/docs/configuration/tls.md
Normal file
34
docs/docs/configuration/tls.md
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
id: tls
|
||||
title: TLS
|
||||
---
|
||||
|
||||
# TLS
|
||||
|
||||
Frigate's integrated NGINX server supports TLS certificates. By default Frigate will generate a self signed certificate that will be used for port 443. Frigate is designed to make it easy to use whatever tool you prefer to manage certificates.
|
||||
|
||||
Frigate is often running behind a reverse proxy that manages TLS certificates for multiple services. However, if you are running on a device that's separate from your proxy or if you expose Frigate directly to the internet, you may want to configure TLS.
|
||||
|
||||
## Certificates
|
||||
|
||||
TLS certificates can be mounted at `/etc/letsencrypt/live/frigate` using a bind mount or docker volume.
|
||||
|
||||
```yaml
|
||||
frigate:
|
||||
...
|
||||
volumes:
|
||||
- /path/to/your/certificate_folder:/etc/letsencrypt/live/frigate
|
||||
...
|
||||
```
|
||||
|
||||
Within the folder, the private key is expected to be named `privkey.pem` and the certificate is expected to be named `fullchain.pem`.
|
||||
|
||||
Frigate automatically compares the fingerprint of the certificate at `/etc/letsencrypt/live/frigate/fullchain.pem` against the fingerprint of the TLS cert in NGINX every minute. If these differ, the NGINX config is reloaded to pick up the updated certificate.
|
||||
|
||||
## ACME Challenge
|
||||
|
||||
Frigate also supports hosting the acme challenge files for the HTTP challenge method if needed. The challenge files should be mounted at `/etc/letsencrypt/www`.
|
||||
|
||||
## Advanced customization
|
||||
|
||||
If you would like to customize the TLS configuration, you can do so by using a bind mount to override `/usr/local/nginx/conf/tls.conf`. Check the source code for the default configuration and modify from there.
|
||||
@@ -95,6 +95,18 @@ Frigate supports all Jetson boards, from the inexpensive Jetson Nano to the powe
|
||||
|
||||
Inference speed will vary depending on the YOLO model, jetson platform and jetson nvpmodel (GPU/DLA/EMC clock speed). It is typically 20-40 ms for most models. The DLA is more efficient than the GPU, but not faster, so using the DLA will reduce power consumption but will slightly increase inference time.
|
||||
|
||||
#### Rockchip platform
|
||||
|
||||
Frigate supports hardware video processing on all Rockchip boards. However, hardware object detection is only supported on these boards:
|
||||
|
||||
- RK3562
|
||||
- RK3566
|
||||
- RK3568
|
||||
- RK3576
|
||||
- RK3588
|
||||
|
||||
The inference time of a rk3588 with all 3 cores enabled is typically 25-30 ms for yolo-nas s.
|
||||
|
||||
## What does Frigate use the CPU for and what does it use a detector for? (ELI5 Version)
|
||||
|
||||
This is taken from a [user question on reddit](https://www.reddit.com/r/homeassistant/comments/q8mgau/comment/hgqbxh5/?utm_source=share&utm_medium=web2x&context=3). Modified slightly for clarity.
|
||||
|
||||
@@ -34,7 +34,8 @@ The following ports are used by Frigate and can be mapped via docker as required
|
||||
|
||||
| Port | Description |
|
||||
| ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `8080` | Authenticated UI and API access. Reverse proxies should use this port. |
|
||||
| `8080` | Authenticated UI and API access without TLS. Reverse proxies should use this port. |
|
||||
| `443` | Authenticated UI and API access with TLS. See the [TLS configuration](/configuration/tls) for more details. |
|
||||
| `5000` | Internal unauthenticated UI and API access. Access to this port should be limited. Intended to be used within the docker network for services that integrate with Frigate. |
|
||||
| `8554` | RTSP restreaming. By default, these streams are unauthenticated. Authentication can be configured in go2rtc section of config. |
|
||||
| `8555` | WebRTC connections for low latency live views. |
|
||||
@@ -44,7 +45,6 @@ The following ports are used by Frigate and can be mapped via docker as required
|
||||
Writing to a local disk or external USB drive:
|
||||
|
||||
```yaml
|
||||
version: "3.9"
|
||||
services:
|
||||
frigate:
|
||||
...
|
||||
@@ -95,6 +95,56 @@ By default, the Raspberry Pi limits the amount of memory available to the GPU. I
|
||||
|
||||
Additionally, the USB Coral draws a considerable amount of power. If using any other USB devices such as an SSD, you will experience instability due to the Pi not providing enough power to USB devices. You will need to purchase an external USB hub with it's own power supply. Some have reported success with <a href="https://amzn.to/3a2mH0P" target="_blank" rel="nofollow noopener sponsored">this</a> (affiliate link).
|
||||
|
||||
### Rockchip platform
|
||||
|
||||
Make sure that you use a linux distribution that comes with the rockchip BSP kernel 5.10 or 6.1 and necessary drivers (especially rkvdec2 and rknpu). To check, enter the following commands:
|
||||
|
||||
```
|
||||
$ uname -r
|
||||
5.10.xxx-rockchip # or 6.1.xxx; the -rockchip suffix is important
|
||||
$ ls /dev/dri
|
||||
by-path card0 card1 renderD128 renderD129 # should list renderD128 (VPU) and renderD129 (NPU)
|
||||
$ sudo cat /sys/kernel/debug/rknpu/version
|
||||
RKNPU driver: v0.9.2 # or later version
|
||||
```
|
||||
|
||||
I recommend [Joshua Riek's Ubuntu for Rockchip](https://github.com/Joshua-Riek/ubuntu-rockchip), if your board is supported.
|
||||
|
||||
#### Setup
|
||||
|
||||
Follow Frigate's default installation instructions, but use a docker image with `-rk` suffix for example `ghcr.io/blakeblackshear/frigate:stable-rk`.
|
||||
|
||||
Next, you need to grant docker permissions to access your hardware:
|
||||
|
||||
- During the configuration process, you should run docker in privileged mode to avoid any errors due to insufficient permissions. To do so, add `privileged: true` to your `docker-compose.yml` file or the `--privileged` flag to your docker run command.
|
||||
- After everything works, you should only grant necessary permissions to increase security. Disable the privileged mode and add the lines below to your `docker-compose.yml` file:
|
||||
|
||||
```yaml
|
||||
security_opt:
|
||||
- apparmor=unconfined
|
||||
- systempaths=unconfined
|
||||
devices:
|
||||
- /dev/dri
|
||||
- /dev/dma_heap
|
||||
- /dev/rga
|
||||
- /dev/mpp_service
|
||||
```
|
||||
|
||||
or add these options to your `docker run` command:
|
||||
|
||||
```
|
||||
--security-opt systempaths=unconfined \
|
||||
--security-opt apparmor=unconfined \
|
||||
--device /dev/dri \
|
||||
--device /dev/dma_heap \
|
||||
--device /dev/rga \
|
||||
--device /dev/mpp_service
|
||||
```
|
||||
|
||||
#### Configuration
|
||||
|
||||
Next, you should configure [hardware object detection](/configuration/object_detectors#rockchip-platform) and [hardware video processing](/configuration/hardware_acceleration#rockchip-platform).
|
||||
|
||||
## Docker
|
||||
|
||||
Running in Docker with compose is the recommended install method.
|
||||
|
||||
665
docs/package-lock.json
generated
665
docs/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -14,9 +14,9 @@
|
||||
"write-heading-ids": "docusaurus write-heading-ids"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "^3.3.2",
|
||||
"@docusaurus/preset-classic": "^3.3.2",
|
||||
"@docusaurus/theme-mermaid": "^3.3.2",
|
||||
"@docusaurus/core": "^3.4.0",
|
||||
"@docusaurus/preset-classic": "^3.4.0",
|
||||
"@docusaurus/theme-mermaid": "^3.4.0",
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"clsx": "^2.0.0",
|
||||
"prism-react-renderer": "^2.1.0",
|
||||
@@ -37,8 +37,8 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "^3.3.2",
|
||||
"@docusaurus/types": "^3.3.2",
|
||||
"@docusaurus/module-type-aliases": "^3.4.0",
|
||||
"@docusaurus/types": "^3.4.0",
|
||||
"@types/react": "^18.2.79"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -52,6 +52,7 @@ module.exports = {
|
||||
"configuration/authentication",
|
||||
"configuration/hardware_acceleration",
|
||||
"configuration/ffmpeg_presets",
|
||||
"configuration/tls",
|
||||
"configuration/advanced",
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1232,7 +1232,7 @@ def preview_gif(camera_name: str, start_ts, end_ts, max_cache_age=2592000):
|
||||
|
||||
@MediaBp.route("/<camera_name>/start/<int:start_ts>/end/<int:end_ts>/preview.mp4")
|
||||
@MediaBp.route("/<camera_name>/start/<float:start_ts>/end/<float:end_ts>/preview.mp4")
|
||||
def preview_mp4(camera_name: str, start_ts, end_ts):
|
||||
def preview_mp4(camera_name: str, start_ts, end_ts, max_cache_age=2592000):
|
||||
file_name = f"clip_{camera_name}_{start_ts}-{end_ts}.mp4"
|
||||
|
||||
if len(file_name) > 1000:
|
||||
@@ -1380,7 +1380,7 @@ def preview_mp4(camera_name: str, start_ts, end_ts):
|
||||
|
||||
response = make_response()
|
||||
response.headers["Content-Description"] = "File Transfer"
|
||||
response.headers["Cache-Control"] = "no-cache"
|
||||
response.headers["Cache-Control"] = f"private, max-age={max_cache_age}"
|
||||
response.headers["Content-Type"] = "video/mp4"
|
||||
response.headers["Content-Length"] = os.path.getsize(path)
|
||||
response.headers["X-Accel-Redirect"] = (
|
||||
|
||||
@@ -440,6 +440,11 @@ def motion_activity():
|
||||
|
||||
# resample data using pandas to get activity on scaled basis
|
||||
df = pd.DataFrame(data, columns=["start_time", "motion", "camera"])
|
||||
|
||||
if df.empty:
|
||||
logger.warning("No motion data found for the requested time range")
|
||||
return jsonify([])
|
||||
|
||||
df = df.astype(dtype={"motion": "float16"})
|
||||
|
||||
# set date as datetime index
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -46,9 +45,9 @@ from frigate.util.builtin import (
|
||||
get_ffmpeg_arg_list,
|
||||
load_config_with_no_duplicates,
|
||||
)
|
||||
from frigate.util.config import get_relative_coordinates
|
||||
from frigate.util.config import StreamInfoRetriever, get_relative_coordinates
|
||||
from frigate.util.image import create_mask
|
||||
from frigate.util.services import auto_detect_hwaccel, get_video_properties
|
||||
from frigate.util.services import auto_detect_hwaccel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -73,6 +72,9 @@ DEFAULT_DETECTORS = {"cpu": {"type": "cpu"}}
|
||||
DEFAULT_DETECT_DIMENSIONS = {"width": 1280, "height": 720}
|
||||
DEFAULT_TIME_LAPSE_FFMPEG_ARGS = "-vf setpts=0.04*PTS -r 30"
|
||||
|
||||
# stream info handler
|
||||
stream_info_retriever = StreamInfoRetriever()
|
||||
|
||||
|
||||
class FrigateBaseModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", protected_namespaces=())
|
||||
@@ -98,9 +100,6 @@ class DateTimeStyleEnum(str, Enum):
|
||||
|
||||
|
||||
class UIConfig(FrigateBaseModel):
|
||||
live_mode: LiveModeEnum = Field(
|
||||
default=LiveModeEnum.mse, title="Default Live Mode."
|
||||
)
|
||||
timezone: Optional[str] = Field(default=None, title="Override UI timezone.")
|
||||
time_format: TimeFormatEnum = Field(
|
||||
default=TimeFormatEnum.browser, title="Override UI time format."
|
||||
@@ -1169,12 +1168,20 @@ class LoggerConfig(FrigateBaseModel):
|
||||
class CameraGroupConfig(FrigateBaseModel):
|
||||
"""Represents a group of cameras."""
|
||||
|
||||
cameras: list[str] = Field(
|
||||
cameras: Union[str, List[str]] = Field(
|
||||
default_factory=list, title="List of cameras in this group."
|
||||
)
|
||||
icon: str = Field(default="generic", title="Icon that represents camera group.")
|
||||
order: int = Field(default=0, title="Sort order for group.")
|
||||
|
||||
@field_validator("cameras", mode="before")
|
||||
@classmethod
|
||||
def validate_cameras(cls, v):
|
||||
if isinstance(v, str) and "," not in v:
|
||||
return [v]
|
||||
|
||||
return v
|
||||
|
||||
|
||||
def verify_config_roles(camera_config: CameraConfig) -> None:
|
||||
"""Verify that roles are setup in the config correctly."""
|
||||
@@ -1355,6 +1362,7 @@ class FrigateConfig(FrigateBaseModel):
|
||||
default_factory=TimestampStyleConfig,
|
||||
title="Global timestamp style configuration.",
|
||||
)
|
||||
version: Optional[float] = Field(default=None, title="Current config version.")
|
||||
|
||||
def runtime_config(self, plus_api: PlusApi = None) -> FrigateConfig:
|
||||
"""Merge camera config with globals."""
|
||||
@@ -1415,7 +1423,7 @@ class FrigateConfig(FrigateBaseModel):
|
||||
if need_detect_dimensions or need_record_fourcc:
|
||||
stream_info = {"width": 0, "height": 0, "fourcc": None}
|
||||
try:
|
||||
stream_info = asyncio.run(get_video_properties(input.path))
|
||||
stream_info = stream_info_retriever.get_stream_info(input.path)
|
||||
except Exception:
|
||||
logger.warn(
|
||||
f"Error detecting stream parameters automatically for {input.path} Applying default values."
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
|
||||
import numpy as np
|
||||
import openvino.runtime as ov
|
||||
import openvino as ov
|
||||
from pydantic import Field
|
||||
from typing_extensions import Literal
|
||||
|
||||
@@ -23,28 +23,56 @@ class OvDetector(DetectionApi):
|
||||
|
||||
def __init__(self, detector_config: OvDetectorConfig):
|
||||
self.ov_core = ov.Core()
|
||||
self.ov_model = self.ov_core.read_model(detector_config.model.path)
|
||||
self.ov_model_type = detector_config.model.model_type
|
||||
|
||||
self.h = detector_config.model.height
|
||||
self.w = detector_config.model.width
|
||||
|
||||
self.interpreter = self.ov_core.compile_model(
|
||||
model=self.ov_model, device_name=detector_config.device
|
||||
model=detector_config.model.path, device_name=detector_config.device
|
||||
)
|
||||
|
||||
logger.info(f"Model Input Shape: {self.interpreter.input(0).shape}")
|
||||
self.output_indexes = 0
|
||||
self.model_invalid = False
|
||||
|
||||
# Ensure the SSD model has the right input and output shapes
|
||||
if self.ov_model_type == ModelTypeEnum.ssd:
|
||||
model_inputs = self.interpreter.inputs
|
||||
model_outputs = self.interpreter.outputs
|
||||
|
||||
if len(model_inputs) != 1:
|
||||
logger.error(
|
||||
f"SSD models must only have 1 input. Found {len(model_inputs)}."
|
||||
)
|
||||
self.model_invalid = True
|
||||
if len(model_outputs) != 1:
|
||||
logger.error(
|
||||
f"SSD models must only have 1 output. Found {len(model_outputs)}."
|
||||
)
|
||||
self.model_invalid = True
|
||||
|
||||
if model_inputs[0].get_shape() != ov.Shape([1, self.w, self.h, 3]):
|
||||
logger.error(
|
||||
f"SSD model input doesn't match. Found {model_inputs[0].get_shape()}."
|
||||
)
|
||||
self.model_invalid = True
|
||||
|
||||
output_shape = model_outputs[0].get_shape()
|
||||
if output_shape[0] != 1 or output_shape[1] != 1 or output_shape[3] != 7:
|
||||
logger.error(f"SSD model output doesn't match. Found {output_shape}.")
|
||||
self.model_invalid = True
|
||||
|
||||
while True:
|
||||
try:
|
||||
tensor_shape = self.interpreter.output(self.output_indexes).shape
|
||||
logger.info(f"Model Output-{self.output_indexes} Shape: {tensor_shape}")
|
||||
self.output_indexes += 1
|
||||
except Exception:
|
||||
logger.info(f"Model has {self.output_indexes} Output Tensors")
|
||||
break
|
||||
if self.ov_model_type == ModelTypeEnum.yolox:
|
||||
self.output_indexes = 0
|
||||
while True:
|
||||
try:
|
||||
tensor_shape = self.interpreter.output(self.output_indexes).shape
|
||||
logger.info(
|
||||
f"Model Output-{self.output_indexes} Shape: {tensor_shape}"
|
||||
)
|
||||
self.output_indexes += 1
|
||||
except Exception:
|
||||
logger.info(f"Model has {self.output_indexes} Output Tensors")
|
||||
break
|
||||
self.num_classes = tensor_shape[2] - 5
|
||||
logger.info(f"YOLOX model has {self.num_classes} classes")
|
||||
self.set_strides_grids()
|
||||
@@ -81,29 +109,32 @@ class OvDetector(DetectionApi):
|
||||
|
||||
def detect_raw(self, tensor_input):
|
||||
infer_request = self.interpreter.create_infer_request()
|
||||
infer_request.infer([tensor_input])
|
||||
# TODO: see if we can use shared_memory=True
|
||||
input_tensor = ov.Tensor(array=tensor_input)
|
||||
infer_request.infer(input_tensor)
|
||||
|
||||
if self.ov_model_type == ModelTypeEnum.ssd:
|
||||
results = infer_request.get_output_tensor()
|
||||
|
||||
detections = np.zeros((20, 6), np.float32)
|
||||
i = 0
|
||||
for object_detected in results.data[0, 0, :]:
|
||||
if object_detected[0] != -1:
|
||||
logger.debug(object_detected)
|
||||
if object_detected[2] < 0.1 or i == 20:
|
||||
|
||||
if self.model_invalid:
|
||||
return detections
|
||||
|
||||
results = infer_request.get_output_tensor(0).data[0][0]
|
||||
|
||||
for i, (_, class_id, score, xmin, ymin, xmax, ymax) in enumerate(results):
|
||||
if i == 20:
|
||||
break
|
||||
detections[i] = [
|
||||
object_detected[1], # Label ID
|
||||
float(object_detected[2]), # Confidence
|
||||
object_detected[4], # y_min
|
||||
object_detected[3], # x_min
|
||||
object_detected[6], # y_max
|
||||
object_detected[5], # x_max
|
||||
class_id,
|
||||
float(score),
|
||||
ymin,
|
||||
xmin,
|
||||
ymax,
|
||||
xmax,
|
||||
]
|
||||
i += 1
|
||||
return detections
|
||||
elif self.ov_model_type == ModelTypeEnum.yolox:
|
||||
|
||||
if self.ov_model_type == ModelTypeEnum.yolox:
|
||||
out_tensor = infer_request.get_output_tensor()
|
||||
# [x, y, h, w, box_score, class_no_1, ..., class_no_80],
|
||||
results = out_tensor.data
|
||||
|
||||
@@ -50,11 +50,13 @@ def get_ffmpeg_command(ffmpeg: FfmpegConfig) -> list[str]:
|
||||
or get_ffmpeg_arg_list(ffmpeg.input_args)
|
||||
)
|
||||
return (
|
||||
["ffmpeg", "-vn"]
|
||||
["ffmpeg", "-vn", "-threads", "1"]
|
||||
+ input_args
|
||||
+ ["-i"]
|
||||
+ [ffmpeg_input.path]
|
||||
+ [
|
||||
"-threads",
|
||||
"1",
|
||||
"-f",
|
||||
f"{AUDIO_FORMAT}",
|
||||
"-ar",
|
||||
|
||||
@@ -498,6 +498,10 @@ class CameraState:
|
||||
|
||||
frame_copy = cv2.cvtColor(frame_copy, cv2.COLOR_YUV2BGR_I420)
|
||||
# draw on the frame
|
||||
if draw_options.get("mask"):
|
||||
mask_overlay = np.where(self.camera_config.motion.mask == [0])
|
||||
frame_copy[mask_overlay] = [0, 0, 0]
|
||||
|
||||
if draw_options.get("bounding_boxes"):
|
||||
# draw the bounding boxes on the frame
|
||||
for obj in tracked_objects.values():
|
||||
@@ -622,10 +626,6 @@ class CameraState:
|
||||
)
|
||||
cv2.drawContours(frame_copy, [zone.contour], -1, zone.color, thickness)
|
||||
|
||||
if draw_options.get("mask"):
|
||||
mask_overlay = np.where(self.camera_config.motion.mask == [0])
|
||||
frame_copy[mask_overlay] = [0, 0, 0]
|
||||
|
||||
if draw_options.get("motion_boxes"):
|
||||
for m_box in motion_boxes:
|
||||
cv2.rectangle(
|
||||
|
||||
@@ -134,6 +134,8 @@ class FFMpegConverter(threading.Thread):
|
||||
|
||||
ffmpeg_cmd = [
|
||||
"ffmpeg",
|
||||
"-threads",
|
||||
"1",
|
||||
"-f",
|
||||
"rawvideo",
|
||||
"-pix_fmt",
|
||||
@@ -142,6 +144,8 @@ class FFMpegConverter(threading.Thread):
|
||||
f"{in_width}x{in_height}",
|
||||
"-i",
|
||||
"pipe:",
|
||||
"-threads",
|
||||
"1",
|
||||
"-f",
|
||||
"mpegts",
|
||||
"-s",
|
||||
|
||||
@@ -31,6 +31,8 @@ class FFMpegConverter(threading.Thread):
|
||||
|
||||
ffmpeg_cmd = [
|
||||
"ffmpeg",
|
||||
"-threads",
|
||||
"1",
|
||||
"-f",
|
||||
"rawvideo",
|
||||
"-pix_fmt",
|
||||
@@ -39,6 +41,8 @@ class FFMpegConverter(threading.Thread):
|
||||
f"{in_width}x{in_height}",
|
||||
"-i",
|
||||
"pipe:",
|
||||
"-threads",
|
||||
"1",
|
||||
"-f",
|
||||
"mpegts",
|
||||
"-s",
|
||||
|
||||
@@ -5,6 +5,7 @@ import logging
|
||||
import os
|
||||
import subprocess as sp
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
@@ -101,12 +102,24 @@ class FFMpegConverter(threading.Thread):
|
||||
f"duration {self.frame_times[t_idx + 1] - self.frame_times[t_idx]}"
|
||||
)
|
||||
|
||||
p = sp.run(
|
||||
self.ffmpeg_cmd.split(" "),
|
||||
input="\n".join(playlist),
|
||||
encoding="ascii",
|
||||
capture_output=True,
|
||||
)
|
||||
try:
|
||||
p = sp.run(
|
||||
self.ffmpeg_cmd.split(" "),
|
||||
input="\n".join(playlist),
|
||||
encoding="ascii",
|
||||
capture_output=True,
|
||||
)
|
||||
except BlockingIOError:
|
||||
logger.warning(
|
||||
f"Failed to create preview for {self.config.name}, retrying..."
|
||||
)
|
||||
time.sleep(2)
|
||||
p = sp.run(
|
||||
self.ffmpeg_cmd.split(" "),
|
||||
input="\n".join(playlist),
|
||||
encoding="ascii",
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
start = self.frame_times[0]
|
||||
end = self.frame_times[-1]
|
||||
@@ -167,6 +180,7 @@ class PreviewRecorder:
|
||||
# end segment at end of hour
|
||||
self.segment_end = (
|
||||
(datetime.datetime.now() + datetime.timedelta(hours=1))
|
||||
.astimezone(datetime.timezone.utc)
|
||||
.replace(minute=0, second=0, microsecond=0)
|
||||
.timestamp()
|
||||
)
|
||||
@@ -179,6 +193,7 @@ class PreviewRecorder:
|
||||
# check for existing items in cache
|
||||
start_ts = (
|
||||
datetime.datetime.now()
|
||||
.astimezone(datetime.timezone.utc)
|
||||
.replace(minute=0, second=0, microsecond=0)
|
||||
.timestamp()
|
||||
)
|
||||
@@ -194,7 +209,12 @@ class PreviewRecorder:
|
||||
os.unlink(os.path.join(PREVIEW_CACHE_DIR, file))
|
||||
continue
|
||||
|
||||
ts = float(file.split("-")[1][: -(len(PREVIEW_FRAME_TYPE) + 1)])
|
||||
file_time = file.split("-")[1][: -(len(PREVIEW_FRAME_TYPE) + 1)]
|
||||
|
||||
if not file_time:
|
||||
continue
|
||||
|
||||
ts = float(file_time)
|
||||
|
||||
if self.start_time == 0:
|
||||
self.start_time = ts
|
||||
@@ -287,6 +307,7 @@ class PreviewRecorder:
|
||||
# reset frame cache
|
||||
self.segment_end = (
|
||||
(datetime.datetime.now() + datetime.timedelta(hours=1))
|
||||
.astimezone(datetime.timezone.utc)
|
||||
.replace(minute=0, second=0, microsecond=0)
|
||||
.timestamp()
|
||||
)
|
||||
|
||||
@@ -356,7 +356,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
if (
|
||||
not severity
|
||||
and (
|
||||
not camera_config.review.detections.labels
|
||||
camera_config.review.detections.labels is None
|
||||
or object["label"] in (camera_config.review.detections.labels)
|
||||
)
|
||||
and (
|
||||
@@ -467,7 +467,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
current_segment.audio.add(audio)
|
||||
current_segment.severity = SeverityEnum.alert
|
||||
elif (
|
||||
not camera_config.review.detections.labels
|
||||
camera_config.review.detections.labels is None
|
||||
or audio in camera_config.review.detections.labels
|
||||
):
|
||||
current_segment.audio.add(audio)
|
||||
@@ -510,7 +510,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
detections.add(audio)
|
||||
severity = SeverityEnum.alert
|
||||
elif (
|
||||
not camera_config.review.detections.labels
|
||||
camera_config.review.detections.labels is None
|
||||
or audio in camera_config.review.detections.labels
|
||||
):
|
||||
detections.add(audio)
|
||||
@@ -571,7 +571,7 @@ def get_active_objects(
|
||||
and (
|
||||
o["label"] in camera_config.review.alerts.labels
|
||||
or (
|
||||
not camera_config.review.detections.labels
|
||||
camera_config.review.detections.labels is None
|
||||
or o["label"] in camera_config.review.detections.labels
|
||||
)
|
||||
) # object must be in the alerts or detections label list
|
||||
|
||||
@@ -237,7 +237,7 @@ def update_yaml(data, key_path, new_value):
|
||||
temp[key[0]] += [{}] * (key[1] - len(temp[key[0]]) + 1)
|
||||
temp = temp[key[0]][key[1]]
|
||||
else:
|
||||
if key not in temp:
|
||||
if key not in temp or temp[key] is None:
|
||||
temp[key] = {}
|
||||
temp = temp[key]
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""configuration utils."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
@@ -8,6 +9,7 @@ from typing import Optional, Union
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
from frigate.const import CONFIG_DIR, EXPORT_DIR
|
||||
from frigate.util.services import get_video_properties
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -17,16 +19,17 @@ CURRENT_CONFIG_VERSION = 0.14
|
||||
def migrate_frigate_config(config_file: str):
|
||||
"""handle migrating the frigate config."""
|
||||
logger.info("Checking if frigate config needs migration...")
|
||||
version_file = os.path.join(CONFIG_DIR, ".version")
|
||||
|
||||
if not os.path.isfile(version_file):
|
||||
previous_version = 0.13
|
||||
else:
|
||||
with open(version_file) as f:
|
||||
try:
|
||||
previous_version = float(f.readline())
|
||||
except Exception:
|
||||
previous_version = 0.13
|
||||
if not os.access(config_file, mode=os.W_OK):
|
||||
logger.error("Config file is read-only, unable to migrate config file.")
|
||||
return
|
||||
|
||||
yaml = YAML()
|
||||
yaml.indent(mapping=2, sequence=4, offset=2)
|
||||
with open(config_file, "r") as f:
|
||||
config: dict[str, dict[str, any]] = yaml.load(f)
|
||||
|
||||
previous_version = config.get("version", 0.13)
|
||||
|
||||
if previous_version == CURRENT_CONFIG_VERSION:
|
||||
logger.info("frigate config does not need migration...")
|
||||
@@ -35,11 +38,6 @@ def migrate_frigate_config(config_file: str):
|
||||
logger.info("copying config as backup...")
|
||||
shutil.copy(config_file, os.path.join(CONFIG_DIR, "backup_config.yaml"))
|
||||
|
||||
yaml = YAML()
|
||||
yaml.indent(mapping=2, sequence=4, offset=2)
|
||||
with open(config_file, "r") as f:
|
||||
config: dict[str, dict[str, any]] = yaml.load(f)
|
||||
|
||||
if previous_version < 0.14:
|
||||
logger.info(f"Migrating frigate config from {previous_version} to 0.14...")
|
||||
new_config = migrate_014(config)
|
||||
@@ -57,9 +55,6 @@ def migrate_frigate_config(config_file: str):
|
||||
os.path.join(EXPORT_DIR, file), os.path.join(EXPORT_DIR, new_name)
|
||||
)
|
||||
|
||||
with open(version_file, "w") as f:
|
||||
f.write(str(CURRENT_CONFIG_VERSION))
|
||||
|
||||
logger.info("Finished frigate config migration...")
|
||||
|
||||
|
||||
@@ -92,8 +87,12 @@ def migrate_014(config: dict[str, dict[str, any]]) -> dict[str, dict[str, any]]:
|
||||
if not new_config["record"]:
|
||||
del new_config["record"]
|
||||
|
||||
if new_config.get("ui", {}).get("use_experimental"):
|
||||
del new_config["ui"]["experimental"]
|
||||
if new_config.get("ui"):
|
||||
if new_config["ui"].get("use_experimental"):
|
||||
del new_config["ui"]["experimental"]
|
||||
|
||||
if new_config["ui"].get("live_mode"):
|
||||
del new_config["ui"]["live_mode"]
|
||||
|
||||
if not new_config["ui"]:
|
||||
del new_config["ui"]
|
||||
@@ -141,6 +140,7 @@ def migrate_014(config: dict[str, dict[str, any]]) -> dict[str, dict[str, any]]:
|
||||
|
||||
new_config["cameras"][name] = camera_config
|
||||
|
||||
new_config["version"] = 0.14
|
||||
return new_config
|
||||
|
||||
|
||||
@@ -200,3 +200,16 @@ def get_relative_coordinates(
|
||||
return mask
|
||||
|
||||
return mask
|
||||
|
||||
|
||||
class StreamInfoRetriever:
|
||||
def __init__(self) -> None:
|
||||
self.stream_cache: dict[str, tuple[int, int]] = {}
|
||||
|
||||
def get_stream_info(self, path: str) -> str:
|
||||
if path in self.stream_cache:
|
||||
return self.stream_cache[path]
|
||||
|
||||
info = asyncio.run(get_video_properties(path))
|
||||
self.stream_cache[path] = info
|
||||
return info
|
||||
|
||||
654
web/package-lock.json
generated
654
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -46,9 +46,10 @@
|
||||
"immer": "^10.1.1",
|
||||
"konva": "^9.3.9",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.379.0",
|
||||
"lucide-react": "^0.381.0",
|
||||
"monaco-yaml": "^5.1.1",
|
||||
"next-themes": "^0.3.0",
|
||||
"nosleep.js": "^0.12.0",
|
||||
"react": "^18.3.1",
|
||||
"react-apexcharts": "^1.4.1",
|
||||
"react-day-picker": "^8.10.1",
|
||||
@@ -92,7 +93,7 @@
|
||||
"@vitejs/plugin-react-swc": "^3.6.0",
|
||||
"@vitest/coverage-v8": "^1.6.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^8.55.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-jest": "^28.2.0",
|
||||
"eslint-plugin-prettier": "^5.0.1",
|
||||
@@ -105,7 +106,7 @@
|
||||
"msw": "^2.3.0",
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-tailwindcss": "^0.5.14",
|
||||
"prettier-plugin-tailwindcss": "^0.6.1",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.11",
|
||||
|
||||
@@ -20,12 +20,11 @@ const System = lazy(() => import("@/pages/System"));
|
||||
const Settings = lazy(() => import("@/pages/Settings"));
|
||||
const UIPlayground = lazy(() => import("@/pages/UIPlayground"));
|
||||
const Logs = lazy(() => import("@/pages/Logs"));
|
||||
const NoMatch = lazy(() => import("@/pages/NoMatch"));
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Providers>
|
||||
<BrowserRouter>
|
||||
<BrowserRouter basename={window.baseUrl}>
|
||||
<Wrapper>
|
||||
<div className="size-full overflow-hidden">
|
||||
{isDesktop && <Sidebar />}
|
||||
@@ -52,7 +51,7 @@ function App() {
|
||||
<Route path="/config" element={<ConfigEditor />} />
|
||||
<Route path="/logs" element={<Logs />} />
|
||||
<Route path="/playground" element={<UIPlayground />} />
|
||||
<Route path="*" element={<NoMatch />} />
|
||||
<Route path="*" element={<Redirect to="/" />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
@@ -1,33 +1,20 @@
|
||||
import { useFrigateStats } from "@/api/ws";
|
||||
import {
|
||||
StatusBarMessagesContext,
|
||||
StatusMessage,
|
||||
} from "@/context/statusbar-provider";
|
||||
import useStats from "@/hooks/use-stats";
|
||||
import { FrigateStats } from "@/types/stats";
|
||||
import useStats, { useAutoFrigateStats } from "@/hooks/use-stats";
|
||||
import { useContext, useEffect, useMemo } from "react";
|
||||
import { FaCheck } from "react-icons/fa";
|
||||
import { IoIosWarning } from "react-icons/io";
|
||||
import { MdCircle } from "react-icons/md";
|
||||
import { Link } from "react-router-dom";
|
||||
import useSWR from "swr";
|
||||
|
||||
export default function Statusbar() {
|
||||
const { data: initialStats } = useSWR<FrigateStats>("stats", {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
const { payload: latestStats } = useFrigateStats();
|
||||
const { messages, addMessage, clearMessages } = useContext(
|
||||
StatusBarMessagesContext,
|
||||
)!;
|
||||
|
||||
const stats = useMemo(() => {
|
||||
if (latestStats) {
|
||||
return latestStats;
|
||||
}
|
||||
|
||||
return initialStats;
|
||||
}, [initialStats, latestStats]);
|
||||
const stats = useAutoFrigateStats();
|
||||
|
||||
const cpuPercent = useMemo(() => {
|
||||
const systemCpu = stats?.cpu_usages["frigate.full_system"]?.cpu;
|
||||
@@ -94,6 +81,10 @@ export default function Statusbar() {
|
||||
|
||||
const gpu = parseInt(stats.gpu);
|
||||
|
||||
if (isNaN(gpu)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link key={gpuTitle} to="/system#general">
|
||||
{" "}
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function AutoUpdatingCameraImage({
|
||||
<CameraImage
|
||||
camera={camera}
|
||||
onload={handleLoad}
|
||||
searchParams={`cache=${key}&${searchParams}`}
|
||||
searchParams={`cache=${key}${searchParams && `&${searchParams}`}`}
|
||||
className={cameraClasses}
|
||||
/>
|
||||
{showFps ? <span className="text-xs">Displaying at {fps}fps</span> : null}
|
||||
|
||||
@@ -7,12 +7,10 @@ import { REVIEW_PADDING, ReviewSegment } from "@/types/review";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { RecordingStartingPoint } from "@/types/record";
|
||||
import axios from "axios";
|
||||
import {
|
||||
InProgressPreview,
|
||||
VideoPreview,
|
||||
} from "../player/PreviewThumbnailPlayer";
|
||||
import { VideoPreview } from "../player/PreviewThumbnailPlayer";
|
||||
import { isCurrentHour } from "@/utils/dateUtil";
|
||||
import { useCameraPreviews } from "@/hooks/use-camera-previews";
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
|
||||
type AnimatedEventCardProps = {
|
||||
event: ReviewSegment;
|
||||
@@ -105,19 +103,19 @@ export function AnimatedEventCard({ event }: AnimatedEventCardProps) {
|
||||
windowVisible={windowVisible}
|
||||
/>
|
||||
) : (
|
||||
<InProgressPreview
|
||||
review={event}
|
||||
timeRange={{
|
||||
after: event.start_time,
|
||||
before: event.end_time ?? event.start_time + 20,
|
||||
}}
|
||||
<video
|
||||
preload="auto"
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
disableRemotePlayback
|
||||
loop
|
||||
showProgress={false}
|
||||
setReviewed={() => {}}
|
||||
setIgnoreClick={() => {}}
|
||||
isPlayingBack={() => {}}
|
||||
windowVisible={windowVisible}
|
||||
/>
|
||||
>
|
||||
<source
|
||||
src={`${baseUrl}api/review/${event.id}/preview?format=mp4`}
|
||||
type="video/mp4"
|
||||
/>
|
||||
</video>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute inset-x-0 bottom-0 h-6 rounded bg-gradient-to-t from-slate-900/50 to-transparent">
|
||||
|
||||
@@ -177,7 +177,7 @@ export default function ExportCard({
|
||||
{exportedRecording.thumb_path.length > 0 ? (
|
||||
<img
|
||||
className="absolute inset-0 aspect-video size-full rounded-lg object-contain md:rounded-2xl"
|
||||
src={exportedRecording.thumb_path.replace("/media/frigate", "")}
|
||||
src={`${baseUrl}${exportedRecording.thumb_path.replace("/media/frigate/", "")}`}
|
||||
onLoad={() => setLoading(false)}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -3,12 +3,24 @@ import { useFormattedTimestamp } from "@/hooks/use-date-utils";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { ReviewSegment } from "@/types/review";
|
||||
import { getIconForLabel } from "@/utils/iconUtil";
|
||||
import { isSafari } from "react-device-detect";
|
||||
import { isDesktop, isIOS, isSafari } from "react-device-detect";
|
||||
import useSWR from "swr";
|
||||
import TimeAgo from "../dynamic/TimeAgo";
|
||||
import { useMemo } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import useImageLoaded from "@/hooks/use-image-loaded";
|
||||
import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator";
|
||||
import { FaCompactDisc } from "react-icons/fa";
|
||||
import { FaCircleCheck } from "react-icons/fa6";
|
||||
import { HiTrash } from "react-icons/hi";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "../ui/context-menu";
|
||||
import { Drawer, DrawerContent } from "../ui/drawer";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type ReviewCardProps = {
|
||||
event: ReviewSegment;
|
||||
@@ -33,10 +45,61 @@ export default function ReviewCard({
|
||||
[event, currentTime],
|
||||
);
|
||||
|
||||
return (
|
||||
const [optionsOpen, setOptionsOpen] = useState(false);
|
||||
|
||||
const onMarkAsReviewed = useCallback(async () => {
|
||||
await axios.post(`reviews/viewed`, { ids: [event.id] });
|
||||
event.has_been_reviewed = true;
|
||||
setOptionsOpen(false);
|
||||
}, [event]);
|
||||
|
||||
const onExport = useCallback(async () => {
|
||||
axios
|
||||
.post(
|
||||
`export/${event.camera}/start/${event.start_time}/end/${event.end_time}`,
|
||||
{ playback: "realtime" },
|
||||
)
|
||||
.then((response) => {
|
||||
if (response.status == 200) {
|
||||
toast.success(
|
||||
"Successfully started export. View the file in the /exports folder.",
|
||||
{ position: "top-center" },
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.response?.data?.message) {
|
||||
toast.error(
|
||||
`Failed to start export: ${error.response.data.message}`,
|
||||
{ position: "top-center" },
|
||||
);
|
||||
} else {
|
||||
toast.error(`Failed to start export: ${error.message}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
});
|
||||
setOptionsOpen(false);
|
||||
}, [event]);
|
||||
|
||||
const onDelete = useCallback(async () => {
|
||||
await axios.post(`reviews/delete`, { ids: [event.id] });
|
||||
event.id = "";
|
||||
setOptionsOpen(false);
|
||||
}, [event]);
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className="relative flex w-full cursor-pointer flex-col gap-1.5"
|
||||
onClick={onClick}
|
||||
onContextMenu={
|
||||
isDesktop
|
||||
? undefined
|
||||
: (e) => {
|
||||
e.preventDefault();
|
||||
setOptionsOpen(true);
|
||||
}
|
||||
}
|
||||
>
|
||||
<ImageLoadingIndicator
|
||||
className="absolute inset-0"
|
||||
@@ -47,6 +110,15 @@ export default function ReviewCard({
|
||||
className={`size-full rounded-lg ${isSelected ? "outline outline-[3px] outline-offset-1 outline-selected" : ""} ${imgLoaded ? "visible" : "invisible"}`}
|
||||
src={`${baseUrl}${event.thumb_path.replace("/media/frigate/", "")}`}
|
||||
loading={isSafari ? "eager" : "lazy"}
|
||||
style={
|
||||
isIOS
|
||||
? {
|
||||
WebkitUserSelect: "none",
|
||||
WebkitTouchCallout: "none",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
draggable={false}
|
||||
onLoad={() => {
|
||||
onImgLoad();
|
||||
}}
|
||||
@@ -69,4 +141,78 @@ export default function ReviewCard({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (event.id == "") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDesktop) {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>{content}</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem>
|
||||
<div
|
||||
className="flex w-full cursor-pointer items-center justify-start gap-2 p-2"
|
||||
onClick={onExport}
|
||||
>
|
||||
<FaCompactDisc className="text-secondary-foreground" />
|
||||
<div className="text-primary">Export</div>
|
||||
</div>
|
||||
</ContextMenuItem>
|
||||
{!event.has_been_reviewed && (
|
||||
<ContextMenuItem>
|
||||
<div
|
||||
className="flex w-full cursor-pointer items-center justify-start gap-2 p-2"
|
||||
onClick={onMarkAsReviewed}
|
||||
>
|
||||
<FaCircleCheck className="text-secondary-foreground" />
|
||||
<div className="text-primary">Mark as reviewed</div>
|
||||
</div>
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem>
|
||||
<div
|
||||
className="flex w-full cursor-pointer items-center justify-start gap-2 p-2"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<HiTrash className="text-secondary-foreground" />
|
||||
<div className="text-primary">Delete</div>
|
||||
</div>
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer open={optionsOpen} onOpenChange={setOptionsOpen}>
|
||||
{content}
|
||||
<DrawerContent>
|
||||
<div
|
||||
className="flex w-full items-center justify-start gap-2 p-2"
|
||||
onClick={onExport}
|
||||
>
|
||||
<FaCompactDisc className="text-secondary-foreground" />
|
||||
<div className="text-primary">Export</div>
|
||||
</div>
|
||||
{!event.has_been_reviewed && (
|
||||
<div
|
||||
className="flex w-full items-center justify-start gap-2 p-2"
|
||||
onClick={onMarkAsReviewed}
|
||||
>
|
||||
<FaCircleCheck className="text-secondary-foreground" />
|
||||
<div className="text-primary">Mark as reviewed</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="flex w-full items-center justify-start gap-2 p-2"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<HiTrash className="text-secondary-foreground" />
|
||||
<div className="text-primary">Delete</div>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -468,7 +468,7 @@ export function CameraGroupRow({
|
||||
|
||||
{isMobile && (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger>
|
||||
<HiOutlineDotsVertical className="size-5" />
|
||||
</DropdownMenuTrigger>
|
||||
@@ -555,9 +555,7 @@ export function CameraGroupEdit({
|
||||
message: "Invalid camera group name.",
|
||||
}),
|
||||
|
||||
cameras: z.array(z.string()).min(2, {
|
||||
message: "You must select at least two cameras.",
|
||||
}),
|
||||
cameras: z.array(z.string()),
|
||||
icon: z
|
||||
.string()
|
||||
.min(1, { message: "You must select an icon." })
|
||||
@@ -663,6 +661,7 @@ export function CameraGroupEdit({
|
||||
<FormDescription>
|
||||
Select cameras for this group.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
{[
|
||||
...(birdseyeConfig?.enabled ? ["birdseye"] : []),
|
||||
...Object.keys(config?.cameras ?? {}),
|
||||
@@ -680,7 +679,6 @@ export function CameraGroupEdit({
|
||||
/>
|
||||
</FormControl>
|
||||
))}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -365,6 +365,7 @@ export function CamerasFilterButton({
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
modal={false}
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
|
||||
138
web/src/components/graph/CameraGraph.tsx
Normal file
138
web/src/components/graph/CameraGraph.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { useTheme } from "@/context/theme-provider";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import Chart from "react-apexcharts";
|
||||
import { isMobileOnly } from "react-device-detect";
|
||||
import { MdCircle } from "react-icons/md";
|
||||
import useSWR from "swr";
|
||||
|
||||
const GRAPH_COLORS = ["#5C7CFA", "#ED5CFA", "#FAD75C"];
|
||||
|
||||
type CameraLineGraphProps = {
|
||||
graphId: string;
|
||||
unit: string;
|
||||
dataLabels: string[];
|
||||
updateTimes: number[];
|
||||
data: ApexAxisChartSeries;
|
||||
};
|
||||
export function CameraLineGraph({
|
||||
graphId,
|
||||
unit,
|
||||
dataLabels,
|
||||
updateTimes,
|
||||
data,
|
||||
}: CameraLineGraphProps) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
|
||||
const lastValues = useMemo<number[] | undefined>(() => {
|
||||
if (!dataLabels || !data || data.length == 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return dataLabels.map(
|
||||
(_, labelIdx) =>
|
||||
// @ts-expect-error y is valid
|
||||
data[labelIdx].data[data[labelIdx].data.length - 1]?.y ?? 0,
|
||||
) as number[];
|
||||
}, [data, dataLabels]);
|
||||
|
||||
const { theme, systemTheme } = useTheme();
|
||||
|
||||
const formatTime = useCallback(
|
||||
(val: unknown) => {
|
||||
const date = new Date(updateTimes[Math.round(val as number)] * 1000);
|
||||
return date.toLocaleTimeString([], {
|
||||
hour12: config?.ui.time_format != "24hour",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
},
|
||||
[config, updateTimes],
|
||||
);
|
||||
|
||||
const options = useMemo(() => {
|
||||
return {
|
||||
chart: {
|
||||
id: graphId,
|
||||
selection: {
|
||||
enabled: false,
|
||||
},
|
||||
toolbar: {
|
||||
show: false,
|
||||
},
|
||||
zoom: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
colors: GRAPH_COLORS,
|
||||
grid: {
|
||||
show: false,
|
||||
},
|
||||
legend: {
|
||||
show: false,
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
},
|
||||
stroke: {
|
||||
width: 1,
|
||||
},
|
||||
tooltip: {
|
||||
theme: systemTheme || theme,
|
||||
},
|
||||
markers: {
|
||||
size: 0,
|
||||
},
|
||||
xaxis: {
|
||||
tickAmount: isMobileOnly ? 3 : 4,
|
||||
tickPlacement: "on",
|
||||
labels: {
|
||||
rotate: 0,
|
||||
formatter: formatTime,
|
||||
},
|
||||
axisBorder: {
|
||||
show: false,
|
||||
},
|
||||
axisTicks: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
yaxis: {
|
||||
show: true,
|
||||
labels: {
|
||||
formatter: (val: number) => Math.ceil(val).toString(),
|
||||
},
|
||||
min: 0,
|
||||
},
|
||||
} as ApexCharts.ApexOptions;
|
||||
}, [graphId, systemTheme, theme, formatTime]);
|
||||
|
||||
useEffect(() => {
|
||||
ApexCharts.exec(graphId, "updateOptions", options, true, true);
|
||||
}, [graphId, options]);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col">
|
||||
{lastValues && (
|
||||
<div className="flex items-center gap-2.5">
|
||||
{dataLabels.map((label, labelIdx) => (
|
||||
<div key={label} className="flex items-center gap-1">
|
||||
<MdCircle
|
||||
className="size-2"
|
||||
style={{ color: GRAPH_COLORS[labelIdx] }}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className="text-xs text-primary">
|
||||
{lastValues[labelIdx]}
|
||||
{unit}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Chart type="line" options={options} series={data} height="120" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
web/src/components/graph/StorageGraph.tsx
Normal file
120
web/src/components/graph/StorageGraph.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useTheme } from "@/context/theme-provider";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import Chart from "react-apexcharts";
|
||||
|
||||
const getUnitSize = (MB: number) => {
|
||||
if (MB === null || isNaN(MB) || MB < 0) return "Invalid number";
|
||||
if (MB < 1024) return `${MB.toFixed(2)} MiB`;
|
||||
if (MB < 1048576) return `${(MB / 1024).toFixed(2)} GiB`;
|
||||
|
||||
return `${(MB / 1048576).toFixed(2)} TiB`;
|
||||
};
|
||||
|
||||
type StorageGraphProps = {
|
||||
graphId: string;
|
||||
used: number;
|
||||
total: number;
|
||||
};
|
||||
export function StorageGraph({ graphId, used, total }: StorageGraphProps) {
|
||||
const { theme, systemTheme } = useTheme();
|
||||
|
||||
const options = useMemo(() => {
|
||||
return {
|
||||
chart: {
|
||||
id: graphId,
|
||||
background: (systemTheme || theme) == "dark" ? "#404040" : "#E5E5E5",
|
||||
selection: {
|
||||
enabled: false,
|
||||
},
|
||||
toolbar: {
|
||||
show: false,
|
||||
},
|
||||
zoom: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
show: false,
|
||||
padding: {
|
||||
bottom: -40,
|
||||
top: -60,
|
||||
left: -20,
|
||||
right: 0,
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
show: false,
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
},
|
||||
plotOptions: {
|
||||
bar: {
|
||||
horizontal: true,
|
||||
},
|
||||
},
|
||||
states: {
|
||||
active: {
|
||||
filter: {
|
||||
type: "none",
|
||||
},
|
||||
},
|
||||
hover: {
|
||||
filter: {
|
||||
type: "none",
|
||||
},
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
},
|
||||
xaxis: {
|
||||
axisBorder: {
|
||||
show: false,
|
||||
},
|
||||
axisTicks: {
|
||||
show: false,
|
||||
},
|
||||
labels: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
yaxis: {
|
||||
show: false,
|
||||
min: 0,
|
||||
max: 100,
|
||||
},
|
||||
} as ApexCharts.ApexOptions;
|
||||
}, [graphId, systemTheme, theme]);
|
||||
|
||||
useEffect(() => {
|
||||
ApexCharts.exec(graphId, "updateOptions", options, true, true);
|
||||
}, [graphId, options]);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2.5">
|
||||
<div className="flex w-full items-center justify-between gap-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="text-xs text-primary">{getUnitSize(used)}</div>
|
||||
<div className="text-xs text-primary">/</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{getUnitSize(total)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-primary">
|
||||
{Math.round((used / total) * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-5 overflow-hidden rounded-md">
|
||||
<Chart
|
||||
type="bar"
|
||||
options={options}
|
||||
series={[
|
||||
{ data: [{ x: "storage", y: Math.round((used / total) * 100) }] },
|
||||
]}
|
||||
height="100%"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { Threshold } from "@/types/graph";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import Chart from "react-apexcharts";
|
||||
import { isMobileOnly } from "react-device-detect";
|
||||
import { MdCircle } from "react-icons/md";
|
||||
import useSWR from "swr";
|
||||
|
||||
type ThresholdBarGraphProps = {
|
||||
@@ -37,7 +36,16 @@ export function ThresholdBarGraph({
|
||||
|
||||
const formatTime = useCallback(
|
||||
(val: unknown) => {
|
||||
const date = new Date(updateTimes[Math.round(val as number) - 1] * 1000);
|
||||
const dateIndex = Math.round(val as number);
|
||||
|
||||
let timeOffset = 0;
|
||||
if (dateIndex < 0) {
|
||||
timeOffset = 5000 * Math.abs(dateIndex);
|
||||
}
|
||||
|
||||
const date = new Date(
|
||||
updateTimes[Math.max(1, dateIndex) - 1] * 1000 - timeOffset,
|
||||
);
|
||||
return date.toLocaleTimeString([], {
|
||||
hour12: config?.ui.time_format != "24hour",
|
||||
hour: "2-digit",
|
||||
@@ -130,6 +138,22 @@ export function ThresholdBarGraph({
|
||||
ApexCharts.exec(graphId, "updateOptions", options, true, true);
|
||||
}, [graphId, options]);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (data.length > 0 && data[0].data.length >= 30) {
|
||||
return data;
|
||||
}
|
||||
|
||||
const copiedData = [...data];
|
||||
const fakeData = [];
|
||||
for (let i = data.length; i < 30; i++) {
|
||||
fakeData.push({ x: i - 30, y: 0 });
|
||||
}
|
||||
|
||||
// @ts-expect-error data types are not obvious
|
||||
copiedData[0].data = [...fakeData, ...data[0].data];
|
||||
return copiedData;
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col">
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -139,255 +163,7 @@ export function ThresholdBarGraph({
|
||||
{unit}
|
||||
</div>
|
||||
</div>
|
||||
<Chart type="bar" options={options} series={data} height="120" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getUnitSize = (MB: number) => {
|
||||
if (MB === null || isNaN(MB) || MB < 0) return "Invalid number";
|
||||
if (MB < 1024) return `${MB.toFixed(2)} MiB`;
|
||||
if (MB < 1048576) return `${(MB / 1024).toFixed(2)} GiB`;
|
||||
|
||||
return `${(MB / 1048576).toFixed(2)} TiB`;
|
||||
};
|
||||
|
||||
type StorageGraphProps = {
|
||||
graphId: string;
|
||||
used: number;
|
||||
total: number;
|
||||
};
|
||||
export function StorageGraph({ graphId, used, total }: StorageGraphProps) {
|
||||
const { theme, systemTheme } = useTheme();
|
||||
|
||||
const options = useMemo(() => {
|
||||
return {
|
||||
chart: {
|
||||
id: graphId,
|
||||
background: (systemTheme || theme) == "dark" ? "#404040" : "#E5E5E5",
|
||||
selection: {
|
||||
enabled: false,
|
||||
},
|
||||
toolbar: {
|
||||
show: false,
|
||||
},
|
||||
zoom: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
show: false,
|
||||
padding: {
|
||||
bottom: -40,
|
||||
top: -60,
|
||||
left: -20,
|
||||
right: 0,
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
show: false,
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
},
|
||||
plotOptions: {
|
||||
bar: {
|
||||
horizontal: true,
|
||||
},
|
||||
},
|
||||
states: {
|
||||
active: {
|
||||
filter: {
|
||||
type: "none",
|
||||
},
|
||||
},
|
||||
hover: {
|
||||
filter: {
|
||||
type: "none",
|
||||
},
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
},
|
||||
xaxis: {
|
||||
axisBorder: {
|
||||
show: false,
|
||||
},
|
||||
axisTicks: {
|
||||
show: false,
|
||||
},
|
||||
labels: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
yaxis: {
|
||||
show: false,
|
||||
min: 0,
|
||||
max: 100,
|
||||
},
|
||||
} as ApexCharts.ApexOptions;
|
||||
}, [graphId, systemTheme, theme]);
|
||||
|
||||
useEffect(() => {
|
||||
ApexCharts.exec(graphId, "updateOptions", options, true, true);
|
||||
}, [graphId, options]);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2.5">
|
||||
<div className="flex w-full items-center justify-between gap-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="text-xs text-primary">{getUnitSize(used)}</div>
|
||||
<div className="text-xs text-primary">/</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{getUnitSize(total)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-primary">
|
||||
{Math.round((used / total) * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-5 overflow-hidden rounded-md">
|
||||
<Chart
|
||||
type="bar"
|
||||
options={options}
|
||||
series={[
|
||||
{ data: [{ x: "storage", y: Math.round((used / total) * 100) }] },
|
||||
]}
|
||||
height="100%"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const GRAPH_COLORS = ["#5C7CFA", "#ED5CFA", "#FAD75C"];
|
||||
|
||||
type CameraLineGraphProps = {
|
||||
graphId: string;
|
||||
unit: string;
|
||||
dataLabels: string[];
|
||||
updateTimes: number[];
|
||||
data: ApexAxisChartSeries;
|
||||
};
|
||||
export function CameraLineGraph({
|
||||
graphId,
|
||||
unit,
|
||||
dataLabels,
|
||||
updateTimes,
|
||||
data,
|
||||
}: CameraLineGraphProps) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
|
||||
const lastValues = useMemo<number[] | undefined>(() => {
|
||||
if (!dataLabels || !data || data.length == 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return dataLabels.map(
|
||||
(_, labelIdx) =>
|
||||
// @ts-expect-error y is valid
|
||||
data[labelIdx].data[data[labelIdx].data.length - 1]?.y ?? 0,
|
||||
) as number[];
|
||||
}, [data, dataLabels]);
|
||||
|
||||
const { theme, systemTheme } = useTheme();
|
||||
|
||||
const formatTime = useCallback(
|
||||
(val: unknown) => {
|
||||
const date = new Date(updateTimes[Math.round(val as number)] * 1000);
|
||||
return date.toLocaleTimeString([], {
|
||||
hour12: config?.ui.time_format != "24hour",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
},
|
||||
[config, updateTimes],
|
||||
);
|
||||
|
||||
const options = useMemo(() => {
|
||||
return {
|
||||
chart: {
|
||||
id: graphId,
|
||||
selection: {
|
||||
enabled: false,
|
||||
},
|
||||
toolbar: {
|
||||
show: false,
|
||||
},
|
||||
zoom: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
colors: GRAPH_COLORS,
|
||||
grid: {
|
||||
show: false,
|
||||
},
|
||||
legend: {
|
||||
show: false,
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
},
|
||||
stroke: {
|
||||
width: 1,
|
||||
},
|
||||
tooltip: {
|
||||
theme: systemTheme || theme,
|
||||
},
|
||||
markers: {
|
||||
size: 0,
|
||||
},
|
||||
xaxis: {
|
||||
tickAmount: isMobileOnly ? 3 : 4,
|
||||
tickPlacement: "on",
|
||||
labels: {
|
||||
rotate: 0,
|
||||
formatter: formatTime,
|
||||
},
|
||||
axisBorder: {
|
||||
show: false,
|
||||
},
|
||||
axisTicks: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
yaxis: {
|
||||
show: true,
|
||||
labels: {
|
||||
formatter: (val: number) => Math.ceil(val).toString(),
|
||||
},
|
||||
min: 0,
|
||||
},
|
||||
} as ApexCharts.ApexOptions;
|
||||
}, [graphId, systemTheme, theme, formatTime]);
|
||||
|
||||
useEffect(() => {
|
||||
ApexCharts.exec(graphId, "updateOptions", options, true, true);
|
||||
}, [graphId, options]);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col">
|
||||
{lastValues && (
|
||||
<div className="flex items-center gap-2.5">
|
||||
{dataLabels.map((label, labelIdx) => (
|
||||
<div key={label} className="flex items-center gap-1">
|
||||
<MdCircle
|
||||
className="size-2"
|
||||
style={{ color: GRAPH_COLORS[labelIdx] }}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className="text-xs text-primary">
|
||||
{lastValues[labelIdx]}
|
||||
{unit}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Chart type="line" options={options} series={data} height="120" />
|
||||
<Chart type="bar" options={options} series={chartData} height="120" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
|
||||
const MenuItem = isDesktop ? DropdownMenuItem : DialogClose;
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Container modal={!isDesktop}>
|
||||
<Trigger>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
DialogTrigger,
|
||||
} from "../ui/dialog";
|
||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type GeneralSettingsProps = {
|
||||
className?: string;
|
||||
@@ -113,249 +114,249 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={className}>
|
||||
<Container>
|
||||
<Trigger>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center ${isDesktop ? "cursor-pointer rounded-lg bg-secondary text-secondary-foreground hover:bg-muted" : "text-secondary-foreground"}`}
|
||||
>
|
||||
<LuSettings className="size-5 md:m-[6px]" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent side="right">
|
||||
<p>Settings</p>
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
</Trigger>
|
||||
<Content
|
||||
className={
|
||||
isDesktop ? "mr-5 w-72" : "max-h-[75dvh] overflow-hidden p-2"
|
||||
}
|
||||
>
|
||||
<div className="w-full flex-col overflow-y-auto overflow-x-hidden">
|
||||
<DropdownMenuLabel>System</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup className={isDesktop ? "" : "flex flex-col"}>
|
||||
<Link to="/system#general">
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex w-full items-center p-2 text-sm"
|
||||
}
|
||||
>
|
||||
<LuActivity className="mr-2 size-4" />
|
||||
<span>System metrics</span>
|
||||
</MenuItem>
|
||||
</Link>
|
||||
<Link to="/logs">
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex w-full items-center p-2 text-sm"
|
||||
}
|
||||
>
|
||||
<LuList className="mr-2 size-4" />
|
||||
<span>System logs</span>
|
||||
</MenuItem>
|
||||
</Link>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
|
||||
Configuration
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<Link to="/settings">
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex w-full items-center p-2 text-sm"
|
||||
}
|
||||
>
|
||||
<LuSettings className="mr-2 size-4" />
|
||||
<span>Settings</span>
|
||||
</MenuItem>
|
||||
</Link>
|
||||
<Link to="/config">
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex w-full items-center p-2 text-sm"
|
||||
}
|
||||
>
|
||||
<LuPenSquare className="mr-2 size-4" />
|
||||
<span>Configuration editor</span>
|
||||
</MenuItem>
|
||||
</Link>
|
||||
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
|
||||
Appearance
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<SubItem>
|
||||
<SubItemTrigger
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex items-center p-2 text-sm"
|
||||
}
|
||||
>
|
||||
<LuSunMoon className="mr-2 size-4" />
|
||||
<span>Dark Mode</span>
|
||||
</SubItemTrigger>
|
||||
<Portal>
|
||||
<SubItemContent
|
||||
className={
|
||||
isDesktop ? "" : "w-[92%] rounded-lg md:rounded-2xl"
|
||||
}
|
||||
>
|
||||
<span tabIndex={0} className="sr-only" />
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex items-center p-2 text-sm"
|
||||
}
|
||||
onClick={() => setTheme("light")}
|
||||
>
|
||||
{theme === "light" ? (
|
||||
<>
|
||||
<LuSun className="mr-2 size-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
Light
|
||||
</>
|
||||
) : (
|
||||
<span className="ml-6 mr-2">Light</span>
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex items-center p-2 text-sm"
|
||||
}
|
||||
onClick={() => setTheme("dark")}
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<>
|
||||
<LuMoon className="mr-2 size-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
Dark
|
||||
</>
|
||||
) : (
|
||||
<span className="ml-6 mr-2">Dark</span>
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex items-center p-2 text-sm"
|
||||
}
|
||||
onClick={() => setTheme("system")}
|
||||
>
|
||||
{theme === "system" ? (
|
||||
<>
|
||||
<CgDarkMode className="mr-2 size-4 scale-100 transition-all" />
|
||||
System
|
||||
</>
|
||||
) : (
|
||||
<span className="ml-6 mr-2">System</span>
|
||||
)}
|
||||
</MenuItem>
|
||||
</SubItemContent>
|
||||
</Portal>
|
||||
</SubItem>
|
||||
<SubItem>
|
||||
<SubItemTrigger
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex items-center p-2 text-sm"
|
||||
}
|
||||
>
|
||||
<LuSunMoon className="mr-2 size-4" />
|
||||
<span>Theme</span>
|
||||
</SubItemTrigger>
|
||||
<Portal>
|
||||
<SubItemContent
|
||||
className={
|
||||
isDesktop ? "" : "w-[92%] rounded-lg md:rounded-2xl"
|
||||
}
|
||||
>
|
||||
<span tabIndex={0} className="sr-only" />
|
||||
{colorSchemes.map((scheme) => (
|
||||
<MenuItem
|
||||
key={scheme}
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex items-center p-2 text-sm"
|
||||
}
|
||||
onClick={() => setColorScheme(scheme)}
|
||||
>
|
||||
{scheme === colorScheme ? (
|
||||
<>
|
||||
<IoColorPalette className="mr-2 size-4 rotate-0 scale-100 transition-all" />
|
||||
{friendlyColorSchemeName(scheme)}
|
||||
</>
|
||||
) : (
|
||||
<span className="ml-6 mr-2">
|
||||
{friendlyColorSchemeName(scheme)}
|
||||
</span>
|
||||
)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</SubItemContent>
|
||||
</Portal>
|
||||
</SubItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
|
||||
Help
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<a href="https://docs.frigate.video">
|
||||
<Container modal={!isDesktop}>
|
||||
<Trigger>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center",
|
||||
isDesktop
|
||||
? "cursor-pointer rounded-lg bg-secondary text-secondary-foreground hover:bg-muted"
|
||||
: "text-secondary-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<LuSettings className="size-5 md:m-[6px]" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent side="right">
|
||||
<p>Settings</p>
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
</Trigger>
|
||||
<Content
|
||||
className={
|
||||
isDesktop ? "mr-5 w-72" : "max-h-[75dvh] overflow-hidden p-2"
|
||||
}
|
||||
>
|
||||
<div className="w-full flex-col overflow-y-auto overflow-x-hidden">
|
||||
<DropdownMenuLabel>System</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup className={isDesktop ? "" : "flex flex-col"}>
|
||||
<Link to="/system#general">
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex w-full items-center p-2 text-sm"
|
||||
}
|
||||
>
|
||||
<LuActivity className="mr-2 size-4" />
|
||||
<span>System metrics</span>
|
||||
</MenuItem>
|
||||
</Link>
|
||||
<Link to="/logs">
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex w-full items-center p-2 text-sm"
|
||||
}
|
||||
>
|
||||
<LuList className="mr-2 size-4" />
|
||||
<span>System logs</span>
|
||||
</MenuItem>
|
||||
</Link>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
|
||||
Configuration
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<Link to="/settings">
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex w-full items-center p-2 text-sm"
|
||||
}
|
||||
>
|
||||
<LuSettings className="mr-2 size-4" />
|
||||
<span>Settings</span>
|
||||
</MenuItem>
|
||||
</Link>
|
||||
<Link to="/config">
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex w-full items-center p-2 text-sm"
|
||||
}
|
||||
>
|
||||
<LuPenSquare className="mr-2 size-4" />
|
||||
<span>Configuration editor</span>
|
||||
</MenuItem>
|
||||
</Link>
|
||||
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
|
||||
Appearance
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<SubItem>
|
||||
<SubItemTrigger
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex items-center p-2 text-sm"
|
||||
}
|
||||
>
|
||||
<LuLifeBuoy className="mr-2 size-4" />
|
||||
<span>Documentation</span>
|
||||
</MenuItem>
|
||||
</a>
|
||||
<a href="https://github.com/blakeblackshear/frigate">
|
||||
<MenuItem
|
||||
<LuSunMoon className="mr-2 size-4" />
|
||||
<span>Dark Mode</span>
|
||||
</SubItemTrigger>
|
||||
<Portal>
|
||||
<SubItemContent
|
||||
className={
|
||||
isDesktop ? "" : "w-[92%] rounded-lg md:rounded-2xl"
|
||||
}
|
||||
>
|
||||
<span tabIndex={0} className="sr-only" />
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex items-center p-2 text-sm"
|
||||
}
|
||||
onClick={() => setTheme("light")}
|
||||
>
|
||||
{theme === "light" ? (
|
||||
<>
|
||||
<LuSun className="mr-2 size-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
Light
|
||||
</>
|
||||
) : (
|
||||
<span className="ml-6 mr-2">Light</span>
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex items-center p-2 text-sm"
|
||||
}
|
||||
onClick={() => setTheme("dark")}
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<>
|
||||
<LuMoon className="mr-2 size-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
Dark
|
||||
</>
|
||||
) : (
|
||||
<span className="ml-6 mr-2">Dark</span>
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex items-center p-2 text-sm"
|
||||
}
|
||||
onClick={() => setTheme("system")}
|
||||
>
|
||||
{theme === "system" ? (
|
||||
<>
|
||||
<CgDarkMode className="mr-2 size-4 scale-100 transition-all" />
|
||||
System
|
||||
</>
|
||||
) : (
|
||||
<span className="ml-6 mr-2">System</span>
|
||||
)}
|
||||
</MenuItem>
|
||||
</SubItemContent>
|
||||
</Portal>
|
||||
</SubItem>
|
||||
<SubItem>
|
||||
<SubItemTrigger
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex items-center p-2 text-sm"
|
||||
}
|
||||
>
|
||||
<LuGithub className="mr-2 size-4" />
|
||||
<span>GitHub</span>
|
||||
</MenuItem>
|
||||
</a>
|
||||
<DropdownMenuSeparator className={isDesktop ? "mt-3" : "mt-1"} />
|
||||
<LuSunMoon className="mr-2 size-4" />
|
||||
<span>Theme</span>
|
||||
</SubItemTrigger>
|
||||
<Portal>
|
||||
<SubItemContent
|
||||
className={
|
||||
isDesktop ? "" : "w-[92%] rounded-lg md:rounded-2xl"
|
||||
}
|
||||
>
|
||||
<span tabIndex={0} className="sr-only" />
|
||||
{colorSchemes.map((scheme) => (
|
||||
<MenuItem
|
||||
key={scheme}
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex items-center p-2 text-sm"
|
||||
}
|
||||
onClick={() => setColorScheme(scheme)}
|
||||
>
|
||||
{scheme === colorScheme ? (
|
||||
<>
|
||||
<IoColorPalette className="mr-2 size-4 rotate-0 scale-100 transition-all" />
|
||||
{friendlyColorSchemeName(scheme)}
|
||||
</>
|
||||
) : (
|
||||
<span className="ml-6 mr-2">
|
||||
{friendlyColorSchemeName(scheme)}
|
||||
</span>
|
||||
)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</SubItemContent>
|
||||
</Portal>
|
||||
</SubItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
|
||||
Help
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<a href="https://docs.frigate.video">
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
|
||||
}
|
||||
onClick={() => setRestartDialogOpen(true)}
|
||||
>
|
||||
<LuRotateCw className="mr-2 size-4" />
|
||||
<span>Restart Frigate</span>
|
||||
<LuLifeBuoy className="mr-2 size-4" />
|
||||
<span>Documentation</span>
|
||||
</MenuItem>
|
||||
</div>
|
||||
</Content>
|
||||
</Container>
|
||||
</div>
|
||||
</a>
|
||||
<a href="https://github.com/blakeblackshear/frigate">
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
|
||||
}
|
||||
>
|
||||
<LuGithub className="mr-2 size-4" />
|
||||
<span>GitHub</span>
|
||||
</MenuItem>
|
||||
</a>
|
||||
<DropdownMenuSeparator className={isDesktop ? "mt-3" : "mt-1"} />
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
|
||||
}
|
||||
onClick={() => setRestartDialogOpen(true)}
|
||||
>
|
||||
<LuRotateCw className="mr-2 size-4" />
|
||||
<span>Restart Frigate</span>
|
||||
</MenuItem>
|
||||
</div>
|
||||
</Content>
|
||||
</Container>
|
||||
{restartDialogOpen && (
|
||||
<AlertDialog
|
||||
open={restartDialogOpen}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Calendar } from "../ui/calendar";
|
||||
import { useMemo } from "react";
|
||||
import { FaCircle } from "react-icons/fa";
|
||||
import { getUTCOffset } from "@/utils/dateUtil";
|
||||
import { type DayContentProps } from "react-day-picker";
|
||||
|
||||
type ReviewActivityCalendarProps = {
|
||||
reviewSummary?: ReviewSummary;
|
||||
@@ -22,6 +23,28 @@ export default function ReviewActivityCalendar({
|
||||
return { from: tomorrow, to: future };
|
||||
}, []);
|
||||
|
||||
const modifiers = useMemo(() => {
|
||||
if (!reviewSummary) {
|
||||
return { alerts: [], detections: [] };
|
||||
}
|
||||
|
||||
const unreviewedDetections: Date[] = [];
|
||||
const unreviewedAlerts: Date[] = [];
|
||||
|
||||
Object.entries(reviewSummary).forEach(([date, data]) => {
|
||||
if (data.total_alert > data.reviewed_alert) {
|
||||
unreviewedAlerts.push(new Date(date));
|
||||
} else if (data.total_detection > data.reviewed_detection) {
|
||||
unreviewedDetections.push(new Date(date));
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
alerts: unreviewedAlerts,
|
||||
detections: unreviewedDetections,
|
||||
};
|
||||
}, [reviewSummary]);
|
||||
|
||||
return (
|
||||
<Calendar
|
||||
mode="single"
|
||||
@@ -29,46 +52,28 @@ export default function ReviewActivityCalendar({
|
||||
showOutsideDays={false}
|
||||
selected={selectedDay}
|
||||
onSelect={onSelect}
|
||||
modifiers={modifiers}
|
||||
components={{
|
||||
DayContent: (date) => (
|
||||
<ReviewActivityDay reviewSummary={reviewSummary} day={date.date} />
|
||||
),
|
||||
DayContent: ReviewActivityDay,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type ReviewActivityDayProps = {
|
||||
reviewSummary?: ReviewSummary;
|
||||
day: Date;
|
||||
};
|
||||
function ReviewActivityDay({ reviewSummary, day }: ReviewActivityDayProps) {
|
||||
function ReviewActivityDay({ date, activeModifiers }: DayContentProps) {
|
||||
const dayActivity = useMemo(() => {
|
||||
if (!reviewSummary) {
|
||||
return "none";
|
||||
}
|
||||
|
||||
const allActivity =
|
||||
reviewSummary[
|
||||
`${day.getFullYear()}-${("0" + (day.getMonth() + 1)).slice(-2)}-${("0" + day.getDate()).slice(-2)}`
|
||||
];
|
||||
|
||||
if (!allActivity) {
|
||||
return "none";
|
||||
}
|
||||
|
||||
if (allActivity.total_alert > allActivity.reviewed_alert) {
|
||||
if (activeModifiers["alerts"]) {
|
||||
return "alert";
|
||||
} else if (allActivity.total_detection > allActivity.reviewed_detection) {
|
||||
} else if (activeModifiers["detections"]) {
|
||||
return "detection";
|
||||
} else {
|
||||
return "none";
|
||||
}
|
||||
}, [reviewSummary, day]);
|
||||
}, [activeModifiers]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
{day.getDate()}
|
||||
<div className="flex flex-col items-center justify-center gap-0.5">
|
||||
{date.getDate()}
|
||||
{dayActivity != "none" && (
|
||||
<FaCircle
|
||||
className={`size-2 ${dayActivity == "alert" ? "fill-severity_alert" : "fill-severity_detection"}`}
|
||||
|
||||
@@ -5,12 +5,14 @@ import JSMpegPlayer from "./JSMpegPlayer";
|
||||
import MSEPlayer from "./MsePlayer";
|
||||
import { LivePlayerMode } from "@/types/live";
|
||||
import { cn } from "@/lib/utils";
|
||||
import React from "react";
|
||||
|
||||
type LivePlayerProps = {
|
||||
className?: string;
|
||||
birdseyeConfig: BirdseyeConfig;
|
||||
liveMode: LivePlayerMode;
|
||||
onClick?: () => void;
|
||||
containerRef?: React.MutableRefObject<HTMLDivElement | null>;
|
||||
};
|
||||
|
||||
export default function BirdseyeLivePlayer({
|
||||
@@ -18,6 +20,7 @@ export default function BirdseyeLivePlayer({
|
||||
birdseyeConfig,
|
||||
liveMode,
|
||||
onClick,
|
||||
containerRef,
|
||||
}: LivePlayerProps) {
|
||||
let player;
|
||||
if (liveMode == "webrtc") {
|
||||
@@ -50,6 +53,7 @@ export default function BirdseyeLivePlayer({
|
||||
camera="birdseye"
|
||||
width={birdseyeConfig.width}
|
||||
height={birdseyeConfig.height}
|
||||
containerRef={containerRef}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -39,7 +39,7 @@ type HlsVideoPlayerProps = {
|
||||
onPlaying?: () => void;
|
||||
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
|
||||
onUploadFrame?: (playTime: number) => Promise<AxiosResponse> | undefined;
|
||||
setFullscreen?: (full: boolean) => void;
|
||||
toggleFullscreen?: () => void;
|
||||
};
|
||||
export default function HlsVideoPlayer({
|
||||
videoRef,
|
||||
@@ -53,7 +53,7 @@ export default function HlsVideoPlayer({
|
||||
onPlaying,
|
||||
setFullResolution,
|
||||
onUploadFrame,
|
||||
setFullscreen,
|
||||
toggleFullscreen,
|
||||
}: HlsVideoPlayerProps) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
@@ -224,7 +224,7 @@ export default function HlsVideoPlayer({
|
||||
}
|
||||
}}
|
||||
fullscreen={fullscreen}
|
||||
setFullscreen={setFullscreen}
|
||||
toggleFullscreen={toggleFullscreen}
|
||||
/>
|
||||
<TransformComponent
|
||||
wrapperStyle={{
|
||||
@@ -272,8 +272,8 @@ export default function HlsVideoPlayer({
|
||||
? onTimeUpdate(videoRef.current.currentTime)
|
||||
: undefined
|
||||
}
|
||||
onLoadedData={onPlayerLoaded}
|
||||
onLoadedMetadata={() => {
|
||||
onLoadedData={() => {
|
||||
onPlayerLoaded?.();
|
||||
handleLoadedMetadata();
|
||||
|
||||
if (videoRef.current) {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||
// @ts-expect-error we know this doesn't have types
|
||||
import JSMpeg from "@cycjimmy/jsmpeg-player";
|
||||
import { useEffect, useRef } from "react";
|
||||
import React, { useEffect, useMemo, useRef } from "react";
|
||||
|
||||
type JSMpegPlayerProps = {
|
||||
className?: string;
|
||||
camera: string;
|
||||
width: number;
|
||||
height: number;
|
||||
containerRef?: React.MutableRefObject<HTMLDivElement | null>;
|
||||
};
|
||||
|
||||
export default function JSMpegPlayer({
|
||||
@@ -15,10 +17,65 @@ export default function JSMpegPlayer({
|
||||
width,
|
||||
height,
|
||||
className,
|
||||
containerRef,
|
||||
}: JSMpegPlayerProps) {
|
||||
const url = `${baseUrl.replace(/^http/, "ws")}live/jsmpeg/${camera}`;
|
||||
const playerRef = useRef<HTMLDivElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const internalContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const selectedContainerRef = useMemo(
|
||||
() => containerRef ?? internalContainerRef,
|
||||
[containerRef, internalContainerRef],
|
||||
);
|
||||
|
||||
const [{ width: containerWidth, height: containerHeight }] =
|
||||
useResizeObserver(selectedContainerRef);
|
||||
|
||||
const stretch = true;
|
||||
const aspectRatio = width / height;
|
||||
|
||||
const fitAspect = useMemo(
|
||||
() => containerWidth / containerHeight,
|
||||
[containerWidth, containerHeight],
|
||||
);
|
||||
|
||||
const scaledHeight = useMemo(() => {
|
||||
if (selectedContainerRef?.current && width && height) {
|
||||
const scaledHeight =
|
||||
aspectRatio < (fitAspect ?? 0)
|
||||
? Math.floor(
|
||||
Math.min(
|
||||
containerHeight,
|
||||
selectedContainerRef.current?.clientHeight,
|
||||
),
|
||||
)
|
||||
: aspectRatio >= fitAspect
|
||||
? Math.floor(containerWidth / aspectRatio)
|
||||
: Math.floor(containerWidth / aspectRatio) / 1.5;
|
||||
const finalHeight = stretch
|
||||
? scaledHeight
|
||||
: Math.min(scaledHeight, height);
|
||||
|
||||
if (finalHeight > 0) {
|
||||
return finalHeight;
|
||||
}
|
||||
}
|
||||
}, [
|
||||
aspectRatio,
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
fitAspect,
|
||||
height,
|
||||
width,
|
||||
stretch,
|
||||
selectedContainerRef,
|
||||
]);
|
||||
|
||||
const scaledWidth = useMemo(() => {
|
||||
if (aspectRatio && scaledHeight) {
|
||||
return Math.ceil(scaledHeight * aspectRatio);
|
||||
}
|
||||
}, [scaledHeight, aspectRatio]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!playerRef.current) {
|
||||
@@ -28,7 +85,7 @@ export default function JSMpegPlayer({
|
||||
const video = new JSMpeg.VideoElement(
|
||||
playerRef.current,
|
||||
url,
|
||||
{},
|
||||
{ canvas: `#${camera}-canvas` },
|
||||
{ protocols: [], audio: false, videoBufferSize: 1024 * 1024 * 4 },
|
||||
);
|
||||
|
||||
@@ -41,15 +98,21 @@ export default function JSMpegPlayer({
|
||||
playerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [url]);
|
||||
}, [url, camera]);
|
||||
|
||||
return (
|
||||
<div className={className} ref={containerRef}>
|
||||
<div
|
||||
ref={playerRef}
|
||||
className="jsmpeg h-full"
|
||||
style={{ aspectRatio: width / height }}
|
||||
/>
|
||||
<div className={className}>
|
||||
<div className="size-full" ref={internalContainerRef}>
|
||||
<div ref={playerRef} className="jsmpeg">
|
||||
<canvas
|
||||
id={`${camera}-canvas`}
|
||||
style={{
|
||||
width: scaledWidth ?? width,
|
||||
height: scaledHeight ?? height,
|
||||
}}
|
||||
></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,11 @@ import JSMpegPlayer from "./JSMpegPlayer";
|
||||
import { MdCircle } from "react-icons/md";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
import { useCameraActivity } from "@/hooks/use-camera-activity";
|
||||
import { LivePlayerMode, VideoResolutionType } from "@/types/live";
|
||||
import {
|
||||
LivePlayerError,
|
||||
LivePlayerMode,
|
||||
VideoResolutionType,
|
||||
} from "@/types/live";
|
||||
import useCameraLiveMode from "@/hooks/use-camera-live-mode";
|
||||
import { getIconForLabel } from "@/utils/iconUtil";
|
||||
import Chip from "../indicators/Chip";
|
||||
@@ -17,6 +21,7 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
type LivePlayerProps = {
|
||||
cameraRef?: (ref: HTMLDivElement | null) => void;
|
||||
containerRef?: React.MutableRefObject<HTMLDivElement | null>;
|
||||
className?: string;
|
||||
cameraConfig: CameraConfig;
|
||||
preferredLiveMode?: LivePlayerMode;
|
||||
@@ -26,12 +31,15 @@ type LivePlayerProps = {
|
||||
micEnabled?: boolean; // only webrtc supports mic
|
||||
iOSCompatFullScreen?: boolean;
|
||||
pip?: boolean;
|
||||
autoLive?: boolean;
|
||||
onClick?: () => void;
|
||||
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
|
||||
onError?: (error: LivePlayerError) => void;
|
||||
};
|
||||
|
||||
export default function LivePlayer({
|
||||
cameraRef = undefined,
|
||||
containerRef,
|
||||
className,
|
||||
cameraConfig,
|
||||
preferredLiveMode,
|
||||
@@ -41,12 +49,14 @@ export default function LivePlayer({
|
||||
micEnabled = false,
|
||||
iOSCompatFullScreen = false,
|
||||
pip,
|
||||
autoLive = true,
|
||||
onClick,
|
||||
setFullResolution,
|
||||
onError,
|
||||
}: LivePlayerProps) {
|
||||
// camera activity
|
||||
|
||||
const { activeMotion, activeTracking, objects } =
|
||||
const { activeMotion, activeTracking, objects, offline } =
|
||||
useCameraActivity(cameraConfig);
|
||||
|
||||
const cameraActive = useMemo(
|
||||
@@ -62,6 +72,10 @@ export default function LivePlayer({
|
||||
|
||||
const [liveReady, setLiveReady] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!autoLive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!liveReady) {
|
||||
if (cameraActive && liveMode == "jsmpeg") {
|
||||
setLiveReady(true);
|
||||
@@ -75,12 +89,12 @@ export default function LivePlayer({
|
||||
}
|
||||
// live mode won't change
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cameraActive, liveReady]);
|
||||
}, [autoLive, cameraActive, liveReady]);
|
||||
|
||||
// camera still state
|
||||
|
||||
const stillReloadInterval = useMemo(() => {
|
||||
if (!windowVisible) {
|
||||
if (!windowVisible || offline) {
|
||||
return -1; // no reason to update the image when the window is not visible
|
||||
}
|
||||
|
||||
@@ -88,19 +102,32 @@ export default function LivePlayer({
|
||||
return 60000;
|
||||
}
|
||||
|
||||
if (cameraActive) {
|
||||
return 200;
|
||||
if (activeMotion || activeTracking) {
|
||||
if (autoLive) {
|
||||
return 200;
|
||||
} else {
|
||||
return 59000;
|
||||
}
|
||||
}
|
||||
|
||||
return 30000;
|
||||
}, [liveReady, cameraActive, windowVisible]);
|
||||
}, [
|
||||
autoLive,
|
||||
liveReady,
|
||||
activeMotion,
|
||||
activeTracking,
|
||||
offline,
|
||||
windowVisible,
|
||||
]);
|
||||
|
||||
if (!cameraConfig) {
|
||||
return <ActivityIndicator />;
|
||||
}
|
||||
|
||||
let player;
|
||||
if (liveMode == "webrtc") {
|
||||
if (!autoLive) {
|
||||
player = null;
|
||||
} else if (liveMode == "webrtc") {
|
||||
player = (
|
||||
<WebRtcPlayer
|
||||
className={`size-full rounded-lg md:rounded-2xl ${liveReady ? "" : "hidden"}`}
|
||||
@@ -124,6 +151,7 @@ export default function LivePlayer({
|
||||
onPlaying={() => setLiveReady(true)}
|
||||
pip={pip}
|
||||
setFullResolution={setFullResolution}
|
||||
onError={onError}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
@@ -135,14 +163,19 @@ export default function LivePlayer({
|
||||
);
|
||||
}
|
||||
} else if (liveMode == "jsmpeg") {
|
||||
player = (
|
||||
<JSMpegPlayer
|
||||
className="flex size-full justify-center overflow-hidden rounded-lg md:rounded-2xl"
|
||||
camera={cameraConfig.live.stream_name}
|
||||
width={cameraConfig.detect.width}
|
||||
height={cameraConfig.detect.height}
|
||||
/>
|
||||
);
|
||||
if (cameraActive || !showStillWithoutActivity) {
|
||||
player = (
|
||||
<JSMpegPlayer
|
||||
className="flex justify-center overflow-hidden rounded-lg md:rounded-2xl"
|
||||
camera={cameraConfig.live.stream_name}
|
||||
width={cameraConfig.detect.width}
|
||||
height={cameraConfig.detect.height}
|
||||
containerRef={containerRef}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
player = null;
|
||||
}
|
||||
} else {
|
||||
player = <ActivityIndicator />;
|
||||
}
|
||||
@@ -152,9 +185,7 @@ export default function LivePlayer({
|
||||
ref={cameraRef}
|
||||
data-camera={cameraConfig.name}
|
||||
className={cn(
|
||||
"relative flex justify-center",
|
||||
liveMode === "jsmpeg" ? "size-full" : "w-full",
|
||||
"cursor-pointer outline",
|
||||
"relative flex w-full cursor-pointer justify-center outline",
|
||||
activeTracking
|
||||
? "outline-3 rounded-lg shadow-severity_alert outline-severity_alert md:rounded-2xl"
|
||||
: "outline-0 outline-background",
|
||||
@@ -224,9 +255,16 @@ export default function LivePlayer({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="absolute right-2 top-2 size-4">
|
||||
{activeMotion && (
|
||||
<MdCircle className="size-2 animate-pulse text-danger shadow-danger drop-shadow-md" />
|
||||
<div className="absolute right-2 top-2">
|
||||
{autoLive && !offline && activeMotion && (
|
||||
<MdCircle className="mr-2 size-2 animate-pulse text-danger shadow-danger drop-shadow-md" />
|
||||
)}
|
||||
{offline && (
|
||||
<Chip
|
||||
className={`z-0 flex items-start justify-between space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 text-xs capitalize`}
|
||||
>
|
||||
{cameraConfig.name.replaceAll("_", " ")}
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { VideoResolutionType } from "@/types/live";
|
||||
import { LivePlayerError, VideoResolutionType } from "@/types/live";
|
||||
import {
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
@@ -17,6 +17,7 @@ type MSEPlayerProps = {
|
||||
pip?: boolean;
|
||||
onPlaying?: () => void;
|
||||
setFullResolution?: React.Dispatch<SetStateAction<VideoResolutionType>>;
|
||||
onError?: (error: LivePlayerError) => void;
|
||||
};
|
||||
|
||||
function MSEPlayer({
|
||||
@@ -27,6 +28,7 @@ function MSEPlayer({
|
||||
pip = false,
|
||||
onPlaying,
|
||||
setFullResolution,
|
||||
onError,
|
||||
}: MSEPlayerProps) {
|
||||
const RECONNECT_TIMEOUT: number = 30000;
|
||||
|
||||
@@ -45,6 +47,7 @@ function MSEPlayer({
|
||||
|
||||
const [wsState, setWsState] = useState<number>(WebSocket.CLOSED);
|
||||
const [connectTS, setConnectTS] = useState<number>(0);
|
||||
const [bufferTimeout, setBufferTimeout] = useState<NodeJS.Timeout>();
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
@@ -303,10 +306,39 @@ function MSEPlayer({
|
||||
className={className}
|
||||
playsInline
|
||||
preload="auto"
|
||||
onLoadedData={onPlaying}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onLoadedData={() => {
|
||||
handleLoadedMetadata?.();
|
||||
onPlaying?.();
|
||||
}}
|
||||
muted={!audioEnabled}
|
||||
onError={() => {
|
||||
onProgress={
|
||||
onError != undefined
|
||||
? () => {
|
||||
if (videoRef.current?.paused) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (bufferTimeout) {
|
||||
clearTimeout(bufferTimeout);
|
||||
setBufferTimeout(undefined);
|
||||
}
|
||||
|
||||
setBufferTimeout(
|
||||
setTimeout(() => {
|
||||
onError("stalled");
|
||||
}, 3000),
|
||||
);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onError={(e) => {
|
||||
if (
|
||||
// @ts-expect-error code does exist
|
||||
e.target.error.code == MediaError.MEDIA_ERR_NETWORK
|
||||
) {
|
||||
onError?.("startup");
|
||||
}
|
||||
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
|
||||
@@ -10,7 +10,7 @@ import useSWR from "swr";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { Preview } from "@/types/preview";
|
||||
import { PreviewPlayback } from "@/types/playback";
|
||||
import { getTimestampOffset, isCurrentHour } from "@/utils/dateUtil";
|
||||
import { isCurrentHour } from "@/utils/dateUtil";
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { isAndroid, isChrome, isMobile } from "react-device-detect";
|
||||
import { TimeRange } from "@/types/timeline";
|
||||
@@ -41,13 +41,11 @@ export default function PreviewPlayer({
|
||||
const [currentHourFrame, setCurrentHourFrame] = useState<string>();
|
||||
|
||||
const currentPreview = useMemo(() => {
|
||||
const timeRangeOffset = getTimestampOffset(timeRange.before);
|
||||
|
||||
return cameraPreviews.find(
|
||||
(preview) =>
|
||||
preview.camera == camera &&
|
||||
Math.round(preview.start) >= timeRange.after + timeRangeOffset &&
|
||||
Math.floor(preview.end) <= timeRange.before + timeRangeOffset,
|
||||
Math.round(preview.start) >= timeRange.after &&
|
||||
Math.floor(preview.end) <= timeRange.before,
|
||||
);
|
||||
}, [cameraPreviews, camera, timeRange]);
|
||||
|
||||
@@ -197,6 +195,7 @@ function PreviewVideoPlayer({
|
||||
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const [videoSize, setVideoSize] = useState<number[]>([0, 0]);
|
||||
const [changeoverTimeout, setChangeoverTimeout] = useState<NodeJS.Timeout>();
|
||||
|
||||
const changeSource = useCallback(
|
||||
(newPreview: Preview | undefined, video: HTMLVideoElement | null) => {
|
||||
@@ -220,6 +219,15 @@ function PreviewVideoPlayer({
|
||||
}
|
||||
|
||||
setCurrentPreview(newPreview);
|
||||
const timeout = setTimeout(() => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
setChangeoverTimeout(undefined);
|
||||
}
|
||||
|
||||
previewRef.current?.load();
|
||||
}, 1000);
|
||||
setChangeoverTimeout(timeout);
|
||||
|
||||
// we only want this to change when current preview changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -232,17 +240,15 @@ function PreviewVideoPlayer({
|
||||
return;
|
||||
}
|
||||
|
||||
// account for minutes offset in timezone
|
||||
const timeRangeOffset = getTimestampOffset(timeRange.before);
|
||||
|
||||
const preview = cameraPreviews.find(
|
||||
(preview) =>
|
||||
preview.camera == camera &&
|
||||
Math.round(preview.start) >= timeRange.after + timeRangeOffset &&
|
||||
Math.floor(preview.end) <= timeRange.before + timeRangeOffset,
|
||||
Math.round(preview.start) >= timeRange.after &&
|
||||
Math.floor(preview.end) <= timeRange.before,
|
||||
);
|
||||
|
||||
if (preview != currentPreview) {
|
||||
controller.newPreviewLoaded = false;
|
||||
changeSource(preview, previewRef.current);
|
||||
}
|
||||
|
||||
@@ -267,7 +273,14 @@ function PreviewVideoPlayer({
|
||||
<img
|
||||
className={`absolute size-full object-contain ${currentHourFrame ? "visible" : "invisible"}`}
|
||||
src={currentHourFrame}
|
||||
onLoad={() => previewRef.current?.load()}
|
||||
onLoad={() => {
|
||||
if (changeoverTimeout) {
|
||||
clearTimeout(changeoverTimeout);
|
||||
setChangeoverTimeout(undefined);
|
||||
}
|
||||
|
||||
previewRef.current?.load();
|
||||
}}
|
||||
/>
|
||||
<video
|
||||
ref={previewRef}
|
||||
@@ -302,7 +315,10 @@ function PreviewVideoPlayer({
|
||||
}}
|
||||
>
|
||||
{currentPreview != undefined && (
|
||||
<source src={currentPreview.src} type={currentPreview.type} />
|
||||
<source
|
||||
src={`${baseUrl}${currentPreview.src.substring(1)}`}
|
||||
type={currentPreview.type}
|
||||
/>
|
||||
)}
|
||||
</video>
|
||||
{cameraPreviews && !currentPreview && (
|
||||
@@ -324,6 +340,7 @@ class PreviewVideoController extends PreviewController {
|
||||
private preview: Preview | undefined = undefined;
|
||||
private timeToSeek: number | undefined = undefined;
|
||||
public scrubbing = false;
|
||||
public newPreviewLoaded = true;
|
||||
private seeking = false;
|
||||
|
||||
constructor(
|
||||
@@ -342,7 +359,12 @@ class PreviewVideoController extends PreviewController {
|
||||
}
|
||||
|
||||
override scrubToTimestamp(time: number): boolean {
|
||||
if (!this.previewRef.current || !this.preview || !this.timeRange) {
|
||||
if (
|
||||
!this.newPreviewLoaded ||
|
||||
!this.previewRef.current ||
|
||||
!this.preview ||
|
||||
!this.timeRange
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -393,6 +415,7 @@ class PreviewVideoController extends PreviewController {
|
||||
}
|
||||
|
||||
previewReady() {
|
||||
this.newPreviewLoaded = true;
|
||||
this.seeking = false;
|
||||
this.previewRef.current?.pause();
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import { TimeRange } from "@/types/timeline";
|
||||
import { NoThumbSlider } from "../ui/slider";
|
||||
import { PREVIEW_FPS, PREVIEW_PADDING } from "@/types/preview";
|
||||
import { capitalizeFirstLetter } from "@/utils/stringUtil";
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
|
||||
type PreviewPlayerProps = {
|
||||
review: ReviewSegment;
|
||||
@@ -575,7 +576,10 @@ export function VideoPreview({
|
||||
muted
|
||||
onTimeUpdate={onProgress}
|
||||
>
|
||||
<source src={relevantPreview.src} type={relevantPreview.type} />
|
||||
<source
|
||||
src={`${baseUrl}${relevantPreview.src.substring(1)}`}
|
||||
type={relevantPreview.type}
|
||||
/>
|
||||
</video>
|
||||
{showProgress && (
|
||||
<NoThumbSlider
|
||||
@@ -668,7 +672,9 @@ export function InProgressPreview({
|
||||
setReviewed(review.id);
|
||||
}
|
||||
|
||||
setKey(key + 1);
|
||||
if (previewFrames[key + 1]) {
|
||||
setKey(key + 1);
|
||||
}
|
||||
}, MIN_LOAD_TIMEOUT_MS);
|
||||
|
||||
// we know that these deps are correct
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { isMobileOnly, isSafari } from "react-device-detect";
|
||||
import { LuPause, LuPlay } from "react-icons/lu";
|
||||
import {
|
||||
@@ -68,7 +68,7 @@ type VideoControlsProps = {
|
||||
onSeek: (diff: number) => void;
|
||||
onSetPlaybackRate: (rate: number) => void;
|
||||
onUploadFrame?: () => void;
|
||||
setFullscreen?: (full: boolean) => void;
|
||||
toggleFullscreen?: () => void;
|
||||
};
|
||||
export default function VideoControls({
|
||||
className,
|
||||
@@ -88,8 +88,14 @@ export default function VideoControls({
|
||||
onSeek,
|
||||
onSetPlaybackRate,
|
||||
onUploadFrame,
|
||||
setFullscreen,
|
||||
toggleFullscreen,
|
||||
}: VideoControlsProps) {
|
||||
// layout
|
||||
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// controls
|
||||
|
||||
const onReplay = useCallback(
|
||||
(e: React.MouseEvent<SVGElement>) => {
|
||||
e.stopPropagation();
|
||||
@@ -133,6 +139,11 @@ export default function VideoControls({
|
||||
const onKeyboardShortcut = useCallback(
|
||||
(key: string, down: boolean, repeat: boolean) => {
|
||||
switch (key) {
|
||||
case "ArrowDown":
|
||||
if (down) {
|
||||
onSeek(-1);
|
||||
}
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
if (down) {
|
||||
onSeek(-10);
|
||||
@@ -143,9 +154,14 @@ export default function VideoControls({
|
||||
onSeek(10);
|
||||
}
|
||||
break;
|
||||
case "ArrowUp":
|
||||
if (down) {
|
||||
onSeek(1);
|
||||
}
|
||||
break;
|
||||
case "f":
|
||||
if (setFullscreen && down && !repeat) {
|
||||
setFullscreen(!fullscreen);
|
||||
if (toggleFullscreen && down && !repeat) {
|
||||
toggleFullscreen();
|
||||
}
|
||||
break;
|
||||
case "m":
|
||||
@@ -162,10 +178,12 @@ export default function VideoControls({
|
||||
},
|
||||
// only update when preview only changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[video, isPlaying, fullscreen, setFullscreen, onSeek],
|
||||
[video, isPlaying, fullscreen, toggleFullscreen, onSeek],
|
||||
);
|
||||
useKeyboardListener(
|
||||
hotKeys ? ["ArrowLeft", "ArrowRight", "f", "m", " "] : [],
|
||||
hotKeys
|
||||
? ["ArrowDown", "ArrowLeft", "ArrowRight", "ArrowUp", "f", "m", " "]
|
||||
: [],
|
||||
onKeyboardShortcut,
|
||||
);
|
||||
|
||||
@@ -183,6 +201,7 @@ export default function VideoControls({
|
||||
MIN_ITEMS_WRAP &&
|
||||
"min-w-[75%] flex-wrap",
|
||||
)}
|
||||
ref={containerRef}
|
||||
>
|
||||
{video && features.volume && (
|
||||
<div className="flex cursor-pointer items-center justify-normal gap-2">
|
||||
@@ -223,6 +242,7 @@ export default function VideoControls({
|
||||
)}
|
||||
{features.playbackRate && (
|
||||
<DropdownMenu
|
||||
modal={false}
|
||||
onOpenChange={(open) => {
|
||||
if (setControlsOpen) {
|
||||
setControlsOpen(open);
|
||||
@@ -230,7 +250,9 @@ export default function VideoControls({
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger>{`${playbackRate}x`}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuContent
|
||||
portalProps={{ container: containerRef.current }}
|
||||
>
|
||||
<DropdownMenuRadioGroup
|
||||
onValueChange={(rate) => onSetPlaybackRate(parseFloat(rate))}
|
||||
>
|
||||
@@ -265,11 +287,8 @@ export default function VideoControls({
|
||||
onUploadFrame={onUploadFrame}
|
||||
/>
|
||||
)}
|
||||
{features.fullscreen && setFullscreen && (
|
||||
<div
|
||||
className="cursor-pointer"
|
||||
onClick={() => setFullscreen(!fullscreen)}
|
||||
>
|
||||
{features.fullscreen && toggleFullscreen && (
|
||||
<div className="cursor-pointer" onClick={toggleFullscreen}>
|
||||
{fullscreen ? <FaCompress /> : <FaExpand />}
|
||||
</div>
|
||||
)}
|
||||
@@ -321,11 +340,11 @@ function FrigatePlusUploadButton({
|
||||
}}
|
||||
/>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent className="md:max-w-[80%]">
|
||||
<AlertDialogContent className="md:max-w-2xl lg:max-w-3xl xl:max-w-4xl">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Submit this frame to Frigate+?</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
<img className="w-full object-contain" src={videoImg} />
|
||||
<img className="aspect-video w-full object-contain" src={videoImg} />
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogAction className="bg-selected" onClick={onUploadFrame}>
|
||||
Submit
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { LivePlayerError } from "@/types/live";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
type WebRtcPlayerProps = {
|
||||
@@ -10,6 +11,7 @@ type WebRtcPlayerProps = {
|
||||
iOSCompatFullScreen?: boolean; // ios doesn't support fullscreen divs so we must support the video element
|
||||
pip?: boolean;
|
||||
onPlaying?: () => void;
|
||||
onError?: (error: LivePlayerError) => void;
|
||||
};
|
||||
|
||||
export default function WebRtcPlayer({
|
||||
@@ -21,6 +23,7 @@ export default function WebRtcPlayer({
|
||||
iOSCompatFullScreen = false,
|
||||
pip = false,
|
||||
onPlaying,
|
||||
onError,
|
||||
}: WebRtcPlayerProps) {
|
||||
// metadata
|
||||
|
||||
@@ -32,6 +35,7 @@ export default function WebRtcPlayer({
|
||||
|
||||
const pcRef = useRef<RTCPeerConnection | undefined>();
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const [bufferTimeout, setBufferTimeout] = useState<NodeJS.Timeout>();
|
||||
|
||||
const PeerConnection = useCallback(
|
||||
async (media: string) => {
|
||||
@@ -198,11 +202,39 @@ export default function WebRtcPlayer({
|
||||
playsInline
|
||||
muted={!audioEnabled}
|
||||
onLoadedData={onPlaying}
|
||||
onProgress={
|
||||
onError != undefined
|
||||
? () => {
|
||||
if (videoRef.current?.paused) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (bufferTimeout) {
|
||||
clearTimeout(bufferTimeout);
|
||||
setBufferTimeout(undefined);
|
||||
}
|
||||
|
||||
setBufferTimeout(
|
||||
setTimeout(() => {
|
||||
onError("stalled");
|
||||
}, 3000),
|
||||
);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onClick={
|
||||
iOSCompatFullScreen
|
||||
? () => setiOSCompatControls(!iOSCompatControls)
|
||||
: undefined
|
||||
}
|
||||
onError={(e) => {
|
||||
if (
|
||||
// @ts-expect-error code does exist
|
||||
e.target.error.code == MediaError.MEDIA_ERR_NETWORK
|
||||
) {
|
||||
onError?.("startup");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ type DynamicVideoPlayerProps = {
|
||||
onTimestampUpdate?: (timestamp: number) => void;
|
||||
onClipEnded?: () => void;
|
||||
setFullResolution: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
|
||||
setFullscreen: (full: boolean) => void;
|
||||
toggleFullscreen: () => void;
|
||||
};
|
||||
export default function DynamicVideoPlayer({
|
||||
className,
|
||||
@@ -44,7 +44,7 @@ export default function DynamicVideoPlayer({
|
||||
onTimestampUpdate,
|
||||
onClipEnded,
|
||||
setFullResolution,
|
||||
setFullscreen,
|
||||
toggleFullscreen,
|
||||
}: DynamicVideoPlayerProps) {
|
||||
const apiHost = useApiHost();
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
@@ -207,7 +207,7 @@ export default function DynamicVideoPlayer({
|
||||
}}
|
||||
setFullResolution={setFullResolution}
|
||||
onUploadFrame={onUploadFrameToPlus}
|
||||
setFullscreen={setFullscreen}
|
||||
toggleFullscreen={toggleFullscreen}
|
||||
/>
|
||||
<PreviewPlayer
|
||||
className={cn(
|
||||
|
||||
@@ -135,9 +135,12 @@ export default function MotionMaskEditPane({
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
toast.success(`${polygon.name || "Motion Mask"} has been saved.`, {
|
||||
position: "top-center",
|
||||
});
|
||||
toast.success(
|
||||
`${polygon.name || "Motion Mask"} has been saved. Restart Frigate to apply changes.`,
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
updateConfig();
|
||||
} else {
|
||||
toast.error(`Failed to save config changes: ${res.statusText}`, {
|
||||
|
||||
@@ -193,9 +193,12 @@ export default function ObjectMaskEditPane({
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
toast.success(`${polygon.name || "Object Mask"} has been saved.`, {
|
||||
position: "top-center",
|
||||
});
|
||||
toast.success(
|
||||
`${polygon.name || "Object Mask"} has been saved. Restart Frigate to apply changes.`,
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
updateConfig();
|
||||
} else {
|
||||
toast.error(`Failed to save config changes: ${res.statusText}`, {
|
||||
|
||||
@@ -266,7 +266,7 @@ export default function PolygonItem({
|
||||
|
||||
{isMobile && (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger>
|
||||
<HiOutlineDotsVertical className="size-5" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -261,9 +261,12 @@ export default function ZoneEditPane({
|
||||
)
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
toast.success(`Zone (${zoneName}) has been saved.`, {
|
||||
position: "top-center",
|
||||
});
|
||||
toast.success(
|
||||
`Zone (${zoneName}) has been saved. Restart Frigate to apply changes.`,
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
updateConfig();
|
||||
} else {
|
||||
toast.error(`Failed to save config changes: ${res.statusText}`, {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { MinimapBounds, Tick, Timestamp } from "./segment-metadata";
|
||||
import { useMotionSegmentUtils } from "@/hooks/use-motion-segment-utils";
|
||||
import { isDesktop, isMobile } from "react-device-detect";
|
||||
import useTapUtils from "@/hooks/use-tap-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type MotionSegmentProps = {
|
||||
events: ReviewSegment[];
|
||||
@@ -170,7 +171,16 @@ export function MotionSegment({
|
||||
<div
|
||||
key={segmentKey}
|
||||
data-segment-id={segmentKey}
|
||||
className={`segment ${firstHalfSegmentWidth > 0 || secondHalfSegmentWidth > 0 ? "has-data" : ""} ${segmentClasses} bg-gradient-to-r ${severityColorsBg[severity[0]]}`}
|
||||
className={cn(
|
||||
"segment",
|
||||
{
|
||||
"has-data":
|
||||
firstHalfSegmentWidth > 0 || secondHalfSegmentWidth > 0,
|
||||
},
|
||||
segmentClasses,
|
||||
severity[0] && "bg-gradient-to-r",
|
||||
severity[0] && severityColorsBg[severity[0]],
|
||||
)}
|
||||
onClick={segmentClick}
|
||||
onTouchEnd={(event) => handleTouchStart(event, segmentClick)}
|
||||
>
|
||||
@@ -210,7 +220,14 @@ export function MotionSegment({
|
||||
<div
|
||||
key={`${segmentKey}_motion_data_1`}
|
||||
data-motion-value={secondHalfSegmentWidth}
|
||||
className={`${isDesktop && animationClassesSecondHalf} h-[2px] rounded-full bg-motion_review`}
|
||||
className={cn(
|
||||
isDesktop && animationClassesSecondHalf,
|
||||
"h-[2px]",
|
||||
"rounded-full",
|
||||
secondHalfSegmentWidth
|
||||
? "bg-motion_review"
|
||||
: "bg-muted-foreground",
|
||||
)}
|
||||
style={{
|
||||
width: secondHalfSegmentWidth || 1,
|
||||
}}
|
||||
@@ -223,7 +240,14 @@ export function MotionSegment({
|
||||
<div
|
||||
key={`${segmentKey}_motion_data_2`}
|
||||
data-motion-value={firstHalfSegmentWidth}
|
||||
className={`${isDesktop && animationClassesFirstHalf} h-[2px] rounded-full bg-motion_review`}
|
||||
className={cn(
|
||||
isDesktop && animationClassesFirstHalf,
|
||||
"h-[2px]",
|
||||
"rounded-full",
|
||||
firstHalfSegmentWidth
|
||||
? "bg-motion_review"
|
||||
: "bg-muted-foreground",
|
||||
)}
|
||||
style={{
|
||||
width: firstHalfSegmentWidth || 1,
|
||||
}}
|
||||
|
||||
@@ -258,9 +258,9 @@ export function ReviewTimeline({
|
||||
if (isDragging && isMobile && draggableElementType === draggableElement) {
|
||||
return "text-lg";
|
||||
} else if (dense) {
|
||||
return "text-[8px] md:text-xs";
|
||||
return "text-[8px] md:text-[11px]";
|
||||
} else {
|
||||
return "text-xs";
|
||||
return "text-[11px]";
|
||||
}
|
||||
},
|
||||
[dense, isDragging, draggableElementType],
|
||||
@@ -350,7 +350,7 @@ export function ReviewTimeline({
|
||||
dense
|
||||
? "w-12 md:w-20"
|
||||
: segmentDuration < 60
|
||||
? "w-24"
|
||||
? "w-[80px]"
|
||||
: "w-20"
|
||||
} h-5 ${isDraggingHandlebar && isMobile ? "fixed left-1/2 top-[18px] z-20 h-[30px] w-32 -translate-x-1/2 transform bg-destructive/80" : "static"} flex items-center justify-center`}
|
||||
>
|
||||
@@ -388,7 +388,7 @@ export function ReviewTimeline({
|
||||
dense
|
||||
? "w-12 md:w-20"
|
||||
: segmentDuration < 60
|
||||
? "w-24"
|
||||
? "w-[80px]"
|
||||
: "w-20"
|
||||
} h-5 ${isDraggingExportEnd && isMobile ? "fixed left-1/2 top-[18px] z-20 mt-0 h-[30px] w-32 -translate-x-1/2 transform rounded-full bg-selected/80" : "static rounded-tl-lg rounded-tr-lg"} flex items-center justify-center`}
|
||||
>
|
||||
@@ -430,7 +430,7 @@ export function ReviewTimeline({
|
||||
dense
|
||||
? "w-12 md:w-20"
|
||||
: segmentDuration < 60
|
||||
? "w-24"
|
||||
? "w-[80px]"
|
||||
: "w-20"
|
||||
} h-5 ${isDraggingExportStart && isMobile ? "fixed left-1/2 top-[4px] z-20 mt-0 h-[30px] w-32 -translate-x-1/2 transform rounded-full bg-selected/80" : "static rounded-bl-lg rounded-br-lg"} flex items-center justify-center`}
|
||||
>
|
||||
|
||||
@@ -56,9 +56,11 @@ DropdownMenuSubContent.displayName =
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & {
|
||||
portalProps?: DropdownMenuPrimitive.DropdownMenuPortalProps;
|
||||
}
|
||||
>(({ className, portalProps, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal {...portalProps}>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
|
||||
@@ -10,11 +10,13 @@ import { useTimelineUtils } from "./use-timeline-utils";
|
||||
import { ObjectType } from "@/types/ws";
|
||||
import useDeepMemo from "./use-deep-memo";
|
||||
import { isEqual } from "lodash";
|
||||
import { useAutoFrigateStats } from "./use-stats";
|
||||
|
||||
type useCameraActivityReturn = {
|
||||
activeTracking: boolean;
|
||||
activeMotion: boolean;
|
||||
objects: ObjectType[];
|
||||
offline: boolean;
|
||||
};
|
||||
|
||||
export function useCameraActivity(
|
||||
@@ -116,12 +118,31 @@ export function useCameraActivity(
|
||||
handleSetObjects(newObjects);
|
||||
}, [camera, updatedEvent, objects, handleSetObjects]);
|
||||
|
||||
// determine if camera is offline
|
||||
|
||||
const stats = useAutoFrigateStats();
|
||||
|
||||
const offline = useMemo(() => {
|
||||
if (!stats) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cameras = stats["cameras"];
|
||||
|
||||
if (!cameras) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return cameras[camera.name].camera_fps == 0;
|
||||
}, [camera, stats]);
|
||||
|
||||
return {
|
||||
activeTracking: hasActiveObjects,
|
||||
activeMotion: detectingMotion
|
||||
? detectingMotion === "ON"
|
||||
: initialCameraState?.motion === true,
|
||||
objects,
|
||||
offline,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { LivePlayerMode } from "@/types/live";
|
||||
|
||||
export default function useCameraLiveMode(
|
||||
cameraConfig: CameraConfig,
|
||||
preferredMode?: string,
|
||||
preferredMode?: LivePlayerMode,
|
||||
): LivePlayerMode | undefined {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
@@ -23,18 +23,16 @@ export default function useCameraLiveMode(
|
||||
);
|
||||
}, [config, cameraConfig]);
|
||||
const defaultLiveMode = useMemo<LivePlayerMode | undefined>(() => {
|
||||
if (config && cameraConfig) {
|
||||
if (config) {
|
||||
if (restreamEnabled) {
|
||||
return cameraConfig.ui.live_mode || config.ui.live_mode;
|
||||
return preferredMode || "mse";
|
||||
}
|
||||
|
||||
return "jsmpeg";
|
||||
}
|
||||
|
||||
return undefined;
|
||||
// config will be updated if camera config is updated
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cameraConfig, restreamEnabled]);
|
||||
}, [config, preferredMode, restreamEnabled]);
|
||||
const [viewSource] = usePersistence<LivePlayerMode>(
|
||||
`${cameraConfig.name}-source`,
|
||||
defaultLiveMode,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { RefObject, useCallback, useEffect, useState } from "react";
|
||||
import nosleep from "nosleep.js";
|
||||
|
||||
const NoSleep = new nosleep();
|
||||
|
||||
function getFullscreenElement(): HTMLElement | null {
|
||||
return (
|
||||
@@ -96,9 +99,11 @@ export function useFullscreen<T extends HTMLElement = HTMLElement>(
|
||||
const toggleFullscreen = useCallback(async () => {
|
||||
try {
|
||||
if (!getFullscreenElement()) {
|
||||
NoSleep.enable();
|
||||
await enterFullScreen(elementRef.current!);
|
||||
} else {
|
||||
await exitFullscreen();
|
||||
NoSleep.disable();
|
||||
}
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
|
||||
@@ -3,11 +3,11 @@ import { useState, useEffect, useCallback, useRef } from "react";
|
||||
type OptimisticStateResult<T> = [T, (newValue: T) => void];
|
||||
|
||||
const useOptimisticState = <T>(
|
||||
initialState: T,
|
||||
currentState: T,
|
||||
setState: (newValue: T) => void,
|
||||
delay: number = 20,
|
||||
): OptimisticStateResult<T> => {
|
||||
const [optimisticValue, setOptimisticValue] = useState<T>(initialState);
|
||||
const [optimisticValue, setOptimisticValue] = useState<T>(currentState);
|
||||
const debounceTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
@@ -37,6 +37,16 @@ const useOptimisticState = <T>(
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentState != optimisticValue) {
|
||||
setOptimisticValue(currentState);
|
||||
}
|
||||
// sometimes an external action will cause the currentState to change
|
||||
// without handleValueChange being called. In this case
|
||||
// we need to update the optimistic value so the UI reflects the change
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentState]);
|
||||
|
||||
return [optimisticValue, handleValueChange];
|
||||
};
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useMemo } from "react";
|
||||
import useSWR from "swr";
|
||||
import useDeepMemo from "./use-deep-memo";
|
||||
import { capitalizeFirstLetter } from "@/utils/stringUtil";
|
||||
import { useFrigateStats } from "@/api/ws";
|
||||
|
||||
export default function useStats(stats: FrigateStats | undefined) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
@@ -91,3 +92,20 @@ export default function useStats(stats: FrigateStats | undefined) {
|
||||
|
||||
return { potentialProblems };
|
||||
}
|
||||
|
||||
export function useAutoFrigateStats() {
|
||||
const { data: initialStats } = useSWR<FrigateStats>("stats", {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
const { payload: latestStats } = useFrigateStats();
|
||||
|
||||
const stats = useMemo(() => {
|
||||
if (latestStats) {
|
||||
return latestStats;
|
||||
}
|
||||
|
||||
return initialStats;
|
||||
}, [initialStats, latestStats]);
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
@@ -162,3 +162,7 @@
|
||||
font-display: swap;
|
||||
src: url("../fonts/Inter-BlackItalic.woff2") format("woff2");
|
||||
}
|
||||
|
||||
.react-resizable-handle {
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ function ConfigEditor() {
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
|
||||
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
|
||||
const modelRef = useRef<monaco.editor.IEditorModel | null>(null);
|
||||
const modelRef = useRef<monaco.editor.ITextModel | null>(null);
|
||||
const configRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const onHandleSaveConfig = useCallback(
|
||||
@@ -124,6 +124,12 @@ function ConfigEditor() {
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (config && modelRef.current) {
|
||||
modelRef.current.setValue(config);
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
if (!config) {
|
||||
return <ActivityIndicator />;
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
ReviewSegment,
|
||||
ReviewSeverity,
|
||||
ReviewSummary,
|
||||
SegmentedReviewData,
|
||||
} from "@/types/review";
|
||||
import { getTimestampOffset } from "@/utils/dateUtil";
|
||||
import EventView from "@/views/events/EventView";
|
||||
import { RecordingView } from "@/views/events/RecordingView";
|
||||
import axios from "axios";
|
||||
@@ -138,6 +138,66 @@ export default function Events() {
|
||||
},
|
||||
);
|
||||
|
||||
const reviewItems = useMemo<SegmentedReviewData>(() => {
|
||||
if (!reviews) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const all: ReviewSegment[] = [];
|
||||
const alerts: ReviewSegment[] = [];
|
||||
const detections: ReviewSegment[] = [];
|
||||
const motion: ReviewSegment[] = [];
|
||||
|
||||
reviews?.forEach((segment) => {
|
||||
all.push(segment);
|
||||
|
||||
switch (segment.severity) {
|
||||
case "alert":
|
||||
alerts.push(segment);
|
||||
break;
|
||||
case "detection":
|
||||
detections.push(segment);
|
||||
break;
|
||||
default:
|
||||
motion.push(segment);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
all: all,
|
||||
alert: alerts,
|
||||
detection: detections,
|
||||
significant_motion: motion,
|
||||
};
|
||||
}, [reviews]);
|
||||
|
||||
const currentItems = useMemo(() => {
|
||||
if (!reviewItems || !severity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let current;
|
||||
|
||||
if (reviewFilter?.showAll) {
|
||||
current = reviewItems.all;
|
||||
} else {
|
||||
current = reviewItems[severity];
|
||||
}
|
||||
|
||||
if (!current || current.length == 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (reviewFilter?.showReviewed != 1) {
|
||||
return current.filter((seg) => !seg.has_been_reviewed);
|
||||
} else {
|
||||
return current;
|
||||
}
|
||||
// only refresh when severity or filter changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [severity, reviewFilter, reviewItems?.all.length]);
|
||||
|
||||
// review summary
|
||||
|
||||
const { data: reviewSummary, mutate: updateSummary } = useSWR<ReviewSummary>(
|
||||
@@ -163,18 +223,15 @@ export default function Events() {
|
||||
|
||||
// preview videos
|
||||
const previewTimes = useMemo(() => {
|
||||
// offset by timezone minutes
|
||||
const timestampOffset = getTimestampOffset(Date.now() / 1000);
|
||||
|
||||
const startDate = new Date(selectedTimeRange.after * 1000);
|
||||
startDate.setMinutes(0, 0, 0);
|
||||
startDate.setUTCMinutes(0, 0, 0);
|
||||
|
||||
const endDate = new Date(selectedTimeRange.before * 1000);
|
||||
endDate.setHours(endDate.getHours() + 1, 0, 0, 0);
|
||||
|
||||
return {
|
||||
after: startDate.getTime() / 1000 + timestampOffset,
|
||||
before: endDate.getTime() / 1000 + timestampOffset,
|
||||
after: startDate.getTime() / 1000,
|
||||
before: endDate.getTime() / 1000,
|
||||
};
|
||||
}, [selectedTimeRange]);
|
||||
|
||||
@@ -353,7 +410,8 @@ export default function Events() {
|
||||
} else {
|
||||
return (
|
||||
<EventView
|
||||
reviews={reviews}
|
||||
reviewItems={reviewItems}
|
||||
currentReviewItems={currentItems}
|
||||
reviewSummary={reviewSummary}
|
||||
relevantPreviews={allPreviews}
|
||||
timeRange={selectedTimeRange}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useFullscreen } from "@/hooks/use-fullscreen";
|
||||
import {
|
||||
useHashState,
|
||||
usePersistedOverlayState,
|
||||
@@ -6,7 +7,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import LiveBirdseyeView from "@/views/live/LiveBirdseyeView";
|
||||
import LiveCameraView from "@/views/live/LiveCameraView";
|
||||
import LiveDashboardView from "@/views/live/LiveDashboardView";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import useSWR from "swr";
|
||||
|
||||
function Live() {
|
||||
@@ -20,6 +21,12 @@ function Live() {
|
||||
"default" as string,
|
||||
);
|
||||
|
||||
// fullscreen
|
||||
|
||||
const mainRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const { fullscreen, toggleFullscreen } = useFullscreen(mainRef);
|
||||
|
||||
// document title
|
||||
|
||||
useEffect(() => {
|
||||
@@ -78,21 +85,31 @@ function Live() {
|
||||
[cameras, selectedCameraName],
|
||||
);
|
||||
|
||||
if (selectedCameraName == "birdseye") {
|
||||
return <LiveBirdseyeView />;
|
||||
}
|
||||
|
||||
if (selectedCamera) {
|
||||
return <LiveCameraView config={config} camera={selectedCamera} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<LiveDashboardView
|
||||
cameras={cameras}
|
||||
cameraGroup={cameraGroup}
|
||||
includeBirdseye={includesBirdseye}
|
||||
onSelectCamera={setSelectedCameraName}
|
||||
/>
|
||||
<div className="size-full" ref={mainRef}>
|
||||
{selectedCameraName === "birdseye" ? (
|
||||
<LiveBirdseyeView
|
||||
fullscreen={fullscreen}
|
||||
toggleFullscreen={toggleFullscreen}
|
||||
/>
|
||||
) : selectedCamera ? (
|
||||
<LiveCameraView
|
||||
config={config}
|
||||
camera={selectedCamera}
|
||||
fullscreen={fullscreen}
|
||||
toggleFullscreen={toggleFullscreen}
|
||||
/>
|
||||
) : (
|
||||
<LiveDashboardView
|
||||
cameras={cameras}
|
||||
cameraGroup={cameraGroup}
|
||||
includeBirdseye={includesBirdseye}
|
||||
onSelectCamera={setSelectedCameraName}
|
||||
fullscreen={fullscreen}
|
||||
toggleFullscreen={toggleFullscreen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { LogData, LogLine, LogSeverity } from "@/types/log";
|
||||
import { LogData, LogLine, LogSeverity, LogType, logTypes } from "@/types/log";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import axios from "axios";
|
||||
@@ -10,25 +10,20 @@ import { LogLevelFilterButton } from "@/components/filter/LogLevelFilter";
|
||||
import { FaCopy } from "react-icons/fa6";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { toast } from "sonner";
|
||||
import { isDesktop } from "react-device-detect";
|
||||
import {
|
||||
isDesktop,
|
||||
isMobile,
|
||||
isMobileOnly,
|
||||
isTablet,
|
||||
} from "react-device-detect";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MdVerticalAlignBottom } from "react-icons/md";
|
||||
|
||||
const logTypes = ["frigate", "go2rtc", "nginx"] as const;
|
||||
type LogType = (typeof logTypes)[number];
|
||||
import { parseLogLines } from "@/utils/logUtil";
|
||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||
|
||||
type LogRange = { start: number; end: number };
|
||||
|
||||
const frigateDateStamp = /\[[\d\s-:]*]/;
|
||||
const frigateSeverity = /(DEBUG)|(INFO)|(WARNING)|(ERROR)/;
|
||||
const frigateSection = /[\w.]*/;
|
||||
|
||||
const goSeverity = /(DEB )|(INF )|(WRN )|(ERR )/;
|
||||
const goSection = /\[[\w]*]/;
|
||||
|
||||
const ngSeverity = /(GET)|(POST)|(PUT)|(PATCH)|(DELETE)/;
|
||||
|
||||
function Logs() {
|
||||
const [logService, setLogService] = useState<LogType>("frigate");
|
||||
|
||||
@@ -38,24 +33,38 @@ function Logs() {
|
||||
|
||||
// log data handling
|
||||
|
||||
const logPageSize = useMemo(() => {
|
||||
if (isMobileOnly) {
|
||||
return 15;
|
||||
}
|
||||
|
||||
if (isTablet) {
|
||||
return 25;
|
||||
}
|
||||
|
||||
return 40;
|
||||
}, []);
|
||||
|
||||
const [logRange, setLogRange] = useState<LogRange>({ start: 0, end: 0 });
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const [logLines, setLogLines] = useState<LogLine[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
axios
|
||||
.get(`logs/${logService}?start=-100`)
|
||||
.get(`logs/${logService}?start=-${logPageSize}`)
|
||||
.then((resp) => {
|
||||
if (resp.status == 200) {
|
||||
const data = resp.data as LogData;
|
||||
setLogRange({
|
||||
start: Math.max(0, data.totalLines - 100),
|
||||
start: Math.max(0, data.totalLines - logPageSize),
|
||||
end: data.totalLines,
|
||||
});
|
||||
setLogs(data.lines);
|
||||
setLogLines(parseLogLines(logService, data.lines));
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [logService]);
|
||||
}, [logPageSize, logService]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!logs || logs.length == 0) {
|
||||
@@ -75,6 +84,10 @@ function Logs() {
|
||||
end: data.totalLines,
|
||||
});
|
||||
setLogs([...logs, ...data.lines]);
|
||||
setLogLines([
|
||||
...logLines,
|
||||
...parseLogLines(logService, data.lines),
|
||||
]);
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -86,137 +99,12 @@ function Logs() {
|
||||
clearTimeout(id);
|
||||
}
|
||||
};
|
||||
}, [logs, logService, logRange]);
|
||||
// we need to listen on the current range of visible items
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [logLines, logService, logRange]);
|
||||
|
||||
// convert to log data
|
||||
|
||||
const logLines = useMemo<LogLine[]>(() => {
|
||||
if (!logs) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (logService == "frigate") {
|
||||
return logs
|
||||
.map((line) => {
|
||||
const match = frigateDateStamp.exec(line);
|
||||
|
||||
if (!match) {
|
||||
const infoIndex = line.indexOf("[INFO]");
|
||||
|
||||
if (infoIndex != -1) {
|
||||
return {
|
||||
dateStamp: line.substring(0, 19),
|
||||
severity: "info",
|
||||
section: "startup",
|
||||
content: line.substring(infoIndex + 6).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
dateStamp: line.substring(0, 19),
|
||||
severity: "unknown",
|
||||
section: "unknown",
|
||||
content: line.substring(30).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
const sectionMatch = frigateSection.exec(
|
||||
line.substring(match.index + match[0].length).trim(),
|
||||
);
|
||||
|
||||
if (!sectionMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
dateStamp: match.toString().slice(1, -1),
|
||||
severity: frigateSeverity
|
||||
.exec(line)
|
||||
?.at(0)
|
||||
?.toString()
|
||||
?.toLowerCase() as LogSeverity,
|
||||
section: sectionMatch.toString(),
|
||||
content: line
|
||||
.substring(line.indexOf(":", match.index + match[0].length) + 2)
|
||||
.trim(),
|
||||
};
|
||||
})
|
||||
.filter((value) => value != null) as LogLine[];
|
||||
} else if (logService == "go2rtc") {
|
||||
return logs
|
||||
.map((line) => {
|
||||
if (line.length == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const severity = goSeverity.exec(line);
|
||||
|
||||
let section =
|
||||
goSection.exec(line)?.toString()?.slice(1, -1) ?? "startup";
|
||||
|
||||
if (frigateSeverity.exec(section)) {
|
||||
section = "startup";
|
||||
}
|
||||
|
||||
let contentStart;
|
||||
|
||||
if (section == "startup") {
|
||||
if (severity) {
|
||||
contentStart = severity.index + severity[0].length;
|
||||
} else {
|
||||
contentStart = line.lastIndexOf("]") + 1;
|
||||
}
|
||||
} else {
|
||||
contentStart = line.indexOf(section) + section.length + 2;
|
||||
}
|
||||
|
||||
let severityCat: LogSeverity;
|
||||
switch (severity?.at(0)?.toString().trim()) {
|
||||
case "INF":
|
||||
severityCat = "info";
|
||||
break;
|
||||
case "WRN":
|
||||
severityCat = "warning";
|
||||
break;
|
||||
case "ERR":
|
||||
severityCat = "error";
|
||||
break;
|
||||
case "DBG":
|
||||
case "TRC":
|
||||
severityCat = "debug";
|
||||
break;
|
||||
default:
|
||||
severityCat = "info";
|
||||
}
|
||||
|
||||
return {
|
||||
dateStamp: line.substring(0, 19),
|
||||
severity: severityCat,
|
||||
section: section,
|
||||
content: line.substring(contentStart).trim(),
|
||||
};
|
||||
})
|
||||
.filter((value) => value != null) as LogLine[];
|
||||
} else if (logService == "nginx") {
|
||||
return logs
|
||||
.map((line) => {
|
||||
if (line.length == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
dateStamp: line.substring(0, 19),
|
||||
severity: "info",
|
||||
section: ngSeverity.exec(line)?.at(0)?.toString() ?? "META",
|
||||
content: line.substring(line.indexOf(" ", 20)).trim(),
|
||||
};
|
||||
})
|
||||
.filter((value) => value != null) as LogLine[];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}, [logs, logService]);
|
||||
|
||||
const handleCopyLogs = useCallback(() => {
|
||||
if (logs) {
|
||||
copy(logs.join("\n"));
|
||||
@@ -261,31 +149,38 @@ function Logs() {
|
||||
}
|
||||
|
||||
try {
|
||||
startObserver.current = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting && logRange.start > 0) {
|
||||
const start = Math.max(0, logRange.start - 100);
|
||||
startObserver.current = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && logRange.start > 0) {
|
||||
const start = Math.max(0, logRange.start - logPageSize);
|
||||
|
||||
axios
|
||||
.get(`logs/${logService}?start=${start}&end=${logRange.start}`)
|
||||
.then((resp) => {
|
||||
if (resp.status == 200) {
|
||||
const data = resp.data as LogData;
|
||||
axios
|
||||
.get(`logs/${logService}?start=${start}&end=${logRange.start}`)
|
||||
.then((resp) => {
|
||||
if (resp.status == 200) {
|
||||
const data = resp.data as LogData;
|
||||
|
||||
if (data.lines.length > 0) {
|
||||
setLogRange({
|
||||
start: start,
|
||||
end: logRange.end,
|
||||
});
|
||||
setLogs([...data.lines, ...logs]);
|
||||
if (data.lines.length > 0) {
|
||||
setLogRange({
|
||||
start: start,
|
||||
end: logRange.end,
|
||||
});
|
||||
setLogs([...data.lines, ...logs]);
|
||||
setLogLines([
|
||||
...parseLogLines(logService, data.lines),
|
||||
...logLines,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
contentRef.current?.scrollBy({
|
||||
top: 10,
|
||||
});
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
contentRef.current?.scrollBy({
|
||||
top: 10,
|
||||
});
|
||||
}
|
||||
},
|
||||
{ rootMargin: `${10 * (isMobile ? 64 : 48)}px 0px 0px 0px` },
|
||||
);
|
||||
if (node) startObserver.current.observe(node);
|
||||
} catch (e) {
|
||||
// no op
|
||||
@@ -332,6 +227,40 @@ function Logs() {
|
||||
|
||||
const [selectedLog, setSelectedLog] = useState<LogLine>();
|
||||
|
||||
// interaction
|
||||
|
||||
useKeyboardListener(
|
||||
["PageDown", "PageUp", "ArrowDown", "ArrowUp"],
|
||||
(key, down, _) => {
|
||||
if (!down) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case "PageDown":
|
||||
contentRef.current?.scrollBy({
|
||||
top: 480,
|
||||
});
|
||||
break;
|
||||
case "PageUp":
|
||||
contentRef.current?.scrollBy({
|
||||
top: -480,
|
||||
});
|
||||
break;
|
||||
case "ArrowDown":
|
||||
contentRef.current?.scrollBy({
|
||||
top: 48,
|
||||
});
|
||||
break;
|
||||
case "ArrowUp":
|
||||
contentRef.current?.scrollBy({
|
||||
top: -48,
|
||||
});
|
||||
break;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col p-2">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
@@ -346,6 +275,7 @@ function Logs() {
|
||||
onValueChange={(value: LogType) => {
|
||||
if (value) {
|
||||
setLogs([]);
|
||||
setLogLines([]);
|
||||
setFilterSeverity(undefined);
|
||||
setLogService(value);
|
||||
}
|
||||
|
||||
@@ -17,8 +17,6 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
|
||||
import MotionTuner from "@/components/settings/MotionTuner";
|
||||
import MasksAndZones from "@/components/settings/MasksAndZones";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import useOptimisticState from "@/hooks/use-optimistic-state";
|
||||
@@ -26,14 +24,16 @@ import { isMobile } from "react-device-detect";
|
||||
import { FaVideo } from "react-icons/fa";
|
||||
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
||||
import useSWR from "swr";
|
||||
import General from "@/components/settings/General";
|
||||
import FilterSwitch from "@/components/filter/FilterSwitch";
|
||||
import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter";
|
||||
import { PolygonType } from "@/types/canvas";
|
||||
import ObjectSettings from "@/components/settings/ObjectSettings";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import Authentication from "@/components/settings/Authentication";
|
||||
import GeneralSettingsView from "@/views/settings/GeneralSettingsView";
|
||||
import ObjectSettingsView from "@/views/settings/ObjectSettingsView";
|
||||
import MotionTunerView from "@/views/settings/MotionTunerView";
|
||||
import MasksAndZonesView from "@/views/settings/MasksAndZonesView";
|
||||
import AuthenticationView from "@/views/settings/AuthenticationView";
|
||||
|
||||
export default function Settings() {
|
||||
const settingsViews = [
|
||||
@@ -156,22 +156,24 @@ export default function Settings() {
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex h-full w-full flex-col items-start md:h-dvh md:pb-24">
|
||||
{page == "general" && <General />}
|
||||
{page == "debug" && <ObjectSettings selectedCamera={selectedCamera} />}
|
||||
{page == "general" && <GeneralSettingsView />}
|
||||
{page == "debug" && (
|
||||
<ObjectSettingsView selectedCamera={selectedCamera} />
|
||||
)}
|
||||
{page == "masks / zones" && (
|
||||
<MasksAndZones
|
||||
<MasksAndZonesView
|
||||
selectedCamera={selectedCamera}
|
||||
selectedZoneMask={filterZoneMask}
|
||||
setUnsavedChanges={setUnsavedChanges}
|
||||
/>
|
||||
)}
|
||||
{page == "motion tuner" && (
|
||||
<MotionTuner
|
||||
<MotionTunerView
|
||||
selectedCamera={selectedCamera}
|
||||
setUnsavedChanges={setUnsavedChanges}
|
||||
/>
|
||||
)}
|
||||
{page == "users" && <Authentication />}
|
||||
{page == "users" && <AuthenticationView />}
|
||||
</div>
|
||||
{confirmationDialogOpen && (
|
||||
<AlertDialog
|
||||
@@ -282,6 +284,7 @@ function CameraSelectButton({
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
modal={false}
|
||||
open={open}
|
||||
onOpenChange={(open: boolean) => {
|
||||
if (!open) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { IconName } from "@/components/icons/IconPicker";
|
||||
import { LivePlayerMode } from "./live";
|
||||
|
||||
export interface UiConfig {
|
||||
timezone?: string;
|
||||
@@ -7,8 +6,6 @@ export interface UiConfig {
|
||||
date_style?: "full" | "long" | "medium" | "short";
|
||||
time_style?: "full" | "long" | "medium" | "short";
|
||||
strftime_fmt?: string;
|
||||
live_mode?: LivePlayerMode;
|
||||
use_experimental?: boolean;
|
||||
dashboard: boolean;
|
||||
order: number;
|
||||
}
|
||||
|
||||
@@ -30,3 +30,5 @@ export type LiveStreamMetadata = {
|
||||
producers: LiveProducerMetadata[];
|
||||
consumers: LiveConsumerMetadata[];
|
||||
};
|
||||
|
||||
export type LivePlayerError = "stalled" | "startup";
|
||||
|
||||
@@ -11,3 +11,6 @@ export type LogLine = {
|
||||
section: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export const logTypes = ["frigate", "go2rtc", "nginx"] as const;
|
||||
export type LogType = (typeof logTypes)[number];
|
||||
|
||||
@@ -20,6 +20,15 @@ export type ReviewData = {
|
||||
zones: string[];
|
||||
};
|
||||
|
||||
export type SegmentedReviewData =
|
||||
| {
|
||||
all: ReviewSegment[];
|
||||
alert: ReviewSegment[];
|
||||
detection: ReviewSegment[];
|
||||
significant_motion: ReviewSegment[];
|
||||
}
|
||||
| undefined;
|
||||
|
||||
export type ReviewFilter = {
|
||||
cameras?: string[];
|
||||
labels?: string[];
|
||||
|
||||
@@ -269,15 +269,6 @@ export const getUTCOffset = (
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the minute offset in seconds of the current timezone from UTC.
|
||||
* Any timezones with an offset in hours will return 0,
|
||||
* any timezones with an offset of 30 or 45 minutes will return that amount in seconds.
|
||||
*/
|
||||
export function getTimestampOffset(timestamp: number) {
|
||||
return (getUTCOffset(new Date(timestamp * 1000)) % 60) * 60;
|
||||
}
|
||||
|
||||
export function getRangeForTimestamp(timestamp: number) {
|
||||
const date = new Date(timestamp * 1000);
|
||||
date.setMinutes(0, 0, 0);
|
||||
@@ -301,9 +292,7 @@ export function getEndOfDayTimestamp(date: Date) {
|
||||
|
||||
export function isCurrentHour(timestamp: number) {
|
||||
const now = new Date();
|
||||
now.setMinutes(0, 0, 0);
|
||||
now.setUTCMinutes(0, 0, 0);
|
||||
|
||||
const timeShift = getTimestampOffset(timestamp);
|
||||
|
||||
return timestamp + timeShift > now.getTime() / 1000;
|
||||
return timestamp > now.getTime() / 1000;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
FaFire,
|
||||
FaUps,
|
||||
} from "react-icons/fa";
|
||||
import { GiHummingbird } from "react-icons/gi";
|
||||
import { GiDeer, GiHummingbird } from "react-icons/gi";
|
||||
import { LuBox, LuLassoSelect } from "react-icons/lu";
|
||||
import * as LuIcons from "react-icons/lu";
|
||||
import { MdRecordVoiceOver } from "react-icons/md";
|
||||
@@ -38,6 +38,8 @@ export function getIconForLabel(label: string, className?: string) {
|
||||
return <FaCarSide key={label} className={className} />;
|
||||
case "cat":
|
||||
return <FaCat key={label} className={className} />;
|
||||
case "deer":
|
||||
return <GiDeer key={label} className={className} />;
|
||||
case "animal":
|
||||
case "bark":
|
||||
case "dog":
|
||||
|
||||
133
web/src/utils/logUtil.ts
Normal file
133
web/src/utils/logUtil.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { LogLine, LogSeverity, LogType } from "@/types/log";
|
||||
|
||||
const frigateDateStamp = /\[[\d\s-:]*]/;
|
||||
const frigateSeverity = /(DEBUG)|(INFO)|(WARNING)|(ERROR)/;
|
||||
const frigateSection = /[\w.]*/;
|
||||
|
||||
const goSeverity = /(DEB )|(INF )|(WRN )|(ERR )/;
|
||||
const goSection = /\[[\w]*]/;
|
||||
|
||||
const ngSeverity = /(GET)|(POST)|(PUT)|(PATCH)|(DELETE)/;
|
||||
|
||||
export function parseLogLines(logService: LogType, logs: string[]) {
|
||||
if (logService == "frigate") {
|
||||
return logs
|
||||
.map((line) => {
|
||||
const match = frigateDateStamp.exec(line);
|
||||
|
||||
if (!match) {
|
||||
const infoIndex = line.indexOf("[INFO]");
|
||||
|
||||
if (infoIndex != -1) {
|
||||
return {
|
||||
dateStamp: line.substring(0, 19),
|
||||
severity: "info",
|
||||
section: "startup",
|
||||
content: line.substring(infoIndex + 6).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
dateStamp: line.substring(0, 19),
|
||||
severity: "unknown",
|
||||
section: "unknown",
|
||||
content: line.substring(30).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
const sectionMatch = frigateSection.exec(
|
||||
line.substring(match.index + match[0].length).trim(),
|
||||
);
|
||||
|
||||
if (!sectionMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
dateStamp: match.toString().slice(1, -1),
|
||||
severity: frigateSeverity
|
||||
.exec(line)
|
||||
?.at(0)
|
||||
?.toString()
|
||||
?.toLowerCase() as LogSeverity,
|
||||
section: sectionMatch.toString(),
|
||||
content: line
|
||||
.substring(line.indexOf(":", match.index + match[0].length) + 2)
|
||||
.trim(),
|
||||
};
|
||||
})
|
||||
.filter((value) => value != null) as LogLine[];
|
||||
} else if (logService == "go2rtc") {
|
||||
return logs
|
||||
.map((line) => {
|
||||
if (line.length == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const severity = goSeverity.exec(line);
|
||||
|
||||
let section =
|
||||
goSection.exec(line)?.toString()?.slice(1, -1) ?? "startup";
|
||||
|
||||
if (frigateSeverity.exec(section)) {
|
||||
section = "startup";
|
||||
}
|
||||
|
||||
let contentStart;
|
||||
|
||||
if (section == "startup") {
|
||||
if (severity) {
|
||||
contentStart = severity.index + severity[0].length;
|
||||
} else {
|
||||
contentStart = line.lastIndexOf("]") + 1;
|
||||
}
|
||||
} else {
|
||||
contentStart = line.indexOf(section) + section.length + 2;
|
||||
}
|
||||
|
||||
let severityCat: LogSeverity;
|
||||
switch (severity?.at(0)?.toString().trim()) {
|
||||
case "INF":
|
||||
severityCat = "info";
|
||||
break;
|
||||
case "WRN":
|
||||
severityCat = "warning";
|
||||
break;
|
||||
case "ERR":
|
||||
severityCat = "error";
|
||||
break;
|
||||
case "DBG":
|
||||
case "TRC":
|
||||
severityCat = "debug";
|
||||
break;
|
||||
default:
|
||||
severityCat = "info";
|
||||
}
|
||||
|
||||
return {
|
||||
dateStamp: line.substring(0, 19),
|
||||
severity: severityCat,
|
||||
section: section,
|
||||
content: line.substring(contentStart).trim(),
|
||||
};
|
||||
})
|
||||
.filter((value) => value != null) as LogLine[];
|
||||
} else if (logService == "nginx") {
|
||||
return logs
|
||||
.map((line) => {
|
||||
if (line.length == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
dateStamp: line.substring(0, 19),
|
||||
severity: "info",
|
||||
section: ngSeverity.exec(line)?.at(0)?.toString() ?? "META",
|
||||
content: line.substring(line.indexOf(" ", 20)).trim(),
|
||||
};
|
||||
})
|
||||
.filter((value) => value != null) as LogLine[];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user