Merge branch 'dev' into multiselect_icon

This commit is contained in:
Daniel 2023-10-07 15:48:18 -04:00 committed by GitHub
commit 1c755095bd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 1561 additions and 537 deletions

View File

@ -1,4 +1,4 @@
# syntax=docker/dockerfile:1.4
# syntax=docker/dockerfile:1.6
# https://askubuntu.com/questions/972516/debian-frontend-environment-variable
ARG DEBIAN_FRONTEND=noninteractive
@ -199,6 +199,9 @@ ENV S6_LOGGING_SCRIPT="T 1 n0 s10000000 T"
ENTRYPOINT ["/init"]
CMD []
HEALTHCHECK --start-period=120s --start-interval=5s --interval=15s --timeout=5s --retries=3 \
CMD curl --fail --silent --show-error http://127.0.0.1:5000/api/version || exit 1
# Frigate deps with Node.js and NPM for devcontainer
FROM deps AS devcontainer

View File

@ -93,10 +93,6 @@ http {
secure_token $args;
secure_token_types application/vnd.apple.mpegurl;
add_header Access-Control-Allow-Headers '*';
add_header Access-Control-Expose-Headers 'Server,range,Content-Length,Content-Range';
add_header Access-Control-Allow-Methods 'GET, HEAD, OPTIONS';
add_header Access-Control-Allow-Origin '*';
add_header Cache-Control "no-store";
expires off;
}
@ -104,16 +100,6 @@ http {
location /stream/ {
add_header Cache-Control "no-store";
expires off;
add_header 'Access-Control-Allow-Origin' "$http_origin" always;
add_header 'Access-Control-Allow-Credentials' 'true';
add_header 'Access-Control-Expose-Headers' 'Content-Length';
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' "$http_origin";
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain charset=UTF-8';
add_header 'Content-Length' 0;
return 204;
}
types {
application/dash+xml mpd;
@ -126,16 +112,6 @@ http {
}
location /clips/ {
add_header 'Access-Control-Allow-Origin' "$http_origin" always;
add_header 'Access-Control-Allow-Credentials' 'true';
add_header 'Access-Control-Expose-Headers' 'Content-Length';
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' "$http_origin";
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain charset=UTF-8';
add_header 'Content-Length' 0;
return 204;
}
types {
video/mp4 mp4;
@ -152,17 +128,6 @@ http {
}
location /recordings/ {
add_header 'Access-Control-Allow-Origin' "$http_origin" always;
add_header 'Access-Control-Allow-Credentials' 'true';
add_header 'Access-Control-Expose-Headers' 'Content-Length';
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' "$http_origin";
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain charset=UTF-8';
add_header 'Content-Length' 0;
return 204;
}
types {
video/mp4 mp4;
}
@ -173,17 +138,6 @@ http {
}
location /exports/ {
add_header 'Access-Control-Allow-Origin' "$http_origin" always;
add_header 'Access-Control-Allow-Credentials' 'true';
add_header 'Access-Control-Expose-Headers' 'Content-Length';
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' "$http_origin";
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain charset=UTF-8';
add_header 'Content-Length' 0;
return 204;
}
types {
video/mp4 mp4;
}
@ -235,8 +189,6 @@ http {
}
location ~* /api/.*\.(jpg|jpeg|png)$ {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
rewrite ^/api/(.*)$ $1 break;
proxy_pass http://frigate_api;
proxy_pass_request_headers on;
@ -248,10 +200,6 @@ http {
location /api/ {
add_header Cache-Control "no-store";
expires off;
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
proxy_pass http://frigate_api/;
proxy_pass_request_headers on;
proxy_set_header Host $host;

View File

@ -1,4 +1,4 @@
# syntax=docker/dockerfile:1.4
# syntax=docker/dockerfile:1.6
# https://askubuntu.com/questions/972516/debian-frontend-environment-variable
ARG DEBIAN_FRONTEND=noninteractive
@ -24,3 +24,6 @@ COPY --from=trt-deps /usr/local/lib/libyolo_layer.so /usr/local/lib/libyolo_laye
COPY --from=trt-deps /usr/local/src/tensorrt_demos /usr/local/src/tensorrt_demos
COPY docker/tensorrt/detector/rootfs/ /
ENV YOLO_MODELS="yolov7-320"
HEALTHCHECK --start-period=600s --start-interval=5s --interval=15s --timeout=5s --retries=3 \
CMD curl --fail --silent --show-error http://127.0.0.1:5000/api/version || exit 1

View File

@ -50,7 +50,7 @@ cameras:
### Configuring Minimum Volume
The audio detector uses volume levels in the same way that motion in a camera feed is used for object detection. This means that frigate will not run audio detection unless the audio volume is above the configured level in order to reduce resource usage. Audio levels can vary widelely between camera models so it is important to run tests to see what volume levels are. MQTT explorer can be used on the audio topic to see what volume level is being detected.
The audio detector uses volume levels in the same way that motion in a camera feed is used for object detection. This means that frigate will not run audio detection unless the audio volume is above the configured level in order to reduce resource usage. Audio levels can vary widely between camera models so it is important to run tests to see what volume levels are. MQTT explorer can be used on the audio topic to see what volume level is being detected.
:::tip

View File

@ -53,7 +53,7 @@ For camera model specific settings check the [camera specific](camera_specific.m
:::caution
Not every PTZ supports ONVIF, which is the standard protocol Frigate uses to communicate with your camera. Check your camera documentation or manufacturer's website to ensure your camera supports ONVIF. If your camera supports ONVIF and you continue to have trouble, make sure your camera is running the latest firmware.
Not every PTZ supports ONVIF, which is the standard protocol Frigate uses to communicate with your camera. Check the [official list of ONVIF conformant products](https://www.onvif.org/conformant-products/), your camera documentation, or camera manufacturer's website to ensure your PTZ supports ONVIF. Also, ensure your camera is running the latest firmware.
:::
@ -78,12 +78,17 @@ An ONVIF-capable camera that supports relative movement within the field of view
This list of working and non-working PTZ cameras is based on user feedback.
| Brand or specific camera | PTZ Controls | Autotracking | Notes |
| ------------------------ | :----------: | :----------: | ------------------------------------------------------- |
| Amcrest | ✅ | ⛔️ | Some older models (IP2M-841) don't support autotracking |
| Amcrest ASH21 | ❌ | ❌ | No ONVIF support |
| Dahua | ✅ | ✅ |
| Reolink 511WA | ✅ | ❌ | Zoom only |
| Reolink E1 Zoom | ✅ | ❌ | |
| Tapo C210 | ❌ | ❌ | Incomplete ONVIF support |
| Vikylin PTZ-2804X-I2 | ❌ | ❌ | Incomplete ONVIF support |
| Brand or specific camera | PTZ Controls | Autotracking | Notes |
| ------------------------ | :----------: | :----------: | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| Amcrest | ✅ | ✅ | ⛔️ Generally, Amcrest should work, but some older models (like the common IP2M-841) don't support autotracking |
| Amcrest ASH21 | ❌ | ❌ | No ONVIF support |
| Ctronics PTZ | ✅ | ❌ | |
| Dahua | ✅ | ✅ | |
| Foscam R5 | ✅ | ❌ | |
| Hikvision | ✅ | ❌ | Incomplete ONVIF support (MoveStatus won't update even on latest firmware) - reported with HWP-N4215IH-DE and DS-2DE3304W-DE, but likely others |
| Reolink 511WA | ✅ | ❌ | Zoom only |
| Reolink E1 Pro | ✅ | ❌ | |
| Reolink E1 Zoom | ✅ | ❌ | |
| Sunba 405-D20X | ✅ | ❌ | |
| Tapo C210 | ❌ | ❌ | Incomplete ONVIF support |
| Vikylin PTZ-2804X-I2 | ❌ | ❌ | Incomplete ONVIF support |

View File

@ -527,7 +527,7 @@ cameras:
# Required: List of x,y coordinates to define the polygon of the zone.
# NOTE: Presence in a zone is evaluated only based on the bottom center of the objects bounding box.
coordinates: 545,1077,747,939,788,805
# Optional: Number of consecutive frames required for object to be considered present in the zone. Allowed values are 1-10 (default: shown below)
# Optional: Number of consecutive frames required for object to be considered present in the zone (default: shown below).
inertia: 3
# Optional: List of objects that can trigger this zone (default: all tracked objects)
objects:

View File

@ -56,3 +56,27 @@ camera:
```
Only car objects can trigger the `front_yard_street` zone and only person can trigger the `entire_yard`. You will get events for person objects that enter anywhere in the yard, and events for cars only if they enter the street.
### Zone Inertia
Sometimes an objects bounding box may be slightly incorrect and the bottom center of the bounding box is inside the zone while the object is not actually in the zone. Zone inertia helps guard against this by requiring an object's bounding box to be within the zone for multiple consecutive frames. This value can be configured:
```yaml
camera:
zones:
front_yard:
inertia: 3
objects:
- person
```
There may also be cases where you expect an object to quickly enter and exit a zone, like when a car is pulling into the driveway, and you may want to have the object be considered present in the zone immediately:
```yaml
camera:
zones:
driveway_entrance:
inertia: 1
objects:
- car
```

View File

@ -155,10 +155,6 @@ cd web && npm install
cd web && npm run dev
```
#### 3a. Run the development server against a non-local instance
To run the development server against a non-local instance, you will need to modify the API_HOST default return in `web/src/env.js`.
#### 4. Making changes
The Web UI is built using [Vite](https://vitejs.dev/), [Preact](https://preactjs.com), and [Tailwind CSS](https://tailwindcss.com).

View File

@ -204,6 +204,8 @@ It is recommended to run Frigate in LXC for maximum performance. See [this discu
For details on running Frigate using ESXi, please see the instructions [here](https://williamlam.com/2023/05/frigate-nvr-with-coral-tpu-igpu-passthrough-using-esxi-on-intel-nuc.html).
If you're running Frigate on a rack mounted server and want to passthough the Google Coral, [read this.](https://github.com/blakeblackshear/frigate/issues/305)
## Synology NAS on DSM 7
These settings were tested on DSM 7.1.1-42962 Update 4

View File

@ -155,20 +155,23 @@ Version info
Events from the database. Accepts the following query string parameters:
| param | Type | Description |
| -------------------- | ---- | ----------------------------------------------- |
| `before` | int | Epoch time |
| `after` | int | Epoch time |
| `cameras` | str | , separated list of cameras |
| `labels` | str | , separated list of labels |
| `zones` | str | , separated list of zones |
| `limit` | int | Limit the number of events returned |
| `has_snapshot` | int | Filter to events that have snapshots (0 or 1) |
| `has_clip` | int | Filter to events that have clips (0 or 1) |
| `include_thumbnails` | int | Include thumbnails in the response (0 or 1) |
| `in_progress` | int | Limit to events in progress (0 or 1) |
| `time_range` | str | Time range in format after,before (00:00,24:00) |
| `timezone` | str | Timezone to use for time range |
| param | Type | Description |
| -------------------- | ----- | ----------------------------------------------------- |
| `before` | int | Epoch time |
| `after` | int | Epoch time |
| `cameras` | str | , separated list of cameras |
| `labels` | str | , separated list of labels |
| `zones` | str | , separated list of zones |
| `limit` | int | Limit the number of events returned |
| `has_snapshot` | int | Filter to events that have snapshots (0 or 1) |
| `has_clip` | int | Filter to events that have clips (0 or 1) |
| `include_thumbnails` | int | Include thumbnails in the response (0 or 1) |
| `in_progress` | int | Limit to events in progress (0 or 1) |
| `time_range` | str | Time range in format after,before (00:00,24:00) |
| `timezone` | str | Timezone to use for time range |
| `min_score` | float | Minimum score of the event |
| `max_score` | float | Maximum score of the event |
| `is_submitted` | int | Filter events that are submitted to Frigate+ (0 or 1) |
### `GET /api/timeline`

View File

@ -163,7 +163,7 @@ class AudioEventMaintainer(threading.Thread):
self.recordings_info_queue = recordings_info_queue
self.feature_metrics = feature_metrics
self.inter_process_communicator = inter_process_communicator
self.detections: dict[dict[str, any]] = feature_metrics
self.detections: dict[dict[str, any]] = {}
self.stop_event = stop_event
self.detector = AudioTfl(stop_event, self.config.audio.num_threads)
self.shape = (int(round(AUDIO_DURATION * AUDIO_SAMPLE_RATE)),)
@ -196,6 +196,8 @@ class AudioEventMaintainer(threading.Thread):
model_detections = self.detector.detect(waveform)
for label, score, _ in model_detections:
logger.debug(f"Heard {label} with a score of {score}")
if label not in self.config.audio.listen:
continue

View File

@ -42,6 +42,9 @@ def should_update_state(prev_event: Event, current_event: Event) -> bool:
if prev_event["stationary"] != current_event["stationary"]:
return True
if prev_event["attributes"] != current_event["attributes"]:
return True
return False

View File

@ -20,6 +20,7 @@ from flask import (
Flask,
Response,
current_app,
escape,
jsonify,
make_response,
request,
@ -28,6 +29,7 @@ from peewee import DoesNotExist, fn, operator
from playhouse.shortcuts import model_to_dict
from playhouse.sqliteq import SqliteQueueDatabase
from tzlocal import get_localzone_name
from werkzeug.utils import secure_filename
from frigate.config import FrigateConfig
from frigate.const import (
@ -73,6 +75,13 @@ def create_app(
):
app = Flask(__name__)
@app.before_request
def check_csrf():
if request.method in ["GET", "HEAD", "OPTIONS", "TRACE"]:
pass
if "origin" in request.headers and "x-csrf-token" not in request.headers:
return jsonify({"success": False, "message": "Missing CSRF header"}), 401
@app.before_request
def _db_connect():
if database.is_closed():
@ -532,10 +541,14 @@ def event_thumbnail(id, max_cache_age=2592000):
if tracked_obj is not None:
thumbnail_bytes = tracked_obj.get_thumbnail()
except Exception:
return "Event not found", 404
return make_response(
jsonify({"success": False, "message": "Event not found"}), 404
)
if thumbnail_bytes is None:
return "Event not found", 404
return make_response(
jsonify({"success": False, "message": "Event not found"}), 404
)
# android notifications prefer a 2:1 ratio
if format == "android":
@ -630,7 +643,9 @@ def event_snapshot(id):
event = Event.get(Event.id == id, Event.end_time != None)
event_complete = True
if not event.has_snapshot:
return "Snapshot not available", 404
return make_response(
jsonify({"success": False, "message": "Snapshot not available"}), 404
)
# read snapshot from disk
with open(
os.path.join(CLIPS_DIR, f"{event.camera}-{id}.jpg"), "rb"
@ -652,12 +667,18 @@ def event_snapshot(id):
quality=request.args.get("quality", default=70, type=int),
)
except Exception:
return "Event not found", 404
return make_response(
jsonify({"success": False, "message": "Event not found"}), 404
)
except Exception:
return "Event not found", 404
return make_response(
jsonify({"success": False, "message": "Event not found"}), 404
)
if jpg_bytes is None:
return "Event not found", 404
return make_response(
jsonify({"success": False, "message": "Event not found"}), 404
)
response = make_response(jpg_bytes)
response.headers["Content-Type"] = "image/jpeg"
@ -710,10 +731,14 @@ def event_clip(id):
try:
event: Event = Event.get(Event.id == id)
except DoesNotExist:
return "Event not found.", 404
return make_response(
jsonify({"success": False, "message": "Event not found"}), 404
)
if not event.has_clip:
return "Clip not available", 404
return make_response(
jsonify({"success": False, "message": "Clip not available"}), 404
)
file_name = f"{event.camera}-{id}.mp4"
clip_path = os.path.join(CLIPS_DIR, file_name)
@ -777,6 +802,9 @@ def events():
in_progress = request.args.get("in_progress", type=int)
include_thumbnails = request.args.get("include_thumbnails", default=1, type=int)
favorites = request.args.get("favorites", type=int)
min_score = request.args.get("min_score", type=float)
max_score = request.args.get("max_score", type=float)
is_submitted = request.args.get("is_submitted", type=int)
clauses = []
@ -899,6 +927,18 @@ def events():
if favorites:
clauses.append((Event.retain_indefinitely == favorites))
if max_score is not None:
clauses.append((Event.data["score"] <= max_score))
if min_score is not None:
clauses.append((Event.data["score"] >= min_score))
if is_submitted is not None:
if is_submitted == 0:
clauses.append((Event.plus_id.is_null()))
else:
clauses.append((Event.plus_id != ""))
if len(clauses) == 0:
clauses.append((True))
@ -1019,7 +1059,9 @@ def config_raw():
config_file = config_file_yaml
if not os.path.isfile(config_file):
return "Could not find file", 410
return make_response(
jsonify({"success": False, "message": "Could not find file"}), 404
)
with open(config_file, "r") as f:
raw_config = f.read()
@ -1035,7 +1077,12 @@ def config_save():
new_config = request.get_data().decode()
if not new_config:
return "Config with body param is required", 400
return make_response(
jsonify(
{"success": False, "message": "Config with body param is required"}
),
400,
)
# Validate the config schema
try:
@ -1045,7 +1092,7 @@ def config_save():
jsonify(
{
"success": False,
"message": f"\nConfig Error:\n\n{str(traceback.format_exc())}",
"message": f"\nConfig Error:\n\n{escape(str(traceback.format_exc()))}",
}
),
400,
@ -1080,14 +1127,30 @@ def config_save():
restart_frigate()
except Exception as e:
logging.error(f"Error restarting Frigate: {e}")
return "Config successfully saved, unable to restart Frigate", 200
return make_response(
jsonify(
{
"success": True,
"message": "Config successfully saved, unable to restart Frigate",
}
),
200,
)
return (
"Config successfully saved, restarting (this can take up to one minute)...",
return make_response(
jsonify(
{
"success": True,
"message": "Config successfully saved, restarting (this can take up to one minute)...",
}
),
200,
)
else:
return "Config successfully saved.", 200
return make_response(
jsonify({"success": True, "message": "Config successfully saved."}),
200,
)
@bp.route("/config/set", methods=["PUT"])
@ -1127,9 +1190,20 @@ def config_set():
)
except Exception as e:
logging.error(f"Error updating config: {e}")
return "Error updating config", 500
return make_response(
jsonify({"success": False, "message": "Error updating config"}),
500,
)
return "Config successfully updated, restart to apply", 200
return make_response(
jsonify(
{
"success": True,
"message": "Config successfully updated, restart to apply",
}
),
200,
)
@bp.route("/config/schema.json")
@ -1179,7 +1253,10 @@ def mjpeg_feed(camera_name):
mimetype="multipart/x-mixed-replace; boundary=frame",
)
else:
return "Camera named {} not found".format(camera_name), 404
return make_response(
jsonify({"success": False, "message": "Camera not found"}),
404,
)
@bp.route("/<camera_name>/ptz/info")
@ -1187,7 +1264,10 @@ def camera_ptz_info(camera_name):
if camera_name in current_app.frigate_config.cameras:
return jsonify(current_app.onvif.get_camera_info(camera_name))
else:
return "Camera named {} not found".format(camera_name), 404
return make_response(
jsonify({"success": False, "message": "Camera not found"}),
404,
)
@bp.route("/<camera_name>/latest.jpg")
@ -1229,7 +1309,10 @@ def latest_frame(camera_name):
width = int(height * frame.shape[1] / frame.shape[0])
if frame is None:
return "Unable to get valid frame from {}".format(camera_name), 500
return make_response(
jsonify({"success": False, "message": "Unable to get valid frame"}),
500,
)
if height < 1 or width < 1:
return (
@ -1265,7 +1348,10 @@ def latest_frame(camera_name):
response.headers["Cache-Control"] = "no-store"
return response
else:
return "Camera named {} not found".format(camera_name), 404
return make_response(
jsonify({"success": False, "message": "Camera not found"}),
404,
)
@bp.route("/<camera_name>/recordings/<frame_time>/snapshot.png")
@ -1280,7 +1366,10 @@ def get_snapshot_from_recording(camera_name: str, frame_time: str):
Recordings.start_time,
)
.where(
((frame_time > Recordings.start_time) & (frame_time < Recordings.end_time))
(
(frame_time >= Recordings.start_time)
& (frame_time <= Recordings.end_time)
)
)
.where(Recordings.camera == camera_name)
)
@ -1315,7 +1404,15 @@ def get_snapshot_from_recording(camera_name: str, frame_time: str):
response.headers["Content-Type"] = "image/png"
return response
except DoesNotExist:
return "Recording not found for {} at {}".format(camera_name, frame_time), 404
return make_response(
jsonify(
{
"success": False,
"message": "Recording not found at {}".format(frame_time),
}
),
404,
)
@bp.route("/recordings/storage", methods=["GET"])
@ -1517,7 +1614,15 @@ def recording_clip(camera_name, start_ts, end_ts):
if p.returncode != 0:
logger.error(p.stderr)
return f"Could not create clip from recordings for {camera_name}.", 500
return make_response(
jsonify(
{
"success": False,
"message": "Could not create clip from recordings",
}
),
500,
)
else:
logger.debug(
f"Ignoring subsequent request for {path} as it already exists in the cache."
@ -1573,7 +1678,15 @@ def vod_ts(camera_name, start_ts, end_ts):
if not clips:
logger.error("No recordings found for the requested time range")
return "No recordings found.", 404
return make_response(
jsonify(
{
"success": False,
"message": "No recordings found.",
}
),
404,
)
hour_ago = datetime.now() - timedelta(hours=1)
return jsonify(
@ -1616,11 +1729,27 @@ def vod_event(id):
event: Event = Event.get(Event.id == id)
except DoesNotExist:
logger.error(f"Event not found: {id}")
return "Event not found.", 404
return make_response(
jsonify(
{
"success": False,
"message": "Event not found.",
}
),
404,
)
if not event.has_clip:
logger.error(f"Event does not have recordings: {id}")
return "Recordings not available", 404
return make_response(
jsonify(
{
"success": False,
"message": "Recordings not available.",
}
),
404,
)
clip_path = os.path.join(CLIPS_DIR, f"{event.camera}-{id}.mp4")
@ -1697,12 +1826,21 @@ def export_recording(camera_name: str, start_time, end_time):
else PlaybackFactorEnum.realtime,
)
exporter.start()
return "Starting export of recording", 200
return make_response(
jsonify(
{
"success": True,
"message": "Starting export of recording.",
}
),
200,
)
@bp.route("/export/<file_name>", methods=["DELETE"])
def export_delete(file_name: str):
file = os.path.join(EXPORT_DIR, file_name)
safe_file_name = secure_filename(file_name)
file = os.path.join(EXPORT_DIR, safe_file_name)
if not os.path.exists(file):
return make_response(
@ -1711,7 +1849,15 @@ def export_delete(file_name: str):
)
os.unlink(file)
return "Successfully deleted file", 200
return make_response(
jsonify(
{
"success": True,
"message": "Successfully deleted file.",
}
),
200,
)
def imagestream(detected_frames_processor, camera_name, fps, height, draw_options):
@ -1811,8 +1957,11 @@ def logs(service: str):
}
service_location = log_locations.get(service)
if not service:
return f"{service} is not a valid service", 404
if not service_location:
return make_response(
jsonify({"success": False, "message": "Not a valid service"}),
404,
)
try:
file = open(service_location, "r")
@ -1820,4 +1969,7 @@ def logs(service: str):
file.close()
return contents, 200
except FileNotFoundError as e:
return f"Could not find log file: {e}", 500
return make_response(
jsonify({"success": False, "message": f"Could not find log file: {e}"}),
500,
)

View File

@ -232,6 +232,9 @@ class TrackedObject:
if self.obj_data["position_changes"] != obj_data["position_changes"]:
significant_change = True
if self.obj_data["attributes"] != obj_data["attributes"]:
significant_change = True
# if the motionless_count reaches the stationary threshold
if (
self.obj_data["motionless_count"]

View File

@ -60,7 +60,7 @@ def get_canvas_shape(width: int, height: int) -> tuple[int, int]:
if round(a_w / a_h, 2) != round(width / height, 2):
canvas_width = width
canvas_height = (canvas_width / a_w) * a_h
canvas_height = int((canvas_width / a_w) * a_h)
logger.warning(
f"The birdseye resolution is a non-standard aspect ratio, forcing birdseye resolution to {canvas_width} x {canvas_height}"
)

View File

@ -237,13 +237,12 @@ class PtzAutoTracker:
self.onvif.get_camera_status(camera_name)
# movement thread per camera
if not self.move_threads or not self.move_threads[camera_name]:
self.move_threads[camera_name] = threading.Thread(
name=f"move_thread_{camera_name}",
target=partial(self._process_move_queue, camera_name),
)
self.move_threads[camera_name].daemon = True
self.move_threads[camera_name].start()
self.move_threads[camera_name] = threading.Thread(
name=f"move_thread_{camera_name}",
target=partial(self._process_move_queue, camera_name),
)
self.move_threads[camera_name].daemon = True
self.move_threads[camera_name].start()
if cam.onvif.autotracking.movement_weights:
self.intercept[camera_name] = cam.onvif.autotracking.movement_weights[0]

View File

@ -131,18 +131,24 @@ class OnvifController:
# try setting relative zoom translation space
try:
if self.config.cameras[camera_name].onvif.autotracking.zooming:
if (
self.config.cameras[camera_name].onvif.autotracking.zooming
== ZoomingModeEnum.relative
):
if zoom_space_id is not None:
move_request.Translation.Zoom.space = ptz_config["Spaces"][
"RelativeZoomTranslationSpace"
][0]["URI"]
except Exception:
if self.config.cameras[camera_name].onvif.autotracking.zoom_relative:
if (
self.config.cameras[camera_name].onvif.autotracking.zooming
== ZoomingModeEnum.relative
):
self.config.cameras[
camera_name
].onvif.autotracking.zoom_relative = False
].onvif.autotracking.zooming = ZoomingModeEnum.disabled
logger.warning(
f"Disabling autotracking zooming for {camera_name}: Absolute zoom not supported"
f"Disabling autotracking zooming for {camera_name}: Relative zoom not supported"
)
if move_request.Speed is None:
@ -169,7 +175,9 @@ class OnvifController:
presets = []
for preset in presets:
self.cams[camera_name]["presets"][preset["Name"].lower()] = preset["token"]
self.cams[camera_name]["presets"][
preset.get("Name", f"preset {preset['token']}").lower()
] = preset["token"]
# get list of supported features
ptz_config = ptz.GetConfigurationOptions(request)

View File

@ -50,7 +50,7 @@ class RecordingExporter(threading.Thread):
def get_datetime_from_timestamp(self, timestamp: int) -> str:
"""Convenience fun to get a simple date time from timestamp."""
return datetime.datetime.fromtimestamp(timestamp).strftime("%Y_%m_%d_%H:%M")
return datetime.datetime.fromtimestamp(timestamp).strftime("%Y_%m_%d_%H_%M")
def run(self) -> None:
logger.debug(

View File

@ -74,6 +74,7 @@ class TimelineProcessor(threading.Thread):
camera_config.detect.height,
event_data["region"],
),
"attribute": "",
},
}
if event_type == "start":
@ -93,6 +94,12 @@ class TimelineProcessor(threading.Thread):
"stationary" if event_data["stationary"] else "active"
)
Timeline.insert(timeline_entry).execute()
elif prev_event_data["attributes"] == {} and event_data["attributes"] != {}:
timeline_entry[Timeline.class_type] = "attribute"
timeline_entry[Timeline.data]["attribute"] = list(
event_data["attributes"].keys()
)[0]
Timeline.insert(timeline_entry).execute()
elif event_type == "end":
timeline_entry[Timeline.class_type] = "gone"
Timeline.insert(timeline_entry).execute()

View File

@ -87,7 +87,8 @@ def load_config_with_no_duplicates(raw_config) -> dict:
"""Get config ensuring duplicate keys are not allowed."""
# https://stackoverflow.com/a/71751051
class PreserveDuplicatesLoader(yaml.loader.Loader):
# important to use SafeLoader here to avoid RCE
class PreserveDuplicatesLoader(yaml.loader.SafeLoader):
pass
def map_constructor(loader, node, deep=False):

View File

@ -20,7 +20,6 @@
},
"ignorePatterns": ["*.d.ts"],
"rules": {
"indent": ["error", 2, { "SwitchCase": 1 }],
"comma-dangle": [
"error",
{

View File

@ -1,4 +1,5 @@
{
"printWidth": 120,
"trailingComma": "es5",
"singleQuote": true
}

View File

@ -1,8 +1,8 @@
import { rest } from 'msw';
import { API_HOST } from '../src/env';
// import { API_HOST } from '../src/env';
export const handlers = [
rest.get(`${API_HOST}api/config`, (req, res, ctx) => {
rest.get(`api/config`, (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
@ -37,7 +37,7 @@ export const handlers = [
})
);
}),
rest.get(`${API_HOST}api/stats`, (req, res, ctx) => {
rest.get(`api/stats`, (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
@ -58,7 +58,7 @@ export const handlers = [
})
);
}),
rest.get(`${API_HOST}api/events`, (req, res, ctx) => {
rest.get(`api/events`, (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json(
@ -77,7 +77,7 @@ export const handlers = [
)
);
}),
rest.get(`${API_HOST}api/sub_labels`, (req, res, ctx) => {
rest.get(`api/sub_labels`, (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json([

1290
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -45,6 +45,7 @@
"eslint-config-preact": "^1.3.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-jest": "^27.2.3",
"eslint-plugin-prettier": "^5.0.0",
"eslint-plugin-vitest-globals": "^1.4.0",
"fake-indexeddb": "^4.0.1",
"jsdom": "^22.0.0",

View File

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

View File

@ -18,6 +18,6 @@ describe('useApiHost', () => {
<Test />
</ApiProvider>
);
expect(screen.queryByText('http://base-url.local:5000/')).toBeInTheDocument();
expect(screen.queryByText('http://localhost:3000/')).toBeInTheDocument();
});
});

View File

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

View File

@ -5,6 +5,9 @@ import { WsProvider } from './ws';
import axios from 'axios';
axios.defaults.baseURL = `${baseUrl}api/`;
axios.defaults.headers.common = {
'X-CSRF-TOKEN': 1,
};
export function ApiProvider({ children, options }) {
return (

View File

@ -58,7 +58,7 @@ export default function CameraImage({ camera, onload, searchParams = '', stretch
if (!config || scaledHeight === 0 || !canvasRef.current) {
return;
}
img.src = `${apiHost}/api/${name}/latest.jpg?h=${scaledHeight}${searchParams ? `&${searchParams}` : ''}`;
img.src = `${apiHost}api/${name}/latest.jpg?h=${scaledHeight}${searchParams ? `&${searchParams}` : ''}`;
}, [apiHost, canvasRef, name, img, searchParams, scaledHeight, config]);
return (

View File

@ -56,10 +56,10 @@ export const HistoryVideo = ({
}
video.src({
src: `${apiHost}/vod/event/${id}/master.m3u8`,
src: `${apiHost}vod/event/${id}/master.m3u8`,
type: 'application/vnd.apple.mpegurl',
});
video.poster(`${apiHost}/api/events/${id}/snapshot.jpg`);
video.poster(`${apiHost}api/events/${id}/snapshot.jpg`);
if (videoIsPlaying) {
video.play();
}

View File

@ -60,7 +60,6 @@ export default function MultiSelect({ className, title, options, selection, onTo
onClick={() => onSelectSingle(item)}
>
{ ( <SelectOnlyIcon /> ) }
</Button>
</div>
</div>

View File

@ -153,7 +153,7 @@ export function EventCard({ camera, event }) {
<Link className="" href={`/recording/${camera}/${format(start, 'yyyy-MM-dd/HH/mm/ss')}`}>
<div className="flex flex-row mb-2">
<div className="w-28 mr-4">
<img className="antialiased" loading="lazy" src={`${apiHost}/api/events/${event.id}/thumbnail.jpg`} />
<img className="antialiased" loading="lazy" src={`${apiHost}api/events/${event.id}/thumbnail.jpg`} />
</div>
<div className="flex flex-row w-full border-b">
<div className="w-full text-gray-700 font-semibold relative pt-0">

View File

@ -7,7 +7,10 @@ import ActiveObjectIcon from '../icons/ActiveObject';
import PlayIcon from '../icons/Play';
import ExitIcon from '../icons/Exit';
import StationaryObjectIcon from '../icons/StationaryObject';
import { Zone } from '../icons/Zone';
import FaceIcon from '../icons/Face';
import LicensePlateIcon from '../icons/LicensePlate';
import DeliveryTruckIcon from '../icons/DeliveryTruck';
import ZoneIcon from '../icons/Zone';
import { useMemo, useState } from 'preact/hooks';
import Button from './Button';
@ -88,7 +91,7 @@ export default function TimelineSummary({ event, onFrameSelected }) {
aria-label={window.innerWidth > 640 ? getTimelineItemDescription(config, item, event) : ''}
onClick={() => onSelectMoment(index)}
>
{getTimelineIcon(item.class_type)}
{getTimelineIcon(item)}
</Button>
))}
</div>
@ -113,8 +116,8 @@ export default function TimelineSummary({ event, onFrameSelected }) {
);
}
function getTimelineIcon(classType) {
switch (classType) {
function getTimelineIcon(timelineItem) {
switch (timelineItem.class_type) {
case 'visible':
return <PlayIcon className="w-8" />;
case 'gone':
@ -124,7 +127,16 @@ function getTimelineIcon(classType) {
case 'stationary':
return <StationaryObjectIcon className="w-8" />;
case 'entered_zone':
return <Zone className="w-8" />;
return <ZoneIcon className="w-8" />;
case 'attribute':
switch (timelineItem.data.attribute) {
case 'face':
return <FaceIcon className="w-8" />;
case 'license_plate':
return <LicensePlateIcon className="w-8" />;
default:
return <DeliveryTruckIcon className="w-8" />;
}
}
}
@ -156,6 +168,15 @@ function getTimelineItemDescription(config, timelineItem, event) {
time_style: 'medium',
time_format: config.ui.time_format,
})}`;
case 'attribute':
return `${timelineItem.data.attribute.replaceAll("_", " ")} detected for ${event.label} at ${formatUnixTimestampToDateTime(
timelineItem.timestamp,
{
date_style: 'short',
time_style: 'medium',
time_format: config.ui.time_format,
}
)}`;
case 'gone':
return `${event.label} left at ${formatUnixTimestampToDateTime(timelineItem.timestamp, {
date_style: 'short',

View File

@ -1,2 +1 @@
export const ENV = import.meta.env.MODE;
export const API_HOST = ENV === 'production' ? '' : 'http://localhost:5000/';
export const ENV = import.meta.env.MODE;

View File

@ -0,0 +1,15 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function StationaryObject({ className = '' }) {
return (
<svg className={`fill-current ${className}`} viewBox="0 -960 960 960">
<path
fill="currentColor"
d="M240-160q-50 0-85-35t-35-85H40v-440q0-33 23.5-56.5T120-800h560v160h120l120 160v200h-80q0 50-35 85t-85 35q-50 0-85-35t-35-85H360q0 50-35 85t-85 35Zm0-80q17 0 28.5-11.5T280-280q0-17-11.5-28.5T240-320q-17 0-28.5 11.5T200-280q0 17 11.5 28.5T240-240ZM120-360h32q17-18 39-29t49-11q27 0 49 11t39 29h272v-360H120v360Zm600 120q17 0 28.5-11.5T760-280q0-17-11.5-28.5T720-320q-17 0-28.5 11.5T680-280q0 17 11.5 28.5T720-240Zm-40-200h170l-90-120h-80v120ZM360-540Z"
/>
</svg>
);
}
export default memo(StationaryObject);

22
web/src/icons/Face.jsx Normal file
View File

@ -0,0 +1,22 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function Zone({ className = 'h-6 w-6', stroke = 'currentColor', fill = 'none', onClick = () => {} }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
fill={fill}
viewBox="0 -960 960 960"
stroke={stroke}
onClick={onClick}
>
<path
fill="currentColor"
d="M360-390q-21 0-35.5-14.5T310-440q0-21 14.5-35.5T360-490q21 0 35.5 14.5T410-440q0 21-14.5 35.5T360-390Zm240 0q-21 0-35.5-14.5T550-440q0-21 14.5-35.5T600-490q21 0 35.5 14.5T650-440q0 21-14.5 35.5T600-390ZM480-160q134 0 227-93t93-227q0-24-3-46.5T786-570q-21 5-42 7.5t-44 2.5q-91 0-172-39T390-708q-32 78-91.5 135.5T160-486v6q0 134 93 227t227 93Zm0 80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm-54-715q42 70 114 112.5T700-640q14 0 27-1.5t27-3.5q-42-70-114-112.5T480-800q-14 0-27 1.5t-27 3.5ZM177-581q51-29 89-75t57-103q-51 29-89 75t-57 103Zm249-214Zm-103 36Z"
/>
</svg>
);
}
export default memo(Zone);

View File

@ -0,0 +1,15 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function StationaryObject({ className = '' }) {
return (
<svg className={`fill-current ${className}`} viewBox="0 -960 960 960">
<path
fill="currentColor"
d="M400-280h360v-240H400v240ZM160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h640q33 0 56.5 23.5T880-720v480q0 33-23.5 56.5T800-160H160Zm0-80h640v-480H160v480Zm0 0v-480 480Z"
/>
</svg>
);
}
export default memo(StationaryObject);

View File

@ -212,7 +212,7 @@ export default function Camera({ camera }) {
key={objectType}
header={objectType}
href={`/events?cameras=${camera}&labels=${encodeURIComponent(objectType)}`}
media={<img src={`${apiHost}/api/${camera}/${encodeURIComponent(objectType)}/thumbnail.jpg`} />}
media={<img src={`${apiHost}api/${camera}/${encodeURIComponent(objectType)}/thumbnail.jpg`} />}
/>
))}
</div>

View File

@ -30,8 +30,8 @@ export default function CameraMasks({ camera }) {
Array.isArray(motionMask)
? motionMask.map((mask) => getPolylinePoints(mask))
: motionMask
? [getPolylinePoints(motionMask)]
: []
? [getPolylinePoints(motionMask)]
: []
);
const [zonePoints, setZonePoints] = useState(
@ -45,8 +45,8 @@ export default function CameraMasks({ camera }) {
[name]: Array.isArray(objectFilters[name].mask)
? objectFilters[name].mask.map((mask) => getPolylinePoints(mask))
: objectFilters[name].mask
? [getPolylinePoints(objectFilters[name].mask)]
: [],
? [getPolylinePoints(objectFilters[name].mask)]
: [],
}),
{}
)
@ -146,7 +146,6 @@ export default function CameraMasks({ camera }) {
}
}, [camera, motionMaskPoints]);
// Zone methods
const handleEditZone = useCallback(
(key) => {
@ -175,9 +174,11 @@ export default function CameraMasks({ camera }) {
const handleCopyZones = useCallback(async () => {
const textToCopy = ` zones:
${Object.keys(zonePoints)
.map(
(zoneName) => ` ${zoneName}:
coordinates: ${polylinePointsToPolyline(zonePoints[zoneName])}`).join('\n')}`;
.map(
(zoneName) => ` ${zoneName}:
coordinates: ${polylinePointsToPolyline(zonePoints[zoneName])}`
)
.join('\n')}`;
if (window.navigator.clipboard && window.navigator.clipboard.writeText) {
// Use Clipboard API if available
@ -207,7 +208,10 @@ ${Object.keys(zonePoints)
const handleSaveZones = useCallback(async () => {
try {
const queryParameters = Object.keys(zonePoints)
.map((zoneName) => `cameras.${camera}.zones.${zoneName}.coordinates=${polylinePointsToPolyline(zonePoints[zoneName])}`)
.map(
(zoneName) =>
`cameras.${camera}.zones.${zoneName}.coordinates=${polylinePointsToPolyline(zonePoints[zoneName])}`
)
.join('&');
const endpoint = `config/set?${queryParameters}`;
const response = await axios.put(endpoint);
@ -252,21 +256,26 @@ ${Object.keys(zonePoints)
await window.navigator.clipboard.writeText(` objects:
filters:
${Object.keys(objectMaskPoints)
.map((objectName) =>
objectMaskPoints[objectName].length
? ` ${objectName}:
.map((objectName) =>
objectMaskPoints[objectName].length
? ` ${objectName}:
mask: ${polylinePointsToPolyline(objectMaskPoints[objectName])}`
: ''
)
.filter(Boolean)
.join('\n')}`);
: ''
)
.filter(Boolean)
.join('\n')}`);
}, [objectMaskPoints]);
const handleSaveObjectMasks = useCallback(async () => {
try {
const queryParameters = Object.keys(objectMaskPoints)
.filter((objectName) => objectMaskPoints[objectName].length > 0)
.map((objectName, index) => `cameras.${camera}.objects.filters.${objectName}.mask.${index}=${polylinePointsToPolyline(objectMaskPoints[objectName])}`)
.map(
(objectName, index) =>
`cameras.${camera}.objects.filters.${objectName}.mask.${index}=${polylinePointsToPolyline(
objectMaskPoints[objectName]
)}`
)
.join('&');
const endpoint = `config/set?${queryParameters}`;
const response = await axios.put(endpoint);
@ -324,8 +333,8 @@ ${Object.keys(objectMaskPoints)
<Card
content={
<p>
When done, copy each mask configuration into your <code className="font-mono">config.yml</code> file
restart your Frigate instance to save your changes.
When done, copy each mask configuration into your <code className="font-mono">config.yml</code> file restart
your Frigate instance to save your changes.
</p>
}
header="Warning"
@ -336,7 +345,7 @@ ${Object.keys(objectMaskPoints)
<div className="space-y-4">
<div className="relative">
<img ref={imageRef} src={`${apiHost}/api/${camera}/latest.jpg`} />
<img ref={imageRef} src={`${apiHost}api/${camera}/latest.jpg`} />
<EditableMask
onChange={handleUpdateEditable}
points={'subkey' in editing ? editing.set[editing.key][editing.subkey] : editing.set[editing.key]}
@ -487,16 +496,16 @@ function EditableMask({ onChange, points, scale, snap, width, height }) {
{!scaledPoints
? null
: scaledPoints.map(([x, y], i) => (
<PolyPoint
key={i}
boundingRef={boundingRef}
index={i}
onMove={handleMovePoint}
onRemove={handleRemovePoint}
x={x + MaskInset}
y={y + MaskInset}
/>
))}
<PolyPoint
key={i}
boundingRef={boundingRef}
index={i}
onMove={handleMovePoint}
onRemove={handleRemovePoint}
x={x + MaskInset}
y={y + MaskInset}
/>
))}
<div className="absolute inset-0 right-0 bottom-0" onClick={handleAddPoint} ref={boundingRef} />
<svg
width="100%"
@ -569,8 +578,6 @@ function MaskValues({
[onAdd]
);
return (
<div className="overflow-hidden" onMouseOver={handleMousein} onMouseOut={handleMouseout}>
<div className="flex space-x-4">

View File

@ -34,7 +34,7 @@ export default function Config() {
})
.catch((error) => {
setSuccess('');
if (error.response) {
setError(error.response.data.message);
} else {
@ -61,9 +61,9 @@ export default function Config() {
let yamlModel;
if (editor.getModels().length > 0) {
yamlModel = editor.getModel(modelUri)
yamlModel = editor.getModel(modelUri);
} else {
yamlModel = editor.createModel(config, 'yaml', modelUri)
yamlModel = editor.createModel(config, 'yaml', modelUri);
}
setDiagnosticsOptions({
@ -74,7 +74,7 @@ export default function Config() {
format: true,
schemas: [
{
uri: `${apiHost}/api/config/schema.json`,
uri: `${apiHost}api/config/schema.json`,
fileMatch: [String(modelUri)],
},
],
@ -100,10 +100,10 @@ export default function Config() {
<Button className="mx-2" onClick={(e) => handleCopyConfig(e)}>
Copy Config
</Button>
<Button className="mx-2" onClick={(e) => onHandleSaveConfig(e, "restart")}>
<Button className="mx-2" onClick={(e) => onHandleSaveConfig(e, 'restart')}>
Save & Restart
</Button>
<Button className="mx-2" onClick={(e) => onHandleSaveConfig(e, "saveonly")}>
<Button className="mx-2" onClick={(e) => onHandleSaveConfig(e, 'saveonly')}>
Save Only
</Button>
</div>

View File

@ -402,7 +402,7 @@ export default function Events({ path, ...props }) {
icon={Snapshot}
label="Download Snapshot"
value="snapshot"
href={`${apiHost}/api/events/${downloadEvent.id}/snapshot.jpg?download=true`}
href={`${apiHost}api/events/${downloadEvent.id}/snapshot.jpg?download=true`}
download
/>
)}
@ -411,7 +411,7 @@ export default function Events({ path, ...props }) {
icon={Clip}
label="Download Clip"
value="clip"
href={`${apiHost}/api/events/${downloadEvent.id}/clip.mp4?download=true`}
href={`${apiHost}api/events/${downloadEvent.id}/clip.mp4?download=true`}
download
/>
)}
@ -419,13 +419,13 @@ export default function Events({ path, ...props }) {
downloadEvent.end_time &&
downloadEvent.has_snapshot &&
!downloadEvent.plus_id && (
<MenuItem
icon={UploadPlus}
label={uploading.includes(downloadEvent.id) ? 'Uploading...' : 'Send to Frigate+'}
value="plus"
onSelect={() => showSubmitToPlus(downloadEvent.id, downloadEvent.label, downloadEvent.box)}
/>
)}
<MenuItem
icon={UploadPlus}
label={uploading.includes(downloadEvent.id) ? 'Uploading...' : 'Send to Frigate+'}
value="plus"
onSelect={() => showSubmitToPlus(downloadEvent.id, downloadEvent.label, downloadEvent.box)}
/>
)}
{downloadEvent.plus_id && (
<MenuItem
icon={UploadPlus}
@ -492,7 +492,7 @@ export default function Events({ path, ...props }) {
<img
className="flex-grow-0"
src={`${apiHost}/api/events/${plusSubmitEvent.id}/snapshot.jpg`}
src={`${apiHost}api/events/${plusSubmitEvent.id}/snapshot.jpg`}
alt={`${plusSubmitEvent.label}`}
/>
@ -619,7 +619,7 @@ export default function Events({ path, ...props }) {
<div
className="relative rounded-l flex-initial min-w-[125px] h-[125px] bg-contain bg-no-repeat bg-center"
style={{
'background-image': `url(${apiHost}/api/events/${event.id}/thumbnail.jpg)`,
'background-image': `url(${apiHost}api/events/${event.id}/thumbnail.jpg)`,
}}
>
<StarRecording
@ -776,8 +776,8 @@ export default function Events({ path, ...props }) {
className="flex-grow-0"
src={
event.has_snapshot
? `${apiHost}/api/events/${event.id}/snapshot.jpg`
: `${apiHost}/api/events/${event.id}/thumbnail.jpg`
? `${apiHost}api/events/${event.id}/snapshot.jpg`
: `${apiHost}api/events/${event.id}/thumbnail.jpg`
}
alt={`${event.label} at ${((event?.data?.top_score || event.top_score) * 100).toFixed(
0

View File

@ -334,80 +334,86 @@ export default function System() {
<ActivityIndicator />
) : (
<div data-testid="cameras" className="grid grid-cols-1 3xl:grid-cols-3 md:grid-cols-2 gap-4">
{cameraNames.map((camera) => ( config.cameras[camera]["enabled"] && (
<div key={camera} className="dark:bg-gray-800 shadow-md hover:shadow-lg rounded-lg transition-shadow">
<div className="capitalize text-lg flex justify-between p-4">
<Link href={`/cameras/${camera}`}>{camera.replaceAll('_', ' ')}</Link>
<Button onClick={(e) => onHandleFfprobe(camera, e)}>ffprobe</Button>
</div>
<div className="p-2">
<Table className="w-full">
<Thead>
<Tr>
<Th>Process</Th>
<Th>P-ID</Th>
<Th>FPS</Th>
<Th>CPU %</Th>
<Th>Memory %</Th>
{config.telemetry.network_bandwidth && <Th>Network Bandwidth</Th>}
</Tr>
</Thead>
<Tbody>
<Tr key="ffmpeg" index="0">
<Td>
ffmpeg
<Button
className="rounded-full"
type="text"
color="gray"
aria-label={cpu_usages[cameras[camera]['ffmpeg_pid']]?.['cmdline']}
onClick={() => copy(cpu_usages[cameras[camera]['ffmpeg_pid']]?.['cmdline'])}
>
<About className="w-3" />
</Button>
</Td>
<Td>{cameras[camera]['ffmpeg_pid'] || '- '}</Td>
<Td>{cameras[camera]['camera_fps'] || '- '}</Td>
<Td>{cpu_usages[cameras[camera]['ffmpeg_pid']]?.['cpu'] || '- '}%</Td>
<Td>{cpu_usages[cameras[camera]['ffmpeg_pid']]?.['mem'] || '- '}%</Td>
{config.telemetry.network_bandwidth && (
<Td>{bandwidth_usages[cameras[camera]['ffmpeg_pid']]?.['bandwidth'] || '- '}KB/s</Td>
)}
</Tr>
<Tr key="capture" index="1">
<Td>Capture</Td>
<Td>{cameras[camera]['capture_pid'] || '- '}</Td>
<Td>{cameras[camera]['process_fps'] || '- '}</Td>
<Td>{cpu_usages[cameras[camera]['capture_pid']]?.['cpu'] || '- '}%</Td>
<Td>{cpu_usages[cameras[camera]['capture_pid']]?.['mem'] || '- '}%</Td>
{config.telemetry.network_bandwidth && <Td>-</Td>}
</Tr>
<Tr key="detect" index="2">
<Td>Detect</Td>
<Td>{cameras[camera]['pid'] || '- '}</Td>
{cameraNames.map(
(camera) =>
config.cameras[camera]['enabled'] && (
<div
key={camera}
className="dark:bg-gray-800 shadow-md hover:shadow-lg rounded-lg transition-shadow"
>
<div className="capitalize text-lg flex justify-between p-4">
<Link href={`/cameras/${camera}`}>{camera.replaceAll('_', ' ')}</Link>
<Button onClick={(e) => onHandleFfprobe(camera, e)}>ffprobe</Button>
</div>
<div className="p-2">
<Table className="w-full">
<Thead>
<Tr>
<Th>Process</Th>
<Th>P-ID</Th>
<Th>FPS</Th>
<Th>CPU %</Th>
<Th>Memory %</Th>
{config.telemetry.network_bandwidth && <Th>Network Bandwidth</Th>}
</Tr>
</Thead>
<Tbody>
<Tr key="ffmpeg" index="0">
<Td>
ffmpeg
<Button
className="rounded-full"
type="text"
color="gray"
aria-label={cpu_usages[cameras[camera]['ffmpeg_pid']]?.['cmdline']}
onClick={() => copy(cpu_usages[cameras[camera]['ffmpeg_pid']]?.['cmdline'])}
>
<About className="w-3" />
</Button>
</Td>
<Td>{cameras[camera]['ffmpeg_pid'] || '- '}</Td>
<Td>{cameras[camera]['camera_fps'] || '- '}</Td>
<Td>{cpu_usages[cameras[camera]['ffmpeg_pid']]?.['cpu'] || '- '}%</Td>
<Td>{cpu_usages[cameras[camera]['ffmpeg_pid']]?.['mem'] || '- '}%</Td>
{config.telemetry.network_bandwidth && (
<Td>{bandwidth_usages[cameras[camera]['ffmpeg_pid']]?.['bandwidth'] || '- '}KB/s</Td>
)}
</Tr>
<Tr key="capture" index="1">
<Td>Capture</Td>
<Td>{cameras[camera]['capture_pid'] || '- '}</Td>
<Td>{cameras[camera]['process_fps'] || '- '}</Td>
<Td>{cpu_usages[cameras[camera]['capture_pid']]?.['cpu'] || '- '}%</Td>
<Td>{cpu_usages[cameras[camera]['capture_pid']]?.['mem'] || '- '}%</Td>
{config.telemetry.network_bandwidth && <Td>-</Td>}
</Tr>
<Tr key="detect" index="2">
<Td>Detect</Td>
<Td>{cameras[camera]['pid'] || '- '}</Td>
{(() => {
if (cameras[camera]['pid'] && cameras[camera]['detection_enabled'] == 1)
return (
<Td>
{cameras[camera]['detection_fps']} ({cameras[camera]['skipped_fps']} skipped)
</Td>
);
else if (cameras[camera]['pid'] && cameras[camera]['detection_enabled'] == 0)
return <Td>disabled</Td>;
{(() => {
if (cameras[camera]['pid'] && cameras[camera]['detection_enabled'] == 1)
return (
<Td>
{cameras[camera]['detection_fps']} ({cameras[camera]['skipped_fps']} skipped)
</Td>
);
else if (cameras[camera]['pid'] && cameras[camera]['detection_enabled'] == 0)
return <Td>disabled</Td>;
return <Td>- </Td>;
})()}
return <Td>- </Td>;
})()}
<Td>{cpu_usages[cameras[camera]['pid']]?.['cpu'] || '- '}%</Td>
<Td>{cpu_usages[cameras[camera]['pid']]?.['mem'] || '- '}%</Td>
{config.telemetry.network_bandwidth && <Td>-</Td>}
</Tr>
</Tbody>
</Table>
</div>
</div> )
))}
<Td>{cpu_usages[cameras[camera]['pid']]?.['cpu'] || '- '}%</Td>
<Td>{cpu_usages[cameras[camera]['pid']]?.['mem'] || '- '}%</Td>
{config.telemetry.network_bandwidth && <Td>-</Td>}
</Tr>
</Tbody>
</Table>
</div>
</div>
)
)}
</div>
)}

View File

@ -9,6 +9,13 @@ export default defineConfig({
define: {
'import.meta.vitest': 'undefined',
},
server: {
proxy: {
'/api': {
target: 'http://localhost:5000'
}
}
},
plugins: [
preact(),
monacoEditorPlugin.default({