mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-25 23:18:58 +03:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fce18f156 | ||
|
|
7001623ba2 | ||
|
|
f8c180319c | ||
|
|
8b5dc77891 | ||
|
|
3990a64988 | ||
|
|
f185e64379 | ||
|
|
635aee552e | ||
|
|
1834d9a22e | ||
|
|
f0d070899f | ||
|
|
92e2a8b444 | ||
|
|
a8ed8b8592 | ||
|
|
7f2fd71d12 | ||
|
|
274277b85e | ||
|
|
faa0b68bc4 | ||
|
|
c252e07ffb | ||
|
|
90e1fa756d | ||
|
|
ab43524fe2 | ||
|
|
51eb5058b3 | ||
|
|
aa72e555f5 |
@@ -124,5 +124,7 @@ jobs:
|
|||||||
run: devcontainer exec --workspace-folder . bash -lc "python3 -u -m mypy --config-file frigate/mypy.ini frigate"
|
run: devcontainer exec --workspace-folder . bash -lc "python3 -u -m mypy --config-file frigate/mypy.ini frigate"
|
||||||
- name: Check API spec is up to date
|
- name: Check API spec is up to date
|
||||||
run: devcontainer exec --workspace-folder . bash -lc "python3 generate_api_auth_spec.py --check"
|
run: devcontainer exec --workspace-folder . bash -lc "python3 generate_api_auth_spec.py --check"
|
||||||
|
- name: Check analytics schema is up to date
|
||||||
|
run: devcontainer exec --workspace-folder . bash -lc "python3 generate_analytics_schema.py --check"
|
||||||
- name: Run unit tests in devcontainer
|
- name: Run unit tests in devcontainer
|
||||||
run: devcontainer exec --workspace-folder . bash -lc "python3 -u -m unittest"
|
run: devcontainer exec --workspace-folder . bash -lc "python3 -u -m unittest"
|
||||||
|
|||||||
@@ -381,6 +381,7 @@ FROM deps AS frigate
|
|||||||
|
|
||||||
WORKDIR /opt/frigate/
|
WORKDIR /opt/frigate/
|
||||||
COPY --from=rootfs / /
|
COPY --from=rootfs / /
|
||||||
|
ENV FRIGATE_IMAGE_VARIANT=standard
|
||||||
|
|
||||||
# Pre-compile bytecode so a read-only rootfs doesn't force re-parsing the
|
# Pre-compile bytecode so a read-only rootfs doesn't force re-parsing the
|
||||||
# source tree on every boot (pip-installed packages are already compiled)
|
# source tree on every boot (pip-installed packages are already compiled)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ RUN --mount=type=bind,from=rk-wheels,source=/rk-wheels,target=/deps/rk-wheels \
|
|||||||
|
|
||||||
WORKDIR /opt/frigate/
|
WORKDIR /opt/frigate/
|
||||||
COPY --from=rootfs / /
|
COPY --from=rootfs / /
|
||||||
|
ENV FRIGATE_IMAGE_VARIANT=rk
|
||||||
COPY docker/rockchip/COCO /COCO
|
COPY docker/rockchip/COCO /COCO
|
||||||
COPY docker/rockchip/conv2rknn.py /opt/conv2rknn.py
|
COPY docker/rockchip/conv2rknn.py /opt/conv2rknn.py
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ RUN echo "deb http://deb.debian.org/debian trixie main" > /etc/apt/sources.list.
|
|||||||
|
|
||||||
WORKDIR /opt/frigate
|
WORKDIR /opt/frigate
|
||||||
COPY --from=rootfs / /
|
COPY --from=rootfs / /
|
||||||
|
ENV FRIGATE_IMAGE_VARIANT=rocm
|
||||||
|
|
||||||
RUN wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \
|
RUN wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \
|
||||||
&& sed -i 's/args.append("setuptools")/args.append("setuptools==77.0.3")/' get-pip.py \
|
&& sed -i 's/args.append("setuptools")/args.append("setuptools==77.0.3")/' get-pip.py \
|
||||||
|
|||||||
@@ -15,3 +15,4 @@ ENV INCLUDED_FFMPEG_VERSIONS="${DEFAULT_FFMPEG_VERSION}:${INCLUDED_FFMPEG_VERSIO
|
|||||||
|
|
||||||
WORKDIR /opt/frigate/
|
WORKDIR /opt/frigate/
|
||||||
COPY --from=rootfs / /
|
COPY --from=rootfs / /
|
||||||
|
ENV FRIGATE_IMAGE_VARIANT=rpi
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ pip3 install --no-deps -U /deps/synap-wheels/*.whl
|
|||||||
|
|
||||||
WORKDIR /opt/frigate/
|
WORKDIR /opt/frigate/
|
||||||
COPY --from=rootfs / /
|
COPY --from=rootfs / /
|
||||||
|
ENV FRIGATE_IMAGE_VARIANT=synaptics
|
||||||
|
|
||||||
COPY --from=synap1680-wheels /rootfs/usr/local/lib/*.so /usr/lib
|
COPY --from=synap1680-wheels /rootfs/usr/local/lib/*.so /usr/lib
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ RUN --mount=type=bind,from=trt-wheels,source=/trt-wheels,target=/deps/trt-wheels
|
|||||||
&& pip3 install -U /deps/trt-wheels/*.whl
|
&& pip3 install -U /deps/trt-wheels/*.whl
|
||||||
|
|
||||||
COPY --from=rootfs / /
|
COPY --from=rootfs / /
|
||||||
|
ENV FRIGATE_IMAGE_VARIANT=tensorrt
|
||||||
COPY docker/tensorrt/detector/rootfs/etc/ld.so.conf.d /etc/ld.so.conf.d
|
COPY docker/tensorrt/detector/rootfs/etc/ld.so.conf.d /etc/ld.so.conf.d
|
||||||
RUN ldconfig
|
RUN ldconfig
|
||||||
|
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ RUN --mount=type=bind,from=trt-wheels,source=/trt-wheels,target=/deps/trt-wheels
|
|||||||
|
|
||||||
WORKDIR /opt/frigate/
|
WORKDIR /opt/frigate/
|
||||||
COPY --from=rootfs / /
|
COPY --from=rootfs / /
|
||||||
|
ENV FRIGATE_IMAGE_VARIANT=tensorrt-jp6
|
||||||
|
|
||||||
# Fixes "Error importing detector runtime: /usr/lib/aarch64-linux-gnu/libstdc++.so.6: cannot allocate memory in static TLS block"
|
# Fixes "Error importing detector runtime: /usr/lib/aarch64-linux-gnu/libstdc++.so.6: cannot allocate memory in static TLS block"
|
||||||
ENV LD_PRELOAD /usr/lib/aarch64-linux-gnu/libstdc++.so.6
|
ENV LD_PRELOAD /usr/lib/aarch64-linux-gnu/libstdc++.so.6
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
id: analytics
|
||||||
|
title: Anonymous Analytics
|
||||||
|
---
|
||||||
|
|
||||||
|
import AnalyticsFields from "@site/src/components/AnalyticsFields";
|
||||||
|
import NavPath from "@site/src/components/NavPath";
|
||||||
|
|
||||||
|
Frigate can send one anonymous usage report a day. The reports show the maintainers which hardware to support, which features people use, and how releases perform. Sharing is off until you turn it on.
|
||||||
|
|
||||||
|
## Turning it on
|
||||||
|
|
||||||
|
Enable **Share anonymous analytics** at <NavPath path="Settings > System > Telemetry" />, or set it in your config:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
telemetry:
|
||||||
|
analytics: true
|
||||||
|
```
|
||||||
|
|
||||||
|
The same page has a **Preview the report** button that shows exactly what the next report contains.
|
||||||
|
|
||||||
|
## How it's sent
|
||||||
|
|
||||||
|
- Once a day, as a JSON POST to `https://analytics.frigate.video/report`
|
||||||
|
- The server looks up your country and region from your IP address and never stores the address
|
||||||
|
- Raw reports are kept for 60 days; only aggregate totals are published
|
||||||
|
- A random install ID, stored in `/config/.analytics.json`, keeps your install from being counted twice. Turning sharing off deletes it
|
||||||
|
|
||||||
|
## What's never sent
|
||||||
|
|
||||||
|
- Camera, zone, group, profile, or user names
|
||||||
|
- Object labels, face names, or license plate text
|
||||||
|
- IP addresses, hostnames, URLs, or stream paths
|
||||||
|
- Credentials or API keys
|
||||||
|
- Events, recordings, or anything from them
|
||||||
|
|
||||||
|
## Every field
|
||||||
|
|
||||||
|
Fields marked public appear in the published totals. The machine-readable schema is [frigate-analytics-schema.json](pathname:///frigate-analytics-schema.json).
|
||||||
|
|
||||||
|
<AnalyticsFields />
|
||||||
@@ -1194,6 +1194,9 @@ ui:
|
|||||||
|
|
||||||
# Optional: Telemetry configuration
|
# Optional: Telemetry configuration
|
||||||
telemetry:
|
telemetry:
|
||||||
|
# Optional: Share one anonymous usage report a day (default: shown below)
|
||||||
|
# NOTE: See https://docs.frigate.video/configuration/advanced/analytics for what is sent
|
||||||
|
analytics: False
|
||||||
# Optional: Enabled network interfaces for bandwidth stats monitoring (default: empty list, let nethogs search all)
|
# Optional: Enabled network interfaces for bandwidth stats monitoring (default: empty list, let nethogs search all)
|
||||||
network_interfaces:
|
network_interfaces:
|
||||||
- eth
|
- eth
|
||||||
|
|||||||
@@ -134,6 +134,15 @@ telemetry:
|
|||||||
version_check: false
|
version_check: false
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Anonymous Analytics
|
||||||
|
|
||||||
|
If [anonymous analytics](/configuration/advanced/analytics) sharing is turned on, Frigate sends one report a day to `https://analytics.frigate.video`. It's off by default, so no outbound connection happens unless you enable it:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
telemetry:
|
||||||
|
analytics: true
|
||||||
|
```
|
||||||
|
|
||||||
### Push Notifications
|
### Push Notifications
|
||||||
|
|
||||||
When [notifications](/configuration/notifications) are enabled and users have registered for push notifications in the web UI, Frigate sends push messages through the browser vendor's push service (e.g., Google FCM, Mozilla autopush). This requires internet access from the Frigate server to these push endpoints.
|
When [notifications](/configuration/notifications) are enabled and users have registered for push notifications in the web UI, Frigate sends push messages through the browser vendor's push service (e.g., Google FCM, Mozilla autopush). This requires internet access from the Frigate server to these push endpoints.
|
||||||
@@ -170,7 +179,7 @@ To run Frigate in an air-gapped or offline environment:
|
|||||||
2. **Pre-download the training base weights**: If you plan to train custom classification models, set `TF_KERAS_MOBILENET_V2_WEIGHTS_URL` before training, then run one training job while online. Without this variable the base weights are cached outside `/config/` and are lost whenever the container is recreated, so a later training run will fail offline. If the machine never has internet access, copy the weights in manually as described below.
|
2. **Pre-download the training base weights**: If you plan to train custom classification models, set `TF_KERAS_MOBILENET_V2_WEIGHTS_URL` before training, then run one training job while online. Without this variable the base weights are cached outside `/config/` and are lost whenever the container is recreated, so a later training run will fail offline. If the machine never has internet access, copy the weights in manually as described below.
|
||||||
3. **Disable version check**: Set `telemetry.version_check: false` in your configuration.
|
3. **Disable version check**: Set `telemetry.version_check: false` in your configuration.
|
||||||
4. **Block outbound model requests**: Set the `HF_HUB_OFFLINE=1` and `TRANSFORMERS_OFFLINE=1` environment variables to prevent HuggingFace and Transformers from attempting any network requests.
|
4. **Block outbound model requests**: Set the `HF_HUB_OFFLINE=1` and `TRANSFORMERS_OFFLINE=1` environment variables to prevent HuggingFace and Transformers from attempting any network requests.
|
||||||
5. **Avoid cloud features**: Do not configure Frigate+, Generative AI providers that require internet, or cloud MQTT brokers.
|
5. **Avoid cloud features**: Do not configure Frigate+, Generative AI providers that require internet, or cloud MQTT brokers, and leave anonymous analytics off (its default).
|
||||||
6. **Use local model mirrors**: If limited internet is available, set the `HF_ENDPOINT`, `GITHUB_ENDPOINT`, `GITHUB_RAW_ENDPOINT`, and `TF_KERAS_MOBILENET_V2_WEIGHTS_URL` environment variables to point to local mirrors.
|
6. **Use local model mirrors**: If limited internet is available, set the `HF_ENDPOINT`, `GITHUB_ENDPOINT`, `GITHUB_RAW_ENDPOINT`, and `TF_KERAS_MOBILENET_V2_WEIGHTS_URL` environment variables to point to local mirrors.
|
||||||
|
|
||||||
After these steps, Frigate will operate with no outbound internet connections.
|
After these steps, Frigate will operate with no outbound internet connections.
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ const sidebars: SidebarsConfig = {
|
|||||||
label: "Advanced Configuration",
|
label: "Advanced Configuration",
|
||||||
items: [
|
items: [
|
||||||
"configuration/advanced/system",
|
"configuration/advanced/system",
|
||||||
|
"configuration/advanced/analytics",
|
||||||
"configuration/advanced/reference",
|
"configuration/advanced/reference",
|
||||||
{
|
{
|
||||||
type: "link",
|
type: "link",
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import React from "react";
|
||||||
|
import schema from "@site/static/frigate-analytics-schema.json";
|
||||||
|
|
||||||
|
function resolve(node) {
|
||||||
|
if (!node) return node;
|
||||||
|
if (node.$ref) return schema.$defs[node.$ref.split("/").pop()];
|
||||||
|
if (node.anyOf) {
|
||||||
|
const inner = node.anyOf.find((option) => option.type !== "null");
|
||||||
|
return inner ? resolve(inner) : node;
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rows(properties, prefix = "") {
|
||||||
|
return Object.entries(properties).flatMap(([name, field]) => {
|
||||||
|
const path = prefix ? `${prefix}.${name}` : name;
|
||||||
|
const row = {
|
||||||
|
path,
|
||||||
|
description: field.description,
|
||||||
|
isPublic: field["x-public"],
|
||||||
|
};
|
||||||
|
const target = resolve(field);
|
||||||
|
|
||||||
|
if (target?.properties) return [row, ...rows(target.properties, path)];
|
||||||
|
|
||||||
|
const values = resolve(target?.additionalProperties);
|
||||||
|
if (values?.properties)
|
||||||
|
return [row, ...rows(values.properties, `${path}.<key>`)];
|
||||||
|
|
||||||
|
const items = resolve(target?.items);
|
||||||
|
if (items?.properties) return [row, ...rows(items.properties, `${path}[]`)];
|
||||||
|
|
||||||
|
return [row];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AnalyticsFields() {
|
||||||
|
return (
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Field</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Public</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows(schema.properties).map((row) => (
|
||||||
|
<tr key={row.path}>
|
||||||
|
<td>
|
||||||
|
<code>{row.path}</code>
|
||||||
|
</td>
|
||||||
|
<td>{row.description}</td>
|
||||||
|
<td>{row.isPublic ? "Yes" : "No"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
+1482
File diff suppressed because it is too large
Load Diff
Vendored
+19
@@ -10,6 +10,25 @@ servers:
|
|||||||
- url: https://demo.frigate.video/api
|
- url: https://demo.frigate.video/api
|
||||||
- url: http://localhost:5001/api
|
- url: http://localhost:5001/api
|
||||||
paths:
|
paths:
|
||||||
|
/analytics/preview:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Analytics
|
||||||
|
summary: Get Analytics Preview
|
||||||
|
description: |-
|
||||||
|
**Access:** Admin role required.
|
||||||
|
|
||||||
|
Get the analytics report Frigate would send next, without sending it.
|
||||||
|
operationId: get_analytics_preview_analytics_preview_get
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Successful Response
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {}
|
||||||
|
security:
|
||||||
|
- frigateAdminAuth: []
|
||||||
|
x-required-role: admin
|
||||||
/auth/first_time_login:
|
/auth/first_time_login:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Opt-in anonymous analytics reports."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""One collector per report section."""
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
"""Cameras section: counts and histograms across cameras, never per camera."""
|
||||||
|
|
||||||
|
from collections import Counter
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from frigate.analytics.collectors.common import closed, histogram
|
||||||
|
from frigate.analytics.context import ReportContext
|
||||||
|
from frigate.analytics.schema import (
|
||||||
|
CamerasSection,
|
||||||
|
ConnectionQuality,
|
||||||
|
FpsBucket,
|
||||||
|
HeightBucket,
|
||||||
|
HwaccelKey,
|
||||||
|
InputPresetKey,
|
||||||
|
RetainBucket,
|
||||||
|
RetainDays,
|
||||||
|
)
|
||||||
|
from frigate.config import CameraConfig
|
||||||
|
from frigate.config.camera.camera import CameraTypeEnum
|
||||||
|
from frigate.config.camera.ffmpeg import CameraInput, CameraRoleEnum
|
||||||
|
from frigate.const import REPLAY_CAMERA_PREFIX
|
||||||
|
|
||||||
|
# the upper edge of every height bucket but the last
|
||||||
|
HEIGHT_BUCKETS = (
|
||||||
|
(360, HeightBucket.le_360),
|
||||||
|
(540, HeightBucket.h480),
|
||||||
|
(900, HeightBucket.h720),
|
||||||
|
(1260, HeightBucket.h1080),
|
||||||
|
(1800, HeightBucket.h1440),
|
||||||
|
)
|
||||||
|
RESTREAM_HOSTS = frozenset({"127.0.0.1", "localhost"})
|
||||||
|
RESTREAM_PORT = 8554
|
||||||
|
QUALITIES = frozenset(ConnectionQuality)
|
||||||
|
|
||||||
|
|
||||||
|
def height_bucket(height: int) -> HeightBucket:
|
||||||
|
for limit, bucket in HEIGHT_BUCKETS:
|
||||||
|
if height <= limit:
|
||||||
|
return bucket
|
||||||
|
|
||||||
|
return HeightBucket.ge_2160
|
||||||
|
|
||||||
|
|
||||||
|
def fps_bucket(fps: int) -> FpsBucket:
|
||||||
|
if fps <= 5:
|
||||||
|
return FpsBucket.le_5
|
||||||
|
|
||||||
|
if fps <= 10:
|
||||||
|
return FpsBucket.f6_10
|
||||||
|
|
||||||
|
return FpsBucket.gt_10
|
||||||
|
|
||||||
|
|
||||||
|
def retain_bucket(days: float) -> RetainBucket:
|
||||||
|
if days <= 0:
|
||||||
|
return RetainBucket.zero
|
||||||
|
|
||||||
|
if days <= 7:
|
||||||
|
return RetainBucket.d1_7
|
||||||
|
|
||||||
|
if days <= 30:
|
||||||
|
return RetainBucket.d8_30
|
||||||
|
|
||||||
|
return RetainBucket.gt_30
|
||||||
|
|
||||||
|
|
||||||
|
def preset_key(args: str | list[str], enum: Any) -> Any:
|
||||||
|
"""The preset's name, custom for hand written args, or none."""
|
||||||
|
if not args:
|
||||||
|
return enum("none")
|
||||||
|
|
||||||
|
if isinstance(args, str) and args.startswith("preset-"):
|
||||||
|
return closed(enum, args.removeprefix("preset-"), enum("custom"))
|
||||||
|
|
||||||
|
return enum("custom")
|
||||||
|
|
||||||
|
|
||||||
|
def is_restream(path: str) -> bool:
|
||||||
|
try:
|
||||||
|
url = urlsplit(path)
|
||||||
|
return url.hostname in RESTREAM_HOSTS and url.port == RESTREAM_PORT
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def role_input(camera: CameraConfig, role: CameraRoleEnum) -> CameraInput | None:
|
||||||
|
return next((i for i in camera.ffmpeg.inputs if role in i.roles), None)
|
||||||
|
|
||||||
|
|
||||||
|
def object_masks(camera: CameraConfig) -> int:
|
||||||
|
# parsing copies camera-wide masks into every filter as global_<id>
|
||||||
|
own = sum(1 for mask in camera.objects.mask.values() if mask is not None)
|
||||||
|
per_label = sum(
|
||||||
|
1
|
||||||
|
for label_filter in camera.objects.filters.values()
|
||||||
|
for mask_id, mask in label_filter.mask.items()
|
||||||
|
if mask is not None and not mask_id.startswith("global_")
|
||||||
|
)
|
||||||
|
return own + per_label
|
||||||
|
|
||||||
|
|
||||||
|
def collect(ctx: ReportContext) -> CamerasSection:
|
||||||
|
cameras = {
|
||||||
|
name: camera
|
||||||
|
for name, camera in ctx.config.cameras.items()
|
||||||
|
if not name.startswith(REPLAY_CAMERA_PREFIX)
|
||||||
|
}
|
||||||
|
camera_stats = ctx.stats.get("cameras", {})
|
||||||
|
|
||||||
|
flags: Counter[str] = Counter()
|
||||||
|
types: Counter[CameraTypeEnum] = Counter()
|
||||||
|
heights: Counter[HeightBucket] = Counter()
|
||||||
|
fps: Counter[FpsBucket] = Counter()
|
||||||
|
hwaccel: Counter[Any] = Counter()
|
||||||
|
input_presets: Counter[Any] = Counter()
|
||||||
|
quality: Counter[ConnectionQuality] = Counter()
|
||||||
|
retain: dict[str, Counter[RetainBucket]] = {
|
||||||
|
period: Counter() for period in ("continuous", "motion", "alerts", "detections")
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, camera in cameras.items():
|
||||||
|
detect_input = role_input(camera, CameraRoleEnum.detect)
|
||||||
|
record_input = role_input(camera, CameraRoleEnum.record)
|
||||||
|
|
||||||
|
# config validation requires a detect input, so this never skips
|
||||||
|
if detect_input is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
types[camera.type] += 1
|
||||||
|
fps[fps_bucket(camera.detect.fps)] += 1
|
||||||
|
hwaccel[
|
||||||
|
preset_key(
|
||||||
|
detect_input.hwaccel_args or camera.ffmpeg.hwaccel_args, HwaccelKey
|
||||||
|
)
|
||||||
|
] += 1
|
||||||
|
input_presets[
|
||||||
|
preset_key(
|
||||||
|
detect_input.input_args or camera.ffmpeg.input_args, InputPresetKey
|
||||||
|
)
|
||||||
|
] += 1
|
||||||
|
|
||||||
|
if camera.detect.height:
|
||||||
|
heights[height_bucket(camera.detect.height)] += 1
|
||||||
|
|
||||||
|
state = camera_stats.get(name, {}).get("connection_quality")
|
||||||
|
|
||||||
|
if state in QUALITIES:
|
||||||
|
quality[ConnectionQuality(state)] += 1
|
||||||
|
|
||||||
|
if camera.record.enabled:
|
||||||
|
retain["continuous"][retain_bucket(camera.record.continuous.days)] += 1
|
||||||
|
retain["motion"][retain_bucket(camera.record.motion.days)] += 1
|
||||||
|
retain["alerts"][retain_bucket(camera.record.alerts.retain.days)] += 1
|
||||||
|
retain["detections"][
|
||||||
|
retain_bucket(camera.record.detections.retain.days)
|
||||||
|
] += 1
|
||||||
|
|
||||||
|
zones = len(camera.zones)
|
||||||
|
flags["enabled"] += camera.enabled
|
||||||
|
flags["go2rtc_restream"] += any(
|
||||||
|
is_restream(i.path) for i in camera.ffmpeg.inputs
|
||||||
|
)
|
||||||
|
flags["separate_detect_stream"] += (
|
||||||
|
record_input is not None and record_input.path != detect_input.path
|
||||||
|
)
|
||||||
|
flags["detect"] += camera.detect.enabled
|
||||||
|
flags["record"] += camera.record.enabled
|
||||||
|
flags["sub_stream_record"] += camera.record.sub.enabled
|
||||||
|
flags["snapshots"] += camera.snapshots.enabled
|
||||||
|
flags["audio"] += camera.audio.enabled
|
||||||
|
flags["audio_transcription"] += camera.audio_transcription.enabled
|
||||||
|
flags["birdseye"] += camera.birdseye.enabled
|
||||||
|
flags["onvif"] += bool(camera.onvif.host)
|
||||||
|
flags["autotracking"] += camera.onvif.autotracking.enabled
|
||||||
|
flags["face_recognition"] += camera.face_recognition.enabled
|
||||||
|
flags["lpr"] += camera.lpr.enabled
|
||||||
|
flags["review_genai"] += camera.review.genai.enabled
|
||||||
|
flags["object_genai"] += camera.objects.genai.enabled
|
||||||
|
flags["notifications"] += camera.notifications.enabled
|
||||||
|
flags["zones"] += zones
|
||||||
|
flags["cameras_with_zones"] += zones > 0
|
||||||
|
flags["motion_masks"] += sum(
|
||||||
|
1 for mask in camera.motion.mask.values() if mask is not None
|
||||||
|
)
|
||||||
|
flags["object_masks"] += object_masks(camera)
|
||||||
|
|
||||||
|
return CamerasSection(
|
||||||
|
total=len(cameras),
|
||||||
|
enabled=flags["enabled"],
|
||||||
|
types=histogram(types),
|
||||||
|
detect_height=histogram(heights),
|
||||||
|
detect_fps=histogram(fps),
|
||||||
|
hwaccel=histogram(hwaccel),
|
||||||
|
input_preset=histogram(input_presets),
|
||||||
|
go2rtc_restream=flags["go2rtc_restream"],
|
||||||
|
separate_detect_stream=flags["separate_detect_stream"],
|
||||||
|
detect=flags["detect"],
|
||||||
|
record=flags["record"],
|
||||||
|
sub_stream_record=flags["sub_stream_record"],
|
||||||
|
snapshots=flags["snapshots"],
|
||||||
|
audio=flags["audio"],
|
||||||
|
audio_transcription=flags["audio_transcription"],
|
||||||
|
birdseye=flags["birdseye"],
|
||||||
|
onvif=flags["onvif"],
|
||||||
|
autotracking=flags["autotracking"],
|
||||||
|
face_recognition=flags["face_recognition"],
|
||||||
|
lpr=flags["lpr"],
|
||||||
|
review_genai=flags["review_genai"],
|
||||||
|
object_genai=flags["object_genai"],
|
||||||
|
notifications=flags["notifications"],
|
||||||
|
zones=flags["zones"],
|
||||||
|
cameras_with_zones=flags["cameras_with_zones"],
|
||||||
|
motion_masks=flags["motion_masks"],
|
||||||
|
object_masks=flags["object_masks"],
|
||||||
|
connection_quality=histogram(quality),
|
||||||
|
retain_days=RetainDays(
|
||||||
|
continuous=histogram(retain["continuous"]),
|
||||||
|
motion=histogram(retain["motion"]),
|
||||||
|
alerts=histogram(retain["alerts"]),
|
||||||
|
detections=histogram(retain["detections"]),
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Helpers the section collectors share."""
|
||||||
|
|
||||||
|
import math
|
||||||
|
from collections import Counter
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, TypeVar
|
||||||
|
|
||||||
|
E = TypeVar("E", bound=Enum)
|
||||||
|
|
||||||
|
|
||||||
|
def closed(enum: type[E], value: Any, fallback: E) -> E:
|
||||||
|
"""The member for a value, or the fallback for one the enum doesn't know."""
|
||||||
|
try:
|
||||||
|
return enum(str(value))
|
||||||
|
except ValueError:
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
def rate(value: Any) -> float:
|
||||||
|
"""A finite, non-negative number rounded to 2 decimals, else 0.
|
||||||
|
|
||||||
|
A NaN would serialize as null and fail the schema's number type.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
number = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
if not math.isfinite(number):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
return round(max(number, 0.0), 2)
|
||||||
|
|
||||||
|
|
||||||
|
def histogram(counter: "Counter[E]") -> dict[E, int]:
|
||||||
|
"""Drop the empty buckets, since an absent key means zero."""
|
||||||
|
return {key: total for key, total in counter.items() if total > 0}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Detection section: models, the detectors they run on, and inference speed."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from frigate.analytics.collectors.common import rate
|
||||||
|
from frigate.analytics.context import ReportContext
|
||||||
|
from frigate.analytics.schema import DetectionModel, DetectionSection, ModelSource
|
||||||
|
from frigate.config.config import DEFAULT_MODEL
|
||||||
|
from frigate.const import MODEL_CACHE_DIR
|
||||||
|
from frigate.detectors.detector_config import SceneEnum
|
||||||
|
from frigate.detectors.detector_types import DetectorTypeEnum
|
||||||
|
from frigate.detectors.device import runner_names
|
||||||
|
|
||||||
|
# the paths FrigateConfig fills in for a model that sets none
|
||||||
|
BUNDLED_MODEL_PATHS = frozenset(
|
||||||
|
{"/cpu_model.tflite", "/edgetpu_model.tflite", str(DEFAULT_MODEL["path"])}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def model_source(path: str | None) -> ModelSource:
|
||||||
|
"""Default, Frigate+ (a cached model next to its info file), or custom.
|
||||||
|
|
||||||
|
Parsing rewrites plus://<id> to the model cache, so the prefix is gone by now.
|
||||||
|
"""
|
||||||
|
if path is None or path in BUNDLED_MODEL_PATHS:
|
||||||
|
return ModelSource.default
|
||||||
|
|
||||||
|
if path.startswith(f"{MODEL_CACHE_DIR}/") and os.path.isfile(f"{path}.json"):
|
||||||
|
return ModelSource.plus
|
||||||
|
|
||||||
|
return ModelSource.custom
|
||||||
|
|
||||||
|
|
||||||
|
def collect(ctx: ReportContext) -> DetectionSection:
|
||||||
|
config = ctx.config
|
||||||
|
detectors = ctx.stats.get("detectors", {})
|
||||||
|
model_specs = [(model, config.devices_for_model(model)) for model in config.models]
|
||||||
|
|
||||||
|
# FrigateApp.start_detectors names the processes in this same order
|
||||||
|
names = iter(runner_names([spec for _, specs in model_specs for spec in specs]))
|
||||||
|
models: dict[SceneEnum, DetectionModel] = {}
|
||||||
|
|
||||||
|
for model, specs in model_specs:
|
||||||
|
speeds: list[float] = []
|
||||||
|
|
||||||
|
for _ in specs:
|
||||||
|
speed = detectors.get(next(names), {}).get("inference_speed")
|
||||||
|
|
||||||
|
if isinstance(speed, int | float) and speed > 0:
|
||||||
|
speeds.append(float(speed))
|
||||||
|
|
||||||
|
models[model.scene] = DetectionModel(
|
||||||
|
detector=DetectorTypeEnum(specs[0].detector),
|
||||||
|
devices=len(specs),
|
||||||
|
model_type=model.model_type,
|
||||||
|
input=f"{model.width}x{model.height}",
|
||||||
|
source=model_source(model.path),
|
||||||
|
inference_ms=rate(sum(speeds) / len(speeds)) if speeds else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
return DetectionSection(
|
||||||
|
models=models,
|
||||||
|
detection_fps=rate(ctx.stats.get("detection_fps")),
|
||||||
|
skipped_fps=rate(ctx.stats.get("skipped_fps")),
|
||||||
|
)
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""Features section: enrichments, GenAI, integrations, and users."""
|
||||||
|
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
from frigate.analytics.collectors.common import closed, histogram
|
||||||
|
from frigate.analytics.context import ReportContext
|
||||||
|
from frigate.analytics.schema import (
|
||||||
|
BirdseyeUsage,
|
||||||
|
ClassificationUsage,
|
||||||
|
EnrichmentDevice,
|
||||||
|
EnrichmentUsage,
|
||||||
|
FeaturesSection,
|
||||||
|
GenAIUsage,
|
||||||
|
SemanticSearchModel,
|
||||||
|
SemanticSearchUsage,
|
||||||
|
TranscriptionModel,
|
||||||
|
TranscriptionUsage,
|
||||||
|
UserRole,
|
||||||
|
)
|
||||||
|
from frigate.config.camera.genai import GenAIProviderEnum, GenAIRoleEnum
|
||||||
|
from frigate.const import REPLAY_CAMERA_PREFIX
|
||||||
|
from frigate.models import User
|
||||||
|
|
||||||
|
RUNTIME_DEVICES = {
|
||||||
|
"cpu": EnrichmentDevice.cpu,
|
||||||
|
"cuda": EnrichmentDevice.cuda,
|
||||||
|
"tensorrt": EnrichmentDevice.tensorrt,
|
||||||
|
"migraphx": EnrichmentDevice.migraphx,
|
||||||
|
}
|
||||||
|
OPENVINO_DEVICES = {
|
||||||
|
"cpu": EnrichmentDevice.openvino_cpu,
|
||||||
|
"gpu": EnrichmentDevice.openvino_gpu,
|
||||||
|
"npu": EnrichmentDevice.openvino_npu,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def enrichment_device(label: object) -> EnrichmentDevice | None:
|
||||||
|
"""Map a runner's device label, like "CUDA" or "OpenVINO GPU.0,CPU"."""
|
||||||
|
if not isinstance(label, str) or not label:
|
||||||
|
return None
|
||||||
|
|
||||||
|
runtime, _, target = label.partition(" ")
|
||||||
|
|
||||||
|
if runtime == "OpenVINO":
|
||||||
|
first = target.split(",")[0].split(".")[0].strip().lower()
|
||||||
|
return OPENVINO_DEVICES.get(first, EnrichmentDevice.other)
|
||||||
|
|
||||||
|
return RUNTIME_DEVICES.get(label.lower(), EnrichmentDevice.other)
|
||||||
|
|
||||||
|
|
||||||
|
def model_name(model: object) -> str | None:
|
||||||
|
if model is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return str(getattr(model, "value", model))
|
||||||
|
|
||||||
|
|
||||||
|
def semantic_model(model: object) -> SemanticSearchModel | None:
|
||||||
|
# any string that isn't a built-in model names a GenAI provider
|
||||||
|
name = model_name(model)
|
||||||
|
|
||||||
|
if name is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if name in ("jinav1", "jinav2"):
|
||||||
|
return SemanticSearchModel(name)
|
||||||
|
|
||||||
|
return SemanticSearchModel.genai
|
||||||
|
|
||||||
|
|
||||||
|
def transcription_model(model: object) -> TranscriptionModel | None:
|
||||||
|
name = model_name(model)
|
||||||
|
|
||||||
|
if name is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return TranscriptionModel.whisper if name == "whisper" else TranscriptionModel.genai
|
||||||
|
|
||||||
|
|
||||||
|
def users() -> dict[UserRole, int]:
|
||||||
|
roles: Counter[UserRole] = Counter(
|
||||||
|
closed(UserRole, user.role, UserRole.custom) for user in User.select(User.role)
|
||||||
|
)
|
||||||
|
return histogram(roles)
|
||||||
|
|
||||||
|
|
||||||
|
def collect(ctx: ReportContext) -> FeaturesSection:
|
||||||
|
config = ctx.config
|
||||||
|
devices = ctx.stats.get("embeddings", {}).get("devices", {})
|
||||||
|
cameras = [
|
||||||
|
camera
|
||||||
|
for name, camera in config.cameras.items()
|
||||||
|
if not name.startswith(REPLAY_CAMERA_PREFIX)
|
||||||
|
]
|
||||||
|
providers: Counter[GenAIProviderEnum] = Counter(
|
||||||
|
genai.provider for genai in config.genai.values()
|
||||||
|
)
|
||||||
|
roles: Counter[GenAIRoleEnum] = Counter(
|
||||||
|
role for genai in config.genai.values() for role in genai.roles
|
||||||
|
)
|
||||||
|
custom = list(config.classification.custom.values())
|
||||||
|
|
||||||
|
return FeaturesSection(
|
||||||
|
face_recognition=EnrichmentUsage(
|
||||||
|
enabled=config.face_recognition.enabled,
|
||||||
|
model_size=config.face_recognition.model_size,
|
||||||
|
device=enrichment_device(devices.get("face_recognition")),
|
||||||
|
),
|
||||||
|
lpr=EnrichmentUsage(
|
||||||
|
enabled=config.lpr.enabled,
|
||||||
|
model_size=config.lpr.model_size,
|
||||||
|
device=enrichment_device(devices.get("lpr")),
|
||||||
|
),
|
||||||
|
semantic_search=SemanticSearchUsage(
|
||||||
|
enabled=config.semantic_search.enabled,
|
||||||
|
model=semantic_model(config.semantic_search.model),
|
||||||
|
model_size=config.semantic_search.model_size,
|
||||||
|
device=enrichment_device(devices.get("semantic_search")),
|
||||||
|
triggers=sum(len(camera.semantic_search.triggers) for camera in cameras),
|
||||||
|
),
|
||||||
|
audio_transcription=TranscriptionUsage(
|
||||||
|
enabled=config.audio_transcription.enabled,
|
||||||
|
model=transcription_model(config.audio_transcription.model),
|
||||||
|
model_size=config.audio_transcription.model_size,
|
||||||
|
),
|
||||||
|
genai=GenAIUsage(providers=histogram(providers), roles=histogram(roles)),
|
||||||
|
classification_models=ClassificationUsage(
|
||||||
|
state=sum(1 for model in custom if model.state_config is not None),
|
||||||
|
object=sum(1 for model in custom if model.object_config is not None),
|
||||||
|
),
|
||||||
|
birdseye=BirdseyeUsage(
|
||||||
|
enabled=config.birdseye.enabled,
|
||||||
|
modes=list(config.birdseye.modes),
|
||||||
|
restream=config.birdseye.restream,
|
||||||
|
),
|
||||||
|
mqtt=config.mqtt.enabled,
|
||||||
|
notifications=config.notifications.enabled,
|
||||||
|
auth=config.auth.enabled,
|
||||||
|
proxy_auth=config.proxy.header_map.user is not None,
|
||||||
|
tls=config.tls.enabled,
|
||||||
|
users=users(),
|
||||||
|
camera_groups=len(config.camera_groups),
|
||||||
|
profiles=len(config.profiles),
|
||||||
|
plus_api_key=config.plus_api.is_active(),
|
||||||
|
)
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""Hardware section: CPU, memory, GPUs, decode and detection hardware, storage."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from collections import Counter
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import psutil
|
||||||
|
|
||||||
|
from frigate.analytics.collectors.common import closed, histogram
|
||||||
|
from frigate.analytics.context import ReportContext
|
||||||
|
from frigate.analytics.schema import (
|
||||||
|
DecodeFamily,
|
||||||
|
GpuInfo,
|
||||||
|
GpuVendor,
|
||||||
|
HardwareKey,
|
||||||
|
HardwareSection,
|
||||||
|
StorageInfo,
|
||||||
|
)
|
||||||
|
from frigate.const import RECORD_DIR
|
||||||
|
from frigate.detectors.hardware import hardware_prober
|
||||||
|
from frigate.util.hwaccel import hwaccel_options
|
||||||
|
|
||||||
|
DEVICE_TREE_MODEL = "/proc/device-tree/model"
|
||||||
|
CPUINFO = "/proc/cpuinfo"
|
||||||
|
|
||||||
|
|
||||||
|
def cpu_model() -> str:
|
||||||
|
"""The board model on ARM boards, else the CPU's model name."""
|
||||||
|
try:
|
||||||
|
with open(DEVICE_TREE_MODEL) as f:
|
||||||
|
board = f.read().strip("\x00\n ")
|
||||||
|
|
||||||
|
if board:
|
||||||
|
return board[:64]
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(CPUINFO) as f:
|
||||||
|
for line in f:
|
||||||
|
key, _, value = line.partition(":")
|
||||||
|
|
||||||
|
if key.strip() == "model name" and value.strip():
|
||||||
|
return value.strip()[:64]
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def gpus(stats: dict[str, Any]) -> list[GpuInfo]:
|
||||||
|
found: list[GpuInfo] = []
|
||||||
|
|
||||||
|
for name, entry in stats.get("gpu_usages", {}).items():
|
||||||
|
vendor = entry.get("vendor") if isinstance(entry, dict) else None
|
||||||
|
found.append(
|
||||||
|
GpuInfo(
|
||||||
|
vendor=closed(GpuVendor, vendor, GpuVendor.other),
|
||||||
|
name=str(name)[:64],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def decode_families() -> list[DecodeFamily]:
|
||||||
|
_, available = hwaccel_options()
|
||||||
|
families = [
|
||||||
|
closed(DecodeFamily, family.key, DecodeFamily.other) for family in available
|
||||||
|
]
|
||||||
|
return list(dict.fromkeys(families))
|
||||||
|
|
||||||
|
|
||||||
|
def detection_hardware() -> dict[HardwareKey, int]:
|
||||||
|
units: Counter[HardwareKey] = Counter()
|
||||||
|
|
||||||
|
for found in hardware_prober.probe():
|
||||||
|
units[closed(HardwareKey, found.key, HardwareKey.other)] += found.count
|
||||||
|
|
||||||
|
return histogram(units)
|
||||||
|
|
||||||
|
|
||||||
|
def storage(stats: dict[str, Any]) -> StorageInfo:
|
||||||
|
# stats report sizes in MB
|
||||||
|
entry = stats.get("service", {}).get("storage", {}).get(RECORD_DIR) or {}
|
||||||
|
total_mb = float(entry.get("total") or 0)
|
||||||
|
used_mb = float(entry.get("used") or 0)
|
||||||
|
|
||||||
|
return StorageInfo(
|
||||||
|
record_fs=str(entry.get("mount_type") or "unknown")[:16],
|
||||||
|
record_total_gb=round(total_mb / 1024),
|
||||||
|
record_used_pct=min(round(used_mb / total_mb * 100), 100)
|
||||||
|
if total_mb > 0
|
||||||
|
else 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def collect(ctx: ReportContext) -> HardwareSection:
|
||||||
|
return HardwareSection(
|
||||||
|
cpu_model=cpu_model(),
|
||||||
|
cpu_cores=os.cpu_count() or 0,
|
||||||
|
memory_gb=round(psutil.virtual_memory().total / 2**30),
|
||||||
|
gpus=gpus(ctx.stats),
|
||||||
|
decode_families=decode_families(),
|
||||||
|
detection_hardware=detection_hardware(),
|
||||||
|
storage=storage(ctx.stats),
|
||||||
|
)
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Health section: uptime, CPU, enrichment speed, and notice counts."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from frigate.analytics.collectors.common import rate
|
||||||
|
from frigate.analytics.context import ReportContext
|
||||||
|
from frigate.analytics.schema import (
|
||||||
|
EnrichmentTiming,
|
||||||
|
HealthSection,
|
||||||
|
NoticeCounts,
|
||||||
|
NoticeKindKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
TIMING_STATS = {
|
||||||
|
EnrichmentTiming.face: "face_recognition_speed",
|
||||||
|
EnrichmentTiming.lpr: "plate_recognition_speed",
|
||||||
|
EnrichmentTiming.plate_detection: "yolov9_plate_detection_speed",
|
||||||
|
EnrichmentTiming.image_embedding: "image_embedding_speed",
|
||||||
|
EnrichmentTiming.text_embedding: "text_embedding_speed",
|
||||||
|
EnrichmentTiming.review_description: "review_description_speed",
|
||||||
|
EnrichmentTiming.object_description: "object_description_speed",
|
||||||
|
}
|
||||||
|
REPORTABLE_KINDS = frozenset(key.value for key in NoticeKindKey)
|
||||||
|
|
||||||
|
|
||||||
|
def notice_deltas(notice_stats: list[dict[str, Any]]) -> dict[Any, NoticeCounts]:
|
||||||
|
"""What changed since the last accepted report, per reportable kind."""
|
||||||
|
deltas: dict[Any, NoticeCounts] = {}
|
||||||
|
|
||||||
|
for row in notice_stats:
|
||||||
|
if row["kind"] not in REPORTABLE_KINDS:
|
||||||
|
continue
|
||||||
|
|
||||||
|
occurrences = max(row["occurrences"] - row["reported_occurrences"], 0)
|
||||||
|
dismissals = max(row["dismissals"] - row["reported_dismissals"], 0)
|
||||||
|
|
||||||
|
if occurrences or dismissals:
|
||||||
|
deltas[NoticeKindKey(row["kind"])] = NoticeCounts(
|
||||||
|
occurrences=occurrences, dismissals=dismissals
|
||||||
|
)
|
||||||
|
|
||||||
|
return deltas
|
||||||
|
|
||||||
|
|
||||||
|
def collect(ctx: ReportContext) -> HealthSection:
|
||||||
|
service = ctx.stats.get("service", {})
|
||||||
|
embeddings = ctx.stats.get("embeddings", {})
|
||||||
|
cpu = ctx.stats.get("cpu_usages", {}).get("frigate.full_system", {}).get("cpu")
|
||||||
|
timings = {
|
||||||
|
timing: rate(embeddings.get(key)) for timing, key in TIMING_STATS.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
return HealthSection(
|
||||||
|
uptime_hours=int(rate(service.get("uptime")) // 3600),
|
||||||
|
cpu_percent=min(round(rate(cpu)), 100),
|
||||||
|
enrichment_ms={timing: value for timing, value in timings.items() if value > 0},
|
||||||
|
retention_unmet=bool(service.get("retention_unmet", False)),
|
||||||
|
notices=notice_deltas(ctx.notice_stats),
|
||||||
|
)
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Install section: version, image variant, install type, platform."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import re
|
||||||
|
|
||||||
|
from frigate.analytics.collectors.common import closed
|
||||||
|
from frigate.analytics.context import ReportContext
|
||||||
|
from frigate.analytics.schema import Arch, ImageVariant, InstallSection, InstallType
|
||||||
|
from frigate.version import VERSION
|
||||||
|
|
||||||
|
KERNEL_PATTERN = re.compile(r"^(\d{1,3})\.(\d{1,3})")
|
||||||
|
|
||||||
|
|
||||||
|
def image_variant(value: str | None) -> ImageVariant:
|
||||||
|
"""The published image from the build-time FRIGATE_IMAGE_VARIANT, dev when unset."""
|
||||||
|
if not value:
|
||||||
|
return ImageVariant.dev
|
||||||
|
|
||||||
|
return closed(ImageVariant, value, ImageVariant.other)
|
||||||
|
|
||||||
|
|
||||||
|
def install_type() -> InstallType:
|
||||||
|
# the add-on is a container too, so it has to be checked first
|
||||||
|
if os.path.isfile("/data/options.json"):
|
||||||
|
return InstallType.ha_addon
|
||||||
|
|
||||||
|
if os.environ.get("KUBERNETES_SERVICE_HOST"):
|
||||||
|
return InstallType.kubernetes
|
||||||
|
|
||||||
|
if os.path.exists("/run/.containerenv"):
|
||||||
|
return InstallType.podman
|
||||||
|
|
||||||
|
if os.path.exists("/.dockerenv"):
|
||||||
|
return InstallType.docker
|
||||||
|
|
||||||
|
return InstallType.unknown
|
||||||
|
|
||||||
|
|
||||||
|
def arch(machine: str) -> Arch:
|
||||||
|
match machine.lower():
|
||||||
|
case "x86_64" | "amd64":
|
||||||
|
return Arch.x86_64
|
||||||
|
case "aarch64" | "arm64":
|
||||||
|
return Arch.aarch64
|
||||||
|
case _:
|
||||||
|
return Arch.other
|
||||||
|
|
||||||
|
|
||||||
|
def kernel(release: str) -> str:
|
||||||
|
match = KERNEL_PATTERN.match(release)
|
||||||
|
return f"{match.group(1)}.{match.group(2)}" if match else "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def collect(ctx: ReportContext) -> InstallSection:
|
||||||
|
return InstallSection(
|
||||||
|
version=VERSION[:32],
|
||||||
|
image_variant=image_variant(os.environ.get("FRIGATE_IMAGE_VARIANT")),
|
||||||
|
install_type=install_type(),
|
||||||
|
arch=arch(platform.machine()),
|
||||||
|
kernel=kernel(platform.release()),
|
||||||
|
run_as_root=os.geteuid() == 0,
|
||||||
|
)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"""What the collectors read, gathered once per report."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from frigate.config import FrigateConfig
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ReportContext:
|
||||||
|
config: FrigateConfig
|
||||||
|
stats: dict[str, Any]
|
||||||
|
notice_stats: list[dict[str, Any]] = field(default_factory=list)
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""Build a report from the section collectors."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from frigate.analytics.collectors import (
|
||||||
|
cameras,
|
||||||
|
detection,
|
||||||
|
features,
|
||||||
|
hardware,
|
||||||
|
health,
|
||||||
|
install,
|
||||||
|
)
|
||||||
|
from frigate.analytics.context import ReportContext
|
||||||
|
from frigate.analytics.schema import SCHEMA_VERSION, AnalyticsReport
|
||||||
|
from frigate.analytics.state import load_state
|
||||||
|
from frigate.config import FrigateConfig
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from frigate.notices.registry import NoticeRegistry
|
||||||
|
from frigate.stats.emitter import StatsEmitter
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
COLLECTORS: dict[str, Callable[[ReportContext], BaseModel | None]] = {
|
||||||
|
"install": install.collect,
|
||||||
|
"hardware": hardware.collect,
|
||||||
|
"detection": detection.collect,
|
||||||
|
"cameras": cameras.collect,
|
||||||
|
"features": features.collect,
|
||||||
|
"health": health.collect,
|
||||||
|
}
|
||||||
|
|
||||||
|
# shown in the preview until the first report creates a real ID
|
||||||
|
PREVIEW_INSTALL_ID = "0" * 32
|
||||||
|
|
||||||
|
|
||||||
|
def build_report(
|
||||||
|
ctx: ReportContext, install_id: str, sent_at: int | None = None
|
||||||
|
) -> AnalyticsReport:
|
||||||
|
"""Run every collector; one that fails sends its section as null."""
|
||||||
|
sections: dict[str, Any] = {}
|
||||||
|
|
||||||
|
for name, collect in COLLECTORS.items():
|
||||||
|
try:
|
||||||
|
sections[name] = collect(ctx)
|
||||||
|
except Exception:
|
||||||
|
# a collector bug must cost one section, never the whole report
|
||||||
|
logger.warning("Analytics %s section failed", name, exc_info=True)
|
||||||
|
sections[name] = None
|
||||||
|
|
||||||
|
return AnalyticsReport.model_validate(
|
||||||
|
{
|
||||||
|
"schema_version": SCHEMA_VERSION,
|
||||||
|
"install_id": install_id,
|
||||||
|
"report_id": str(uuid4()),
|
||||||
|
"sent_at": int(time.time()) if sent_at is None else sent_at,
|
||||||
|
**sections,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def gather_context(
|
||||||
|
config: FrigateConfig,
|
||||||
|
stats_emitter: "StatsEmitter | None",
|
||||||
|
notice_registry: "NoticeRegistry | None",
|
||||||
|
) -> ReportContext:
|
||||||
|
return ReportContext(
|
||||||
|
config=config,
|
||||||
|
stats=stats_emitter.get_latest_stats() if stats_emitter is not None else {},
|
||||||
|
notice_stats=notice_registry.stats() if notice_registry is not None else [],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def preview_report(
|
||||||
|
config: FrigateConfig,
|
||||||
|
stats_emitter: "StatsEmitter | None",
|
||||||
|
notice_registry: "NoticeRegistry | None",
|
||||||
|
) -> AnalyticsReport:
|
||||||
|
"""The report the next send would carry, without sending it."""
|
||||||
|
state = load_state()
|
||||||
|
ctx = gather_context(config, stats_emitter, notice_registry)
|
||||||
|
return build_report(ctx, state.install_id if state else PREVIEW_INSTALL_ID)
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"""Send one analytics report a day while the admin has opted in."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
|
from multiprocessing.synchronize import Event as MpEvent
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from frigate.analytics.context import ReportContext
|
||||||
|
from frigate.analytics.report import build_report
|
||||||
|
from frigate.analytics.state import (
|
||||||
|
STATE_PATH,
|
||||||
|
AnalyticsState,
|
||||||
|
delete_state,
|
||||||
|
load_state,
|
||||||
|
new_state,
|
||||||
|
save_state,
|
||||||
|
)
|
||||||
|
from frigate.analytics.transport import SendOutcome, send_report
|
||||||
|
from frigate.config import FrigateConfig
|
||||||
|
from frigate.config.holder import ConfigHolder
|
||||||
|
from frigate.const import ANALYTICS_URL
|
||||||
|
from frigate.notices import raise_notice, resolve_notice
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from frigate.notices.registry import NoticeRegistry
|
||||||
|
from frigate.stats.emitter import StatsEmitter
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
WAKE_S = 10 * 60
|
||||||
|
INTERVAL_S = 24 * 60 * 60
|
||||||
|
JITTER_S = 60 * 60
|
||||||
|
FIRST_DELAY_S = (15 * 60, 45 * 60)
|
||||||
|
PROMPT_KIND = "analytics_prompt"
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsReporter(threading.Thread):
|
||||||
|
"""Follows the live config, so a settings save needs no restart."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config_holder: ConfigHolder,
|
||||||
|
stats_emitter: "StatsEmitter",
|
||||||
|
notice_registry: "NoticeRegistry",
|
||||||
|
stop_event: MpEvent | threading.Event,
|
||||||
|
*,
|
||||||
|
state_path: str = STATE_PATH,
|
||||||
|
url: str = ANALYTICS_URL,
|
||||||
|
send: Callable[[str, str], SendOutcome] = send_report,
|
||||||
|
clock: Callable[[], float] = time.time,
|
||||||
|
rng: random.Random | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(name="analytics_reporter", daemon=True)
|
||||||
|
self.config_holder = config_holder
|
||||||
|
self.stats_emitter = stats_emitter
|
||||||
|
self.notice_registry = notice_registry
|
||||||
|
self.stop_event = stop_event
|
||||||
|
self.state_path = state_path
|
||||||
|
self.url = url
|
||||||
|
self.send = send
|
||||||
|
self.clock = clock
|
||||||
|
self.rng = rng or random.Random()
|
||||||
|
self.first_due = clock() + self.rng.uniform(*FIRST_DELAY_S)
|
||||||
|
self.interval = self._next_interval()
|
||||||
|
self.opted_in: bool | None = None
|
||||||
|
self.warned_unwritable = False
|
||||||
|
# a settings save runs on an API thread while a wake may be mid-attempt
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
config_holder.subscribe(self._on_config)
|
||||||
|
|
||||||
|
def _next_interval(self) -> float:
|
||||||
|
return INTERVAL_S + self.rng.uniform(-JITTER_S, JITTER_S)
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
self.tick()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Analytics reporter failed")
|
||||||
|
|
||||||
|
if self.stop_event.wait(WAKE_S):
|
||||||
|
break
|
||||||
|
|
||||||
|
def _on_config(self, config: FrigateConfig) -> None:
|
||||||
|
# a save can turn sharing off and back on between two wakes, so an
|
||||||
|
# opt-out is handled when it's saved rather than at the next wake
|
||||||
|
with self._lock:
|
||||||
|
if not config.safe_mode:
|
||||||
|
self._apply_consent(config.telemetry.analytics)
|
||||||
|
|
||||||
|
def _apply_consent(self, opted_in: bool) -> None:
|
||||||
|
# called with the lock held
|
||||||
|
if opted_in == self.opted_in:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.opted_in = opted_in
|
||||||
|
|
||||||
|
if opted_in:
|
||||||
|
resolve_notice(PROMPT_KIND)
|
||||||
|
else:
|
||||||
|
raise_notice(PROMPT_KIND)
|
||||||
|
delete_state(self.state_path)
|
||||||
|
|
||||||
|
def tick(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
config = self.config_holder.config
|
||||||
|
|
||||||
|
# safe mode parses a default config where analytics reads as off,
|
||||||
|
# and handling that as an opt-out would delete the install ID
|
||||||
|
if config.safe_mode:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._apply_consent(config.telemetry.analytics)
|
||||||
|
|
||||||
|
if not config.telemetry.analytics:
|
||||||
|
return
|
||||||
|
|
||||||
|
now = self.clock()
|
||||||
|
|
||||||
|
if now < self.first_due:
|
||||||
|
return
|
||||||
|
|
||||||
|
state = load_state(self.state_path) or new_state()
|
||||||
|
|
||||||
|
if not self._due(state.last_attempt_at, now):
|
||||||
|
return
|
||||||
|
|
||||||
|
# saved before sending, so a failing endpoint or a crash mid-send
|
||||||
|
# still waits a full interval; without saved state every boot would
|
||||||
|
# send under a new install ID
|
||||||
|
if not save_state(AnalyticsState(state.install_id, now), self.state_path):
|
||||||
|
if not self.warned_unwritable:
|
||||||
|
logger.warning(
|
||||||
|
"Analytics is on, but %s isn't writable, so no report is sent",
|
||||||
|
self.state_path,
|
||||||
|
)
|
||||||
|
self.warned_unwritable = True
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
self.interval = self._next_interval()
|
||||||
|
|
||||||
|
# the lock stays free during the request, so a save never waits on it
|
||||||
|
self._send(state.install_id, now)
|
||||||
|
|
||||||
|
def _due(self, last_attempt_at: float, now: float) -> bool:
|
||||||
|
# a last attempt stamped in the future came from a wrong clock
|
||||||
|
return (
|
||||||
|
now >= last_attempt_at + self.interval or last_attempt_at > now + INTERVAL_S
|
||||||
|
)
|
||||||
|
|
||||||
|
def _send(self, install_id: str, now: float) -> None:
|
||||||
|
notice_stats = self.notice_registry.stats()
|
||||||
|
ctx = ReportContext(
|
||||||
|
config=self.config_holder.config,
|
||||||
|
stats=self.stats_emitter.get_latest_stats(),
|
||||||
|
notice_stats=notice_stats,
|
||||||
|
)
|
||||||
|
report = build_report(ctx, install_id, sent_at=int(now))
|
||||||
|
body = report.model_dump_json()
|
||||||
|
|
||||||
|
# consent can be withdrawn while the report builds
|
||||||
|
if not self.config_holder.config.telemetry.analytics:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.send(self.url, body) is not SendOutcome.accepted:
|
||||||
|
return
|
||||||
|
|
||||||
|
# a failed health section sent no notice counts, so they stay pending
|
||||||
|
if report.health is not None:
|
||||||
|
self.notice_registry.mark_reported(notice_stats)
|
||||||
|
|
||||||
|
logger.info("Sent the daily analytics report")
|
||||||
|
logger.debug("Analytics report: %s", body)
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
"""Models for the analytics report, the contract with the ingest endpoint.
|
||||||
|
|
||||||
|
Every field carries a description and an x-public flag, and no field accepts
|
||||||
|
user-entered text, so a report can't carry camera names or other free text.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from enum import StrEnum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from frigate.config.camera.birdseye import BirdseyeModeEnum
|
||||||
|
from frigate.config.camera.camera import CameraTypeEnum
|
||||||
|
from frigate.config.camera.genai import GenAIProviderEnum, GenAIRoleEnum
|
||||||
|
from frigate.config.classification import ModelSizeEnum
|
||||||
|
from frigate.detectors.detector_config import ModelTypeEnum, SceneEnum
|
||||||
|
from frigate.detectors.detector_types import DetectorTypeEnum
|
||||||
|
from frigate.ffmpeg_presets import PRESETS_HW_ACCEL_DECODE, PRESETS_INPUT
|
||||||
|
from frigate.notices.types import NOTICE_KINDS
|
||||||
|
|
||||||
|
SCHEMA_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
|
class ImageVariant(StrEnum):
|
||||||
|
standard = "standard"
|
||||||
|
rpi = "rpi"
|
||||||
|
tensorrt = "tensorrt"
|
||||||
|
tensorrt_jp6 = "tensorrt-jp6"
|
||||||
|
rocm = "rocm"
|
||||||
|
rk = "rk"
|
||||||
|
synaptics = "synaptics"
|
||||||
|
dev = "dev"
|
||||||
|
other = "other"
|
||||||
|
|
||||||
|
|
||||||
|
class InstallType(StrEnum):
|
||||||
|
ha_addon = "ha_addon"
|
||||||
|
docker = "docker"
|
||||||
|
podman = "podman"
|
||||||
|
kubernetes = "kubernetes"
|
||||||
|
unknown = "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
class Arch(StrEnum):
|
||||||
|
x86_64 = "x86_64"
|
||||||
|
aarch64 = "aarch64"
|
||||||
|
other = "other"
|
||||||
|
|
||||||
|
|
||||||
|
class GpuVendor(StrEnum):
|
||||||
|
intel = "intel"
|
||||||
|
amd = "amd"
|
||||||
|
nvidia = "nvidia"
|
||||||
|
rockchip = "rockchip"
|
||||||
|
rpi = "rpi"
|
||||||
|
other = "other"
|
||||||
|
|
||||||
|
|
||||||
|
class DecodeFamily(StrEnum):
|
||||||
|
nvidia = "nvidia"
|
||||||
|
vaapi = "vaapi"
|
||||||
|
rkmpp = "rkmpp"
|
||||||
|
intel_qsv = "intel-qsv"
|
||||||
|
jetson = "jetson"
|
||||||
|
rpi = "rpi"
|
||||||
|
other = "other"
|
||||||
|
|
||||||
|
|
||||||
|
class HardwareKey(StrEnum):
|
||||||
|
edgetpu_pci = "edgetpu:pci"
|
||||||
|
edgetpu_usb = "edgetpu:usb"
|
||||||
|
openvino_gpu = "openvino:GPU"
|
||||||
|
openvino_npu = "openvino:NPU"
|
||||||
|
onnx_amd = "onnx:amd"
|
||||||
|
onnx_nvidia = "onnx:nvidia"
|
||||||
|
tensorrt = "tensorrt"
|
||||||
|
hailo = "hailo"
|
||||||
|
memryx = "memryx"
|
||||||
|
deepx = "deepx"
|
||||||
|
rknn = "rknn"
|
||||||
|
axengine = "axengine"
|
||||||
|
synaptics = "synaptics"
|
||||||
|
cpu = "cpu"
|
||||||
|
other = "other"
|
||||||
|
|
||||||
|
|
||||||
|
class ModelSource(StrEnum):
|
||||||
|
default = "default"
|
||||||
|
plus = "plus"
|
||||||
|
custom = "custom"
|
||||||
|
|
||||||
|
|
||||||
|
class HeightBucket(StrEnum):
|
||||||
|
le_360 = "le_360"
|
||||||
|
h480 = "480"
|
||||||
|
h720 = "720"
|
||||||
|
h1080 = "1080"
|
||||||
|
h1440 = "1440"
|
||||||
|
ge_2160 = "ge_2160"
|
||||||
|
|
||||||
|
|
||||||
|
class FpsBucket(StrEnum):
|
||||||
|
le_5 = "le_5"
|
||||||
|
f6_10 = "6_10"
|
||||||
|
gt_10 = "gt_10"
|
||||||
|
|
||||||
|
|
||||||
|
class RetainBucket(StrEnum):
|
||||||
|
zero = "0"
|
||||||
|
d1_7 = "1_7"
|
||||||
|
d8_30 = "8_30"
|
||||||
|
gt_30 = "gt_30"
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionQuality(StrEnum):
|
||||||
|
excellent = "excellent"
|
||||||
|
fair = "fair"
|
||||||
|
poor = "poor"
|
||||||
|
unusable = "unusable"
|
||||||
|
|
||||||
|
|
||||||
|
class EnrichmentDevice(StrEnum):
|
||||||
|
cpu = "cpu"
|
||||||
|
cuda = "cuda"
|
||||||
|
tensorrt = "tensorrt"
|
||||||
|
migraphx = "migraphx"
|
||||||
|
openvino_cpu = "openvino_cpu"
|
||||||
|
openvino_gpu = "openvino_gpu"
|
||||||
|
openvino_npu = "openvino_npu"
|
||||||
|
other = "other"
|
||||||
|
|
||||||
|
|
||||||
|
class SemanticSearchModel(StrEnum):
|
||||||
|
jinav1 = "jinav1"
|
||||||
|
jinav2 = "jinav2"
|
||||||
|
genai = "genai"
|
||||||
|
|
||||||
|
|
||||||
|
class TranscriptionModel(StrEnum):
|
||||||
|
whisper = "whisper"
|
||||||
|
genai = "genai"
|
||||||
|
|
||||||
|
|
||||||
|
class UserRole(StrEnum):
|
||||||
|
admin = "admin"
|
||||||
|
viewer = "viewer"
|
||||||
|
custom = "custom"
|
||||||
|
|
||||||
|
|
||||||
|
class EnrichmentTiming(StrEnum):
|
||||||
|
face = "face"
|
||||||
|
lpr = "lpr"
|
||||||
|
plate_detection = "plate_detection"
|
||||||
|
image_embedding = "image_embedding"
|
||||||
|
text_embedding = "text_embedding"
|
||||||
|
review_description = "review_description"
|
||||||
|
object_description = "object_description"
|
||||||
|
|
||||||
|
|
||||||
|
def _preset_keys(presets: dict[str, Any]) -> dict[str, str]:
|
||||||
|
keys = [name.removeprefix("preset-") for name in presets]
|
||||||
|
return {key: key for key in [*keys, "custom", "none"]}
|
||||||
|
|
||||||
|
|
||||||
|
# built from the registries they mirror, so a new preset or notice kind reaches
|
||||||
|
# the schema through generate_analytics_schema.py instead of a hand edit
|
||||||
|
HwaccelKey = StrEnum("HwaccelKey", _preset_keys(PRESETS_HW_ACCEL_DECODE)) # type: ignore[misc]
|
||||||
|
InputPresetKey = StrEnum("InputPresetKey", _preset_keys(PRESETS_INPUT)) # type: ignore[misc]
|
||||||
|
NoticeKindKey = StrEnum( # type: ignore[misc]
|
||||||
|
"NoticeKindKey",
|
||||||
|
{key: key for key, kind in NOTICE_KINDS.items() if kind.reportable},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsModel(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
||||||
|
def metric(description: str, *, public: bool = True, **kwargs: Any) -> Any:
|
||||||
|
"""Declare a report field; the description and flag land in the schema."""
|
||||||
|
return Field(
|
||||||
|
description=description, json_schema_extra={"x-public": public}, **kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def count(description: str) -> Any:
|
||||||
|
return metric(description, ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class InstallSection(AnalyticsModel):
|
||||||
|
version: str = metric("Frigate version string", max_length=32)
|
||||||
|
image_variant: ImageVariant = metric("Published image the install runs")
|
||||||
|
install_type: InstallType = metric("How Frigate is installed")
|
||||||
|
arch: Arch = metric("CPU architecture")
|
||||||
|
kernel: str = metric(
|
||||||
|
"Host kernel as major.minor", pattern=r"^(\d{1,3}\.\d{1,3}|unknown)$"
|
||||||
|
)
|
||||||
|
run_as_root: bool = metric("Whether the main process runs as root")
|
||||||
|
|
||||||
|
|
||||||
|
class GpuInfo(AnalyticsModel):
|
||||||
|
vendor: GpuVendor = metric("GPU vendor")
|
||||||
|
name: str = metric("GPU name as the hardware reports it", max_length=64)
|
||||||
|
|
||||||
|
|
||||||
|
class StorageInfo(AnalyticsModel):
|
||||||
|
record_fs: str = metric("Filesystem of the recordings volume", max_length=16)
|
||||||
|
record_total_gb: int = count("Size of the recordings volume in GB")
|
||||||
|
record_used_pct: int = metric(
|
||||||
|
"Percent of the recordings volume in use", ge=0, le=100
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HardwareSection(AnalyticsModel):
|
||||||
|
cpu_model: str = metric(
|
||||||
|
"CPU or board model as the hardware reports it", max_length=64
|
||||||
|
)
|
||||||
|
cpu_cores: int = count("Logical CPU count")
|
||||||
|
memory_gb: int = count("Total memory in GB")
|
||||||
|
gpus: list[GpuInfo] = metric("GPUs the stats collector found")
|
||||||
|
decode_families: list[DecodeFamily] = metric(
|
||||||
|
"Hardware decode families this system can use"
|
||||||
|
)
|
||||||
|
detection_hardware: dict[HardwareKey, int] = metric(
|
||||||
|
"Detection hardware found, as unit counts by kind"
|
||||||
|
)
|
||||||
|
storage: StorageInfo = metric("Recordings storage")
|
||||||
|
|
||||||
|
|
||||||
|
class DetectionModel(AnalyticsModel):
|
||||||
|
detector: DetectorTypeEnum = metric("Detector type the model runs on")
|
||||||
|
devices: int = count("Devices the model runs on")
|
||||||
|
model_type: ModelTypeEnum = metric("Model architecture")
|
||||||
|
input: str = metric("Model input size as WxH", pattern=r"^\d{1,5}x\d{1,5}$")
|
||||||
|
source: ModelSource = metric("Where the model came from", public=False)
|
||||||
|
inference_ms: float | None = metric(
|
||||||
|
"Mean inference time across the model's detector processes, null before the first stats",
|
||||||
|
ge=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DetectionSection(AnalyticsModel):
|
||||||
|
models: dict[SceneEnum, DetectionModel] = metric("Detection models keyed by scene")
|
||||||
|
detection_fps: float = metric("Detections per second across cameras", ge=0)
|
||||||
|
skipped_fps: float = metric("Frames per second skipped across cameras", ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class RetainDays(AnalyticsModel):
|
||||||
|
continuous: dict[RetainBucket, int] = metric(
|
||||||
|
"Recording cameras by continuous retention in days"
|
||||||
|
)
|
||||||
|
motion: dict[RetainBucket, int] = metric(
|
||||||
|
"Recording cameras by motion retention in days"
|
||||||
|
)
|
||||||
|
alerts: dict[RetainBucket, int] = metric(
|
||||||
|
"Recording cameras by alert retention in days"
|
||||||
|
)
|
||||||
|
detections: dict[RetainBucket, int] = metric(
|
||||||
|
"Recording cameras by detection retention in days"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CamerasSection(AnalyticsModel):
|
||||||
|
total: int = count("Configured cameras")
|
||||||
|
enabled: int = count("Enabled cameras")
|
||||||
|
types: dict[CameraTypeEnum, int] = metric("Cameras by type")
|
||||||
|
detect_height: dict[HeightBucket, int] = metric(
|
||||||
|
"Cameras by detect resolution height"
|
||||||
|
)
|
||||||
|
detect_fps: dict[FpsBucket, int] = metric("Cameras by detect fps")
|
||||||
|
hwaccel: dict[HwaccelKey, int] = metric(
|
||||||
|
"Cameras by the resolved hwaccel preset of the detect input"
|
||||||
|
)
|
||||||
|
input_preset: dict[InputPresetKey, int] = metric(
|
||||||
|
"Cameras by the input preset of the detect input"
|
||||||
|
)
|
||||||
|
go2rtc_restream: int = count("Cameras with an input from the go2rtc restream")
|
||||||
|
separate_detect_stream: int = count(
|
||||||
|
"Cameras whose detect input differs from their record input"
|
||||||
|
)
|
||||||
|
detect: int = count("Cameras with detection on")
|
||||||
|
record: int = count("Cameras with recording on")
|
||||||
|
sub_stream_record: int = count("Cameras with sub stream recording on")
|
||||||
|
snapshots: int = count("Cameras with snapshots on")
|
||||||
|
audio: int = count("Cameras with audio detection on")
|
||||||
|
audio_transcription: int = count("Cameras with audio transcription on")
|
||||||
|
birdseye: int = count("Cameras in birdseye")
|
||||||
|
onvif: int = count("Cameras with an ONVIF host")
|
||||||
|
autotracking: int = count("Cameras with PTZ autotracking on")
|
||||||
|
face_recognition: int = count("Cameras with face recognition on")
|
||||||
|
lpr: int = count("Cameras with license plate recognition on")
|
||||||
|
review_genai: int = count("Cameras with GenAI review summaries on")
|
||||||
|
object_genai: int = count("Cameras with GenAI object descriptions on")
|
||||||
|
notifications: int = count("Cameras with notifications on")
|
||||||
|
zones: int = count("Zones across cameras")
|
||||||
|
cameras_with_zones: int = count("Cameras with at least one zone")
|
||||||
|
motion_masks: int = count("Motion masks across cameras")
|
||||||
|
object_masks: int = count("Object masks across cameras")
|
||||||
|
connection_quality: dict[ConnectionQuality, int] = metric(
|
||||||
|
"Cameras by connection quality at send time"
|
||||||
|
)
|
||||||
|
retain_days: RetainDays = metric("Recording retention")
|
||||||
|
|
||||||
|
|
||||||
|
class EnrichmentUsage(AnalyticsModel):
|
||||||
|
enabled: bool = metric("Whether the enrichment is on")
|
||||||
|
model_size: ModelSizeEnum = metric("Configured model size")
|
||||||
|
device: EnrichmentDevice | None = metric(
|
||||||
|
"Device the model loaded on, null when it isn't loaded"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SemanticSearchUsage(AnalyticsModel):
|
||||||
|
enabled: bool = metric("Whether semantic search is on")
|
||||||
|
model: SemanticSearchModel | None = metric(
|
||||||
|
"Embedding model, genai for a GenAI provider"
|
||||||
|
)
|
||||||
|
model_size: ModelSizeEnum = metric("Configured model size")
|
||||||
|
device: EnrichmentDevice | None = metric(
|
||||||
|
"Device the model loaded on, null when it isn't loaded"
|
||||||
|
)
|
||||||
|
triggers: int = count("Semantic search triggers across cameras")
|
||||||
|
|
||||||
|
|
||||||
|
class TranscriptionUsage(AnalyticsModel):
|
||||||
|
enabled: bool = metric("Whether audio transcription is on")
|
||||||
|
model: TranscriptionModel | None = metric(
|
||||||
|
"Transcription model, genai for a GenAI provider"
|
||||||
|
)
|
||||||
|
model_size: ModelSizeEnum = metric("Configured model size")
|
||||||
|
|
||||||
|
|
||||||
|
class GenAIUsage(AnalyticsModel):
|
||||||
|
providers: dict[GenAIProviderEnum, int] = metric(
|
||||||
|
"Configured GenAI providers by type"
|
||||||
|
)
|
||||||
|
roles: dict[GenAIRoleEnum, int] = metric("Configured GenAI providers by role")
|
||||||
|
|
||||||
|
|
||||||
|
class ClassificationUsage(AnalyticsModel):
|
||||||
|
state: int = count("Custom state classification models")
|
||||||
|
object: int = count("Custom object classification models")
|
||||||
|
|
||||||
|
|
||||||
|
class BirdseyeUsage(AnalyticsModel):
|
||||||
|
enabled: bool = metric("Whether birdseye is on")
|
||||||
|
modes: list[BirdseyeModeEnum] = metric("Birdseye modes")
|
||||||
|
restream: bool = metric("Whether birdseye is restreamed")
|
||||||
|
|
||||||
|
|
||||||
|
class FeaturesSection(AnalyticsModel):
|
||||||
|
face_recognition: EnrichmentUsage = metric("Face recognition")
|
||||||
|
lpr: EnrichmentUsage = metric("License plate recognition")
|
||||||
|
semantic_search: SemanticSearchUsage = metric("Semantic search")
|
||||||
|
audio_transcription: TranscriptionUsage = metric("Audio transcription")
|
||||||
|
genai: GenAIUsage = metric("Generative AI providers")
|
||||||
|
classification_models: ClassificationUsage = metric("Custom classification models")
|
||||||
|
birdseye: BirdseyeUsage = metric("Birdseye")
|
||||||
|
mqtt: bool = metric("Whether MQTT is on")
|
||||||
|
notifications: bool = metric("Whether web push notifications are on")
|
||||||
|
auth: bool = metric("Whether authentication is on")
|
||||||
|
proxy_auth: bool = metric("Whether a proxy supplies the user header")
|
||||||
|
tls: bool = metric("Whether TLS is on")
|
||||||
|
users: dict[UserRole, int] = metric("Users by role")
|
||||||
|
camera_groups: int = count("Camera groups")
|
||||||
|
profiles: int = count("Profiles")
|
||||||
|
plus_api_key: bool = metric("Whether a Frigate+ API key is set", public=False)
|
||||||
|
|
||||||
|
|
||||||
|
class NoticeCounts(AnalyticsModel):
|
||||||
|
occurrences: int = count("Occurrences since the last accepted report")
|
||||||
|
dismissals: int = count("Dismissals since the last accepted report")
|
||||||
|
|
||||||
|
|
||||||
|
class HealthSection(AnalyticsModel):
|
||||||
|
uptime_hours: int = count("Hours since Frigate started")
|
||||||
|
cpu_percent: int = metric("System CPU use at send time", ge=0, le=100)
|
||||||
|
enrichment_ms: dict[EnrichmentTiming, float] = metric(
|
||||||
|
"Mean enrichment inference times in milliseconds"
|
||||||
|
)
|
||||||
|
retention_unmet: bool = metric(
|
||||||
|
"Whether storage can't keep the configured retention"
|
||||||
|
)
|
||||||
|
notices: dict[NoticeKindKey, NoticeCounts] = metric(
|
||||||
|
"Notice counts by kind since the last accepted report", public=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsReport(AnalyticsModel):
|
||||||
|
schema_version: int = metric("Report format version", ge=1)
|
||||||
|
install_id: str = metric(
|
||||||
|
"Random install identifier", public=False, pattern=r"^[0-9a-f]{32}$"
|
||||||
|
)
|
||||||
|
report_id: str = metric(
|
||||||
|
"Random identifier of this report",
|
||||||
|
public=False,
|
||||||
|
pattern=r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
|
||||||
|
)
|
||||||
|
sent_at: int = count("Unix time the report was built")
|
||||||
|
install: InstallSection | None = metric("Install, null if its collector failed")
|
||||||
|
hardware: HardwareSection | None = metric("Hardware, null if its collector failed")
|
||||||
|
detection: DetectionSection | None = metric(
|
||||||
|
"Object detection, null if its collector failed"
|
||||||
|
)
|
||||||
|
cameras: CamerasSection | None = metric("Cameras, null if its collector failed")
|
||||||
|
features: FeaturesSection | None = metric("Features, null if its collector failed")
|
||||||
|
health: HealthSection | None = metric("Health, null if its collector failed")
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""The install's analytics identity and last attempt time, kept under /config."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from frigate.const import CONFIG_DIR
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
STATE_PATH = os.path.join(CONFIG_DIR, ".analytics.json")
|
||||||
|
INSTALL_ID_PATTERN = re.compile(r"[0-9a-f]{32}")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AnalyticsState:
|
||||||
|
install_id: str
|
||||||
|
last_attempt_at: float
|
||||||
|
|
||||||
|
|
||||||
|
def new_state() -> AnalyticsState:
|
||||||
|
return AnalyticsState(install_id=uuid4().hex, last_attempt_at=0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def load_state(path: str = STATE_PATH) -> AnalyticsState | None:
|
||||||
|
"""The saved state, or None when the file is missing or unusable."""
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
except (OSError, ValueError):
|
||||||
|
logger.warning("Ignoring unreadable analytics state at %s", path)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
install_id = data.get("install_id")
|
||||||
|
last_attempt_at = data.get("last_attempt_at")
|
||||||
|
|
||||||
|
if (
|
||||||
|
not isinstance(install_id, str)
|
||||||
|
or not INSTALL_ID_PATTERN.fullmatch(install_id)
|
||||||
|
or isinstance(last_attempt_at, bool)
|
||||||
|
or not isinstance(last_attempt_at, int | float)
|
||||||
|
):
|
||||||
|
logger.warning("Ignoring invalid analytics state at %s", path)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return AnalyticsState(install_id=install_id, last_attempt_at=float(last_attempt_at))
|
||||||
|
|
||||||
|
|
||||||
|
def save_state(state: AnalyticsState, path: str = STATE_PATH) -> bool:
|
||||||
|
"""Write the state atomically, returning False when it can't be written."""
|
||||||
|
temp_path = f"{path}.tmp"
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(temp_path, "w") as f:
|
||||||
|
json.dump(
|
||||||
|
{
|
||||||
|
"install_id": state.install_id,
|
||||||
|
"last_attempt_at": state.last_attempt_at,
|
||||||
|
},
|
||||||
|
f,
|
||||||
|
)
|
||||||
|
|
||||||
|
os.replace(temp_path, path)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def delete_state(path: str = STATE_PATH) -> None:
|
||||||
|
"""Forget the install ID, so a later opt-in starts a fresh identity."""
|
||||||
|
try:
|
||||||
|
os.remove(path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
except OSError:
|
||||||
|
logger.warning("Unable to delete analytics state at %s", path)
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""POST a report to the ingest endpoint."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from frigate.version import VERSION
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
TIMEOUT_S = 30
|
||||||
|
|
||||||
|
|
||||||
|
class SendOutcome(Enum):
|
||||||
|
accepted = "accepted"
|
||||||
|
rejected = "rejected"
|
||||||
|
rate_limited = "rate_limited"
|
||||||
|
failed = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
def send_report(url: str, body: str) -> SendOutcome:
|
||||||
|
"""Send one report. Never raises; the outcome says what happened."""
|
||||||
|
try:
|
||||||
|
# a redirect would turn the POST into a GET, so it counts as a failure
|
||||||
|
response = requests.post(
|
||||||
|
url,
|
||||||
|
data=body.encode(),
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": f"Frigate/{VERSION}",
|
||||||
|
},
|
||||||
|
timeout=TIMEOUT_S,
|
||||||
|
allow_redirects=False,
|
||||||
|
)
|
||||||
|
except requests.RequestException as err:
|
||||||
|
logger.warning("Unable to send the analytics report: %s", err)
|
||||||
|
return SendOutcome.failed
|
||||||
|
|
||||||
|
status = response.status_code
|
||||||
|
|
||||||
|
if 200 <= status < 300:
|
||||||
|
return SendOutcome.accepted
|
||||||
|
|
||||||
|
if status == 400:
|
||||||
|
logger.warning("The analytics report was rejected: %s", response.text[:200])
|
||||||
|
return SendOutcome.rejected
|
||||||
|
|
||||||
|
if status == 429:
|
||||||
|
logger.debug("The analytics endpoint is rate limiting this install")
|
||||||
|
return SendOutcome.rate_limited
|
||||||
|
|
||||||
|
logger.warning("The analytics endpoint returned %s", status)
|
||||||
|
return SendOutcome.failed
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Analytics APIs."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from frigate.analytics.report import preview_report
|
||||||
|
from frigate.api.auth import require_role
|
||||||
|
from frigate.api.defs.tags import Tags
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(tags=[Tags.analytics])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/analytics/preview", dependencies=[Depends(require_role(["admin"]))])
|
||||||
|
def get_analytics_preview(request: Request) -> JSONResponse:
|
||||||
|
"""Get the analytics report Frigate would send next, without sending it."""
|
||||||
|
report = preview_report(
|
||||||
|
request.app.frigate_config,
|
||||||
|
request.app.stats_emitter,
|
||||||
|
request.app.notice_registry,
|
||||||
|
)
|
||||||
|
return JSONResponse(content=report.model_dump(mode="json"))
|
||||||
@@ -2,6 +2,7 @@ from enum import Enum
|
|||||||
|
|
||||||
|
|
||||||
class Tags(Enum):
|
class Tags(Enum):
|
||||||
|
analytics = "Analytics"
|
||||||
app = "App"
|
app = "App"
|
||||||
auth = "Auth"
|
auth = "Auth"
|
||||||
camera = "Camera"
|
camera = "Camera"
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ from slowapi.middleware import SlowAPIMiddleware
|
|||||||
from starlette_context import middleware, plugins
|
from starlette_context import middleware, plugins
|
||||||
from starlette_context.plugins import Plugin
|
from starlette_context.plugins import Plugin
|
||||||
|
|
||||||
from frigate.api import app as main_app
|
|
||||||
from frigate.api import (
|
from frigate.api import (
|
||||||
|
analytics,
|
||||||
auth,
|
auth,
|
||||||
camera,
|
camera,
|
||||||
chat,
|
chat,
|
||||||
@@ -30,6 +30,7 @@ from frigate.api import (
|
|||||||
record,
|
record,
|
||||||
review,
|
review,
|
||||||
)
|
)
|
||||||
|
from frigate.api import app as main_app
|
||||||
from frigate.api.auth import get_jwt_secret, limiter, require_admin_by_default
|
from frigate.api.auth import get_jwt_secret, limiter, require_admin_by_default
|
||||||
from frigate.comms.dispatcher import Dispatcher
|
from frigate.comms.dispatcher import Dispatcher
|
||||||
from frigate.comms.event_metadata_updater import (
|
from frigate.comms.event_metadata_updater import (
|
||||||
@@ -140,6 +141,7 @@ def create_fastapi_app(
|
|||||||
|
|
||||||
# Routes
|
# Routes
|
||||||
# Order of include_router matters: https://fastapi.tiangolo.com/tutorial/path-params/#order-matters
|
# Order of include_router matters: https://fastapi.tiangolo.com/tutorial/path-params/#order-matters
|
||||||
|
app.include_router(analytics.router)
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
app.include_router(camera.router)
|
app.include_router(camera.router)
|
||||||
app.include_router(chat.router)
|
app.include_router(chat.router)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import uvicorn
|
|||||||
from peewee_migrate import Router
|
from peewee_migrate import Router
|
||||||
from playhouse.sqlite_ext import SqliteExtDatabase
|
from playhouse.sqlite_ext import SqliteExtDatabase
|
||||||
|
|
||||||
|
from frigate.analytics.reporter import AnalyticsReporter
|
||||||
from frigate.api.auth import hash_password
|
from frigate.api.auth import hash_password
|
||||||
from frigate.api.fastapi_app import create_fastapi_app
|
from frigate.api.fastapi_app import create_fastapi_app
|
||||||
from frigate.camera import CameraMetrics, PTZMetrics
|
from frigate.camera import CameraMetrics, PTZMetrics
|
||||||
@@ -529,6 +530,15 @@ class FrigateApp:
|
|||||||
)
|
)
|
||||||
self.stats_emitter.start()
|
self.stats_emitter.start()
|
||||||
|
|
||||||
|
def start_analytics_reporter(self) -> None:
|
||||||
|
self.analytics_reporter = AnalyticsReporter(
|
||||||
|
self.config_holder,
|
||||||
|
self.stats_emitter,
|
||||||
|
self.notice_registry,
|
||||||
|
self.stop_event,
|
||||||
|
)
|
||||||
|
self.analytics_reporter.start()
|
||||||
|
|
||||||
def start_watchdog(self) -> None:
|
def start_watchdog(self) -> None:
|
||||||
self.frigate_watchdog = FrigateWatchdog(self.detectors, self.stop_event)
|
self.frigate_watchdog = FrigateWatchdog(self.detectors, self.stop_event)
|
||||||
|
|
||||||
@@ -680,6 +690,7 @@ class FrigateApp:
|
|||||||
self.start_audio_processor()
|
self.start_audio_processor()
|
||||||
self.start_storage_maintainer()
|
self.start_storage_maintainer()
|
||||||
self.start_stats_emitter()
|
self.start_stats_emitter()
|
||||||
|
self.start_analytics_reporter()
|
||||||
self.start_timeline_processor()
|
self.start_timeline_processor()
|
||||||
self.start_event_processor()
|
self.start_event_processor()
|
||||||
self.start_event_cleanup()
|
self.start_event_cleanup()
|
||||||
@@ -779,6 +790,8 @@ class FrigateApp:
|
|||||||
self.event_cleanup.join()
|
self.event_cleanup.join()
|
||||||
self.record_cleanup.join()
|
self.record_cleanup.join()
|
||||||
self.stats_emitter.join()
|
self.stats_emitter.join()
|
||||||
|
# a send in flight can hold the thread for the whole request timeout
|
||||||
|
self.analytics_reporter.join(timeout=5)
|
||||||
self.frigate_watchdog.join()
|
self.frigate_watchdog.join()
|
||||||
self.camera_maintainer.join()
|
self.camera_maintainer.join()
|
||||||
self.db.stop()
|
self.db.stop()
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
"""Shared handle on the config object that is current for this instance."""
|
"""Shared handle on the config object that is current for this instance."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
from .config import FrigateConfig
|
from .config import FrigateConfig
|
||||||
|
|
||||||
__all__ = ["ConfigHolder"]
|
__all__ = ["ConfigHolder"]
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ConfigHolder:
|
class ConfigHolder:
|
||||||
"""Indirection for the most recently parsed config.
|
"""Indirection for the most recently parsed config.
|
||||||
@@ -23,12 +28,24 @@ class ConfigHolder:
|
|||||||
|
|
||||||
def __init__(self, config: FrigateConfig) -> None:
|
def __init__(self, config: FrigateConfig) -> None:
|
||||||
self._config = config
|
self._config = config
|
||||||
|
self._listeners: list[Callable[[FrigateConfig], None]] = []
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def config(self) -> FrigateConfig:
|
def config(self) -> FrigateConfig:
|
||||||
"""The config as of the most recent successful save."""
|
"""The config as of the most recent successful save."""
|
||||||
return self._config
|
return self._config
|
||||||
|
|
||||||
|
def subscribe(self, listener: Callable[[FrigateConfig], None]) -> None:
|
||||||
|
"""Call listener on the saving thread with each config installed later."""
|
||||||
|
self._listeners.append(listener)
|
||||||
|
|
||||||
def set(self, config: FrigateConfig) -> None:
|
def set(self, config: FrigateConfig) -> None:
|
||||||
"""Install a freshly parsed config as the current one."""
|
"""Install a freshly parsed config as the current one."""
|
||||||
self._config = config
|
self._config = config
|
||||||
|
|
||||||
|
for listener in self._listeners:
|
||||||
|
try:
|
||||||
|
listener(config)
|
||||||
|
except Exception:
|
||||||
|
# a listener bug must not fail the save that has already applied
|
||||||
|
logger.exception("Config listener failed")
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ class StatsConfig(FrigateBaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class TelemetryConfig(FrigateBaseModel):
|
class TelemetryConfig(FrigateBaseModel):
|
||||||
|
analytics: bool = Field(
|
||||||
|
default=False,
|
||||||
|
title="Share anonymous analytics",
|
||||||
|
description="Send one anonymous usage report a day to help the Frigate maintainers decide what to support. Nothing is sent until this is on.",
|
||||||
|
)
|
||||||
network_interfaces: list[str] = Field(
|
network_interfaces: list[str] = Field(
|
||||||
default=[],
|
default=[],
|
||||||
title="Network interfaces",
|
title="Network interfaces",
|
||||||
|
|||||||
@@ -101,6 +101,9 @@ MAX_WAL_SIZE = 10 # MB
|
|||||||
|
|
||||||
DEFAULT_FFMPEG_VERSION = os.environ.get("DEFAULT_FFMPEG_VERSION", "")
|
DEFAULT_FFMPEG_VERSION = os.environ.get("DEFAULT_FFMPEG_VERSION", "")
|
||||||
INCLUDED_FFMPEG_VERSIONS = os.environ.get("INCLUDED_FFMPEG_VERSIONS", "").split(":")
|
INCLUDED_FFMPEG_VERSIONS = os.environ.get("INCLUDED_FFMPEG_VERSIONS", "").split(":")
|
||||||
|
ANALYTICS_URL = os.environ.get(
|
||||||
|
"FRIGATE_ANALYTICS_URL", "https://analytics.frigate.video/report"
|
||||||
|
)
|
||||||
LIBAVFORMAT_VERSION_MAJOR = int(os.environ.get("LIBAVFORMAT_VERSION_MAJOR", "59"))
|
LIBAVFORMAT_VERSION_MAJOR = int(os.environ.get("LIBAVFORMAT_VERSION_MAJOR", "59"))
|
||||||
FFMPEG_HWACCEL_NVIDIA = "preset-nvidia"
|
FFMPEG_HWACCEL_NVIDIA = "preset-nvidia"
|
||||||
FFMPEG_HWACCEL_VAAPI = "preset-vaapi"
|
FFMPEG_HWACCEL_VAAPI = "preset-vaapi"
|
||||||
|
|||||||
@@ -319,6 +319,19 @@ class NoticeRegistry:
|
|||||||
if row.kind in NOTICE_KINDS
|
if row.kind in NOTICE_KINDS
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def mark_reported(self, snapshot: list[dict[str, Any]]) -> None:
|
||||||
|
"""Move the analytics watermarks to the counts a sent report was built from.
|
||||||
|
|
||||||
|
Using the snapshot rather than the current counts sends anything raised
|
||||||
|
while the report was in flight with the next one.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
for row in snapshot:
|
||||||
|
NoticeStats.update(
|
||||||
|
reported_occurrences=row["occurrences"],
|
||||||
|
reported_dismissals=row["dismissals"],
|
||||||
|
).where(NoticeStats.kind == row["kind"]).execute()
|
||||||
|
|
||||||
def _write_repeats(
|
def _write_repeats(
|
||||||
self,
|
self,
|
||||||
row: Notice,
|
row: Notice,
|
||||||
|
|||||||
@@ -86,6 +86,16 @@ _KINDS = (
|
|||||||
link="https://github.com/blakeblackshear/frigate/releases/tag/v{version}",
|
link="https://github.com/blakeblackshear/frigate/releases/tag/v{version}",
|
||||||
counts_repeats=False,
|
counts_repeats=False,
|
||||||
keep_latest=1,
|
keep_latest=1,
|
||||||
|
reportable=False,
|
||||||
|
),
|
||||||
|
# raised while analytics is off; the row keeps a dismissal across restarts
|
||||||
|
NoticeKind(
|
||||||
|
"analytics_prompt",
|
||||||
|
NoticeSeverity.info,
|
||||||
|
"system",
|
||||||
|
link="/settings?page=systemTelemetry",
|
||||||
|
counts_repeats=False,
|
||||||
|
reportable=False,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Shared fixtures for the analytics tests."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from frigate.analytics.context import ReportContext
|
||||||
|
from frigate.config import FrigateConfig
|
||||||
|
|
||||||
|
FRONT_CAMERA: dict[str, Any] = {
|
||||||
|
"ffmpeg": {"inputs": [{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}]},
|
||||||
|
"detect": {"height": 720, "width": 1280, "fps": 5},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def make_config(config: dict[str, Any] | None = None) -> FrigateConfig:
|
||||||
|
"""A parsed config; top-level keys in config replace the defaults here.
|
||||||
|
|
||||||
|
hwaccel_args is set so parsing never probes the test host's GPU.
|
||||||
|
"""
|
||||||
|
base: dict[str, Any] = {
|
||||||
|
"mqtt": {"host": "mqtt"},
|
||||||
|
"ffmpeg": {"hwaccel_args": []},
|
||||||
|
"cameras": {"front": FRONT_CAMERA},
|
||||||
|
}
|
||||||
|
return FrigateConfig(**{**base, **(config or {})})
|
||||||
|
|
||||||
|
|
||||||
|
def make_context(
|
||||||
|
config: FrigateConfig | None = None,
|
||||||
|
stats: dict[str, Any] | None = None,
|
||||||
|
notice_stats: list[dict[str, Any]] | None = None,
|
||||||
|
) -> ReportContext:
|
||||||
|
return ReportContext(
|
||||||
|
config=config or make_config(),
|
||||||
|
stats=stats or {},
|
||||||
|
notice_stats=notice_stats or [],
|
||||||
|
)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""Tests for the analytics preview API."""
|
||||||
|
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from frigate.analytics import report
|
||||||
|
from frigate.analytics.collectors import hardware
|
||||||
|
from frigate.models import Notice, NoticeStats, User
|
||||||
|
from frigate.notices.registry import NoticeRegistry
|
||||||
|
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||||
|
|
||||||
|
|
||||||
|
class TestHttpAnalytics(BaseTestHttp):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp([Notice, NoticeStats, User])
|
||||||
|
stats = Mock()
|
||||||
|
stats.get_latest_stats.return_value = self.test_stats
|
||||||
|
self.app = self.create_app(stats=stats, notice_registry=NoticeRegistry())
|
||||||
|
|
||||||
|
for target, name, value in (
|
||||||
|
(report, "load_state", None),
|
||||||
|
(hardware.hardware_prober, "probe", []),
|
||||||
|
(hardware, "hwaccel_options", ("", [])),
|
||||||
|
):
|
||||||
|
patcher = patch.object(target, name, return_value=value)
|
||||||
|
patcher.start()
|
||||||
|
self.addCleanup(patcher.stop)
|
||||||
|
|
||||||
|
def test_preview_returns_the_report_without_sending_it(self):
|
||||||
|
with (
|
||||||
|
patch("frigate.analytics.transport.requests.post") as post,
|
||||||
|
AuthTestClient(self.app) as client,
|
||||||
|
):
|
||||||
|
response = client.get("/analytics/preview")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
body = response.json()
|
||||||
|
self.assertEqual(body["install_id"], report.PREVIEW_INSTALL_ID)
|
||||||
|
self.assertEqual(body["schema_version"], 1)
|
||||||
|
self.assertEqual(body["cameras"]["total"], 1)
|
||||||
|
post.assert_not_called()
|
||||||
|
|
||||||
|
def test_preview_is_admin_only(self):
|
||||||
|
with AuthTestClient(self.app) as client:
|
||||||
|
response = client.get(
|
||||||
|
"/analytics/preview",
|
||||||
|
headers={"remote-user": "viewer", "remote-role": "viewer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""Tests for the cameras collector."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from frigate.analytics.collectors import cameras
|
||||||
|
from frigate.analytics.schema import HeightBucket, HwaccelKey, RetainBucket
|
||||||
|
from frigate.test.analytics_helpers import make_config, make_context
|
||||||
|
|
||||||
|
MASK = {"coordinates": "0,0,1,0,1,1"}
|
||||||
|
|
||||||
|
CONFIG = {
|
||||||
|
"record": {
|
||||||
|
"enabled": True,
|
||||||
|
"continuous": {"days": 3},
|
||||||
|
"alerts": {"retain": {"days": 14}},
|
||||||
|
"detections": {"retain": {"days": 45}},
|
||||||
|
},
|
||||||
|
"cameras": {
|
||||||
|
"front": {
|
||||||
|
"ffmpeg": {
|
||||||
|
"hwaccel_args": "preset-vaapi",
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"path": "rtsp://127.0.0.1:8554/front_sub",
|
||||||
|
"roles": ["detect"],
|
||||||
|
"input_args": "preset-rtsp-restream",
|
||||||
|
},
|
||||||
|
{"path": "rtsp://127.0.0.1:8554/front", "roles": ["record"]},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"detect": {"enabled": True, "height": 720, "width": 1280, "fps": 5},
|
||||||
|
"zones": {"porch": MASK, "yard": MASK},
|
||||||
|
"motion": {"mask": {"tree": MASK}},
|
||||||
|
"objects": {
|
||||||
|
"mask": {"sign": MASK},
|
||||||
|
"filters": {"person": {"mask": {"bush": MASK}}},
|
||||||
|
},
|
||||||
|
"onvif": {"host": "10.0.0.9"},
|
||||||
|
},
|
||||||
|
"garage": {
|
||||||
|
"enabled": False,
|
||||||
|
"type": "lpr",
|
||||||
|
"ffmpeg": {
|
||||||
|
"hwaccel_args": ["-hwaccel", "vaapi"],
|
||||||
|
"inputs": [
|
||||||
|
{"path": "rtsp://10.0.0.5/stream", "roles": ["detect", "record"]}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"detect": {"height": 2160, "width": 3840, "fps": 15},
|
||||||
|
"record": {"enabled": False},
|
||||||
|
},
|
||||||
|
"_replay_front": {
|
||||||
|
"ffmpeg": {
|
||||||
|
"inputs": [{"path": "rtsp://10.0.0.1/replay", "roles": ["detect"]}]
|
||||||
|
},
|
||||||
|
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
STATS = {
|
||||||
|
"cameras": {
|
||||||
|
"front": {"connection_quality": "excellent"},
|
||||||
|
"garage": {"connection_quality": "offline"},
|
||||||
|
"_replay_front": {"connection_quality": "fair"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestCamerasCollector(unittest.TestCase):
|
||||||
|
def test_aggregates_cameras_and_skips_replays(self):
|
||||||
|
section = cameras.collect(make_context(make_config(CONFIG), STATS))
|
||||||
|
|
||||||
|
self.assertEqual((section.total, section.enabled), (2, 1))
|
||||||
|
self.assertEqual(section.types, {"generic": 1, "lpr": 1})
|
||||||
|
self.assertEqual(section.detect_height, {"720": 1, "ge_2160": 1})
|
||||||
|
self.assertEqual(section.detect_fps, {"le_5": 1, "gt_10": 1})
|
||||||
|
self.assertEqual(section.hwaccel, {"vaapi": 1, "custom": 1})
|
||||||
|
self.assertEqual(section.input_preset, {"rtsp-restream": 1, "rtsp-generic": 1})
|
||||||
|
self.assertEqual(section.go2rtc_restream, 1)
|
||||||
|
self.assertEqual(section.separate_detect_stream, 1)
|
||||||
|
self.assertEqual((section.detect, section.record, section.onvif), (1, 1, 1))
|
||||||
|
self.assertEqual((section.zones, section.cameras_with_zones), (2, 1))
|
||||||
|
self.assertEqual((section.motion_masks, section.object_masks), (1, 2))
|
||||||
|
self.assertEqual(section.connection_quality, {"excellent": 1})
|
||||||
|
self.assertEqual(section.retain_days.continuous, {"1_7": 1})
|
||||||
|
self.assertEqual(section.retain_days.motion, {"0": 1})
|
||||||
|
self.assertEqual(section.retain_days.alerts, {"8_30": 1})
|
||||||
|
self.assertEqual(section.retain_days.detections, {"gt_30": 1})
|
||||||
|
|
||||||
|
def test_buckets(self):
|
||||||
|
self.assertEqual(cameras.height_bucket(360), HeightBucket.le_360)
|
||||||
|
self.assertEqual(cameras.height_bucket(361), HeightBucket.h480)
|
||||||
|
self.assertEqual(cameras.height_bucket(1080), HeightBucket.h1080)
|
||||||
|
self.assertEqual(cameras.height_bucket(1440), HeightBucket.h1440)
|
||||||
|
self.assertEqual(cameras.height_bucket(2160), HeightBucket.ge_2160)
|
||||||
|
self.assertEqual(cameras.retain_bucket(0.5), RetainBucket.d1_7)
|
||||||
|
self.assertEqual(cameras.retain_bucket(0), RetainBucket.zero)
|
||||||
|
|
||||||
|
def test_preset_key(self):
|
||||||
|
self.assertEqual(cameras.preset_key([], HwaccelKey), "none")
|
||||||
|
self.assertEqual(cameras.preset_key("", HwaccelKey), "none")
|
||||||
|
self.assertEqual(cameras.preset_key("preset-bogus", HwaccelKey), "custom")
|
||||||
|
self.assertEqual(cameras.preset_key("-hwaccel vaapi", HwaccelKey), "custom")
|
||||||
|
self.assertEqual(cameras.preset_key("preset-nvidia", HwaccelKey), "nvidia")
|
||||||
|
|
||||||
|
def test_is_restream(self):
|
||||||
|
self.assertTrue(cameras.is_restream("rtsp://127.0.0.1:8554/front"))
|
||||||
|
self.assertTrue(cameras.is_restream("rtsp://localhost:8554/front"))
|
||||||
|
self.assertFalse(cameras.is_restream("rtsp://10.0.0.5:8554/front"))
|
||||||
|
self.assertFalse(cameras.is_restream("rtsp://[bad"))
|
||||||
|
self.assertFalse(cameras.is_restream("rtsp://127.0.0.1:notaport/x"))
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""Tests for the detection collector."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from frigate.analytics.collectors import detection
|
||||||
|
from frigate.analytics.schema import ModelSource
|
||||||
|
from frigate.detectors.detector_config import SceneEnum
|
||||||
|
from frigate.test.analytics_helpers import make_config, make_context
|
||||||
|
|
||||||
|
|
||||||
|
class TestDetectionCollector(unittest.TestCase):
|
||||||
|
def test_reports_each_model_with_its_mean_inference_speed(self):
|
||||||
|
config = make_config({"models": [{"devices": ["cpu", "cpu"]}]})
|
||||||
|
stats = {
|
||||||
|
"detectors": {
|
||||||
|
"cpu": {"inference_speed": 10.0},
|
||||||
|
"cpu#2": {"inference_speed": 20.0},
|
||||||
|
},
|
||||||
|
"detection_fps": 12.346,
|
||||||
|
"skipped_fps": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
section = detection.collect(make_context(config, stats))
|
||||||
|
|
||||||
|
model = section.models[SceneEnum.all]
|
||||||
|
self.assertEqual(model.detector, "cpu")
|
||||||
|
self.assertEqual(model.devices, 2)
|
||||||
|
self.assertEqual(model.model_type, "ssd")
|
||||||
|
self.assertEqual(model.input, "320x320")
|
||||||
|
self.assertEqual(model.source, ModelSource.default)
|
||||||
|
self.assertEqual(model.inference_ms, 15.0)
|
||||||
|
self.assertEqual(section.detection_fps, 12.35)
|
||||||
|
self.assertEqual(section.skipped_fps, 0.0)
|
||||||
|
|
||||||
|
def test_inference_is_null_before_the_first_stats(self):
|
||||||
|
section = detection.collect(make_context())
|
||||||
|
|
||||||
|
self.assertIsNone(section.models[SceneEnum.all].inference_ms)
|
||||||
|
|
||||||
|
def test_model_source(self):
|
||||||
|
with tempfile.TemporaryDirectory() as cache:
|
||||||
|
plus_model = os.path.join(cache, "abc123")
|
||||||
|
open(f"{plus_model}.json", "w").close()
|
||||||
|
|
||||||
|
with patch.object(detection, "MODEL_CACHE_DIR", cache):
|
||||||
|
self.assertEqual(detection.model_source(plus_model), ModelSource.plus)
|
||||||
|
self.assertEqual(
|
||||||
|
detection.model_source(os.path.join(cache, "no_info")),
|
||||||
|
ModelSource.custom,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(detection.model_source(None), ModelSource.default)
|
||||||
|
self.assertEqual(
|
||||||
|
detection.model_source("/cpu_model.tflite"), ModelSource.default
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
detection.model_source("/config/yolo.onnx"), ModelSource.custom
|
||||||
|
)
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""Tests for the features collector."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from peewee_migrate import Router
|
||||||
|
from playhouse.sqlite_ext import SqliteExtDatabase
|
||||||
|
from playhouse.sqliteq import SqliteQueueDatabase
|
||||||
|
|
||||||
|
from frigate.analytics.collectors import features
|
||||||
|
from frigate.analytics.schema import EnrichmentDevice, SemanticSearchModel
|
||||||
|
from frigate.models import User
|
||||||
|
from frigate.test.analytics_helpers import FRONT_CAMERA, make_config, make_context
|
||||||
|
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
|
||||||
|
|
||||||
|
CONFIG = {
|
||||||
|
"mqtt": {"host": "mqtt", "enabled": True},
|
||||||
|
"genai": {
|
||||||
|
"local": {
|
||||||
|
"provider": "ollama",
|
||||||
|
"model": "llava",
|
||||||
|
"base_url": "http://ollama:11434",
|
||||||
|
"roles": ["descriptions", "embeddings"],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"semantic_search": {"enabled": True, "model": "local"},
|
||||||
|
"face_recognition": {"enabled": True, "model_size": "large"},
|
||||||
|
"birdseye": {"enabled": True, "modes": ["motion", "alerts"], "restream": True},
|
||||||
|
"proxy": {"header_map": {"user": "x-forwarded-user"}},
|
||||||
|
"camera_groups": {"outside": {"cameras": ["front"], "icon": "LuCar", "order": 0}},
|
||||||
|
"cameras": {
|
||||||
|
"front": {
|
||||||
|
**FRONT_CAMERA,
|
||||||
|
"semantic_search": {
|
||||||
|
"triggers": {
|
||||||
|
"cat": {
|
||||||
|
"type": "description",
|
||||||
|
"data": "a cat",
|
||||||
|
"threshold": 0.8,
|
||||||
|
"actions": ["notification"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestFeaturesCollector(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
migrate_db = SqliteExtDatabase(TEST_DB)
|
||||||
|
del logging.getLogger("peewee_migrate").handlers[:]
|
||||||
|
Router(migrate_db).run()
|
||||||
|
migrate_db.close()
|
||||||
|
self.db = SqliteQueueDatabase(TEST_DB)
|
||||||
|
self.db.bind([User])
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
# close() leaves the queue's writer thread running against the deleted file
|
||||||
|
self.db.stop()
|
||||||
|
|
||||||
|
if not self.db.is_closed():
|
||||||
|
self.db.close()
|
||||||
|
|
||||||
|
for file in TEST_DB_CLEANUPS:
|
||||||
|
try:
|
||||||
|
os.remove(file)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_collects_enrichments_genai_integrations_and_users(self):
|
||||||
|
for username, role in (("a", "admin"), ("v", "viewer"), ("o", "operator")):
|
||||||
|
User.create(
|
||||||
|
username=username, role=role, password_hash="x", notification_tokens=[]
|
||||||
|
)
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
"embeddings": {
|
||||||
|
"devices": {
|
||||||
|
"face_recognition": "OpenVINO GPU.0",
|
||||||
|
"semantic_search": "CUDA",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section = features.collect(make_context(make_config(CONFIG), stats))
|
||||||
|
|
||||||
|
self.assertEqual(section.face_recognition.device, EnrichmentDevice.openvino_gpu)
|
||||||
|
self.assertEqual(section.face_recognition.model_size, "large")
|
||||||
|
self.assertIsNone(section.lpr.device)
|
||||||
|
self.assertEqual(section.semantic_search.model, SemanticSearchModel.genai)
|
||||||
|
self.assertEqual(section.semantic_search.device, EnrichmentDevice.cuda)
|
||||||
|
self.assertEqual(section.semantic_search.triggers, 1)
|
||||||
|
self.assertEqual(section.genai.providers, {"ollama": 1})
|
||||||
|
self.assertEqual(section.genai.roles, {"descriptions": 1, "embeddings": 1})
|
||||||
|
self.assertEqual(section.birdseye.modes, ["motion", "alerts"])
|
||||||
|
self.assertTrue(section.mqtt)
|
||||||
|
self.assertTrue(section.proxy_auth)
|
||||||
|
self.assertEqual(section.users, {"admin": 1, "viewer": 1, "custom": 1})
|
||||||
|
self.assertEqual((section.camera_groups, section.profiles), (1, 0))
|
||||||
|
self.assertFalse(section.plus_api_key)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFeatureMappings(unittest.TestCase):
|
||||||
|
def test_enrichment_device_labels(self):
|
||||||
|
cases = {
|
||||||
|
"CPU": EnrichmentDevice.cpu,
|
||||||
|
"MIGraphX": EnrichmentDevice.migraphx,
|
||||||
|
"OpenVINO NPU": EnrichmentDevice.openvino_npu,
|
||||||
|
"OpenVINO GPU.0,CPU": EnrichmentDevice.openvino_gpu,
|
||||||
|
"OpenVINO": EnrichmentDevice.other,
|
||||||
|
"CoreML": EnrichmentDevice.other,
|
||||||
|
}
|
||||||
|
|
||||||
|
for label, device in cases.items():
|
||||||
|
with self.subTest(label=label):
|
||||||
|
self.assertEqual(features.enrichment_device(label), device)
|
||||||
|
|
||||||
|
self.assertIsNone(features.enrichment_device(None))
|
||||||
|
|
||||||
|
def test_models_named_after_a_provider_are_genai(self):
|
||||||
|
self.assertEqual(features.semantic_model("jinav2"), SemanticSearchModel.jinav2)
|
||||||
|
self.assertEqual(
|
||||||
|
features.semantic_model("my-ollama"), SemanticSearchModel.genai
|
||||||
|
)
|
||||||
|
self.assertIsNone(features.semantic_model(None))
|
||||||
|
self.assertEqual(features.transcription_model("openai"), "genai")
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Tests for the health collector."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from frigate.analytics.collectors import health
|
||||||
|
from frigate.test.analytics_helpers import make_context
|
||||||
|
|
||||||
|
|
||||||
|
class TestHealthCollector(unittest.TestCase):
|
||||||
|
def test_collects_uptime_cpu_timings_and_notice_deltas(self):
|
||||||
|
stats = {
|
||||||
|
"service": {"uptime": 7300, "retention_unmet": True},
|
||||||
|
"cpu_usages": {"frigate.full_system": {"cpu": "23.6", "mem": "40"}},
|
||||||
|
"embeddings": {
|
||||||
|
"face_recognition_speed": 41.234,
|
||||||
|
"text_embedding_speed": 0,
|
||||||
|
"devices": {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
notice_stats = [
|
||||||
|
{
|
||||||
|
"kind": "detector_stuck",
|
||||||
|
"occurrences": 5,
|
||||||
|
"dismissals": 1,
|
||||||
|
"reported_occurrences": 3,
|
||||||
|
"reported_dismissals": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "shm_too_low",
|
||||||
|
"occurrences": 2,
|
||||||
|
"dismissals": 0,
|
||||||
|
"reported_occurrences": 2,
|
||||||
|
"reported_dismissals": 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "update_available",
|
||||||
|
"occurrences": 4,
|
||||||
|
"dismissals": 0,
|
||||||
|
"reported_occurrences": 0,
|
||||||
|
"reported_dismissals": 0,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
section = health.collect(make_context(stats=stats, notice_stats=notice_stats))
|
||||||
|
|
||||||
|
self.assertEqual(section.uptime_hours, 2)
|
||||||
|
self.assertEqual(section.cpu_percent, 24)
|
||||||
|
self.assertEqual(section.enrichment_ms, {"face": 41.23})
|
||||||
|
self.assertTrue(section.retention_unmet)
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
kind: (c.occurrences, c.dismissals)
|
||||||
|
for kind, c in section.notices.items()
|
||||||
|
},
|
||||||
|
{"detector_stuck": (2, 0)},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_missing_stats_give_zeros(self):
|
||||||
|
section = health.collect(make_context())
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
(section.uptime_hours, section.cpu_percent, section.enrichment_ms),
|
||||||
|
(0, 0, {}),
|
||||||
|
)
|
||||||
|
self.assertEqual(section.notices, {})
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
"""Tests for the install and hardware collectors."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from collections import Counter
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from frigate.analytics.collectors import hardware, install
|
||||||
|
from frigate.analytics.collectors.common import closed, histogram, rate
|
||||||
|
from frigate.analytics.schema import (
|
||||||
|
Arch,
|
||||||
|
DecodeFamily,
|
||||||
|
GpuVendor,
|
||||||
|
HardwareKey,
|
||||||
|
ImageVariant,
|
||||||
|
InstallType,
|
||||||
|
)
|
||||||
|
from frigate.const import RECORD_DIR
|
||||||
|
from frigate.test.analytics_helpers import make_context
|
||||||
|
|
||||||
|
|
||||||
|
class TestCommon(unittest.TestCase):
|
||||||
|
def test_closed_falls_back_for_unknown_values(self):
|
||||||
|
self.assertEqual(closed(Arch, "x86_64", Arch.other), Arch.x86_64)
|
||||||
|
self.assertEqual(closed(Arch, "sparc", Arch.other), Arch.other)
|
||||||
|
self.assertEqual(closed(Arch, None, Arch.other), Arch.other)
|
||||||
|
|
||||||
|
def test_rate_rounds_and_rejects_bad_numbers(self):
|
||||||
|
self.assertEqual(rate("12.346"), 12.35)
|
||||||
|
self.assertEqual(rate(-3), 0.0)
|
||||||
|
self.assertEqual(rate(float("nan")), 0.0)
|
||||||
|
self.assertEqual(rate(float("inf")), 0.0)
|
||||||
|
self.assertEqual(rate(None), 0.0)
|
||||||
|
|
||||||
|
def test_histogram_drops_empty_buckets(self):
|
||||||
|
self.assertEqual(
|
||||||
|
histogram(Counter({Arch.x86_64: 2, Arch.other: 0})), {"x86_64": 2}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestInstallCollector(unittest.TestCase):
|
||||||
|
def test_collects_version_variant_and_platform(self):
|
||||||
|
with (
|
||||||
|
patch.dict(os.environ, {"FRIGATE_IMAGE_VARIANT": "tensorrt-jp6"}),
|
||||||
|
patch.object(install, "install_type", return_value=InstallType.docker),
|
||||||
|
patch("platform.machine", return_value="aarch64"),
|
||||||
|
patch("platform.release", return_value="6.8.0-45-generic"),
|
||||||
|
patch("os.geteuid", return_value=1000),
|
||||||
|
):
|
||||||
|
section = install.collect(make_context())
|
||||||
|
|
||||||
|
self.assertEqual(section.image_variant, ImageVariant.tensorrt_jp6)
|
||||||
|
self.assertEqual(section.install_type, InstallType.docker)
|
||||||
|
self.assertEqual(section.arch, Arch.aarch64)
|
||||||
|
self.assertEqual(section.kernel, "6.8")
|
||||||
|
self.assertFalse(section.run_as_root)
|
||||||
|
|
||||||
|
def test_image_variant_is_dev_when_unset_and_other_when_unknown(self):
|
||||||
|
self.assertEqual(install.image_variant(None), ImageVariant.dev)
|
||||||
|
self.assertEqual(install.image_variant(""), ImageVariant.dev)
|
||||||
|
self.assertEqual(install.image_variant("h8l"), ImageVariant.other)
|
||||||
|
|
||||||
|
def test_install_type_checks_the_add_on_first(self):
|
||||||
|
with patch("os.path.isfile", return_value=True):
|
||||||
|
self.assertEqual(install.install_type(), InstallType.ha_addon)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("os.path.isfile", return_value=False),
|
||||||
|
patch.dict(os.environ, {"KUBERNETES_SERVICE_HOST": "10.0.0.1"}),
|
||||||
|
):
|
||||||
|
self.assertEqual(install.install_type(), InstallType.kubernetes)
|
||||||
|
|
||||||
|
env = {k: v for k, v in os.environ.items() if k != "KUBERNETES_SERVICE_HOST"}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("os.path.isfile", return_value=False),
|
||||||
|
patch.dict(os.environ, env, clear=True),
|
||||||
|
patch("os.path.exists", side_effect=lambda path: path == "/.dockerenv"),
|
||||||
|
):
|
||||||
|
self.assertEqual(install.install_type(), InstallType.docker)
|
||||||
|
|
||||||
|
def test_kernel_and_arch_fall_back(self):
|
||||||
|
self.assertEqual(install.kernel("weird"), "unknown")
|
||||||
|
self.assertEqual(install.arch("armv7l"), Arch.other)
|
||||||
|
self.assertEqual(install.arch("amd64"), Arch.x86_64)
|
||||||
|
|
||||||
|
|
||||||
|
class TestHardwareCollector(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.dir = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.dir.cleanup)
|
||||||
|
|
||||||
|
def write(self, name: str, content: str) -> str:
|
||||||
|
path = os.path.join(self.dir.name, name)
|
||||||
|
|
||||||
|
with open(path, "w") as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
return path
|
||||||
|
|
||||||
|
def test_cpu_model_prefers_the_board_model(self):
|
||||||
|
board = self.write("model", "Raspberry Pi 5 Model B Rev 1.0\x00")
|
||||||
|
cpuinfo = self.write("cpuinfo", "model name\t: Cortex-A76\n")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(hardware, "DEVICE_TREE_MODEL", board),
|
||||||
|
patch.object(hardware, "CPUINFO", cpuinfo),
|
||||||
|
):
|
||||||
|
self.assertEqual(hardware.cpu_model(), "Raspberry Pi 5 Model B Rev 1.0")
|
||||||
|
|
||||||
|
def test_cpu_model_reads_cpuinfo_and_truncates(self):
|
||||||
|
cpuinfo = self.write("cpuinfo", "processor\t: 0\nmodel name\t: " + "x" * 80)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(hardware, "DEVICE_TREE_MODEL", self.dir.name + "/none"),
|
||||||
|
patch.object(hardware, "CPUINFO", cpuinfo),
|
||||||
|
):
|
||||||
|
self.assertEqual(hardware.cpu_model(), "x" * 64)
|
||||||
|
|
||||||
|
def test_collects_gpus_decode_detection_and_storage(self):
|
||||||
|
stats = {
|
||||||
|
"gpu_usages": {
|
||||||
|
"NVIDIA GeForce RTX 3060": {"vendor": "nvidia"},
|
||||||
|
"mystery": {},
|
||||||
|
},
|
||||||
|
"service": {
|
||||||
|
"storage": {
|
||||||
|
RECORD_DIR: {
|
||||||
|
"total": 2048000.0,
|
||||||
|
"used": 512000.0,
|
||||||
|
"mount_type": "nfs4",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
probed = [
|
||||||
|
SimpleNamespace(key="edgetpu:usb", count=2),
|
||||||
|
SimpleNamespace(key="new:thing", count=1),
|
||||||
|
SimpleNamespace(key="cpu", count=1),
|
||||||
|
]
|
||||||
|
families = [
|
||||||
|
SimpleNamespace(key="vaapi"),
|
||||||
|
SimpleNamespace(key="intel-qsv"),
|
||||||
|
SimpleNamespace(key="future"),
|
||||||
|
]
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(hardware.hardware_prober, "probe", return_value=probed),
|
||||||
|
patch.object(hardware, "hwaccel_options", return_value=("vaapi", families)),
|
||||||
|
patch.object(hardware, "cpu_model", return_value="Intel(R) N100"),
|
||||||
|
patch("os.cpu_count", return_value=4),
|
||||||
|
patch(
|
||||||
|
"psutil.virtual_memory", return_value=SimpleNamespace(total=16 * 2**30)
|
||||||
|
),
|
||||||
|
):
|
||||||
|
section = hardware.collect(make_context(stats=stats))
|
||||||
|
|
||||||
|
self.assertEqual(section.cpu_model, "Intel(R) N100")
|
||||||
|
self.assertEqual(section.cpu_cores, 4)
|
||||||
|
self.assertEqual(section.memory_gb, 16)
|
||||||
|
self.assertEqual(
|
||||||
|
[(gpu.vendor, gpu.name) for gpu in section.gpus],
|
||||||
|
[
|
||||||
|
(GpuVendor.nvidia, "NVIDIA GeForce RTX 3060"),
|
||||||
|
(GpuVendor.other, "mystery"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
section.decode_families,
|
||||||
|
[DecodeFamily.vaapi, DecodeFamily.intel_qsv, DecodeFamily.other],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
section.detection_hardware,
|
||||||
|
{HardwareKey.edgetpu_usb: 2, HardwareKey.other: 1, HardwareKey.cpu: 1},
|
||||||
|
)
|
||||||
|
self.assertEqual(section.storage.record_fs, "nfs4")
|
||||||
|
self.assertEqual(section.storage.record_total_gb, 2000)
|
||||||
|
self.assertEqual(section.storage.record_used_pct, 25)
|
||||||
|
|
||||||
|
def test_storage_without_stats_is_unknown_and_empty(self):
|
||||||
|
section = hardware.storage({})
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
(section.record_fs, section.record_total_gb, section.record_used_pct),
|
||||||
|
("unknown", 0, 0),
|
||||||
|
)
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""Tests for building a report from the collectors."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from peewee_migrate import Router
|
||||||
|
from playhouse.sqlite_ext import SqliteExtDatabase
|
||||||
|
from playhouse.sqliteq import SqliteQueueDatabase
|
||||||
|
|
||||||
|
from frigate.analytics import report
|
||||||
|
from frigate.analytics.collectors import hardware
|
||||||
|
from frigate.analytics.schema import AnalyticsReport, InstallSection
|
||||||
|
from frigate.models import User
|
||||||
|
from frigate.test.analytics_helpers import make_context
|
||||||
|
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
|
||||||
|
|
||||||
|
INSTALL = InstallSection(
|
||||||
|
version="0.19.0",
|
||||||
|
image_variant="dev",
|
||||||
|
install_type="docker",
|
||||||
|
arch="x86_64",
|
||||||
|
kernel="6.8",
|
||||||
|
run_as_root=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildReport(unittest.TestCase):
|
||||||
|
def test_a_failing_collector_nulls_only_its_section(self):
|
||||||
|
collectors = {
|
||||||
|
"install": lambda ctx: INSTALL,
|
||||||
|
"hardware": Mock(side_effect=RuntimeError("boom")),
|
||||||
|
"detection": lambda ctx: None,
|
||||||
|
"cameras": lambda ctx: None,
|
||||||
|
"features": lambda ctx: None,
|
||||||
|
"health": lambda ctx: None,
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.dict(report.COLLECTORS, collectors):
|
||||||
|
built = report.build_report(make_context(), "a" * 32, sent_at=1790000000)
|
||||||
|
|
||||||
|
self.assertEqual(built.install, INSTALL)
|
||||||
|
self.assertIsNone(built.hardware)
|
||||||
|
self.assertEqual(built.schema_version, 1)
|
||||||
|
self.assertEqual(built.sent_at, 1790000000)
|
||||||
|
self.assertRegex(built.report_id, r"^[0-9a-f-]{36}$")
|
||||||
|
|
||||||
|
|
||||||
|
class TestRealCollectors(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
migrate_db = SqliteExtDatabase(TEST_DB)
|
||||||
|
del logging.getLogger("peewee_migrate").handlers[:]
|
||||||
|
Router(migrate_db).run()
|
||||||
|
migrate_db.close()
|
||||||
|
self.db = SqliteQueueDatabase(TEST_DB)
|
||||||
|
self.db.bind([User])
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
# close() leaves the queue's writer thread running against the deleted file
|
||||||
|
self.db.stop()
|
||||||
|
|
||||||
|
if not self.db.is_closed():
|
||||||
|
self.db.close()
|
||||||
|
|
||||||
|
for file in TEST_DB_CLEANUPS:
|
||||||
|
try:
|
||||||
|
os.remove(file)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_every_section_builds_and_round_trips_through_json(self):
|
||||||
|
with (
|
||||||
|
patch.object(hardware.hardware_prober, "probe", return_value=[]),
|
||||||
|
patch.object(hardware, "hwaccel_options", return_value=("", [])),
|
||||||
|
):
|
||||||
|
built = report.build_report(make_context(), "a" * 32)
|
||||||
|
|
||||||
|
body = built.model_dump_json()
|
||||||
|
parsed = json.loads(body)
|
||||||
|
|
||||||
|
for section in (
|
||||||
|
"install",
|
||||||
|
"hardware",
|
||||||
|
"detection",
|
||||||
|
"cameras",
|
||||||
|
"features",
|
||||||
|
"health",
|
||||||
|
):
|
||||||
|
with self.subTest(section=section):
|
||||||
|
self.assertIsNotNone(parsed[section])
|
||||||
|
|
||||||
|
self.assertEqual(AnalyticsReport.model_validate_json(body), built)
|
||||||
|
|
||||||
|
def test_preview_uses_a_placeholder_id_before_the_first_report(self):
|
||||||
|
stats_emitter = Mock()
|
||||||
|
stats_emitter.get_latest_stats.return_value = {}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(report, "load_state", return_value=None),
|
||||||
|
patch.object(hardware.hardware_prober, "probe", return_value=[]),
|
||||||
|
patch.object(hardware, "hwaccel_options", return_value=("", [])),
|
||||||
|
):
|
||||||
|
built = report.preview_report(make_context().config, stats_emitter, None)
|
||||||
|
|
||||||
|
self.assertEqual(built.install_id, report.PREVIEW_INSTALL_ID)
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
"""Tests for the analytics reporter's schedule and consent handling."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from frigate.analytics import reporter as reporter_module
|
||||||
|
from frigate.analytics.reporter import (
|
||||||
|
FIRST_DELAY_S,
|
||||||
|
INTERVAL_S,
|
||||||
|
JITTER_S,
|
||||||
|
PROMPT_KIND,
|
||||||
|
WAKE_S,
|
||||||
|
AnalyticsReporter,
|
||||||
|
)
|
||||||
|
from frigate.analytics.state import AnalyticsState, load_state, save_state
|
||||||
|
from frigate.analytics.transport import SendOutcome
|
||||||
|
from frigate.config.holder import ConfigHolder
|
||||||
|
from frigate.test.analytics_helpers import make_config
|
||||||
|
|
||||||
|
SNAPSHOT = [
|
||||||
|
{
|
||||||
|
"kind": "detector_stuck",
|
||||||
|
"occurrences": 3,
|
||||||
|
"dismissals": 0,
|
||||||
|
"reported_occurrences": 1,
|
||||||
|
"reported_dismissals": 0,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnalyticsReporter(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.dir = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.dir.cleanup)
|
||||||
|
self.path = os.path.join(self.dir.name, ".analytics.json")
|
||||||
|
self.now = 1_790_000_000.0
|
||||||
|
self.send = Mock(return_value=SendOutcome.accepted)
|
||||||
|
self.registry = Mock()
|
||||||
|
self.registry.stats.return_value = SNAPSHOT
|
||||||
|
self.stats = Mock()
|
||||||
|
self.stats.get_latest_stats.return_value = {}
|
||||||
|
self.holder = ConfigHolder(make_config({"telemetry": {"analytics": True}}))
|
||||||
|
|
||||||
|
for name in ("raise_notice", "resolve_notice"):
|
||||||
|
patcher = patch.object(reporter_module, name)
|
||||||
|
setattr(self, name, patcher.start())
|
||||||
|
self.addCleanup(patcher.stop)
|
||||||
|
|
||||||
|
self.built = Mock()
|
||||||
|
self.built.model_dump_json.return_value = '{"report": 1}'
|
||||||
|
patcher = patch.object(reporter_module, "build_report", return_value=self.built)
|
||||||
|
self.build_report = patcher.start()
|
||||||
|
self.addCleanup(patcher.stop)
|
||||||
|
|
||||||
|
def reporter(self, path: str | None = None) -> AnalyticsReporter:
|
||||||
|
# uniform(a, b) returns a: the shortest startup delay and interval
|
||||||
|
rng = Mock()
|
||||||
|
rng.uniform.side_effect = lambda low, high: low
|
||||||
|
|
||||||
|
return AnalyticsReporter(
|
||||||
|
self.holder,
|
||||||
|
self.stats,
|
||||||
|
self.registry,
|
||||||
|
threading.Event(),
|
||||||
|
state_path=path or self.path,
|
||||||
|
url="https://example.test/report",
|
||||||
|
send=self.send,
|
||||||
|
clock=lambda: self.now,
|
||||||
|
rng=rng,
|
||||||
|
)
|
||||||
|
|
||||||
|
def advance(self, seconds: float) -> None:
|
||||||
|
self.now += seconds
|
||||||
|
|
||||||
|
def test_waits_for_the_startup_delay_then_sends(self):
|
||||||
|
reporter = self.reporter()
|
||||||
|
|
||||||
|
reporter.tick()
|
||||||
|
self.send.assert_not_called()
|
||||||
|
|
||||||
|
self.advance(FIRST_DELAY_S[0])
|
||||||
|
reporter.tick()
|
||||||
|
|
||||||
|
self.send.assert_called_once_with(
|
||||||
|
"https://example.test/report", '{"report": 1}'
|
||||||
|
)
|
||||||
|
self.registry.mark_reported.assert_called_once_with(SNAPSHOT)
|
||||||
|
self.assertEqual(load_state(self.path).last_attempt_at, self.now)
|
||||||
|
|
||||||
|
def test_sends_once_per_interval(self):
|
||||||
|
reporter = self.reporter()
|
||||||
|
self.advance(FIRST_DELAY_S[0])
|
||||||
|
reporter.tick()
|
||||||
|
|
||||||
|
self.advance(WAKE_S)
|
||||||
|
reporter.tick()
|
||||||
|
self.assertEqual(self.send.call_count, 1)
|
||||||
|
|
||||||
|
self.advance(INTERVAL_S - JITTER_S)
|
||||||
|
reporter.tick()
|
||||||
|
self.assertEqual(self.send.call_count, 2)
|
||||||
|
|
||||||
|
def test_a_restart_inside_the_interval_does_not_send(self):
|
||||||
|
save_state(AnalyticsState("a" * 32, self.now), self.path)
|
||||||
|
reporter = self.reporter()
|
||||||
|
|
||||||
|
self.advance(FIRST_DELAY_S[0])
|
||||||
|
reporter.tick()
|
||||||
|
|
||||||
|
self.send.assert_not_called()
|
||||||
|
|
||||||
|
def test_a_failed_send_keeps_watermarks_and_waits_a_full_interval(self):
|
||||||
|
self.send.return_value = SendOutcome.failed
|
||||||
|
reporter = self.reporter()
|
||||||
|
self.advance(FIRST_DELAY_S[0])
|
||||||
|
|
||||||
|
reporter.tick()
|
||||||
|
self.advance(WAKE_S)
|
||||||
|
reporter.tick()
|
||||||
|
|
||||||
|
self.assertEqual(self.send.call_count, 1)
|
||||||
|
self.registry.mark_reported.assert_not_called()
|
||||||
|
|
||||||
|
def test_an_accepted_report_without_health_keeps_the_watermarks(self):
|
||||||
|
self.built.health = None
|
||||||
|
reporter = self.reporter()
|
||||||
|
self.advance(FIRST_DELAY_S[0])
|
||||||
|
|
||||||
|
reporter.tick()
|
||||||
|
|
||||||
|
self.send.assert_called_once()
|
||||||
|
self.registry.mark_reported.assert_not_called()
|
||||||
|
|
||||||
|
def test_consent_withdrawn_while_the_report_builds_stops_the_send(self):
|
||||||
|
reporter = self.reporter()
|
||||||
|
self.advance(FIRST_DELAY_S[0])
|
||||||
|
|
||||||
|
def withdraw(*args, **kwargs):
|
||||||
|
self.holder.set(make_config())
|
||||||
|
return self.built
|
||||||
|
|
||||||
|
self.build_report.side_effect = withdraw
|
||||||
|
reporter.tick()
|
||||||
|
|
||||||
|
self.send.assert_not_called()
|
||||||
|
self.assertFalse(os.path.exists(self.path))
|
||||||
|
|
||||||
|
def test_turning_sharing_off_and_on_between_wakes_starts_a_fresh_identity(self):
|
||||||
|
save_state(AnalyticsState("a" * 32, 0.0), self.path)
|
||||||
|
reporter = self.reporter()
|
||||||
|
reporter.tick()
|
||||||
|
|
||||||
|
self.holder.set(make_config())
|
||||||
|
self.holder.set(make_config({"telemetry": {"analytics": True}}))
|
||||||
|
self.advance(FIRST_DELAY_S[0])
|
||||||
|
reporter.tick()
|
||||||
|
|
||||||
|
self.raise_notice.assert_called_once_with(PROMPT_KIND)
|
||||||
|
self.assertNotEqual(load_state(self.path).install_id, "a" * 32)
|
||||||
|
|
||||||
|
def test_opting_out_raises_the_prompt_once_and_deletes_the_state(self):
|
||||||
|
self.holder.set(make_config())
|
||||||
|
save_state(AnalyticsState("a" * 32, 0.0), self.path)
|
||||||
|
reporter = self.reporter()
|
||||||
|
|
||||||
|
reporter.tick()
|
||||||
|
reporter.tick()
|
||||||
|
|
||||||
|
self.raise_notice.assert_called_once_with(PROMPT_KIND)
|
||||||
|
self.assertFalse(os.path.exists(self.path))
|
||||||
|
self.send.assert_not_called()
|
||||||
|
|
||||||
|
def test_opting_in_resolves_the_prompt(self):
|
||||||
|
self.holder.set(make_config())
|
||||||
|
reporter = self.reporter()
|
||||||
|
reporter.tick()
|
||||||
|
|
||||||
|
self.holder.set(make_config({"telemetry": {"analytics": True}}))
|
||||||
|
reporter.tick()
|
||||||
|
|
||||||
|
self.resolve_notice.assert_called_once_with(PROMPT_KIND)
|
||||||
|
|
||||||
|
def test_safe_mode_touches_nothing(self):
|
||||||
|
self.holder.set(make_config({"safe_mode": True}))
|
||||||
|
save_state(AnalyticsState("a" * 32, 0.0), self.path)
|
||||||
|
reporter = self.reporter()
|
||||||
|
|
||||||
|
self.advance(FIRST_DELAY_S[0])
|
||||||
|
reporter.tick()
|
||||||
|
|
||||||
|
self.raise_notice.assert_not_called()
|
||||||
|
self.assertTrue(os.path.exists(self.path))
|
||||||
|
self.send.assert_not_called()
|
||||||
|
|
||||||
|
def test_an_unwritable_config_dir_skips_sending_and_warns_once(self):
|
||||||
|
reporter = self.reporter(os.path.join(self.dir.name, "missing", "x.json"))
|
||||||
|
self.advance(FIRST_DELAY_S[0])
|
||||||
|
|
||||||
|
with self.assertLogs("frigate.analytics.reporter", "WARNING") as logs:
|
||||||
|
reporter.tick()
|
||||||
|
self.advance(WAKE_S)
|
||||||
|
reporter.tick()
|
||||||
|
|
||||||
|
self.send.assert_not_called()
|
||||||
|
self.assertEqual(len(logs.output), 1)
|
||||||
|
|
||||||
|
def test_a_last_attempt_in_the_future_is_ignored(self):
|
||||||
|
save_state(AnalyticsState("a" * 32, self.now + 10 * INTERVAL_S), self.path)
|
||||||
|
reporter = self.reporter()
|
||||||
|
|
||||||
|
self.advance(FIRST_DELAY_S[0])
|
||||||
|
reporter.tick()
|
||||||
|
|
||||||
|
self.send.assert_called_once()
|
||||||
|
|
||||||
|
def test_the_loop_survives_a_failing_tick(self):
|
||||||
|
reporter = self.reporter()
|
||||||
|
reporter.stop_event.set()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(reporter, "tick", side_effect=RuntimeError("boom")),
|
||||||
|
self.assertLogs("frigate.analytics.reporter", "ERROR"),
|
||||||
|
):
|
||||||
|
reporter.run()
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"""Tests for the analytics report schema."""
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
import re
|
||||||
|
import unittest
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, get_args, get_origin
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
import frigate.detectors.hardware as detection_hardware
|
||||||
|
import frigate.util.hwaccel as hwaccel
|
||||||
|
from frigate.analytics.schema import (
|
||||||
|
AnalyticsReport,
|
||||||
|
DecodeFamily,
|
||||||
|
HardwareKey,
|
||||||
|
HwaccelKey,
|
||||||
|
InputPresetKey,
|
||||||
|
NoticeKindKey,
|
||||||
|
)
|
||||||
|
from frigate.ffmpeg_presets import PRESETS_HW_ACCEL_DECODE, PRESETS_INPUT
|
||||||
|
from frigate.notices.types import NOTICE_KINDS
|
||||||
|
from frigate.util.hwaccel import HwaccelFamily
|
||||||
|
|
||||||
|
# the only str fields: generated ids and strings the hardware reports
|
||||||
|
ALLOWED_STRINGS = {
|
||||||
|
("AnalyticsReport", "install_id"),
|
||||||
|
("AnalyticsReport", "report_id"),
|
||||||
|
("InstallSection", "version"),
|
||||||
|
("InstallSection", "kernel"),
|
||||||
|
("HardwareSection", "cpu_model"),
|
||||||
|
("GpuInfo", "name"),
|
||||||
|
("StorageInfo", "record_fs"),
|
||||||
|
("DetectionModel", "input"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def nested_models(annotation: Any) -> list[type[BaseModel]]:
|
||||||
|
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
|
||||||
|
return [annotation]
|
||||||
|
|
||||||
|
return [model for arg in get_args(annotation) for model in nested_models(arg)]
|
||||||
|
|
||||||
|
|
||||||
|
def all_models(
|
||||||
|
model: type[BaseModel], seen: set[type[BaseModel]] | None = None
|
||||||
|
) -> list[type[BaseModel]]:
|
||||||
|
seen = set() if seen is None else seen
|
||||||
|
|
||||||
|
if model in seen:
|
||||||
|
return []
|
||||||
|
|
||||||
|
seen.add(model)
|
||||||
|
found = [model]
|
||||||
|
|
||||||
|
for field in model.model_fields.values():
|
||||||
|
for nested in nested_models(field.annotation):
|
||||||
|
found += all_models(nested, seen)
|
||||||
|
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def dict_key_types(annotation: Any) -> list[Any]:
|
||||||
|
keys = [get_args(annotation)[0]] if get_origin(annotation) is dict else []
|
||||||
|
return keys + [key for arg in get_args(annotation) for key in dict_key_types(arg)]
|
||||||
|
|
||||||
|
|
||||||
|
class TestSchemaDiscipline(unittest.TestCase):
|
||||||
|
def test_every_field_has_a_description_and_public_flag(self):
|
||||||
|
for model in all_models(AnalyticsReport):
|
||||||
|
for name, field in model.model_fields.items():
|
||||||
|
with self.subTest(field=f"{model.__name__}.{name}"):
|
||||||
|
self.assertTrue(field.description)
|
||||||
|
self.assertIsInstance(field.json_schema_extra, dict)
|
||||||
|
self.assertIsInstance(field.json_schema_extra.get("x-public"), bool)
|
||||||
|
|
||||||
|
def test_strings_are_limited_to_ids_and_hardware_names(self):
|
||||||
|
for model in all_models(AnalyticsReport):
|
||||||
|
properties = model.model_json_schema()["properties"]
|
||||||
|
|
||||||
|
for name, field in model.model_fields.items():
|
||||||
|
if field.annotation is not str:
|
||||||
|
continue
|
||||||
|
|
||||||
|
with self.subTest(field=f"{model.__name__}.{name}"):
|
||||||
|
self.assertIn((model.__name__, name), ALLOWED_STRINGS)
|
||||||
|
self.assertTrue(
|
||||||
|
"maxLength" in properties[name] or "pattern" in properties[name]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_map_keys_are_enums(self):
|
||||||
|
for model in all_models(AnalyticsReport):
|
||||||
|
for name, field in model.model_fields.items():
|
||||||
|
for key_type in dict_key_types(field.annotation):
|
||||||
|
with self.subTest(field=f"{model.__name__}.{name}"):
|
||||||
|
self.assertTrue(issubclass(key_type, Enum))
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnumsTrackTheirSources(unittest.TestCase):
|
||||||
|
def test_hardware_keys_cover_every_probe(self):
|
||||||
|
probed = set(
|
||||||
|
re.findall(
|
||||||
|
r'_hardware\(\s*"([^"]+)"', inspect.getsource(detection_hardware)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(probed)
|
||||||
|
self.assertLessEqual(probed, {key.value for key in HardwareKey})
|
||||||
|
|
||||||
|
def test_decode_families_cover_every_hwaccel_family(self):
|
||||||
|
families = {
|
||||||
|
value.key
|
||||||
|
for value in vars(hwaccel).values()
|
||||||
|
if isinstance(value, HwaccelFamily)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.assertTrue(families)
|
||||||
|
self.assertLessEqual(families, {family.value for family in DecodeFamily})
|
||||||
|
|
||||||
|
def test_preset_keys_mirror_the_presets(self):
|
||||||
|
for enum, presets in (
|
||||||
|
(HwaccelKey, PRESETS_HW_ACCEL_DECODE),
|
||||||
|
(InputPresetKey, PRESETS_INPUT),
|
||||||
|
):
|
||||||
|
with self.subTest(enum=enum.__name__):
|
||||||
|
self.assertEqual(
|
||||||
|
{key.value for key in enum},
|
||||||
|
{name.removeprefix("preset-") for name in presets}
|
||||||
|
| {"custom", "none"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_notice_keys_are_the_reportable_kinds(self):
|
||||||
|
self.assertEqual(
|
||||||
|
{key.value for key in NoticeKindKey},
|
||||||
|
{key for key, kind in NOTICE_KINDS.items() if kind.reportable},
|
||||||
|
)
|
||||||
|
self.assertNotIn("analytics_prompt", {key.value for key in NoticeKindKey})
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Tests for the analytics state file."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from frigate.analytics.state import (
|
||||||
|
AnalyticsState,
|
||||||
|
delete_state,
|
||||||
|
load_state,
|
||||||
|
new_state,
|
||||||
|
save_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnalyticsState(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.dir = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.dir.cleanup)
|
||||||
|
self.path = os.path.join(self.dir.name, ".analytics.json")
|
||||||
|
|
||||||
|
def write(self, content: str) -> None:
|
||||||
|
with open(self.path, "w") as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
def test_missing_file_loads_as_none(self):
|
||||||
|
self.assertIsNone(load_state(self.path))
|
||||||
|
|
||||||
|
def test_save_then_load_round_trips(self):
|
||||||
|
state = AnalyticsState(install_id="a" * 32, last_attempt_at=1790000000.5)
|
||||||
|
|
||||||
|
self.assertTrue(save_state(state, self.path))
|
||||||
|
self.assertEqual(load_state(self.path), state)
|
||||||
|
|
||||||
|
def test_unusable_files_load_as_none(self):
|
||||||
|
for content in (
|
||||||
|
"{not json",
|
||||||
|
'["a"]',
|
||||||
|
'{"install_id": "short", "last_attempt_at": 1}',
|
||||||
|
'{"install_id": "' + "a" * 32 + '"}',
|
||||||
|
'{"install_id": "' + "a" * 32 + '", "last_attempt_at": true}',
|
||||||
|
):
|
||||||
|
with self.subTest(content=content):
|
||||||
|
self.write(content)
|
||||||
|
self.assertIsNone(load_state(self.path))
|
||||||
|
|
||||||
|
def test_save_reports_failure_instead_of_raising(self):
|
||||||
|
missing = os.path.join(self.dir.name, "missing", ".analytics.json")
|
||||||
|
|
||||||
|
self.assertFalse(save_state(new_state(), missing))
|
||||||
|
|
||||||
|
def test_new_state_has_a_hex_id_and_no_attempt(self):
|
||||||
|
state = new_state()
|
||||||
|
|
||||||
|
self.assertRegex(state.install_id, r"^[0-9a-f]{32}$")
|
||||||
|
self.assertEqual(state.last_attempt_at, 0.0)
|
||||||
|
|
||||||
|
def test_delete_removes_the_file_and_tolerates_a_missing_one(self):
|
||||||
|
save_state(new_state(), self.path)
|
||||||
|
|
||||||
|
delete_state(self.path)
|
||||||
|
delete_state(self.path)
|
||||||
|
|
||||||
|
self.assertFalse(os.path.exists(self.path))
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Tests for sending a report."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from frigate.analytics.transport import TIMEOUT_S, SendOutcome, send_report
|
||||||
|
from frigate.version import VERSION
|
||||||
|
|
||||||
|
URL = "https://example.test/report"
|
||||||
|
|
||||||
|
|
||||||
|
def post(status: int = 204, text: str = "", side_effect: Exception | None = None):
|
||||||
|
return patch(
|
||||||
|
"frigate.analytics.transport.requests.post",
|
||||||
|
return_value=Mock(status_code=status, text=text),
|
||||||
|
side_effect=side_effect,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSendReport(unittest.TestCase):
|
||||||
|
def test_posts_the_body_as_json_with_a_user_agent(self):
|
||||||
|
with post() as mock_post:
|
||||||
|
outcome = send_report(URL, '{"a":1}')
|
||||||
|
|
||||||
|
self.assertIs(outcome, SendOutcome.accepted)
|
||||||
|
args, kwargs = mock_post.call_args
|
||||||
|
self.assertEqual(args[0], URL)
|
||||||
|
self.assertEqual(kwargs["data"], b'{"a":1}')
|
||||||
|
self.assertEqual(kwargs["headers"]["Content-Type"], "application/json")
|
||||||
|
self.assertEqual(kwargs["headers"]["User-Agent"], f"Frigate/{VERSION}")
|
||||||
|
self.assertFalse(kwargs["allow_redirects"])
|
||||||
|
self.assertEqual(kwargs["timeout"], TIMEOUT_S)
|
||||||
|
|
||||||
|
def test_maps_statuses_to_outcomes(self):
|
||||||
|
cases = (
|
||||||
|
(200, SendOutcome.accepted),
|
||||||
|
(400, SendOutcome.rejected),
|
||||||
|
(429, SendOutcome.rate_limited),
|
||||||
|
(302, SendOutcome.failed),
|
||||||
|
(500, SendOutcome.failed),
|
||||||
|
)
|
||||||
|
|
||||||
|
for status, outcome in cases:
|
||||||
|
with self.subTest(status=status), post(status):
|
||||||
|
self.assertIs(send_report(URL, "{}"), outcome)
|
||||||
|
|
||||||
|
def test_logs_the_rejection_reason(self):
|
||||||
|
with (
|
||||||
|
post(400, "install_id: bad"),
|
||||||
|
self.assertLogs("frigate.analytics.transport", "WARNING") as logs,
|
||||||
|
):
|
||||||
|
send_report(URL, "{}")
|
||||||
|
|
||||||
|
self.assertIn("install_id: bad", logs.output[0])
|
||||||
|
|
||||||
|
def test_a_network_error_fails_without_raising(self):
|
||||||
|
with post(side_effect=requests.ConnectionError("down")):
|
||||||
|
self.assertIs(send_report(URL, "{}"), SendOutcome.failed)
|
||||||
@@ -36,31 +36,6 @@ class TestEventsPerSecond(unittest.TestCase):
|
|||||||
clock[0] += 100.0
|
clock[0] += 100.0
|
||||||
self.assertEqual(eps.eps(), 0.0)
|
self.assertEqual(eps.eps(), 0.0)
|
||||||
|
|
||||||
def test_burst_after_start_is_not_divided_by_a_tiny_window(self) -> None:
|
|
||||||
eps = EventsPerSecond(last_n_seconds=10)
|
|
||||||
clock = [1000.0]
|
|
||||||
with patch("frigate.util.builtin.time.monotonic", side_effect=lambda: clock[0]):
|
|
||||||
eps.start()
|
|
||||||
# eleven buffered frames arrive within 100 ms of starting
|
|
||||||
for _ in range(11):
|
|
||||||
clock[0] += 0.01
|
|
||||||
eps.update()
|
|
||||||
# 11 events over less than a second is at most 11 per second
|
|
||||||
self.assertLessEqual(eps.eps(), 11.0)
|
|
||||||
|
|
||||||
def test_subsecond_window_keeps_its_rate(self) -> None:
|
|
||||||
eps = EventsPerSecond(last_n_seconds=0.5)
|
|
||||||
clock = [1000.0]
|
|
||||||
with patch("frigate.util.builtin.time.monotonic", side_effect=lambda: clock[0]):
|
|
||||||
eps.start()
|
|
||||||
# twenty events per second for two seconds
|
|
||||||
for _ in range(40):
|
|
||||||
clock[0] += 0.05
|
|
||||||
eps.update()
|
|
||||||
# read between events, so none sits exactly on the window edge
|
|
||||||
clock[0] += 0.01
|
|
||||||
self.assertAlmostEqual(eps.eps(), 20.0)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ from frigate.util.builtin import deep_merge
|
|||||||
|
|
||||||
|
|
||||||
class TestConfig(unittest.TestCase):
|
class TestConfig(unittest.TestCase):
|
||||||
|
def test_analytics_is_off_by_default(self):
|
||||||
|
frigate_config = FrigateConfig(**self.minimal)
|
||||||
|
|
||||||
|
self.assertFalse(frigate_config.telemetry.analytics)
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.minimal = {
|
self.minimal = {
|
||||||
"mqtt": {"host": "mqtt"},
|
"mqtt": {"host": "mqtt"},
|
||||||
|
|||||||
@@ -82,5 +82,30 @@ class TestSwapRuntimeConfig(unittest.TestCase):
|
|||||||
app.genai_manager.update_config.assert_called_once_with(config)
|
app.genai_manager.update_config.assert_called_once_with(config)
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigHolder(unittest.TestCase):
|
||||||
|
def test_set_passes_the_new_config_to_subscribers(self) -> None:
|
||||||
|
holder = ConfigHolder(MagicMock(name="boot_config"))
|
||||||
|
listener = MagicMock()
|
||||||
|
holder.subscribe(listener)
|
||||||
|
config = MagicMock(name="new_config")
|
||||||
|
|
||||||
|
holder.set(config)
|
||||||
|
|
||||||
|
listener.assert_called_once_with(config)
|
||||||
|
|
||||||
|
def test_a_failing_subscriber_does_not_block_the_swap(self) -> None:
|
||||||
|
holder = ConfigHolder(MagicMock(name="boot_config"))
|
||||||
|
after = MagicMock()
|
||||||
|
holder.subscribe(MagicMock(side_effect=RuntimeError("boom")))
|
||||||
|
holder.subscribe(after)
|
||||||
|
config = MagicMock(name="new_config")
|
||||||
|
|
||||||
|
with self.assertLogs("frigate.config.holder", "ERROR"):
|
||||||
|
holder.set(config)
|
||||||
|
|
||||||
|
self.assertIs(holder.config, config)
|
||||||
|
after.assert_called_once_with(config)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -48,6 +48,16 @@ class TestNoticeKinds(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertIsNone(kind.link_for({"version": "0.19.1"}))
|
self.assertIsNone(kind.link_for({"version": "0.19.1"}))
|
||||||
|
|
||||||
|
def test_analytics_prompt_links_to_telemetry_settings(self):
|
||||||
|
kind = NOTICE_KINDS["analytics_prompt"]
|
||||||
|
|
||||||
|
self.assertEqual(kind.link_for({}), "/settings?page=systemTelemetry")
|
||||||
|
self.assertFalse(kind.counts_repeats)
|
||||||
|
self.assertFalse(kind.reportable)
|
||||||
|
|
||||||
|
def test_update_available_is_not_reported(self):
|
||||||
|
self.assertFalse(NOTICE_KINDS["update_available"].reportable)
|
||||||
|
|
||||||
|
|
||||||
class RegistryTestCase(unittest.TestCase):
|
class RegistryTestCase(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@@ -278,6 +288,24 @@ class TestNoticeRegistry(RegistryTestCase):
|
|||||||
self.assertEqual(dismissals["detector_stuck"], 1)
|
self.assertEqual(dismissals["detector_stuck"], 1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMarkReported(RegistryTestCase):
|
||||||
|
def test_moves_watermarks_to_the_snapshot_not_the_current_count(self):
|
||||||
|
self.registry.raise_notice(
|
||||||
|
"detector_stuck", scope="ov", params={"detector": "ov"}
|
||||||
|
)
|
||||||
|
snapshot = self.registry.stats()
|
||||||
|
self.registry.raise_notice(
|
||||||
|
"detector_stuck", scope="ov", params={"detector": "ov"}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.registry.mark_reported(snapshot)
|
||||||
|
|
||||||
|
row = self.registry.stats()[0]
|
||||||
|
self.assertEqual(row["occurrences"], 2)
|
||||||
|
self.assertEqual(row["reported_occurrences"], 1)
|
||||||
|
self.assertEqual(row["reported_dismissals"], 0)
|
||||||
|
|
||||||
|
|
||||||
class TestApply(RegistryTestCase):
|
class TestApply(RegistryTestCase):
|
||||||
def test_each_action_reaches_its_method(self):
|
def test_each_action_reaches_its_method(self):
|
||||||
self.registry.apply(
|
self.registry.apply(
|
||||||
|
|||||||
@@ -56,13 +56,10 @@ class EventsPerSecond:
|
|||||||
self._start = now
|
self._start = now
|
||||||
# compute the (approximate) events in the last n seconds
|
# compute the (approximate) events in the last n seconds
|
||||||
self.expire_timestamps(now)
|
self.expire_timestamps(now)
|
||||||
# rate over at least one second (or the whole window, if shorter),
|
seconds = min(now - self._start, self._last_n_seconds)
|
||||||
# so a burst of events right after start() is not divided by a
|
# avoid divide by zero
|
||||||
# tiny window
|
if seconds == 0:
|
||||||
seconds = max(
|
seconds = 1
|
||||||
min(now - self._start, self._last_n_seconds),
|
|
||||||
min(1.0, self._last_n_seconds),
|
|
||||||
)
|
|
||||||
return len(self._timestamps) / seconds
|
return len(self._timestamps) / seconds
|
||||||
|
|
||||||
# remove aged out timestamps
|
# remove aged out timestamps
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Generate the analytics report JSON Schema from the Pydantic models.
|
||||||
|
|
||||||
|
The committed docs/static/frigate-analytics-schema.json documents every field
|
||||||
|
for the ingest Worker's aggregation job and the docs page. It is exact for this
|
||||||
|
version only, so the Worker validates just the envelope against it. Never edit
|
||||||
|
it by hand, and never run a formatter over it.
|
||||||
|
|
||||||
|
Usage (from the repository root):
|
||||||
|
|
||||||
|
python3 generate_analytics_schema.py # write the schema
|
||||||
|
python3 generate_analytics_schema.py --check # CI guard: fail if stale
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from frigate.analytics.schema import SCHEMA_VERSION, AnalyticsReport
|
||||||
|
|
||||||
|
OUTPUT = Path(__file__).parent / "docs" / "static" / "frigate-analytics-schema.json"
|
||||||
|
|
||||||
|
|
||||||
|
def render() -> str:
|
||||||
|
schema = AnalyticsReport.model_json_schema()
|
||||||
|
schema["$schema"] = "https://json-schema.org/draft/2020-12/schema"
|
||||||
|
schema["$id"] = "https://docs.frigate.video/frigate-analytics-schema.json"
|
||||||
|
schema["x-schema-version"] = SCHEMA_VERSION
|
||||||
|
return json.dumps(schema, indent=2, sort_keys=True) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
parser.add_argument(
|
||||||
|
"--check", action="store_true", help="fail if the committed schema is stale"
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
rendered = render()
|
||||||
|
|
||||||
|
if args.check:
|
||||||
|
current = OUTPUT.read_text() if OUTPUT.exists() else ""
|
||||||
|
|
||||||
|
if current != rendered:
|
||||||
|
print(
|
||||||
|
f"{OUTPUT} is out of date, run python3 generate_analytics_schema.py",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"{OUTPUT} is up to date")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
OUTPUT.write_text(rendered)
|
||||||
|
print(f"Wrote {OUTPUT}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -47,8 +47,8 @@ from fastapi.routing import APIRoute
|
|||||||
from ruamel.yaml import YAML
|
from ruamel.yaml import YAML
|
||||||
from ruamel.yaml.scalarstring import LiteralScalarString
|
from ruamel.yaml.scalarstring import LiteralScalarString
|
||||||
|
|
||||||
from frigate.api import app as main_app
|
|
||||||
from frigate.api import (
|
from frigate.api import (
|
||||||
|
analytics,
|
||||||
auth,
|
auth,
|
||||||
camera,
|
camera,
|
||||||
chat,
|
chat,
|
||||||
@@ -65,6 +65,7 @@ from frigate.api import (
|
|||||||
record,
|
record,
|
||||||
review,
|
review,
|
||||||
)
|
)
|
||||||
|
from frigate.api import app as main_app
|
||||||
from frigate.api.auth import require_admin_by_default
|
from frigate.api.auth import require_admin_by_default
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||||
@@ -145,6 +146,7 @@ def build_app() -> FastAPI:
|
|||||||
"""
|
"""
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
routers = [
|
routers = [
|
||||||
|
analytics.router,
|
||||||
auth.router,
|
auth.router,
|
||||||
camera.router,
|
camera.router,
|
||||||
chat.router,
|
chat.router,
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,204 +0,0 @@
|
|||||||
/**
|
|
||||||
* Helpers for the live dashboard's draggable grid layout: reading and seeding
|
|
||||||
* the persisted layout, and measuring rendered tiles.
|
|
||||||
*
|
|
||||||
* DraggableGridLayout persists through useUserPersistence, which namespaces
|
|
||||||
* keys by username, and every write is an async idb put. A test that seeds the
|
|
||||||
* bare key, or seeds before the app's own first write has landed, silently
|
|
||||||
* asserts against a key the app never reads. persistedLayoutKey() closes both
|
|
||||||
* holes, so prefer it over building the key by hand.
|
|
||||||
*
|
|
||||||
* Geometry has its own trap: the grid first lays out against window.innerWidth,
|
|
||||||
* then reflows narrower once useResizeObserver reports the real container.
|
|
||||||
* Tiles measured in separate round-trips can straddle that reflow and disagree
|
|
||||||
* on scale, so cameraBoxes() takes every measurement in one evaluate.
|
|
||||||
*
|
|
||||||
* Used by live-grid-aspect-modes.spec.ts and masonry-live-grid.spec.ts.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { expect, type Page } from "@playwright/test";
|
|
||||||
|
|
||||||
export type LayoutItem = {
|
|
||||||
i: string;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
w: number;
|
|
||||||
h: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PersistedLayout = {
|
|
||||||
version: number;
|
|
||||||
naturalAspect: boolean;
|
|
||||||
layout: LayoutItem[];
|
|
||||||
};
|
|
||||||
|
|
||||||
function layoutKeySuffix(group: string): string {
|
|
||||||
return `${group}-draggable-layout`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The key the app has actually written an envelope to, or undefined while its
|
|
||||||
* first write is still in flight.
|
|
||||||
*/
|
|
||||||
function findWrittenKey(
|
|
||||||
page: Page,
|
|
||||||
group: string,
|
|
||||||
): Promise<string | undefined> {
|
|
||||||
return page.evaluate(
|
|
||||||
(suffix) =>
|
|
||||||
new Promise<string | undefined>((resolve) => {
|
|
||||||
const open = indexedDB.open("keyval-store");
|
|
||||||
open.onsuccess = () => {
|
|
||||||
const store = open.result
|
|
||||||
.transaction("keyval", "readonly")
|
|
||||||
.objectStore("keyval");
|
|
||||||
// getAllKeys and getAll both return in key order, so the indexes align
|
|
||||||
const keys = store.getAllKeys();
|
|
||||||
const values = store.getAll();
|
|
||||||
keys.transaction.oncomplete = () => {
|
|
||||||
open.result.close();
|
|
||||||
const names = keys.result as string[];
|
|
||||||
const stored = values.result as { version?: number }[];
|
|
||||||
const match = names.findIndex(
|
|
||||||
(name, index) =>
|
|
||||||
(name === suffix || name.startsWith(`${suffix}:`)) &&
|
|
||||||
typeof stored[index]?.version === "number",
|
|
||||||
);
|
|
||||||
resolve(match === -1 ? undefined : names[match]);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
open.onerror = () => resolve(undefined);
|
|
||||||
}),
|
|
||||||
layoutKeySuffix(group),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wait for the grid to persist its own layout, then return the key it used.
|
|
||||||
* Waiting for that write is what makes a later seed meaningful: it proves the
|
|
||||||
* key is live, and it rules out the app overwriting the seed a moment later.
|
|
||||||
*/
|
|
||||||
export async function persistedLayoutKey(
|
|
||||||
page: Page,
|
|
||||||
group: string,
|
|
||||||
): Promise<string> {
|
|
||||||
let key: string | undefined;
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => (key = await findWrittenKey(page, group)), {
|
|
||||||
timeout: 10_000,
|
|
||||||
message: `grid never persisted a layout for group "${group}"`,
|
|
||||||
})
|
|
||||||
.not.toBeUndefined();
|
|
||||||
|
|
||||||
return key!;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Overwrite the stored layout, resolving only once the put has committed. */
|
|
||||||
export function seedLayout(
|
|
||||||
page: Page,
|
|
||||||
key: string,
|
|
||||||
value: unknown,
|
|
||||||
): Promise<void> {
|
|
||||||
return page.evaluate(
|
|
||||||
([key, value]) =>
|
|
||||||
new Promise<void>((resolve, reject) => {
|
|
||||||
const open = indexedDB.open("keyval-store");
|
|
||||||
open.onupgradeneeded = () => open.result.createObjectStore("keyval");
|
|
||||||
open.onsuccess = () => {
|
|
||||||
const tx = open.result.transaction("keyval", "readwrite");
|
|
||||||
tx.objectStore("keyval").put(value, key as string);
|
|
||||||
tx.oncomplete = () => {
|
|
||||||
open.result.close();
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
tx.onerror = () => reject(tx.error);
|
|
||||||
};
|
|
||||||
open.onerror = () => reject(open.error);
|
|
||||||
}),
|
|
||||||
[key, value] as const,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read the stored layout back. Undefined until the app writes it. */
|
|
||||||
export function readLayout(
|
|
||||||
page: Page,
|
|
||||||
key: string,
|
|
||||||
): Promise<PersistedLayout | undefined> {
|
|
||||||
return page.evaluate(
|
|
||||||
(target) =>
|
|
||||||
new Promise((resolve) => {
|
|
||||||
const open = indexedDB.open("keyval-store");
|
|
||||||
open.onsuccess = () => {
|
|
||||||
const tx = open.result.transaction("keyval", "readonly");
|
|
||||||
const request = tx.objectStore("keyval").get(target);
|
|
||||||
tx.oncomplete = () => {
|
|
||||||
open.result.close();
|
|
||||||
resolve(request.result);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
open.onerror = () => resolve(undefined);
|
|
||||||
}),
|
|
||||||
key,
|
|
||||||
) as Promise<PersistedLayout | undefined>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Box = { w: number; h: number; x: number; y: number };
|
|
||||||
|
|
||||||
/** The card is the player root; the cell is the grid slot it sits in. */
|
|
||||||
export type BoxTarget = "card" | "cell";
|
|
||||||
|
|
||||||
/** One atomic snapshot, or null while any tile is missing or unlaid out. */
|
|
||||||
function snapshotBoxes(
|
|
||||||
page: Page,
|
|
||||||
cameras: readonly string[],
|
|
||||||
target: BoxTarget,
|
|
||||||
): Promise<Record<string, Box> | null> {
|
|
||||||
return page.evaluate(
|
|
||||||
({ cams, target }) => {
|
|
||||||
const boxes: Record<string, Box> = {};
|
|
||||||
|
|
||||||
for (const cam of cams) {
|
|
||||||
const card = document.querySelector(`[data-camera='${cam}']`);
|
|
||||||
const el = target === "cell" ? card?.closest(".p-1") : card;
|
|
||||||
|
|
||||||
if (!el) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const r = el.getBoundingClientRect();
|
|
||||||
|
|
||||||
// a re-rendering tile can briefly report no box at all
|
|
||||||
if (!r.width || !r.height) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
boxes[cam] = { w: r.width, h: r.height, x: r.x, y: r.y };
|
|
||||||
}
|
|
||||||
|
|
||||||
return boxes;
|
|
||||||
},
|
|
||||||
{ cams: cameras as readonly string[], target },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Measure the given cameras' tiles together, once they have all rendered.
|
|
||||||
* Measuring in one evaluate is what keeps the numbers mutually comparable.
|
|
||||||
*/
|
|
||||||
export async function cameraBoxes<T extends string>(
|
|
||||||
page: Page,
|
|
||||||
cameras: readonly T[],
|
|
||||||
target: BoxTarget = "cell",
|
|
||||||
): Promise<Record<T, Box>> {
|
|
||||||
let boxes: Record<string, Box> | null = null;
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => (boxes = await snapshotBoxes(page, cameras, target)), {
|
|
||||||
timeout: 10_000,
|
|
||||||
message: `${target}s never rendered for ${cameras.join(", ")}`,
|
|
||||||
})
|
|
||||||
.not.toBeNull();
|
|
||||||
|
|
||||||
return boxes as unknown as Record<T, Box>;
|
|
||||||
}
|
|
||||||
@@ -45,11 +45,6 @@ export class LivePage extends BasePage {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Edit-layout toggle on the draggable grid (desktop, custom groups). */
|
|
||||||
get editLayoutButton(): Locator {
|
|
||||||
return this.page.getByTestId("toggle-edit-layout");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Open the right-click context menu on a camera card (desktop only). */
|
/** Open the right-click context menu on a camera card (desktop only). */
|
||||||
async openContextMenuOn(cameraName: string): Promise<Locator> {
|
async openContextMenuOn(cameraName: string): Promise<Locator> {
|
||||||
await this.cameraCard(cameraName).first().click({ button: "right" });
|
await this.cameraCard(cameraName).first().click({ button: "right" });
|
||||||
|
|||||||
@@ -1,185 +0,0 @@
|
|||||||
/**
|
|
||||||
* Live grid aspect modes.
|
|
||||||
*
|
|
||||||
* Bucketed mode (the default) snaps every camera to a wide, landscape or tall
|
|
||||||
* tile, and converts layouts saved by pre-masonry versions instead of
|
|
||||||
* discarding them. Natural mode sizes each tile to its own camera.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { test, expect } from "../fixtures/frigate-test";
|
|
||||||
import { LivePage } from "../pages/live.page";
|
|
||||||
import {
|
|
||||||
cameraBoxes,
|
|
||||||
persistedLayoutKey,
|
|
||||||
readLayout,
|
|
||||||
seedLayout,
|
|
||||||
type LayoutItem,
|
|
||||||
} from "../helpers/grid-layout";
|
|
||||||
|
|
||||||
const GROUP = "outdoor";
|
|
||||||
const GRID_COLS = 96;
|
|
||||||
|
|
||||||
test.describe("Live grid aspect modes @critical", () => {
|
|
||||||
test.skip(
|
|
||||||
({ frigateApp }) => frigateApp.isMobile,
|
|
||||||
"Draggable grid is desktop-only",
|
|
||||||
);
|
|
||||||
|
|
||||||
test("an ultra-wide camera gets a 32:9 tile in bucketed mode @mobile", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.installDefaults({
|
|
||||||
config: {
|
|
||||||
cameras: { backyard: { detect: { width: 2560, height: 720 } } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await frigateApp.goto(`/?group=${GROUP}`);
|
|
||||||
const live = new LivePage(frigateApp.page, true);
|
|
||||||
await expect(live.cameraCard("backyard").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { backyard: wide, front_door: normal } = await cameraBoxes(
|
|
||||||
frigateApp.page,
|
|
||||||
["backyard", "front_door"] as const,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(wide.w / wide.h).toBeCloseTo(32 / 9, 1);
|
|
||||||
expect(wide.w / normal.w).toBeCloseTo(2, 1);
|
|
||||||
expect(wide.h).toBeCloseTo(normal.h, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a letterboxed still image rounds its own corners", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
// A portrait camera pillarboxes inside its 8:9 bucket, so the card's
|
|
||||||
// overflow-hidden clip never reaches the picture's corners. The image has
|
|
||||||
// to carry the radius itself or it renders with square edges on the tile.
|
|
||||||
await frigateApp.installDefaults({
|
|
||||||
config: {
|
|
||||||
cameras: { backyard: { detect: { width: 720, height: 1280 } } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await frigateApp.goto(`/?group=${GROUP}`);
|
|
||||||
const live = new LivePage(frigateApp.page, true);
|
|
||||||
await expect(live.cameraCard("backyard").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const radii = await frigateApp.page.evaluate(() => {
|
|
||||||
const card = document.querySelector("[data-camera='backyard']");
|
|
||||||
const img = card?.querySelector("img");
|
|
||||||
return {
|
|
||||||
card: card ? getComputedStyle(card).borderTopLeftRadius : null,
|
|
||||||
img: img ? getComputedStyle(img).borderTopLeftRadius : null,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(radii.card).not.toBe("0px");
|
|
||||||
expect(radii.img).toBe(radii.card);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a pre-masonry layout is converted, keeping resized tiles", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.goto(`/?group=${GROUP}`);
|
|
||||||
const live = new LivePage(frigateApp.page, true);
|
|
||||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 0.17/0.18 shape: bare array on a 12-column grid, 4x4 standard tiles.
|
|
||||||
// backyard was manually resized to 8x8 and sits beside front_door's column,
|
|
||||||
// front_door is a standard tile on the row below.
|
|
||||||
const key = await persistedLayoutKey(frigateApp.page, GROUP);
|
|
||||||
await seedLayout(frigateApp.page, key, [
|
|
||||||
{ i: "backyard", x: 4, y: 0, w: 8, h: 8, moved: false, static: false },
|
|
||||||
{ i: "front_door", x: 0, y: 8, w: 4, h: 4, moved: false, static: false },
|
|
||||||
]);
|
|
||||||
await frigateApp.page.reload();
|
|
||||||
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
|
|
||||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// The conversion is written back on first load, replacing the legacy array
|
|
||||||
// with an envelope. Poll for it: that write is an async idb put.
|
|
||||||
await expect
|
|
||||||
.poll(async () => (await readLayout(frigateApp.page, key))?.version, {
|
|
||||||
timeout: 10_000,
|
|
||||||
})
|
|
||||||
.toBe(2);
|
|
||||||
|
|
||||||
const stored = (await readLayout(frigateApp.page, key))!;
|
|
||||||
expect(stored).toMatchObject({ version: 2, naturalAspect: false });
|
|
||||||
|
|
||||||
// x and w scale 8x (12 -> 96 columns), y and h scale 18x (4 -> 72 rows per
|
|
||||||
// standard tile), so the manual resize survives instead of snapping back.
|
|
||||||
expect(
|
|
||||||
stored.layout.find((i: LayoutItem) => i.i === "backyard"),
|
|
||||||
).toMatchObject({
|
|
||||||
x: 32,
|
|
||||||
y: 0,
|
|
||||||
w: 64,
|
|
||||||
h: 144,
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
stored.layout.find((i: LayoutItem) => i.i === "front_door"),
|
|
||||||
).toMatchObject({
|
|
||||||
x: 0,
|
|
||||||
y: 144,
|
|
||||||
w: 32,
|
|
||||||
h: 72,
|
|
||||||
});
|
|
||||||
|
|
||||||
// arrangement on screen: backyard indented, front_door below it
|
|
||||||
const { backyard, front_door: frontDoor } = await cameraBoxes(
|
|
||||||
frigateApp.page,
|
|
||||||
["backyard", "front_door"] as const,
|
|
||||||
);
|
|
||||||
expect(backyard.x).toBeGreaterThan(frontDoor.x + frontDoor.w / 2);
|
|
||||||
expect(frontDoor.y).toBeGreaterThan(backyard.y + backyard.h / 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("conversion is a pure scale, so odd sizes and positions survive", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.goto(`/?group=${GROUP}`);
|
|
||||||
const live = new LivePage(frigateApp.page, true);
|
|
||||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// The old grid exposed all four resize corners with no aspect constraint,
|
|
||||||
// so a stored tile can be any size. These two are adjacent and non-standard.
|
|
||||||
const key = await persistedLayoutKey(frigateApp.page, GROUP);
|
|
||||||
await seedLayout(frigateApp.page, key, [
|
|
||||||
{ i: "front_door", x: 0, y: 3, w: 5, h: 5 },
|
|
||||||
{ i: "backyard", x: 5, y: 3, w: 7, h: 5 },
|
|
||||||
]);
|
|
||||||
await frigateApp.page.reload();
|
|
||||||
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
|
|
||||||
await expect(live.cameraCard("backyard").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => (await readLayout(frigateApp.page, key))?.version, {
|
|
||||||
timeout: 10_000,
|
|
||||||
})
|
|
||||||
.toBe(2);
|
|
||||||
|
|
||||||
const stored = (await readLayout(frigateApp.page, key))!;
|
|
||||||
const frontDoor = stored.layout.find(
|
|
||||||
(i: LayoutItem) => i.i === "front_door",
|
|
||||||
)!;
|
|
||||||
const backyard = stored.layout.find((i: LayoutItem) => i.i === "backyard")!;
|
|
||||||
|
|
||||||
expect(frontDoor).toMatchObject({ x: 0, y: 54, w: 40, h: 90 });
|
|
||||||
expect(backyard).toMatchObject({ x: 40, y: 54, w: 56, h: 90 });
|
|
||||||
|
|
||||||
// still adjacent, still inside the grid, still not overlapping
|
|
||||||
expect(frontDoor.x + frontDoor.w).toBe(backyard.x);
|
|
||||||
expect(backyard.x + backyard.w).toBe(GRID_COLS);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,298 +0,0 @@
|
|||||||
/**
|
|
||||||
* Masonry live grid -- custom-group draggable layout.
|
|
||||||
*
|
|
||||||
* Verifies natural-aspect tile sizing and that a saved layout the current
|
|
||||||
* version cannot read is regenerated cleanly. The grid renders only for a
|
|
||||||
* custom camera group (here: "outdoor") on desktop; mobile keeps the static
|
|
||||||
* grid, which the @mobile block below guards.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { test, expect } from "../fixtures/frigate-test";
|
|
||||||
import { LivePage } from "../pages/live.page";
|
|
||||||
import {
|
|
||||||
cameraBoxes,
|
|
||||||
persistedLayoutKey,
|
|
||||||
readLayout,
|
|
||||||
seedLayout,
|
|
||||||
} from "../helpers/grid-layout";
|
|
||||||
|
|
||||||
const GROUP = "outdoor"; // custom group: front_door + backyard
|
|
||||||
|
|
||||||
test.describe("Masonry live grid @critical", () => {
|
|
||||||
test.skip(
|
|
||||||
({ frigateApp }) => frigateApp.isMobile,
|
|
||||||
"Draggable masonry grid is desktop-only",
|
|
||||||
);
|
|
||||||
|
|
||||||
test("custom group renders its cameras in the draggable grid", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.goto(`/?group=${GROUP}`);
|
|
||||||
const live = new LivePage(frigateApp.page, true);
|
|
||||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
await expect(live.cameraCard("backyard").first()).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("tiles render at their camera's natural aspect ratio", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
// backyard is 9:16, which bucketed mode would snap to an 8:9 tile
|
|
||||||
await frigateApp.installDefaults({
|
|
||||||
config: {
|
|
||||||
cameras: { backyard: { detect: { width: 720, height: 1280 } } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await frigateApp.goto(`/?group=${GROUP}`);
|
|
||||||
const live = new LivePage(frigateApp.page, true);
|
|
||||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
await seedLayout(frigateApp.page, "naturalAspectLayout:admin", true);
|
|
||||||
await frigateApp.page.reload();
|
|
||||||
await expect(live.cameraCard("backyard").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { front_door: landscape, backyard: portrait } = await cameraBoxes(
|
|
||||||
frigateApp.page,
|
|
||||||
["front_door", "backyard"] as const,
|
|
||||||
"card",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(landscape.w / landscape.h).toBeCloseTo(16 / 9, 1);
|
|
||||||
expect(portrait.w / portrait.h).toBeCloseTo(9 / 16, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("dragging a tile does not shove other tiles far away", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.goto(`/?group=${GROUP}`);
|
|
||||||
const live = new LivePage(frigateApp.page, true);
|
|
||||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
await live.editLayoutButton.click();
|
|
||||||
|
|
||||||
const cameras = ["front_door", "backyard"] as const;
|
|
||||||
const { front_door: fixedBefore, backyard: draggedBox } = await cameraBoxes(
|
|
||||||
frigateApp.page,
|
|
||||||
cameras,
|
|
||||||
"card",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Drag backyard onto front_door's position (a deliberate collision). With
|
|
||||||
// free-placement + prevent-collision, front_door must NOT be shoved down.
|
|
||||||
const from = {
|
|
||||||
x: draggedBox.x + draggedBox.w / 2,
|
|
||||||
y: draggedBox.y + draggedBox.h / 2,
|
|
||||||
};
|
|
||||||
const to = {
|
|
||||||
x: fixedBefore.x + fixedBefore.w / 2,
|
|
||||||
y: fixedBefore.y + fixedBefore.h / 2,
|
|
||||||
};
|
|
||||||
await frigateApp.page.mouse.move(from.x, from.y);
|
|
||||||
await frigateApp.page.mouse.down();
|
|
||||||
await frigateApp.page.mouse.move(to.x, to.y, { steps: 15 });
|
|
||||||
await frigateApp.page.mouse.up();
|
|
||||||
|
|
||||||
const { front_door: fixedAfter } = await cameraBoxes(
|
|
||||||
frigateApp.page,
|
|
||||||
cameras,
|
|
||||||
"card",
|
|
||||||
);
|
|
||||||
// Allow a few px of snap; a collision-push would move it a whole tile down.
|
|
||||||
expect(Math.abs(fixedAfter.y - fixedBefore.y)).toBeLessThan(40);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resizing a top-row tile preserves its aspect ratio (no pillarboxing)", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
// A lone top tile has room to grow sideways, which is what exposed the bug:
|
|
||||||
// a top-edge handle let width grow while height stayed clamped at y=0.
|
|
||||||
await frigateApp.installDefaults({
|
|
||||||
config: { camera_groups: { outdoor: { cameras: ["front_door"] } } },
|
|
||||||
});
|
|
||||||
await frigateApp.goto(`/?group=${GROUP}`);
|
|
||||||
const live = new LivePage(frigateApp.page, true);
|
|
||||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
await live.editLayoutButton.click();
|
|
||||||
|
|
||||||
const tile = frigateApp.page.locator(".react-grid-item", {
|
|
||||||
has: frigateApp.page.locator("[data-camera='front_door']"),
|
|
||||||
});
|
|
||||||
const only = ["front_door"] as const;
|
|
||||||
const { front_door: before } = await cameraBoxes(
|
|
||||||
frigateApp.page,
|
|
||||||
only,
|
|
||||||
"card",
|
|
||||||
);
|
|
||||||
const aspect = before.w / before.h;
|
|
||||||
|
|
||||||
// Regression: if a top-edge handle is exposed, dragging it up/out must NOT
|
|
||||||
// distort the aspect (the old bug grew width while height stayed clamped).
|
|
||||||
const ne = tile.locator(".react-resizable-handle-ne");
|
|
||||||
if (await ne.count()) {
|
|
||||||
await ne.dragTo(tile, {
|
|
||||||
force: true,
|
|
||||||
targetPosition: { x: 1000, y: -160 },
|
|
||||||
});
|
|
||||||
const { front_door: afterNe } = await cameraBoxes(
|
|
||||||
frigateApp.page,
|
|
||||||
only,
|
|
||||||
"card",
|
|
||||||
);
|
|
||||||
// It must actually resize (not a silent no-op) AND keep its aspect.
|
|
||||||
expect(afterNe.w).toBeGreaterThan(before.w);
|
|
||||||
expect(Math.abs(afterNe.w / afterNe.h - aspect)).toBeLessThan(0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Positive: growing from the bottom-right corner resizes and keeps aspect.
|
|
||||||
const se = tile.locator(".react-resizable-handle-se");
|
|
||||||
await se.dragTo(tile, { force: true, targetPosition: { x: 1000, y: 520 } });
|
|
||||||
const { front_door: grown } = await cameraBoxes(
|
|
||||||
frigateApp.page,
|
|
||||||
only,
|
|
||||||
"card",
|
|
||||||
);
|
|
||||||
expect(grown.w).toBeGreaterThan(before.w);
|
|
||||||
expect(Math.abs(grown.w / grown.h - aspect)).toBeLessThan(0.2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the grid keeps its measured width after a back navigation", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
// The grid sizes itself from window.innerWidth until its container is
|
|
||||||
// measured. On a warm back navigation nothing re-renders after that
|
|
||||||
// container mounts, so an observer that never attaches leaves every tile
|
|
||||||
// sized against the full window: the layout widens by the sidebar's width
|
|
||||||
// and the rightmost column clips on a full row.
|
|
||||||
await frigateApp.goto(`/?group=${GROUP}`);
|
|
||||||
const live = new LivePage(frigateApp.page, true);
|
|
||||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// total width the tiles span; tracks the width the grid laid out against
|
|
||||||
const span = () =>
|
|
||||||
frigateApp.page.evaluate(() => {
|
|
||||||
const tiles = [...document.querySelectorAll(".react-grid-item")];
|
|
||||||
|
|
||||||
if (!tiles.length) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rects = tiles.map((tile) => tile.getBoundingClientRect());
|
|
||||||
return +(
|
|
||||||
Math.max(...rects.map((r) => r.right)) -
|
|
||||||
Math.min(...rects.map((r) => r.left))
|
|
||||||
).toFixed(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
let fresh: number | null = null;
|
|
||||||
await expect
|
|
||||||
.poll(async () => (fresh = await span()), { timeout: 10_000 })
|
|
||||||
.not.toBeNull();
|
|
||||||
|
|
||||||
await live.cameraCard("front_door").first().click();
|
|
||||||
await expect(frigateApp.page).toHaveURL(/#front_door/);
|
|
||||||
|
|
||||||
await frigateApp.page.goBack();
|
|
||||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// the layout must settle back to the measured width, not window.innerWidth
|
|
||||||
await expect
|
|
||||||
.poll(span, { timeout: 10_000 })
|
|
||||||
.toBeLessThanOrEqual(fresh! + 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a camera added to a saved layout fills an open column", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.goto(`/?group=${GROUP}`);
|
|
||||||
const live = new LivePage(frigateApp.page, true);
|
|
||||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// one tall tile in the first column; backyard is missing from the layout
|
|
||||||
const key = await persistedLayoutKey(frigateApp.page, GROUP);
|
|
||||||
await seedLayout(frigateApp.page, key, {
|
|
||||||
version: 2,
|
|
||||||
naturalAspect: false,
|
|
||||||
layout: [{ i: "front_door", x: 0, y: 0, w: 32, h: 400 }],
|
|
||||||
});
|
|
||||||
await frigateApp.page.reload();
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
const stored = await readLayout(frigateApp.page, key);
|
|
||||||
return stored?.layout.find((item) => item.i === "backyard");
|
|
||||||
})
|
|
||||||
.toMatchObject({ x: 32, y: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("saved layout from an unreadable version regenerates without error", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.goto(`/?group=${GROUP}`);
|
|
||||||
const live = new LivePage(frigateApp.page, true);
|
|
||||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// A bare array is converted rather than discarded (covered in
|
|
||||||
// live-grid-aspect-modes), so use a version the current grid cannot read.
|
|
||||||
const key = await persistedLayoutKey(frigateApp.page, GROUP);
|
|
||||||
await seedLayout(frigateApp.page, key, {
|
|
||||||
version: 1,
|
|
||||||
naturalAspect: false,
|
|
||||||
layout: [{ i: "front_door", x: 0, y: 0, w: 4, h: 3 }],
|
|
||||||
});
|
|
||||||
|
|
||||||
await frigateApp.page.reload();
|
|
||||||
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
|
|
||||||
|
|
||||||
// Grid regenerated; both cameras still render and the error collector
|
|
||||||
// (frigate-test fixture) catches any crash.
|
|
||||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
await expect(live.cameraCard("backyard").first()).toBeVisible();
|
|
||||||
|
|
||||||
// The app must have replaced the value it could not read. Without this the
|
|
||||||
// test would still pass against a key the app never touches.
|
|
||||||
await expect
|
|
||||||
.poll(async () => (await readLayout(frigateApp.page, key))?.version, {
|
|
||||||
timeout: 10_000,
|
|
||||||
})
|
|
||||||
.toBeGreaterThan(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Masonry live grid on mobile @critical @mobile", () => {
|
|
||||||
test("custom group keeps the static grid, with no draggable layout", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
test.skip(!frigateApp.isMobile, "Mobile-only");
|
|
||||||
|
|
||||||
await frigateApp.goto(`/?group=${GROUP}`);
|
|
||||||
const live = new LivePage(frigateApp.page, false);
|
|
||||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
await expect(live.cameraCard("backyard").first()).toBeVisible();
|
|
||||||
|
|
||||||
// isMobileOnly routes around DraggableGridLayout entirely, so neither the
|
|
||||||
// grid items nor the edit-layout toggle may appear.
|
|
||||||
await expect(frigateApp.page.locator(".react-grid-item")).toHaveCount(0);
|
|
||||||
await expect(live.editLayoutButton).toHaveCount(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Telemetry settings: the analytics preview shows the report Frigate would
|
||||||
|
* send, fetched only when opened.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { test, expect } from "../../fixtures/frigate-test";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const CONFIG_SCHEMA = JSON.parse(
|
||||||
|
readFileSync(
|
||||||
|
resolve(__dirname, "../../fixtures/mock-data/config-schema.json"),
|
||||||
|
"utf-8",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const PREVIEW = {
|
||||||
|
schema_version: 1,
|
||||||
|
install_id: "0".repeat(32),
|
||||||
|
install: { version: "0.19.0-test" },
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe("telemetry settings: analytics preview @medium", () => {
|
||||||
|
test("shows the report on demand", async ({ frigateApp }) => {
|
||||||
|
let requests = 0;
|
||||||
|
const page = frigateApp.page;
|
||||||
|
|
||||||
|
await page.route("**/api/config/schema.json", (route) =>
|
||||||
|
route.fulfill({ json: CONFIG_SCHEMA }),
|
||||||
|
);
|
||||||
|
await page.route("**/api/config/raw_paths", (route) =>
|
||||||
|
route.fulfill({ json: { telemetry: {} } }),
|
||||||
|
);
|
||||||
|
await page.route("**/api/analytics/preview", (route) => {
|
||||||
|
requests += 1;
|
||||||
|
return route.fulfill({ json: PREVIEW });
|
||||||
|
});
|
||||||
|
|
||||||
|
await frigateApp.goto("/settings?page=systemTelemetry");
|
||||||
|
|
||||||
|
const button = page.getByRole("button", { name: "Preview the report" });
|
||||||
|
await expect(button).toBeVisible();
|
||||||
|
expect(requests).toBe(0);
|
||||||
|
|
||||||
|
await button.click();
|
||||||
|
|
||||||
|
await expect(page.getByTestId("analytics-preview")).toContainText(
|
||||||
|
'"schema_version": 1',
|
||||||
|
);
|
||||||
|
expect(requests).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,26 +13,7 @@ import type { Page } from "@playwright/test";
|
|||||||
const OUTDOOR_LAYOUT_KEY = "outdoor-draggable-layout:admin";
|
const OUTDOOR_LAYOUT_KEY = "outdoor-draggable-layout:admin";
|
||||||
const STREAMING_KEY = "streaming-settings:admin";
|
const STREAMING_KEY = "streaming-settings:admin";
|
||||||
|
|
||||||
// the shape DraggableGridLayout writes
|
const OUTDOOR_LAYOUT = [
|
||||||
const OUTDOOR_LAYOUT = {
|
|
||||||
version: 2,
|
|
||||||
naturalAspect: false,
|
|
||||||
layout: [
|
|
||||||
{ i: "front_door", x: 0, y: 0, w: 32, h: 72 },
|
|
||||||
{ i: "backyard", x: 32, y: 0, w: 32, h: 72 },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const NATURAL_OUTDOOR_LAYOUT = {
|
|
||||||
version: 2,
|
|
||||||
naturalAspect: true,
|
|
||||||
layout: [
|
|
||||||
{ i: "front_door", x: 0, y: 0, w: 32, h: 72 },
|
|
||||||
{ i: "backyard", x: 32, y: 0, w: 24, h: 96 },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const LEGACY_OUTDOOR_LAYOUT = [
|
|
||||||
{ i: "front_door", x: 0, y: 0, w: 6, h: 4 },
|
{ i: "front_door", x: 0, y: 0, w: 6, h: 4 },
|
||||||
{ i: "backyard", x: 6, y: 0, w: 6, h: 4 },
|
{ i: "backyard", x: 6, y: 0, w: 6, h: 4 },
|
||||||
];
|
];
|
||||||
@@ -178,160 +159,6 @@ test.describe("UI settings import/export @medium", () => {
|
|||||||
expect(payload.sections.preferences.playbackRate).toBe(2);
|
expect(payload.sections.preferences.playbackRate).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("exports only layouts built for the current tile sizing mode", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
// a group not opened since the mode changed still holds a layout from
|
|
||||||
// the other mode, which would import into a mode that cannot show it
|
|
||||||
await frigateApp.goto("/settings?page=uiSettings");
|
|
||||||
|
|
||||||
await writeIdb(frigateApp.page, {
|
|
||||||
[OUTDOOR_LAYOUT_KEY]: OUTDOOR_LAYOUT,
|
|
||||||
"default-draggable-layout:admin": NATURAL_OUTDOOR_LAYOUT,
|
|
||||||
"naturalAspectLayout:admin": true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const downloadPromise = frigateApp.page.waitForEvent("download");
|
|
||||||
await frigateApp.page
|
|
||||||
.getByRole("button", { name: "Export Settings" })
|
|
||||||
.click();
|
|
||||||
const download = await downloadPromise;
|
|
||||||
const payload = JSON.parse(readFileSync((await download.path())!, "utf-8"));
|
|
||||||
|
|
||||||
expect(payload.sections.layouts).toEqual({
|
|
||||||
default: NATURAL_OUTDOOR_LAYOUT,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("toggling tile sizing mode clears stored layouts", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
test.skip(frigateApp.isMobile, "The setting is hidden on phones");
|
|
||||||
await frigateApp.goto("/settings?page=uiSettings");
|
|
||||||
await writeIdb(frigateApp.page, { [OUTDOOR_LAYOUT_KEY]: OUTDOOR_LAYOUT });
|
|
||||||
|
|
||||||
await frigateApp.page.locator("#natural-aspect-desktop").click();
|
|
||||||
await frigateApp.page
|
|
||||||
.getByRole("alertdialog")
|
|
||||||
.getByRole("button", { name: "Enable" })
|
|
||||||
.click();
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(() => readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY))
|
|
||||||
.toBeNull();
|
|
||||||
expect(await readIdb(frigateApp.page, "naturalAspectLayout:admin")).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("round-trips a layout left unconverted by an upgrade", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
test.skip(frigateApp.isMobile, "Layout import is desktop and tablet only");
|
|
||||||
// DraggableGridLayout rewrites a pre-0.19 layout only when that group's
|
|
||||||
// dashboard is opened, so exporting first carries the bare array into the
|
|
||||||
// file. Import must accept it back rather than rejecting the whole file.
|
|
||||||
await frigateApp.goto("/settings?page=uiSettings");
|
|
||||||
|
|
||||||
await writeIdb(frigateApp.page, {
|
|
||||||
[OUTDOOR_LAYOUT_KEY]: LEGACY_OUTDOOR_LAYOUT,
|
|
||||||
"playbackRate:admin": 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
const downloadPromise = frigateApp.page.waitForEvent("download");
|
|
||||||
await frigateApp.page
|
|
||||||
.getByRole("button", { name: "Export Settings" })
|
|
||||||
.click();
|
|
||||||
const download = await downloadPromise;
|
|
||||||
const contents = readFileSync((await download.path())!, "utf-8");
|
|
||||||
|
|
||||||
expect(JSON.parse(contents).sections.layouts.outdoor).toEqual(
|
|
||||||
LEGACY_OUTDOOR_LAYOUT,
|
|
||||||
);
|
|
||||||
|
|
||||||
await clearIdb(frigateApp.page);
|
|
||||||
await chooseImportText(frigateApp.page, contents);
|
|
||||||
await confirmImport(frigateApp.page);
|
|
||||||
|
|
||||||
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
|
|
||||||
LEGACY_OUTDOOR_LAYOUT,
|
|
||||||
);
|
|
||||||
// the rest of the file must survive alongside it
|
|
||||||
expect(await readIdb(frigateApp.page, "playbackRate:admin")).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("legacy layouts import turns natural aspect off so they display", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
test.skip(frigateApp.isMobile, "Layout import is desktop and tablet only");
|
|
||||||
// Bare-array layouts only render in bucketed mode; with natural aspect on
|
|
||||||
// they would be discarded and regenerated on the next dashboard visit. The
|
|
||||||
// import applies the mode the layouts were built for, and the file's own
|
|
||||||
// naturalAspectLayout preference must not override that.
|
|
||||||
await frigateApp.goto("/settings?page=uiSettings");
|
|
||||||
await writeIdb(frigateApp.page, { "naturalAspectLayout:admin": true });
|
|
||||||
|
|
||||||
await chooseImportFile(
|
|
||||||
frigateApp.page,
|
|
||||||
importPayload({
|
|
||||||
sections: {
|
|
||||||
layouts: { outdoor: LEGACY_OUTDOOR_LAYOUT },
|
|
||||||
streaming: {},
|
|
||||||
preferences: { naturalAspectLayout: true },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const note = frigateApp.page.getByText(/standard tile sizing/);
|
|
||||||
await expect(note).toBeVisible();
|
|
||||||
|
|
||||||
// the note is about the layouts section, so it follows its switch
|
|
||||||
await frigateApp.page.getByText("Camera group layouts (1 group)").click();
|
|
||||||
await expect(note).toBeHidden();
|
|
||||||
await frigateApp.page.getByText("Camera group layouts (1 group)").click();
|
|
||||||
await expect(note).toBeVisible();
|
|
||||||
|
|
||||||
await confirmImport(frigateApp.page);
|
|
||||||
|
|
||||||
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
|
|
||||||
LEGACY_OUTDOOR_LAYOUT,
|
|
||||||
);
|
|
||||||
expect(await readIdb(frigateApp.page, "naturalAspectLayout:admin")).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("natural aspect layouts import turns the setting on", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
test.skip(frigateApp.isMobile, "Layout import is desktop and tablet only");
|
|
||||||
await frigateApp.goto("/settings?page=uiSettings");
|
|
||||||
|
|
||||||
await chooseImportFile(
|
|
||||||
frigateApp.page,
|
|
||||||
importPayload({
|
|
||||||
sections: {
|
|
||||||
layouts: { outdoor: NATURAL_OUTDOOR_LAYOUT },
|
|
||||||
streaming: {},
|
|
||||||
preferences: {},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
frigateApp.page.getByText(/camera aspect ratio tile sizing/),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
await confirmImport(frigateApp.page);
|
|
||||||
|
|
||||||
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
|
|
||||||
NATURAL_OUTDOOR_LAYOUT,
|
|
||||||
);
|
|
||||||
expect(await readIdb(frigateApp.page, "naturalAspectLayout:admin")).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("omits settings that were never stored", async ({ frigateApp }) => {
|
test("omits settings that were never stored", async ({ frigateApp }) => {
|
||||||
await frigateApp.goto("/settings?page=uiSettings");
|
await frigateApp.goto("/settings?page=uiSettings");
|
||||||
|
|
||||||
@@ -363,15 +190,12 @@ test.describe("UI settings import/export @medium", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
frigateApp.page.getByText("UI preferences (2 settings)"),
|
frigateApp.page.getByText("UI preferences (2 settings)"),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
// patio is layout-only, so its warning follows the layouts section
|
await expect(frigateApp.page.getByText(/patio/)).toBeVisible();
|
||||||
await expect(frigateApp.page.getByText(/patio/)).toBeVisible({
|
|
||||||
visible: !frigateApp.isMobile,
|
|
||||||
});
|
|
||||||
|
|
||||||
await confirmImport(frigateApp.page);
|
await confirmImport(frigateApp.page);
|
||||||
|
|
||||||
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
|
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
|
||||||
frigateApp.isMobile ? null : OUTDOOR_LAYOUT,
|
OUTDOOR_LAYOUT,
|
||||||
);
|
);
|
||||||
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual(
|
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual(
|
||||||
STREAMING_SETTINGS,
|
STREAMING_SETTINGS,
|
||||||
@@ -382,7 +206,6 @@ test.describe("UI settings import/export @medium", () => {
|
|||||||
test("hides the unknown-group warning when layouts are switched off", async ({
|
test("hides the unknown-group warning when layouts are switched off", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
test.skip(frigateApp.isMobile, "Layout import is desktop and tablet only");
|
|
||||||
await frigateApp.goto("/settings?page=uiSettings");
|
await frigateApp.goto("/settings?page=uiSettings");
|
||||||
|
|
||||||
// patio is a layout-only group absent from this server, so the warning
|
// patio is a layout-only group absent from this server, so the warning
|
||||||
@@ -412,39 +235,7 @@ test.describe("UI settings import/export @medium", () => {
|
|||||||
|
|
||||||
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual({});
|
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual({});
|
||||||
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
|
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
|
||||||
frigateApp.isMobile ? null : OUTDOOR_LAYOUT,
|
OUTDOOR_LAYOUT,
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("phones refuse layouts and say why @mobile", async ({ frigateApp }) => {
|
|
||||||
test.skip(!frigateApp.isMobile, "Phone-only");
|
|
||||||
|
|
||||||
await frigateApp.goto("/settings?page=uiSettings");
|
|
||||||
await writeIdb(frigateApp.page, { "naturalAspectLayout:admin": false });
|
|
||||||
|
|
||||||
await chooseImportFile(frigateApp.page, importPayload());
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
frigateApp.page.getByText(/aren't imported on phones/),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// the section is still listed, but cannot be switched on
|
|
||||||
await expect(
|
|
||||||
frigateApp.page.getByText("Camera group layouts (2 groups)"),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
frigateApp.page.locator('[id="Camera group layouts (2 groups)"]'),
|
|
||||||
).toBeDisabled();
|
|
||||||
|
|
||||||
await confirmImport(frigateApp.page);
|
|
||||||
|
|
||||||
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toBeNull();
|
|
||||||
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual(
|
|
||||||
STREAMING_SETTINGS,
|
|
||||||
);
|
|
||||||
// a layouts import is what flips this, so it must stay put
|
|
||||||
expect(await readIdb(frigateApp.page, "naturalAspectLayout:admin")).toBe(
|
|
||||||
false,
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -534,7 +325,7 @@ test.describe("UI settings import/export @medium", () => {
|
|||||||
await confirmImport(frigateApp.page);
|
await confirmImport(frigateApp.page);
|
||||||
|
|
||||||
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
|
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
|
||||||
frigateApp.isMobile ? null : OUTDOOR_LAYOUT,
|
OUTDOOR_LAYOUT,
|
||||||
);
|
);
|
||||||
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual(
|
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual(
|
||||||
STREAMING_SETTINGS,
|
STREAMING_SETTINGS,
|
||||||
|
|||||||
@@ -131,7 +131,6 @@
|
|||||||
"close": "Close",
|
"close": "Close",
|
||||||
"expand": "Expand",
|
"expand": "Expand",
|
||||||
"collapse": "Collapse",
|
"collapse": "Collapse",
|
||||||
"clear": "Clear",
|
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
"copiedToClipboard": "Copied to clipboard",
|
"copiedToClipboard": "Copied to clipboard",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
|
|||||||
@@ -222,6 +222,10 @@
|
|||||||
"telemetry": {
|
"telemetry": {
|
||||||
"label": "Telemetry",
|
"label": "Telemetry",
|
||||||
"description": "System telemetry and stats options including GPU and network bandwidth monitoring.",
|
"description": "System telemetry and stats options including GPU and network bandwidth monitoring.",
|
||||||
|
"analytics": {
|
||||||
|
"label": "Share anonymous analytics",
|
||||||
|
"description": "Send one anonymous usage report a day to help the Frigate maintainers decide what to support. Nothing is sent until this is on."
|
||||||
|
},
|
||||||
"network_interfaces": {
|
"network_interfaces": {
|
||||||
"label": "Network interfaces",
|
"label": "Network interfaces",
|
||||||
"description": "List of network interface name prefixes to monitor for bandwidth statistics."
|
"description": "List of network interface name prefixes to monitor for bandwidth statistics."
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
{
|
{
|
||||||
|
"analyticsPreview": {
|
||||||
|
"show": "Preview the report",
|
||||||
|
"desc": "Frigate sends this report once a day while sharing is on. It never includes camera names, zones, labels, or addresses.",
|
||||||
|
"error": "Unable to build the preview"
|
||||||
|
},
|
||||||
"documentTitle": {
|
"documentTitle": {
|
||||||
"default": "Settings - Frigate",
|
"default": "Settings - Frigate",
|
||||||
"authentication": "Authentication Settings - Frigate",
|
"authentication": "Authentication Settings - Frigate",
|
||||||
@@ -167,11 +172,6 @@
|
|||||||
"label": "Always Show Camera Names",
|
"label": "Always Show Camera Names",
|
||||||
"desc": "Always show the camera names in a chip in the multi-camera live view dashboard."
|
"desc": "Always show the camera names in a chip in the multi-camera live view dashboard."
|
||||||
},
|
},
|
||||||
"naturalAspectLayout": {
|
|
||||||
"label": "Use Natural Aspect Ratios",
|
|
||||||
"desc": "On camera group live view dashboards, size each tile to its camera's own natural aspect ratio. When disabled, cameras are snapped to a standard wide, landscape, or tall tile shape.",
|
|
||||||
"descNote": "Toggling this setting on or off will clear the stored layout for all camera group live dashboards. Manual reconfiguration will be required."
|
|
||||||
},
|
|
||||||
"liveFallbackTimeout": {
|
"liveFallbackTimeout": {
|
||||||
"label": "Live Player Fallback Timeout",
|
"label": "Live Player Fallback Timeout",
|
||||||
"desc": "When a camera's high quality live stream is unavailable, fall back to low bandwidth mode after this many seconds. Default: 3."
|
"desc": "When a camera's high quality live stream is unavailable, fall back to low bandwidth mode after this many seconds. Default: 3."
|
||||||
@@ -180,14 +180,12 @@
|
|||||||
"storedLayouts": {
|
"storedLayouts": {
|
||||||
"title": "Stored Layouts",
|
"title": "Stored Layouts",
|
||||||
"desc": "The layout of cameras in a camera group can be dragged/resized. The positions are stored in your browser's local storage.",
|
"desc": "The layout of cameras in a camera group can be dragged/resized. The positions are stored in your browser's local storage.",
|
||||||
"clearAll": "Clear All Layouts",
|
"clearAll": "Clear All Layouts"
|
||||||
"clearConfirm": "This will clear the stored layout for every camera group in this browser. This cannot be undone."
|
|
||||||
},
|
},
|
||||||
"cameraGroupStreaming": {
|
"cameraGroupStreaming": {
|
||||||
"title": "Camera Group Streaming Settings",
|
"title": "Camera Group Streaming Settings",
|
||||||
"desc": "Streaming settings for each camera group are stored in your browser's local storage.",
|
"desc": "Streaming settings for each camera group are stored in your browser's local storage.",
|
||||||
"clearAll": "Clear All Streaming Settings",
|
"clearAll": "Clear All Streaming Settings"
|
||||||
"clearConfirm": "This will clear the streaming settings for every camera group in this browser. This cannot be undone."
|
|
||||||
},
|
},
|
||||||
"backupRestore": {
|
"backupRestore": {
|
||||||
"title": "Backup & Restore",
|
"title": "Backup & Restore",
|
||||||
@@ -202,9 +200,6 @@
|
|||||||
"desc": "Choose what to apply from this file. Frigate will reload when the import finishes.",
|
"desc": "Choose what to apply from this file. Frigate will reload when the import finishes.",
|
||||||
"exportedFrom": "Exported {{date}} from Frigate config version {{version}}",
|
"exportedFrom": "Exported {{date}} from Frigate config version {{version}}",
|
||||||
"layouts_one": "Camera group layouts ({{count}} group)",
|
"layouts_one": "Camera group layouts ({{count}} group)",
|
||||||
"layoutsPhone": "Camera group layouts aren't imported on phones, which always use the standard grid.",
|
|
||||||
"layoutsModeOn": "These layouts use camera aspect ratio tile sizing, so importing them will also turn on \"Use Natural Aspect Ratios\".",
|
|
||||||
"layoutsModeOff": "These layouts use standard tile sizing, so importing them will also turn off \"Use Natural Aspect Ratios\".",
|
|
||||||
"layouts_other": "Camera group layouts ({{count}} groups)",
|
"layouts_other": "Camera group layouts ({{count}} groups)",
|
||||||
"streaming_one": "Streaming settings ({{count}} camera)",
|
"streaming_one": "Streaming settings ({{count}} camera)",
|
||||||
"streaming_other": "Streaming settings ({{count}} cameras)",
|
"streaming_other": "Streaming settings ({{count}} cameras)",
|
||||||
|
|||||||
@@ -45,7 +45,8 @@
|
|||||||
"shm_too_low": "/dev/shm allocation ({{total}} MB) should be increased to at least {{min}} MB",
|
"shm_too_low": "/dev/shm allocation ({{total}} MB) should be increased to at least {{min}} MB",
|
||||||
"failed_login_one": "Failed login attempt for {{user}}",
|
"failed_login_one": "Failed login attempt for {{user}}",
|
||||||
"failed_login_other": "Failed login attempts for {{user}}",
|
"failed_login_other": "Failed login attempts for {{user}}",
|
||||||
"update_available": "Frigate {{version}} is available"
|
"update_available": "Frigate {{version}} is available",
|
||||||
|
"analytics_prompt": "Consider sharing anonymous usage statistics to help the developers improve Frigate"
|
||||||
},
|
},
|
||||||
"streamPrefix": "Stream {{index}}: {{message}}",
|
"streamPrefix": "Stream {{index}}: {{message}}",
|
||||||
"streamPrefixRestream": "Stream {{index}} (via go2rtc): {{message}}",
|
"streamPrefixRestream": "Stream {{index}} (via go2rtc): {{message}}",
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export default function CameraImage({
|
|||||||
)}
|
)}
|
||||||
{!imageLoaded && enabled ? (
|
{!imageLoaded && enabled ? (
|
||||||
<div className="absolute bottom-0 left-0 right-0 top-0 flex items-center justify-center">
|
<div className="absolute bottom-0 left-0 right-0 top-0 flex items-center justify-center">
|
||||||
<ActivityIndicator className={"w-full [.bg-black_&]:text-white"} />
|
<ActivityIndicator />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,9 +3,17 @@ import type { SectionConfigOverrides } from "./types";
|
|||||||
const telemetry: SectionConfigOverrides = {
|
const telemetry: SectionConfigOverrides = {
|
||||||
base: {
|
base: {
|
||||||
sectionDocs: "/configuration/advanced/reference",
|
sectionDocs: "/configuration/advanced/reference",
|
||||||
|
fieldDocs: {
|
||||||
|
analytics: "/configuration/advanced/analytics",
|
||||||
|
},
|
||||||
restartRequired: ["version_check"],
|
restartRequired: ["version_check"],
|
||||||
fieldOrder: ["network_interfaces", "stats", "version_check"],
|
fieldOrder: ["analytics", "network_interfaces", "stats", "version_check"],
|
||||||
advancedFields: [],
|
advancedFields: [],
|
||||||
|
uiSchema: {
|
||||||
|
analytics: {
|
||||||
|
"ui:after": { render: "AnalyticsPreview" },
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { LuChevronDown, LuChevronRight } from "react-icons/lu";
|
||||||
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible";
|
||||||
|
|
||||||
|
export default function AnalyticsPreview() {
|
||||||
|
const { t } = useTranslation("views/settings");
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
// built on demand, since building runs every collector on the server
|
||||||
|
const { data, error } = useSWR<Record<string, unknown>>(
|
||||||
|
open ? "analytics/preview" : null,
|
||||||
|
{ revalidateOnFocus: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible open={open} onOpenChange={setOpen} className="mt-2">
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" className="gap-1">
|
||||||
|
{open ? (
|
||||||
|
<LuChevronDown className="size-4" />
|
||||||
|
) : (
|
||||||
|
<LuChevronRight className="size-4" />
|
||||||
|
)}
|
||||||
|
{t("analyticsPreview.show")}
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{t("analyticsPreview.desc")}
|
||||||
|
</div>
|
||||||
|
<CollapsibleContent className="mt-2">
|
||||||
|
{error ? (
|
||||||
|
<div className="text-sm text-danger">
|
||||||
|
{t("analyticsPreview.error")}
|
||||||
|
</div>
|
||||||
|
) : data ? (
|
||||||
|
<pre
|
||||||
|
data-testid="analytics-preview"
|
||||||
|
className="max-h-96 overflow-auto rounded-md bg-secondary p-3 text-xs"
|
||||||
|
>
|
||||||
|
{JSON.stringify(data, null, 2)}
|
||||||
|
</pre>
|
||||||
|
) : (
|
||||||
|
<ActivityIndicator className="size-5" />
|
||||||
|
)}
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ComponentType } from "react";
|
import type { ComponentType } from "react";
|
||||||
|
import AnalyticsPreview from "./AnalyticsPreview";
|
||||||
import SemanticSearchReindex from "./SemanticSearchReindex.tsx";
|
import SemanticSearchReindex from "./SemanticSearchReindex.tsx";
|
||||||
import CameraReviewStatusToggles from "./CameraReviewStatusToggles";
|
import CameraReviewStatusToggles from "./CameraReviewStatusToggles";
|
||||||
import ProxyRoleMap from "./ProxyRoleMap";
|
import ProxyRoleMap from "./ProxyRoleMap";
|
||||||
@@ -56,6 +57,9 @@ export const sectionRenderers: SectionRenderers = {
|
|||||||
birdseye: {
|
birdseye: {
|
||||||
BirdseyeCameraReorder,
|
BirdseyeCameraReorder,
|
||||||
},
|
},
|
||||||
|
telemetry: {
|
||||||
|
AnalyticsPreview,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default sectionRenderers;
|
export default sectionRenderers;
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ export function ImageShadowOverlay({
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-none absolute inset-x-0 top-0 z-10 h-[30%] w-full bg-gradient-to-b from-black/20 to-transparent",
|
"pointer-events-none absolute inset-x-0 top-0 z-10 h-[30%] w-full rounded-lg bg-gradient-to-b from-black/20 to-transparent",
|
||||||
upperClassName,
|
upperClassName,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-none absolute inset-x-0 bottom-0 z-10 h-[10%] w-full bg-gradient-to-t from-black/20 to-transparent",
|
"pointer-events-none absolute inset-x-0 bottom-0 z-10 h-[10%] w-full rounded-lg bg-gradient-to-t from-black/20 to-transparent",
|
||||||
lowerClassName,
|
lowerClassName,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -10,12 +10,10 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { LuFileJson, LuInfo, LuTriangleAlert } from "react-icons/lu";
|
import { LuTriangleAlert } from "react-icons/lu";
|
||||||
import { isMobileOnly } from "react-device-detect";
|
|
||||||
import FilterSwitch from "@/components/filter/FilterSwitch";
|
import FilterSwitch from "@/components/filter/FilterSwitch";
|
||||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
import {
|
import {
|
||||||
importedLayoutsNaturalAspect,
|
|
||||||
ImportSummary,
|
ImportSummary,
|
||||||
TransferSection,
|
TransferSection,
|
||||||
UiSettingsFile,
|
UiSettingsFile,
|
||||||
@@ -27,7 +25,6 @@ type ImportUiSettingsDialogProps = {
|
|||||||
fileName: string;
|
fileName: string;
|
||||||
file: UiSettingsFile;
|
file: UiSettingsFile;
|
||||||
summary: ImportSummary;
|
summary: ImportSummary;
|
||||||
currentNaturalAspect: boolean;
|
|
||||||
onConfirm: (sections: Record<TransferSection, boolean>) => Promise<void>;
|
onConfirm: (sections: Record<TransferSection, boolean>) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -37,7 +34,6 @@ export default function ImportUiSettingsDialog({
|
|||||||
fileName,
|
fileName,
|
||||||
file,
|
file,
|
||||||
summary,
|
summary,
|
||||||
currentNaturalAspect,
|
|
||||||
onConfirm,
|
onConfirm,
|
||||||
}: ImportUiSettingsDialogProps) {
|
}: ImportUiSettingsDialogProps) {
|
||||||
const { t } = useTranslation(["views/settings", "common"]);
|
const { t } = useTranslation(["views/settings", "common"]);
|
||||||
@@ -45,9 +41,7 @@ export default function ImportUiSettingsDialog({
|
|||||||
|
|
||||||
const available = useMemo(
|
const available = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
// phones use the static grid, so a saved grid layout has nothing to
|
layouts: summary.layoutGroupCount > 0,
|
||||||
// apply to and would only flip the tile sizing mode behind the scenes
|
|
||||||
layouts: !isMobileOnly && summary.layoutGroupCount > 0,
|
|
||||||
streaming: summary.streamingCameraCount > 0,
|
streaming: summary.streamingCameraCount > 0,
|
||||||
preferences: summary.preferenceCount > 0,
|
preferences: summary.preferenceCount > 0,
|
||||||
}),
|
}),
|
||||||
@@ -96,16 +90,6 @@ export default function ImportUiSettingsDialog({
|
|||||||
[sections.streaming, summary.unknownCameras],
|
[sections.streaming, summary.unknownCameras],
|
||||||
);
|
);
|
||||||
|
|
||||||
// importing layouts also applies the tile-sizing mode they were built for
|
|
||||||
const layoutsModeChange = useMemo(() => {
|
|
||||||
if (!sections.layouts) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mode = importedLayoutsNaturalAspect(file);
|
|
||||||
return mode === null || mode === currentNaturalAspect ? null : mode;
|
|
||||||
}, [sections.layouts, file, currentNaturalAspect]);
|
|
||||||
|
|
||||||
const handleConfirm = useCallback(async () => {
|
const handleConfirm = useCallback(async () => {
|
||||||
setIsImporting(true);
|
setIsImporting(true);
|
||||||
await onConfirm(sections);
|
await onConfirm(sections);
|
||||||
@@ -129,103 +113,74 @@ export default function ImportUiSettingsDialog({
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-0.5">
|
||||||
<div className="flex items-start gap-3 rounded-lg bg-secondary p-3">
|
<p className="break-all text-base text-primary-variant">{fileName}</p>
|
||||||
<LuFileJson className="mt-0.5 size-5 shrink-0 text-secondary-foreground" />
|
<p className="text-sm text-muted-foreground">
|
||||||
<div className="min-w-0">
|
{t("general.backupRestore.importDialog.exportedFrom", {
|
||||||
<p className="break-all text-base font-medium text-primary-variant">
|
date: exportedDate,
|
||||||
{fileName}
|
version: file.frigate_version,
|
||||||
</p>
|
})}
|
||||||
<p className="text-xs text-muted-foreground">
|
</p>
|
||||||
{t("general.backupRestore.importDialog.exportedFrom", {
|
|
||||||
date: exportedDate,
|
|
||||||
version: file.frigate_version,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2.5">
|
|
||||||
<FilterSwitch
|
|
||||||
label={t("general.backupRestore.importDialog.layouts", {
|
|
||||||
count: summary.layoutGroupCount,
|
|
||||||
})}
|
|
||||||
isChecked={sections.layouts}
|
|
||||||
disabled={!available.layouts || isImporting}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
setSections((prev) => ({ ...prev, layouts: checked }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<FilterSwitch
|
|
||||||
label={t("general.backupRestore.importDialog.streaming", {
|
|
||||||
count: summary.streamingCameraCount,
|
|
||||||
})}
|
|
||||||
isChecked={sections.streaming}
|
|
||||||
disabled={!available.streaming || isImporting}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
setSections((prev) => ({ ...prev, streaming: checked }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<FilterSwitch
|
|
||||||
label={t("general.backupRestore.importDialog.preferences", {
|
|
||||||
count: summary.preferenceCount,
|
|
||||||
})}
|
|
||||||
isChecked={sections.preferences}
|
|
||||||
disabled={!available.preferences || isImporting}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
setSections((prev) => ({ ...prev, preferences: checked }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isMobileOnly && summary.layoutGroupCount > 0 && (
|
|
||||||
<Alert variant="info">
|
|
||||||
<LuInfo className="size-5" />
|
|
||||||
<AlertDescription>
|
|
||||||
{t("general.backupRestore.importDialog.layoutsPhone")}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{layoutsModeChange !== null && (
|
|
||||||
<Alert variant="info">
|
|
||||||
<LuInfo className="size-5" />
|
|
||||||
<AlertDescription>
|
|
||||||
{t(
|
|
||||||
layoutsModeChange
|
|
||||||
? "general.backupRestore.importDialog.layoutsModeOn"
|
|
||||||
: "general.backupRestore.importDialog.layoutsModeOff",
|
|
||||||
)}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(visibleUnknownGroups.length > 0 ||
|
|
||||||
visibleUnknownCameras.length > 0) && (
|
|
||||||
<Alert variant="warning">
|
|
||||||
<LuTriangleAlert className="size-5" />
|
|
||||||
<AlertDescription className="space-y-2">
|
|
||||||
{visibleUnknownGroups.length > 0 && (
|
|
||||||
<p>
|
|
||||||
{t("general.backupRestore.importDialog.unknownGroups", {
|
|
||||||
count: visibleUnknownGroups.length,
|
|
||||||
groups: visibleUnknownGroups.join(", "),
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{visibleUnknownCameras.length > 0 && (
|
|
||||||
<p>
|
|
||||||
{t("general.backupRestore.importDialog.unknownCameras", {
|
|
||||||
count: visibleUnknownCameras.length,
|
|
||||||
cameras: visibleUnknownCameras.join(", "),
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<FilterSwitch
|
||||||
|
label={t("general.backupRestore.importDialog.layouts", {
|
||||||
|
count: summary.layoutGroupCount,
|
||||||
|
})}
|
||||||
|
isChecked={sections.layouts}
|
||||||
|
disabled={!available.layouts || isImporting}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setSections((prev) => ({ ...prev, layouts: checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FilterSwitch
|
||||||
|
label={t("general.backupRestore.importDialog.streaming", {
|
||||||
|
count: summary.streamingCameraCount,
|
||||||
|
})}
|
||||||
|
isChecked={sections.streaming}
|
||||||
|
disabled={!available.streaming || isImporting}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setSections((prev) => ({ ...prev, streaming: checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FilterSwitch
|
||||||
|
label={t("general.backupRestore.importDialog.preferences", {
|
||||||
|
count: summary.preferenceCount,
|
||||||
|
})}
|
||||||
|
isChecked={sections.preferences}
|
||||||
|
disabled={!available.preferences || isImporting}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setSections((prev) => ({ ...prev, preferences: checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(visibleUnknownGroups.length > 0 ||
|
||||||
|
visibleUnknownCameras.length > 0) && (
|
||||||
|
<Alert variant="warning">
|
||||||
|
<LuTriangleAlert className="size-5" />
|
||||||
|
<AlertDescription className="space-y-2">
|
||||||
|
{visibleUnknownGroups.length > 0 && (
|
||||||
|
<p>
|
||||||
|
{t("general.backupRestore.importDialog.unknownGroups", {
|
||||||
|
count: visibleUnknownGroups.length,
|
||||||
|
groups: visibleUnknownGroups.join(", "),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{visibleUnknownCameras.length > 0 && (
|
||||||
|
<p>
|
||||||
|
{t("general.backupRestore.importDialog.unknownCameras", {
|
||||||
|
count: visibleUnknownCameras.length,
|
||||||
|
cameras: visibleUnknownCameras.join(", "),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
aria-label={t("button.cancel", { ns: "common" })}
|
aria-label={t("button.cancel", { ns: "common" })}
|
||||||
@@ -243,7 +198,7 @@ export default function ImportUiSettingsDialog({
|
|||||||
>
|
>
|
||||||
{isImporting ? (
|
{isImporting ? (
|
||||||
<div className="flex flex-row items-center gap-2">
|
<div className="flex flex-row items-center gap-2">
|
||||||
<ActivityIndicator className="size-4" />
|
<ActivityIndicator />
|
||||||
<span>{t("general.backupRestore.importDialog.confirm")}</span>
|
<span>{t("general.backupRestore.importDialog.confirm")}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -29,10 +29,22 @@ export default function BirdseyeLivePlayer({
|
|||||||
}: LivePlayerProps) {
|
}: LivePlayerProps) {
|
||||||
let player;
|
let player;
|
||||||
if (liveMode == "webrtc") {
|
if (liveMode == "webrtc") {
|
||||||
player = <WebRtcPlayer className="size-full" camera="birdseye" pip={pip} />;
|
player = (
|
||||||
|
<WebRtcPlayer
|
||||||
|
className={`size-full rounded-lg md:rounded-2xl`}
|
||||||
|
camera="birdseye"
|
||||||
|
pip={pip}
|
||||||
|
/>
|
||||||
|
);
|
||||||
} else if (liveMode == "mse") {
|
} else if (liveMode == "mse") {
|
||||||
if ("MediaSource" in window || "ManagedMediaSource" in window) {
|
if ("MediaSource" in window || "ManagedMediaSource" in window) {
|
||||||
player = <MSEPlayer className="size-full" camera="birdseye" pip={pip} />;
|
player = (
|
||||||
|
<MSEPlayer
|
||||||
|
className={`size-full rounded-lg md:rounded-2xl`}
|
||||||
|
camera="birdseye"
|
||||||
|
pip={pip}
|
||||||
|
/>
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
player = (
|
player = (
|
||||||
<div className="w-5xl text-center text-sm">
|
<div className="w-5xl text-center text-sm">
|
||||||
@@ -43,7 +55,7 @@ export default function BirdseyeLivePlayer({
|
|||||||
} else if (liveMode == "jsmpeg") {
|
} else if (liveMode == "jsmpeg") {
|
||||||
player = (
|
player = (
|
||||||
<JSMpegPlayer
|
<JSMpegPlayer
|
||||||
className="flex size-full justify-center overflow-hidden"
|
className="flex size-full justify-center overflow-hidden rounded-lg md:rounded-2xl"
|
||||||
camera="birdseye"
|
camera="birdseye"
|
||||||
width={birdseyeConfig.width}
|
width={birdseyeConfig.width}
|
||||||
height={birdseyeConfig.height}
|
height={birdseyeConfig.height}
|
||||||
@@ -53,31 +65,22 @@ export default function BirdseyeLivePlayer({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
player = <ActivityIndicator className="w-full [.bg-black_&]:text-white" />;
|
player = <ActivityIndicator />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex w-full cursor-pointer justify-center overflow-hidden rounded-lg md:rounded-2xl",
|
"relative flex w-full cursor-pointer justify-center",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<div
|
<ImageShadowOverlay
|
||||||
className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center [container-type:size]"
|
upperClassName="md:rounded-2xl"
|
||||||
style={
|
lowerClassName="md:rounded-2xl"
|
||||||
{
|
/>
|
||||||
"--pic-ar":
|
|
||||||
(birdseyeConfig.width || 1) / (birdseyeConfig.height || 1),
|
|
||||||
} as React.CSSProperties
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="relative aspect-[var(--pic-ar)] h-auto w-[min(100%,calc(100cqh*var(--pic-ar)))]">
|
|
||||||
<ImageShadowOverlay />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="size-full" ref={playerRef}>
|
<div className="size-full" ref={playerRef}>
|
||||||
{player}
|
{player}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ type LivePlayerProps = {
|
|||||||
onError?: (error: LivePlayerError) => void;
|
onError?: (error: LivePlayerError) => void;
|
||||||
onMicrophoneError?: (error: TwoWayTalkError) => void;
|
onMicrophoneError?: (error: TwoWayTalkError) => void;
|
||||||
onResetLiveMode?: () => void;
|
onResetLiveMode?: () => void;
|
||||||
onLiveAspectChange?: (aspectRatio: number | undefined) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function LivePlayer({
|
export default function LivePlayer({
|
||||||
@@ -81,7 +80,6 @@ export default function LivePlayer({
|
|||||||
onError,
|
onError,
|
||||||
onMicrophoneError,
|
onMicrophoneError,
|
||||||
onResetLiveMode,
|
onResetLiveMode,
|
||||||
onLiveAspectChange,
|
|
||||||
}: LivePlayerProps) {
|
}: LivePlayerProps) {
|
||||||
const { t } = useTranslation(["components/player"]);
|
const { t } = useTranslation(["components/player"]);
|
||||||
|
|
||||||
@@ -129,37 +127,6 @@ export default function LivePlayer({
|
|||||||
// camera live state
|
// camera live state
|
||||||
|
|
||||||
const [liveReady, setLiveReady] = useState(false);
|
const [liveReady, setLiveReady] = useState(false);
|
||||||
const [liveAspect, setLiveAspect] = useState<number | undefined>();
|
|
||||||
|
|
||||||
const handleFullResolution = useCallback(
|
|
||||||
(value: React.SetStateAction<VideoResolutionType>) => {
|
|
||||||
setFullResolution?.(value);
|
|
||||||
|
|
||||||
if (typeof value === "function") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLiveAspect(
|
|
||||||
value.width && value.height ? value.width / value.height : undefined,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[setFullResolution],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onLiveAspectChange?.(liveReady ? liveAspect : undefined);
|
|
||||||
}, [liveReady, liveAspect, onLiveAspectChange]);
|
|
||||||
|
|
||||||
// The card can be a different shape than the picture (a bucketed tile, or a
|
|
||||||
// still whose detect aspect differs from the stream), so overlays that are
|
|
||||||
// meant to sit on the image have to be fitted to it rather than to the card.
|
|
||||||
const pictureAspect = useMemo(() => {
|
|
||||||
if (liveReady && liveAspect) {
|
|
||||||
return liveAspect;
|
|
||||||
}
|
|
||||||
const { width, height } = cameraConfig.detect;
|
|
||||||
return width && height ? width / height : 16 / 9;
|
|
||||||
}, [liveReady, liveAspect, cameraConfig.detect]);
|
|
||||||
|
|
||||||
const liveReadyRef = useRef(liveReady);
|
const liveReadyRef = useRef(liveReady);
|
||||||
const cameraActiveRef = useRef(cameraActive);
|
const cameraActiveRef = useRef(cameraActive);
|
||||||
@@ -295,12 +262,11 @@ export default function LivePlayer({
|
|||||||
player = (
|
player = (
|
||||||
<WebRtcPlayer
|
<WebRtcPlayer
|
||||||
key={"webrtc_" + key}
|
key={"webrtc_" + key}
|
||||||
className={`size-full ${liveReady ? "" : "hidden"}`}
|
className={`size-full rounded-lg md:rounded-2xl ${liveReady ? "" : "hidden"}`}
|
||||||
camera={streamName}
|
camera={streamName}
|
||||||
playbackEnabled={cameraActive || liveReady}
|
playbackEnabled={cameraActive || liveReady}
|
||||||
getStats={showStats}
|
getStats={showStats}
|
||||||
setStats={setStats}
|
setStats={setStats}
|
||||||
setFullResolution={handleFullResolution}
|
|
||||||
audioEnabled={playAudio}
|
audioEnabled={playAudio}
|
||||||
volume={volume}
|
volume={volume}
|
||||||
microphoneEnabled={micEnabled}
|
microphoneEnabled={micEnabled}
|
||||||
@@ -316,7 +282,7 @@ export default function LivePlayer({
|
|||||||
player = (
|
player = (
|
||||||
<MSEPlayer
|
<MSEPlayer
|
||||||
key={"mse_" + key}
|
key={"mse_" + key}
|
||||||
className={`size-full ${liveReady ? "" : "hidden"}`}
|
className={`size-full rounded-lg md:rounded-2xl ${liveReady ? "" : "hidden"}`}
|
||||||
camera={streamName}
|
camera={streamName}
|
||||||
playbackEnabled={cameraActive || liveReady}
|
playbackEnabled={cameraActive || liveReady}
|
||||||
audioEnabled={playAudio}
|
audioEnabled={playAudio}
|
||||||
@@ -326,7 +292,7 @@ export default function LivePlayer({
|
|||||||
setStats={setStats}
|
setStats={setStats}
|
||||||
onPlaying={playerIsPlaying}
|
onPlaying={playerIsPlaying}
|
||||||
pip={pip}
|
pip={pip}
|
||||||
setFullResolution={handleFullResolution}
|
setFullResolution={setFullResolution}
|
||||||
onError={onError}
|
onError={onError}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -342,7 +308,7 @@ export default function LivePlayer({
|
|||||||
player = (
|
player = (
|
||||||
<JSMpegPlayer
|
<JSMpegPlayer
|
||||||
key={"jsmpeg_" + key}
|
key={"jsmpeg_" + key}
|
||||||
className="flex justify-center overflow-hidden"
|
className="flex justify-center overflow-hidden rounded-lg md:rounded-2xl"
|
||||||
camera={cameraConfig.name}
|
camera={cameraConfig.name}
|
||||||
width={cameraConfig.detect.width}
|
width={cameraConfig.detect.width}
|
||||||
height={cameraConfig.detect.height}
|
height={cameraConfig.detect.height}
|
||||||
@@ -359,9 +325,7 @@ export default function LivePlayer({
|
|||||||
player = null;
|
player = null;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
player = (
|
player = <ActivityIndicator />;
|
||||||
<ActivityIndicator className={"w-full [.bg-black_&]:text-white"} />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -376,12 +340,10 @@ export default function LivePlayer({
|
|||||||
}}
|
}}
|
||||||
data-camera={cameraConfig.name}
|
data-camera={cameraConfig.name}
|
||||||
className={cn(
|
className={cn(
|
||||||
// the card owns the corner: overflow-hidden clips the stream, the still
|
"relative flex w-full cursor-pointer justify-center outline",
|
||||||
// image, and every overlay to this one radius so they stay concentric
|
|
||||||
"relative flex w-full cursor-pointer justify-center overflow-hidden rounded-lg outline md:rounded-2xl",
|
|
||||||
activeTracking &&
|
activeTracking &&
|
||||||
((showStillWithoutActivity && !liveReady) || liveReady)
|
((showStillWithoutActivity && !liveReady) || liveReady)
|
||||||
? "shadow-severity_alert outline-[3px] outline-severity_alert"
|
? "outline-3 rounded-lg shadow-severity_alert outline-severity_alert md:rounded-2xl"
|
||||||
: "outline-0 outline-background",
|
: "outline-0 outline-background",
|
||||||
"transition-all duration-500",
|
"transition-all duration-500",
|
||||||
className,
|
className,
|
||||||
@@ -395,14 +357,10 @@ export default function LivePlayer({
|
|||||||
>
|
>
|
||||||
{cameraEnabled &&
|
{cameraEnabled &&
|
||||||
((showStillWithoutActivity && !liveReady) || liveReady) && (
|
((showStillWithoutActivity && !liveReady) || liveReady) && (
|
||||||
<div
|
<ImageShadowOverlay
|
||||||
className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center [container-type:size]"
|
upperClassName="md:rounded-2xl"
|
||||||
style={{ "--pic-ar": pictureAspect } as React.CSSProperties}
|
lowerClassName="md:rounded-2xl"
|
||||||
>
|
/>
|
||||||
<div className="relative aspect-[var(--pic-ar)] h-auto w-[min(100%,calc(100cqh*var(--pic-ar)))]">
|
|
||||||
<ImageShadowOverlay />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{player}
|
{player}
|
||||||
{cameraEnabled &&
|
{cameraEnabled &&
|
||||||
@@ -410,7 +368,7 @@ export default function LivePlayer({
|
|||||||
(!showStillWithoutActivity || isReEnabling) &&
|
(!showStillWithoutActivity || isReEnabling) &&
|
||||||
!liveReady && (
|
!liveReady && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
<ActivityIndicator className={"w-full [.bg-black_&]:text-white"} />
|
<ActivityIndicator />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -489,7 +447,7 @@ export default function LivePlayer({
|
|||||||
|
|
||||||
{offline && inDashboard && (
|
{offline && inDashboard && (
|
||||||
<>
|
<>
|
||||||
<div className="absolute inset-0 bg-black/50" />
|
<div className="absolute inset-0 rounded-lg bg-black/50 md:rounded-2xl" />
|
||||||
<div className="absolute inset-0 left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center">
|
<div className="absolute inset-0 left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center">
|
||||||
<div className="flex flex-col items-center justify-center gap-2 rounded-lg bg-background/50 p-3 text-center">
|
<div className="flex flex-col items-center justify-center gap-2 rounded-lg bg-background/50 p-3 text-center">
|
||||||
<div>{t("streamOffline.title")}</div>
|
<div>{t("streamOffline.title")}</div>
|
||||||
@@ -533,7 +491,7 @@ export default function LivePlayer({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!cameraEnabled && (
|
{!cameraEnabled && (
|
||||||
<div className="relative flex h-full w-full items-center justify-center border border-secondary-foreground bg-background_alt">
|
<div className="relative flex h-full w-full items-center justify-center rounded-2xl border border-secondary-foreground bg-background_alt">
|
||||||
<div className="flex h-32 flex-col items-center justify-center rounded-lg p-4 md:h-48 md:w-48">
|
<div className="flex h-32 flex-col items-center justify-center rounded-lg p-4 md:h-48 md:w-48">
|
||||||
<LuVideoOff className="mb-2 size-8 md:size-10" />
|
<LuVideoOff className="mb-2 size-8 md:size-10" />
|
||||||
<p className="max-w-32 text-center text-sm md:max-w-40 md:text-base">
|
<p className="max-w-32 text-center text-sm md:max-w-40 md:text-base">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { LuFolderX } from "react-icons/lu";
|
import { LuFolderX } from "react-icons/lu";
|
||||||
import { isMobileOnly } from "react-device-detect";
|
import { isMobileOnly, isSafari } from "react-device-detect";
|
||||||
import { LuPause, LuPlay } from "react-icons/lu";
|
import { LuPause, LuPlay } from "react-icons/lu";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -54,7 +54,7 @@ const CONTROLS_DEFAULT: VideoControls = {
|
|||||||
snapshot: false,
|
snapshot: false,
|
||||||
fullscreen: false,
|
fullscreen: false,
|
||||||
};
|
};
|
||||||
const PLAYBACK_RATE_DEFAULT = [0.5, 1, 2, 4, 8, 16];
|
const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16];
|
||||||
const MIN_ITEMS_WRAP = 6;
|
const MIN_ITEMS_WRAP = 6;
|
||||||
|
|
||||||
type VideoControlsProps = {
|
type VideoControlsProps = {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
LivePlayerError,
|
LivePlayerError,
|
||||||
PlayerStatsType,
|
PlayerStatsType,
|
||||||
TwoWayTalkError,
|
TwoWayTalkError,
|
||||||
VideoResolutionType,
|
|
||||||
} from "@/types/live";
|
} from "@/types/live";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { webRTCIceServers } from "@/utils/webrtcUtil";
|
import { webRTCIceServers } from "@/utils/webrtcUtil";
|
||||||
@@ -21,7 +20,6 @@ type WebRtcPlayerProps = {
|
|||||||
pip?: boolean;
|
pip?: boolean;
|
||||||
getStats?: boolean;
|
getStats?: boolean;
|
||||||
setStats?: (stats: PlayerStatsType) => void;
|
setStats?: (stats: PlayerStatsType) => void;
|
||||||
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
|
|
||||||
onPlaying?: () => void;
|
onPlaying?: () => void;
|
||||||
onError?: (error: LivePlayerError) => void;
|
onError?: (error: LivePlayerError) => void;
|
||||||
onMicrophoneError?: (error: TwoWayTalkError) => void;
|
onMicrophoneError?: (error: TwoWayTalkError) => void;
|
||||||
@@ -38,7 +36,6 @@ export default function WebRtcPlayer({
|
|||||||
pip = false,
|
pip = false,
|
||||||
getStats = false,
|
getStats = false,
|
||||||
setStats,
|
setStats,
|
||||||
setFullResolution,
|
|
||||||
onPlaying,
|
onPlaying,
|
||||||
onError,
|
onError,
|
||||||
onMicrophoneError,
|
onMicrophoneError,
|
||||||
@@ -345,12 +342,6 @@ export default function WebRtcPlayer({
|
|||||||
if (videoLoadTimeoutRef.current) {
|
if (videoLoadTimeoutRef.current) {
|
||||||
clearTimeout(videoLoadTimeoutRef.current);
|
clearTimeout(videoLoadTimeoutRef.current);
|
||||||
}
|
}
|
||||||
if (videoRef.current) {
|
|
||||||
setFullResolution?.({
|
|
||||||
width: videoRef.current.videoWidth,
|
|
||||||
height: videoRef.current.videoHeight,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
onPlaying?.();
|
onPlaying?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -175,21 +175,6 @@ html {
|
|||||||
background-image: none !important;
|
background-image: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Live masonry grid: only the bottom-right corner resizes, drawn as a corner
|
|
||||||
bracket on the real se handle (drop-shadow keeps it legible over footage). */
|
|
||||||
.grid-layout .react-resizable-handle-se::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
right: 5px;
|
|
||||||
bottom: 5px;
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
border-right: 2.5px solid rgba(233, 238, 246, 0.92);
|
|
||||||
border-bottom: 2.5px solid rgba(233, 238, 246, 0.92);
|
|
||||||
border-bottom-right-radius: 3px;
|
|
||||||
filter: drop-shadow(0 0 1.5px rgba(0, 0, 0, 0.9));
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-grid-item.react-grid-placeholder {
|
.react-grid-item.react-grid-placeholder {
|
||||||
border: 3px solid #a00000 !important;
|
border: 3px solid #a00000 !important;
|
||||||
opacity: 0.5 !important;
|
opacity: 0.5 !important;
|
||||||
|
|||||||
@@ -649,6 +649,7 @@ export interface FrigateConfig {
|
|||||||
};
|
};
|
||||||
|
|
||||||
telemetry: {
|
telemetry: {
|
||||||
|
analytics: boolean;
|
||||||
network_interfaces: string[];
|
network_interfaces: string[];
|
||||||
stats: {
|
stats: {
|
||||||
amd_gpu_stats: boolean;
|
amd_gpu_stats: boolean;
|
||||||
|
|||||||
@@ -54,12 +54,6 @@ export const TRANSFER_KEYS: TransferKey[] = [
|
|||||||
namespaced: true,
|
namespaced: true,
|
||||||
schema: z.boolean(),
|
schema: z.boolean(),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: "naturalAspectLayout",
|
|
||||||
section: "preferences",
|
|
||||||
namespaced: true,
|
|
||||||
schema: z.boolean(),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "alertVideos",
|
key: "alertVideos",
|
||||||
section: "preferences",
|
section: "preferences",
|
||||||
@@ -188,22 +182,13 @@ const layoutItemSchema = z
|
|||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
|
|
||||||
// a group whose dashboard has not been opened since upgrading still holds
|
|
||||||
// the pre-0.19 bare array, so both shapes reach the file
|
|
||||||
const storedLayoutSchema = z.union([
|
|
||||||
z.array(layoutItemSchema),
|
|
||||||
z
|
|
||||||
.object({ version: z.number(), layout: z.array(layoutItemSchema) })
|
|
||||||
.passthrough(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const uiSettingsFileSchema = z.object({
|
export const uiSettingsFileSchema = z.object({
|
||||||
type: z.literal(UI_SETTINGS_FILE_TYPE),
|
type: z.literal(UI_SETTINGS_FILE_TYPE),
|
||||||
version: z.number().int().positive(),
|
version: z.number().int().positive(),
|
||||||
exported_at: z.string(),
|
exported_at: z.string(),
|
||||||
frigate_version: z.string(),
|
frigate_version: z.string(),
|
||||||
sections: z.object({
|
sections: z.object({
|
||||||
layouts: z.record(z.string(), storedLayoutSchema),
|
layouts: z.record(z.string(), z.array(layoutItemSchema)),
|
||||||
streaming: allGroupsStreamingSettingsSchema,
|
streaming: allGroupsStreamingSettingsSchema,
|
||||||
preferences: z.record(z.string(), z.unknown()),
|
preferences: z.record(z.string(), z.unknown()),
|
||||||
}),
|
}),
|
||||||
@@ -219,8 +204,6 @@ export async function buildExportPayload(
|
|||||||
const layouts: UiSettingsFile["sections"]["layouts"] = {};
|
const layouts: UiSettingsFile["sections"]["layouts"] = {};
|
||||||
let streaming: UiSettingsFile["sections"]["streaming"] = {};
|
let streaming: UiSettingsFile["sections"]["streaming"] = {};
|
||||||
const preferences: UiSettingsFile["sections"]["preferences"] = {};
|
const preferences: UiSettingsFile["sections"]["preferences"] = {};
|
||||||
const naturalAspect =
|
|
||||||
(await readTransferable("naturalAspectLayout", true, username)) === true;
|
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
groupNames.map(async (group) => {
|
groupNames.map(async (group) => {
|
||||||
@@ -230,9 +213,7 @@ export async function buildExportPayload(
|
|||||||
username,
|
username,
|
||||||
);
|
);
|
||||||
|
|
||||||
// a group not opened since the mode changed still holds a layout from
|
if (value !== undefined) {
|
||||||
// the other mode, which the grid discards, so leave it out of the file
|
|
||||||
if (value !== undefined && layoutIsNatural(value) === naturalAspect) {
|
|
||||||
layouts[group] = value;
|
layouts[group] = value;
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -403,30 +384,6 @@ export function summarizeImport(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bare arrays are pre-masonry bucketed layouts
|
|
||||||
function layoutIsNatural(layout: unknown): boolean {
|
|
||||||
return (
|
|
||||||
typeof layout === "object" &&
|
|
||||||
layout !== null &&
|
|
||||||
!Array.isArray(layout) &&
|
|
||||||
(layout as { naturalAspect?: unknown }).naturalAspect === true
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A layout only renders under the mode that built it, so importing layouts
|
|
||||||
// applies this mode too. Exports hold a single mode.
|
|
||||||
export function importedLayoutsNaturalAspect(
|
|
||||||
file: UiSettingsFile,
|
|
||||||
): boolean | null {
|
|
||||||
const layouts = Object.values(file.sections.layouts);
|
|
||||||
|
|
||||||
if (!layouts.length) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return layouts.some(layoutIsNatural);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasImportableContent(summary: ImportSummary): boolean {
|
export function hasImportableContent(summary: ImportSummary): boolean {
|
||||||
return (
|
return (
|
||||||
summary.layoutGroupCount > 0 ||
|
summary.layoutGroupCount > 0 ||
|
||||||
@@ -441,9 +398,6 @@ export async function applyImportPayload(
|
|||||||
username: string | undefined,
|
username: string | undefined,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const writes: Promise<void>[] = [];
|
const writes: Promise<void>[] = [];
|
||||||
const layoutsMode = sections.layouts
|
|
||||||
? importedLayoutsNaturalAspect(file)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (sections.layouts) {
|
if (sections.layouts) {
|
||||||
Object.entries(file.sections.layouts).forEach(([group, layout]) => {
|
Object.entries(file.sections.layouts).forEach(([group, layout]) => {
|
||||||
@@ -454,15 +408,6 @@ export async function applyImportPayload(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (layoutsMode !== null) {
|
|
||||||
writes.push(
|
|
||||||
setData(
|
|
||||||
getUserNamespacedKey("naturalAspectLayout", username),
|
|
||||||
layoutsMode,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const streamingEntry = TRANSFER_KEYS.find(
|
const streamingEntry = TRANSFER_KEYS.find(
|
||||||
@@ -502,12 +447,6 @@ export async function applyImportPayload(
|
|||||||
if (sections.preferences) {
|
if (sections.preferences) {
|
||||||
validPreferenceEntries(file.sections.preferences).forEach(
|
validPreferenceEntries(file.sections.preferences).forEach(
|
||||||
({ entry, value }) => {
|
({ entry, value }) => {
|
||||||
// the layouts must win this key or they import into a mode that
|
|
||||||
// cannot display them
|
|
||||||
if (entry.key === "naturalAspectLayout" && layoutsMode !== null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
writes.push(setData(storageKey(entry, username), value));
|
writes.push(setData(storageKey(entry, username), value));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,17 +8,16 @@ import {
|
|||||||
import React, {
|
import React, {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useResizeObserver } from "@/hooks/resize-observer";
|
|
||||||
import {
|
import {
|
||||||
Layout,
|
Layout,
|
||||||
LayoutItem,
|
LayoutItem,
|
||||||
ResponsiveGridLayout as Responsive,
|
ResponsiveGridLayout as Responsive,
|
||||||
} from "react-grid-layout";
|
} from "react-grid-layout";
|
||||||
import { aspectRatio, getCompactor } from "react-grid-layout/core";
|
|
||||||
import "react-grid-layout/css/styles.css";
|
import "react-grid-layout/css/styles.css";
|
||||||
import "react-resizable/css/styles.css";
|
import "react-resizable/css/styles.css";
|
||||||
import {
|
import {
|
||||||
@@ -29,11 +28,12 @@ import {
|
|||||||
StatsState,
|
StatsState,
|
||||||
VolumeState,
|
VolumeState,
|
||||||
} from "@/types/live";
|
} from "@/types/live";
|
||||||
import { ASPECT_WIDE_LAYOUT } from "@/types/record";
|
import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||||
import { isEqual } from "lodash";
|
import { isEqual } from "lodash";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { isDesktop, isMobile, isMobileOnly } from "react-device-detect";
|
import { isDesktop, isMobile } from "react-device-detect";
|
||||||
import BirdseyeLivePlayer from "@/components/player/BirdseyeLivePlayer";
|
import BirdseyeLivePlayer from "@/components/player/BirdseyeLivePlayer";
|
||||||
import LivePlayer from "@/components/player/LivePlayer";
|
import LivePlayer from "@/components/player/LivePlayer";
|
||||||
import { IoClose } from "react-icons/io5";
|
import { IoClose } from "react-icons/io5";
|
||||||
@@ -52,43 +52,6 @@ import LiveContextMenu from "@/components/menu/LiveContextMenu";
|
|||||||
import { useStreamingSettings } from "@/context/streaming-settings-provider";
|
import { useStreamingSettings } from "@/context/streaming-settings-provider";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
// rowHeight is 1/VERTICAL_RESOLUTION of a column, so h = round(w *
|
|
||||||
// VERTICAL_RESOLUTION / aspect) lands a tile on its camera's aspect. GRID_COLS
|
|
||||||
// also sets resize granularity: the aspect constraint derives height from
|
|
||||||
// width, making one column the smallest step in both axes.
|
|
||||||
const GRID_COLS = 96;
|
|
||||||
const TILE_BASE_W = 32;
|
|
||||||
const TILE_WIDE_W = 64;
|
|
||||||
const VERTICAL_RESOLUTION = 4;
|
|
||||||
const DEFAULT_ASPECT = 16 / 9;
|
|
||||||
// Bucketed tile shapes, matching the aspect-wide / aspect-tall Tailwind utilities.
|
|
||||||
const TILE_ASPECT_WIDE = 32 / 9;
|
|
||||||
const TILE_ASPECT_TALL = 8 / 9;
|
|
||||||
|
|
||||||
// Cells quantize to whole rows/columns, so the card takes the camera's exact
|
|
||||||
// ratio and fits itself inside its cell. --ar and container-type live on the
|
|
||||||
// cell; min() picks whichever axis binds first.
|
|
||||||
const CARD_FIT =
|
|
||||||
"h-auto w-[min(100%,calc(100cqh*var(--ar)))] aspect-[var(--ar)]";
|
|
||||||
|
|
||||||
// Stored coordinates are grid units, so bump this whenever GRID_COLS or
|
|
||||||
// VERTICAL_RESOLUTION changes in a released version.
|
|
||||||
const LAYOUT_VERSION = 2;
|
|
||||||
type PersistedLayout = {
|
|
||||||
version: number;
|
|
||||||
naturalAspect: boolean;
|
|
||||||
layout: Layout;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Without preventCollision, RGL shoves collided tiles down the page and never
|
|
||||||
// compacts them back.
|
|
||||||
const FREE_PLACEMENT_COMPACTOR = getCompactor(null, false, true);
|
|
||||||
|
|
||||||
// 0.17/0.18 stored a bare array on a 12-column grid whose standard tile was
|
|
||||||
// 4x4. Bucketed mode reproduces that geometry, so those layouts convert exactly.
|
|
||||||
const LEGACY_GRID_COLS = 12;
|
|
||||||
const LEGACY_TILE_ROWS = 4;
|
|
||||||
|
|
||||||
type DraggableGridLayoutProps = {
|
type DraggableGridLayoutProps = {
|
||||||
cameras: CameraConfig[];
|
cameras: CameraConfig[];
|
||||||
cameraGroup: string;
|
cameraGroup: string;
|
||||||
@@ -135,66 +98,6 @@ export default function DraggableGridLayout({
|
|||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
|
const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
|
||||||
|
|
||||||
const aspectRatios = useMemo(() => {
|
|
||||||
const map: { [key: string]: number } = {};
|
|
||||||
if (birdseyeConfig) {
|
|
||||||
map["birdseye"] =
|
|
||||||
(birdseyeConfig.width || 1) / (birdseyeConfig.height || 1);
|
|
||||||
}
|
|
||||||
cameras.forEach((camera) => {
|
|
||||||
map[camera.name] =
|
|
||||||
camera.detect.width / camera.detect.height || DEFAULT_ASPECT;
|
|
||||||
});
|
|
||||||
return map;
|
|
||||||
}, [cameras, birdseyeConfig]);
|
|
||||||
|
|
||||||
const [naturalAspectSetting, , isNaturalAspectLoaded] = useUserPersistence(
|
|
||||||
"naturalAspectLayout",
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
// phones never reach this grid, and the setting is hidden there, so an
|
|
||||||
// imported or stale value must not take effect
|
|
||||||
const naturalAspectLayout = !isMobileOnly && (naturalAspectSetting ?? false);
|
|
||||||
|
|
||||||
// Bucketed mode snaps every camera to one of three tile shapes, matching the
|
|
||||||
// pre-masonry layout; the picture letterboxes inside its bucket.
|
|
||||||
const layoutAspects = useMemo(() => {
|
|
||||||
if (naturalAspectLayout) {
|
|
||||||
return aspectRatios;
|
|
||||||
}
|
|
||||||
const map: { [key: string]: number } = {};
|
|
||||||
Object.entries(aspectRatios).forEach(([name, ratio]) => {
|
|
||||||
map[name] =
|
|
||||||
ratio > ASPECT_WIDE_LAYOUT
|
|
||||||
? TILE_ASPECT_WIDE
|
|
||||||
: ratio < 1
|
|
||||||
? TILE_ASPECT_TALL
|
|
||||||
: DEFAULT_ASPECT;
|
|
||||||
});
|
|
||||||
return map;
|
|
||||||
}, [aspectRatios, naturalAspectLayout]);
|
|
||||||
|
|
||||||
// A live stream can be shaped differently than detect, so the card follows
|
|
||||||
// whatever is on screen and falls back to detect. Cells stay detect-sized, so
|
|
||||||
// only the card resizes.
|
|
||||||
const [liveAspects, setLiveAspects] = useState<{
|
|
||||||
[key: string]: number | undefined;
|
|
||||||
}>({});
|
|
||||||
|
|
||||||
const liveAspectHandlers = useMemo(() => {
|
|
||||||
const map: { [key: string]: (aspectRatio: number | undefined) => void } =
|
|
||||||
{};
|
|
||||||
cameras.forEach((camera) => {
|
|
||||||
map[camera.name] = (aspectRatio) =>
|
|
||||||
setLiveAspects((prev) =>
|
|
||||||
prev[camera.name] === aspectRatio
|
|
||||||
? prev
|
|
||||||
: { ...prev, [camera.name]: aspectRatio },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
return map;
|
|
||||||
}, [cameras]);
|
|
||||||
|
|
||||||
// preferred live modes per camera
|
// preferred live modes per camera
|
||||||
|
|
||||||
const [globalAutoLive] = useUserPersistence("autoLiveView", true);
|
const [globalAutoLive] = useUserPersistence("autoLiveView", true);
|
||||||
@@ -212,31 +115,7 @@ export default function DraggableGridLayout({
|
|||||||
// grid layout
|
// grid layout
|
||||||
|
|
||||||
const [gridLayout, setGridLayout, isGridLayoutLoaded] =
|
const [gridLayout, setGridLayout, isGridLayoutLoaded] =
|
||||||
useUserPersistence<PersistedLayout>(`${cameraGroup}-draggable-layout`);
|
useUserPersistence<Layout>(`${cameraGroup}-draggable-layout`);
|
||||||
|
|
||||||
const readPersistedLayout = useCallback(
|
|
||||||
(stored: PersistedLayout | undefined): Layout | undefined => {
|
|
||||||
if (
|
|
||||||
!stored ||
|
|
||||||
stored.version !== LAYOUT_VERSION ||
|
|
||||||
!Array.isArray(stored.layout)
|
|
||||||
) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return stored.layout;
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Strips per-item `constraints`, which are functions.
|
|
||||||
const toPersisted = useCallback(
|
|
||||||
(layout: Layout): PersistedLayout => ({
|
|
||||||
version: LAYOUT_VERSION,
|
|
||||||
naturalAspect: naturalAspectLayout,
|
|
||||||
layout: layout.map(({ i, x, y, w, h }) => ({ i, x, y, w, h })),
|
|
||||||
}),
|
|
||||||
[naturalAspectLayout],
|
|
||||||
);
|
|
||||||
|
|
||||||
const [group] = useUserPersistedOverlayState(
|
const [group] = useUserPersistedOverlayState(
|
||||||
"cameraGroup",
|
"cameraGroup",
|
||||||
@@ -261,11 +140,11 @@ export default function DraggableGridLayout({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsEditMode(false);
|
setIsEditMode(false);
|
||||||
setEditGroup(false);
|
setEditGroup(false);
|
||||||
// Keeps the camera-change effect from overwriting the layout we load next.
|
// Reset camera tracking state when group changes to prevent the camera-change
|
||||||
|
// effect from incorrectly overwriting the loaded layout
|
||||||
setCurrentCameras(undefined);
|
setCurrentCameras(undefined);
|
||||||
setCurrentIncludeBirdseye(undefined);
|
setCurrentIncludeBirdseye(undefined);
|
||||||
setCurrentGridLayout(undefined);
|
setCurrentGridLayout(undefined);
|
||||||
setCurrentNaturalAspect(undefined);
|
|
||||||
}, [cameraGroup, setIsEditMode]);
|
}, [cameraGroup, setIsEditMode]);
|
||||||
|
|
||||||
// camera state
|
// camera state
|
||||||
@@ -276,84 +155,21 @@ export default function DraggableGridLayout({
|
|||||||
const [currentGridLayout, setCurrentGridLayout] = useState<
|
const [currentGridLayout, setCurrentGridLayout] = useState<
|
||||||
Layout | undefined
|
Layout | undefined
|
||||||
>();
|
>();
|
||||||
const [currentNaturalAspect, setCurrentNaturalAspect] = useState<boolean>();
|
|
||||||
|
|
||||||
const handleLayoutChange = useCallback(
|
const handleLayoutChange = useCallback(
|
||||||
(currentLayout: Layout) => {
|
(currentLayout: Layout) => {
|
||||||
if (
|
if (!isGridLayoutLoaded || !isEqual(gridLayout, currentGridLayout)) {
|
||||||
!isGridLayoutLoaded ||
|
|
||||||
!isEqual(readPersistedLayout(gridLayout), currentGridLayout)
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setGridLayout(toPersisted(currentLayout));
|
// save layout to idb
|
||||||
|
setGridLayout(currentLayout);
|
||||||
setShowCircles(true);
|
setShowCircles(true);
|
||||||
},
|
},
|
||||||
[
|
[setGridLayout, isGridLayoutLoaded, gridLayout, currentGridLayout],
|
||||||
setGridLayout,
|
|
||||||
isGridLayoutLoaded,
|
|
||||||
gridLayout,
|
|
||||||
currentGridLayout,
|
|
||||||
readPersistedLayout,
|
|
||||||
toPersisted,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const dimsFor = useCallback(
|
|
||||||
(name: string) => {
|
|
||||||
const ratio = layoutAspects[name] ?? DEFAULT_ASPECT;
|
|
||||||
const w = ratio >= ASPECT_WIDE_LAYOUT ? TILE_WIDE_W : TILE_BASE_W;
|
|
||||||
const h = Math.max(1, Math.round((w * VERTICAL_RESOLUTION) / ratio));
|
|
||||||
return { w, h };
|
|
||||||
},
|
|
||||||
[layoutAspects],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Rescale a pre-masonry layout onto the current grid. Both axes scale by a
|
|
||||||
// constant, so tiles the user resized keep their size and their arrangement
|
|
||||||
// stays intact. Only meaningful in bucketed mode, where a tile still has the
|
|
||||||
// shape those coordinates assumed.
|
|
||||||
const convertLegacyLayout = useCallback(
|
|
||||||
(stored: unknown): Layout | undefined => {
|
|
||||||
if (naturalAspectLayout || !Array.isArray(stored) || !stored.length) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const xScale = GRID_COLS / LEGACY_GRID_COLS;
|
|
||||||
const yScale =
|
|
||||||
Math.round((TILE_BASE_W * VERTICAL_RESOLUTION) / DEFAULT_ASPECT) /
|
|
||||||
LEGACY_TILE_ROWS;
|
|
||||||
const converted: LayoutItem[] = [];
|
|
||||||
|
|
||||||
for (const item of stored) {
|
|
||||||
if (
|
|
||||||
!item ||
|
|
||||||
typeof item.i !== "string" ||
|
|
||||||
typeof item.x !== "number" ||
|
|
||||||
typeof item.y !== "number" ||
|
|
||||||
typeof item.w !== "number" ||
|
|
||||||
typeof item.h !== "number"
|
|
||||||
) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const w = Math.min(Math.max(1, Math.round(item.w * xScale)), GRID_COLS);
|
|
||||||
converted.push({
|
|
||||||
i: item.i,
|
|
||||||
x: Math.min(Math.max(0, Math.round(item.x * xScale)), GRID_COLS - w),
|
|
||||||
y: Math.max(0, Math.round(item.y * yScale)),
|
|
||||||
w,
|
|
||||||
h: Math.max(1, Math.round(item.h * yScale)),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return converted;
|
|
||||||
},
|
|
||||||
[naturalAspectLayout],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const generateLayout = useCallback(
|
const generateLayout = useCallback(
|
||||||
(baseLayout: Layout | undefined): Layout | undefined => {
|
(baseLayout: Layout | undefined) => {
|
||||||
if (!isGridLayoutLoaded) {
|
if (!isGridLayoutLoaded) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -363,98 +179,91 @@ export default function DraggableGridLayout({
|
|||||||
? ["birdseye", ...cameras.map((camera) => camera?.name || "")]
|
? ["birdseye", ...cameras.map((camera) => camera?.name || "")]
|
||||||
: cameras.map((camera) => camera?.name || "");
|
: cameras.map((camera) => camera?.name || "");
|
||||||
|
|
||||||
const existing: LayoutItem[] = baseLayout
|
const optionsMap: LayoutItem[] = baseLayout
|
||||||
? baseLayout.filter((layout) => cameraNames.includes(layout.i))
|
? baseLayout.filter((layout) => cameraNames?.includes(layout.i))
|
||||||
: [];
|
: [];
|
||||||
const placed = new Set(existing.map((layout) => layout.i));
|
|
||||||
|
|
||||||
const tileColumns = GRID_COLS / TILE_BASE_W; // 3 standard columns
|
cameraNames.forEach((cameraName, index) => {
|
||||||
// Each column starts below every existing tile that overlaps it, so new
|
const existingLayout = optionsMap.find(
|
||||||
// cameras fill open columns without overlapping the user's tiles.
|
(layout) => layout.i === cameraName,
|
||||||
const colBottoms = Array.from({ length: tileColumns }, (_, c) =>
|
);
|
||||||
existing.reduce(
|
|
||||||
(max, layout) =>
|
|
||||||
layout.x < (c + 1) * TILE_BASE_W &&
|
|
||||||
layout.x + layout.w > c * TILE_BASE_W
|
|
||||||
? Math.max(max, layout.y + layout.h)
|
|
||||||
: max,
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result: LayoutItem[] = [...existing];
|
// Skip if the camera already exists in the layout
|
||||||
|
if (existingLayout) {
|
||||||
cameraNames.forEach((name) => {
|
|
||||||
if (placed.has(name)) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { w, h } = dimsFor(name);
|
|
||||||
|
|
||||||
if (w === TILE_BASE_W) {
|
let aspectRatio;
|
||||||
let col = 0;
|
let col;
|
||||||
for (let c = 1; c < tileColumns; c++) {
|
|
||||||
if (colBottoms[c] < colBottoms[col]) {
|
// Handle "birdseye" camera as a special case
|
||||||
col = c;
|
if (cameraName === "birdseye") {
|
||||||
}
|
aspectRatio =
|
||||||
}
|
(birdseyeConfig?.width || 1) / (birdseyeConfig?.height || 1);
|
||||||
result.push({
|
col = 0; // Set birdseye camera in the first column
|
||||||
i: name,
|
|
||||||
x: col * TILE_BASE_W,
|
|
||||||
y: colBottoms[col],
|
|
||||||
w,
|
|
||||||
h,
|
|
||||||
});
|
|
||||||
colBottoms[col] += h;
|
|
||||||
} else {
|
} else {
|
||||||
let pair = 0;
|
const camera = cameras.find((cam) => cam.name === cameraName);
|
||||||
for (let c = 1; c + 1 < tileColumns; c++) {
|
aspectRatio =
|
||||||
if (
|
(camera && camera?.detect.width / camera?.detect.height) || 16 / 9;
|
||||||
Math.max(colBottoms[c], colBottoms[c + 1]) <
|
col = index % 3; // Regular cameras distributed across columns
|
||||||
Math.max(colBottoms[pair], colBottoms[pair + 1])
|
|
||||||
) {
|
|
||||||
pair = c;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const y = Math.max(colBottoms[pair], colBottoms[pair + 1]);
|
|
||||||
result.push({ i: name, x: pair * TILE_BASE_W, y, w, h });
|
|
||||||
colBottoms[pair] = y + h;
|
|
||||||
colBottoms[pair + 1] = y + h;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate layout options based on aspect ratio
|
||||||
|
const columnsPerPlayer = 4;
|
||||||
|
let height;
|
||||||
|
let width;
|
||||||
|
|
||||||
|
if (aspectRatio < 1) {
|
||||||
|
// Portrait
|
||||||
|
height = 2 * columnsPerPlayer;
|
||||||
|
width = columnsPerPlayer;
|
||||||
|
} else if (aspectRatio > 2) {
|
||||||
|
// Wide
|
||||||
|
height = 1 * columnsPerPlayer;
|
||||||
|
width = 2 * columnsPerPlayer;
|
||||||
|
} else {
|
||||||
|
// Landscape
|
||||||
|
height = 1 * columnsPerPlayer;
|
||||||
|
width = columnsPerPlayer;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
i: cameraName,
|
||||||
|
x: col * width,
|
||||||
|
y: 0, // don't set y, grid does automatically
|
||||||
|
w: width,
|
||||||
|
h: height,
|
||||||
|
};
|
||||||
|
|
||||||
|
optionsMap.push(options);
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return optionsMap;
|
||||||
},
|
},
|
||||||
[cameras, isGridLayoutLoaded, includeBirdseye, birdseyeConfig, dimsFor],
|
[cameras, isGridLayoutLoaded, includeBirdseye, birdseyeConfig],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isGridLayoutLoaded) {
|
if (isGridLayoutLoaded) {
|
||||||
return;
|
if (gridLayout) {
|
||||||
}
|
// set current grid layout from loaded, possibly adding new cameras
|
||||||
|
const updatedLayout = generateLayout(gridLayout);
|
||||||
const saved = readPersistedLayout(gridLayout);
|
setCurrentGridLayout(updatedLayout);
|
||||||
const converted = saved ? undefined : convertLegacyLayout(gridLayout);
|
// Only save if cameras were added (layout changed)
|
||||||
const base = saved ?? converted;
|
if (!isEqual(updatedLayout, gridLayout)) {
|
||||||
|
setGridLayout(updatedLayout);
|
||||||
if (base) {
|
}
|
||||||
const updatedLayout = generateLayout(base) ?? base;
|
// Set camera tracking state so the camera-change effect has a baseline
|
||||||
setCurrentGridLayout(updatedLayout);
|
setCurrentCameras(cameras);
|
||||||
if (converted || !isEqual(updatedLayout, base)) {
|
setCurrentIncludeBirdseye(includeBirdseye);
|
||||||
setGridLayout(toPersisted(updatedLayout));
|
} else {
|
||||||
|
// idb is empty, set it with an initial layout
|
||||||
|
const newLayout = generateLayout(undefined);
|
||||||
|
setCurrentGridLayout(newLayout);
|
||||||
|
setGridLayout(newLayout);
|
||||||
|
setCurrentCameras(cameras);
|
||||||
|
setCurrentIncludeBirdseye(includeBirdseye);
|
||||||
}
|
}
|
||||||
setCurrentCameras(cameras);
|
|
||||||
setCurrentIncludeBirdseye(includeBirdseye);
|
|
||||||
setCurrentNaturalAspect(
|
|
||||||
converted ? naturalAspectLayout : gridLayout?.naturalAspect,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// empty or incompatible (pre-masonry) data
|
|
||||||
const newLayout = generateLayout(undefined) ?? [];
|
|
||||||
setCurrentGridLayout(newLayout);
|
|
||||||
setGridLayout(toPersisted(newLayout));
|
|
||||||
setCurrentCameras(cameras);
|
|
||||||
setCurrentIncludeBirdseye(includeBirdseye);
|
|
||||||
setCurrentNaturalAspect(naturalAspectLayout);
|
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
gridLayout,
|
gridLayout,
|
||||||
@@ -463,15 +272,12 @@ export default function DraggableGridLayout({
|
|||||||
generateLayout,
|
generateLayout,
|
||||||
cameras,
|
cameras,
|
||||||
includeBirdseye,
|
includeBirdseye,
|
||||||
naturalAspectLayout,
|
|
||||||
readPersistedLayout,
|
|
||||||
convertLegacyLayout,
|
|
||||||
toPersisted,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only for camera changes within a loaded group; undefined currentCameras
|
// Only regenerate layout when cameras change WITHIN an already-loaded group
|
||||||
// means the load effect above has not run yet.
|
// Skip if currentCameras is undefined (means we just switched groups and
|
||||||
|
// the first useEffect hasn't run yet to set things up)
|
||||||
if (!isGridLayoutLoaded || currentCameras === undefined) {
|
if (!isGridLayoutLoaded || currentCameras === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -483,10 +289,10 @@ export default function DraggableGridLayout({
|
|||||||
setCurrentCameras(cameras);
|
setCurrentCameras(cameras);
|
||||||
setCurrentIncludeBirdseye(includeBirdseye);
|
setCurrentIncludeBirdseye(includeBirdseye);
|
||||||
|
|
||||||
const updatedLayout =
|
// Regenerate layout based on current layout, adding any new cameras
|
||||||
generateLayout(currentGridLayout) ?? currentGridLayout ?? [];
|
const updatedLayout = generateLayout(currentGridLayout);
|
||||||
setCurrentGridLayout(updatedLayout);
|
setCurrentGridLayout(updatedLayout);
|
||||||
setGridLayout(toPersisted(updatedLayout));
|
setGridLayout(updatedLayout);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
cameras,
|
cameras,
|
||||||
@@ -497,47 +303,39 @@ export default function DraggableGridLayout({
|
|||||||
generateLayout,
|
generateLayout,
|
||||||
setGridLayout,
|
setGridLayout,
|
||||||
isGridLayoutLoaded,
|
isGridLayoutLoaded,
|
||||||
toPersisted,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
const [marginValue, setMarginValue] = useState(16);
|
||||||
if (
|
|
||||||
!isNaturalAspectLoaded ||
|
|
||||||
currentNaturalAspect === undefined ||
|
|
||||||
currentNaturalAspect === naturalAspectLayout
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCurrentNaturalAspect(naturalAspectLayout);
|
// calculate margin value for browsers that don't have default font size of 16px
|
||||||
const regenerated = generateLayout(undefined) ?? [];
|
useLayoutEffect(() => {
|
||||||
setCurrentGridLayout(regenerated);
|
const calculateRemValue = () => {
|
||||||
setGridLayout(toPersisted(regenerated));
|
const htmlElement = document.documentElement;
|
||||||
}, [
|
const fontSize = window.getComputedStyle(htmlElement).fontSize;
|
||||||
naturalAspectLayout,
|
setMarginValue(parseFloat(fontSize));
|
||||||
isNaturalAspectLoaded,
|
};
|
||||||
currentNaturalAspect,
|
|
||||||
generateLayout,
|
|
||||||
setGridLayout,
|
|
||||||
toPersisted,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const gridContainerRef = useRef<HTMLDivElement | null>(null);
|
calculateRemValue();
|
||||||
|
|
||||||
// Commit-time measure: paints the first frame at the real width (no
|
|
||||||
// innerWidth flash), and the setState re-render is what lets
|
|
||||||
// useResizeObserver see a node mounted after the skeleton swap.
|
|
||||||
const [mountWidth, setMountWidth] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const attachGridContainer = useCallback((node: HTMLDivElement | null) => {
|
|
||||||
gridContainerRef.current = node;
|
|
||||||
setMountWidth(node ? node.getBoundingClientRect().width : null);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const gridContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const [{ width: containerWidth, height: containerHeight }] =
|
const [{ width: containerWidth, height: containerHeight }] =
|
||||||
useResizeObserver(gridContainerRef);
|
useResizeObserver(gridContainerRef);
|
||||||
|
|
||||||
const availableWidth = containerWidth || mountWidth || 0;
|
const scrollBarWidth = useMemo(() => {
|
||||||
|
if (containerWidth && containerHeight && containerRef.current) {
|
||||||
|
return (
|
||||||
|
containerRef.current.offsetWidth - containerRef.current.clientWidth
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}, [containerRef, containerHeight, containerWidth]);
|
||||||
|
|
||||||
|
const availableWidth = useMemo(
|
||||||
|
() => (scrollBarWidth ? containerWidth + scrollBarWidth : containerWidth),
|
||||||
|
[containerWidth, scrollBarWidth],
|
||||||
|
);
|
||||||
|
|
||||||
const hasScrollbar = useMemo(() => {
|
const hasScrollbar = useMemo(() => {
|
||||||
if (containerHeight && containerRef.current) {
|
if (containerHeight && containerRef.current) {
|
||||||
@@ -548,10 +346,61 @@ export default function DraggableGridLayout({
|
|||||||
}, [containerRef, containerHeight]);
|
}, [containerRef, containerHeight]);
|
||||||
|
|
||||||
const cellHeight = useMemo(() => {
|
const cellHeight = useMemo(() => {
|
||||||
const width = availableWidth || window.innerWidth;
|
const aspectRatio = 16 / 9;
|
||||||
const columnWidth = width / GRID_COLS;
|
// subtract container margin, 1 camera takes up at least 4 rows
|
||||||
return columnWidth / VERTICAL_RESOLUTION;
|
// account for additional margin on bottom of each row
|
||||||
}, [availableWidth]);
|
return (
|
||||||
|
((availableWidth ?? window.innerWidth) - 2 * marginValue) /
|
||||||
|
12 /
|
||||||
|
aspectRatio -
|
||||||
|
marginValue +
|
||||||
|
marginValue / 4
|
||||||
|
);
|
||||||
|
}, [availableWidth, marginValue]);
|
||||||
|
|
||||||
|
const handleResize = (
|
||||||
|
_layout: Layout,
|
||||||
|
oldLayoutItem: LayoutItem | null,
|
||||||
|
layoutItem: LayoutItem | null,
|
||||||
|
placeholder: LayoutItem | null,
|
||||||
|
) => {
|
||||||
|
if (!oldLayoutItem || !layoutItem || !placeholder) return;
|
||||||
|
|
||||||
|
const heightDiff = layoutItem.h - oldLayoutItem.h;
|
||||||
|
const widthDiff = layoutItem.w - oldLayoutItem.w;
|
||||||
|
const changeCoef = oldLayoutItem.w / oldLayoutItem.h;
|
||||||
|
|
||||||
|
let newWidth, newHeight;
|
||||||
|
|
||||||
|
if (Math.abs(heightDiff) < Math.abs(widthDiff)) {
|
||||||
|
newHeight = Math.round(layoutItem.w / changeCoef);
|
||||||
|
newWidth = Math.round(newHeight * changeCoef);
|
||||||
|
} else {
|
||||||
|
newWidth = Math.round(layoutItem.h * changeCoef);
|
||||||
|
newHeight = Math.round(newWidth / changeCoef);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure dimensions maintain aspect ratio and fit within the grid
|
||||||
|
if (layoutItem.x + newWidth > 12) {
|
||||||
|
newWidth = 12 - layoutItem.x;
|
||||||
|
newHeight = Math.round(newWidth / changeCoef);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changeCoef == 0.5) {
|
||||||
|
// portrait
|
||||||
|
newHeight = Math.ceil(newHeight / 2) * 2;
|
||||||
|
} else if (changeCoef == 2) {
|
||||||
|
// pano/wide
|
||||||
|
newHeight = Math.ceil(newHeight * 2) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
newWidth = Math.round(newHeight * changeCoef);
|
||||||
|
|
||||||
|
layoutItem.w = newWidth;
|
||||||
|
layoutItem.h = newHeight;
|
||||||
|
placeholder.w = layoutItem.w;
|
||||||
|
placeholder.h = layoutItem.h;
|
||||||
|
};
|
||||||
|
|
||||||
// audio and stats states
|
// audio and stats states
|
||||||
|
|
||||||
@@ -654,19 +503,6 @@ export default function DraggableGridLayout({
|
|||||||
onSaveMuting(true);
|
onSaveMuting(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// RGL's per-item constraint derives height from width, holding each tile at
|
|
||||||
// its camera's aspect while resizing. Constraints are functions, so they live
|
|
||||||
// only on this render copy; toPersisted strips them.
|
|
||||||
const layoutWithConstraints = useMemo(() => {
|
|
||||||
if (!currentGridLayout) {
|
|
||||||
return [] as Layout;
|
|
||||||
}
|
|
||||||
return currentGridLayout.map((item) => ({
|
|
||||||
...item,
|
|
||||||
constraints: [aspectRatio(layoutAspects[item.i] ?? DEFAULT_ASPECT)],
|
|
||||||
}));
|
|
||||||
}, [currentGridLayout, layoutAspects]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Toaster position="top-center" closeButton={true} />
|
<Toaster position="top-center" closeButton={true} />
|
||||||
@@ -689,8 +525,8 @@ export default function DraggableGridLayout({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="no-scrollbar my-2 select-none overflow-x-hidden pb-8"
|
className="no-scrollbar my-2 select-none overflow-x-hidden px-2 pb-8"
|
||||||
ref={attachGridContainer}
|
ref={gridContainerRef}
|
||||||
>
|
>
|
||||||
<EditGroupDialog
|
<EditGroupDialog
|
||||||
open={editGroup}
|
open={editGroup}
|
||||||
@@ -700,36 +536,28 @@ export default function DraggableGridLayout({
|
|||||||
/>
|
/>
|
||||||
<Responsive
|
<Responsive
|
||||||
className="grid-layout"
|
className="grid-layout"
|
||||||
width={availableWidth || window.innerWidth}
|
width={availableWidth ?? window.innerWidth}
|
||||||
layouts={{
|
layouts={{
|
||||||
lg: layoutWithConstraints,
|
lg: currentGridLayout,
|
||||||
md: layoutWithConstraints,
|
md: currentGridLayout,
|
||||||
sm: layoutWithConstraints,
|
sm: currentGridLayout,
|
||||||
xs: layoutWithConstraints,
|
xs: currentGridLayout,
|
||||||
xxs: layoutWithConstraints,
|
xxs: currentGridLayout,
|
||||||
}}
|
}}
|
||||||
rowHeight={cellHeight}
|
rowHeight={cellHeight}
|
||||||
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
||||||
cols={{
|
cols={{ lg: 12, md: 12, sm: 12, xs: 12, xxs: 12 }}
|
||||||
lg: GRID_COLS,
|
margin={[marginValue, marginValue]}
|
||||||
md: GRID_COLS,
|
|
||||||
sm: GRID_COLS,
|
|
||||||
xs: GRID_COLS,
|
|
||||||
xxs: GRID_COLS,
|
|
||||||
}}
|
|
||||||
margin={[0, 0]}
|
|
||||||
compactor={FREE_PLACEMENT_COMPACTOR}
|
|
||||||
containerPadding={[0, isEditMode ? 6 : 3]}
|
containerPadding={[0, isEditMode ? 6 : 3]}
|
||||||
resizeConfig={{
|
resizeConfig={{
|
||||||
enabled: isEditMode,
|
enabled: isEditMode,
|
||||||
// se only: top/left handles fight the aspect constraint at a grid
|
handles: isEditMode ? ["sw", "nw", "se", "ne"] : [],
|
||||||
// boundary (RGL re-clamps the opposite edge) and distort the tile.
|
|
||||||
handles: isEditMode ? ["se"] : [],
|
|
||||||
}}
|
}}
|
||||||
dragConfig={{
|
dragConfig={{
|
||||||
enabled: isEditMode,
|
enabled: isEditMode,
|
||||||
}}
|
}}
|
||||||
onDragStop={handleLayoutChange}
|
onDragStop={handleLayoutChange}
|
||||||
|
onResize={handleResize}
|
||||||
onResizeStart={() => setShowCircles(false)}
|
onResizeStart={() => setShowCircles(false)}
|
||||||
onResizeStop={handleLayoutChange}
|
onResizeStop={handleLayoutChange}
|
||||||
>
|
>
|
||||||
@@ -742,12 +570,22 @@ export default function DraggableGridLayout({
|
|||||||
"outline outline-2 outline-muted-foreground hover:cursor-grab hover:outline-4 active:cursor-grabbing",
|
"outline outline-2 outline-muted-foreground hover:cursor-grab hover:outline-4 active:cursor-grabbing",
|
||||||
)}
|
)}
|
||||||
birdseyeConfig={birdseyeConfig}
|
birdseyeConfig={birdseyeConfig}
|
||||||
aspectRatio={layoutAspects["birdseye"] ?? DEFAULT_ASPECT}
|
|
||||||
liveMode={birdseyeConfig.restream ? "mse" : "jsmpeg"}
|
liveMode={birdseyeConfig.restream ? "mse" : "jsmpeg"}
|
||||||
onClick={() => onSelectCamera("birdseye")}
|
onClick={() => onSelectCamera("birdseye")}
|
||||||
></BirdseyeLivePlayerGridItem>
|
>
|
||||||
|
{isEditMode && showCircles && <CornerCircles />}
|
||||||
|
</BirdseyeLivePlayerGridItem>
|
||||||
)}
|
)}
|
||||||
{cameras.map((camera) => {
|
{cameras.map((camera) => {
|
||||||
|
let grow;
|
||||||
|
const aspectRatio = camera.detect.width / camera.detect.height;
|
||||||
|
if (aspectRatio > ASPECT_WIDE_LAYOUT) {
|
||||||
|
grow = `aspect-wide w-full`;
|
||||||
|
} else if (aspectRatio < ASPECT_VERTICAL_LAYOUT) {
|
||||||
|
grow = `aspect-tall h-full`;
|
||||||
|
} else {
|
||||||
|
grow = "aspect-video";
|
||||||
|
}
|
||||||
const availableStreams = camera.live.streams || {};
|
const availableStreams = camera.live.streams || {};
|
||||||
const firstStreamEntry = Object.values(availableStreams)[0] || "";
|
const firstStreamEntry = Object.values(availableStreams)[0] || "";
|
||||||
|
|
||||||
@@ -776,14 +614,7 @@ export default function DraggableGridLayout({
|
|||||||
?.compatibilityMode || false;
|
?.compatibilityMode || false;
|
||||||
return (
|
return (
|
||||||
<GridLiveContextMenu
|
<GridLiveContextMenu
|
||||||
className={CARD_FIT}
|
className={grow}
|
||||||
aspectRatio={
|
|
||||||
(naturalAspectLayout
|
|
||||||
? liveAspects[camera.name]
|
|
||||||
: undefined) ??
|
|
||||||
layoutAspects[camera.name] ??
|
|
||||||
DEFAULT_ASPECT
|
|
||||||
}
|
|
||||||
key={camera.name}
|
key={camera.name}
|
||||||
camera={camera.name}
|
camera={camera.name}
|
||||||
streamName={streamName}
|
streamName={streamName}
|
||||||
@@ -822,8 +653,8 @@ export default function DraggableGridLayout({
|
|||||||
useWebGL={useWebGL}
|
useWebGL={useWebGL}
|
||||||
cameraRef={cameraRef}
|
cameraRef={cameraRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-full",
|
"rounded-lg bg-black md:rounded-2xl",
|
||||||
naturalAspectLayout ? "bg-background" : "bg-black",
|
grow,
|
||||||
isEditMode &&
|
isEditMode &&
|
||||||
showCircles &&
|
showCircles &&
|
||||||
"outline-2 outline-muted-foreground hover:cursor-grab hover:outline-4 active:cursor-grabbing",
|
"outline-2 outline-muted-foreground hover:cursor-grab hover:outline-4 active:cursor-grabbing",
|
||||||
@@ -844,8 +675,8 @@ export default function DraggableGridLayout({
|
|||||||
onResetLiveMode={() => resetPreferredLiveMode(camera.name)}
|
onResetLiveMode={() => resetPreferredLiveMode(camera.name)}
|
||||||
playAudio={audioStates[camera.name]}
|
playAudio={audioStates[camera.name]}
|
||||||
volume={volumeStates[camera.name]}
|
volume={volumeStates[camera.name]}
|
||||||
onLiveAspectChange={liveAspectHandlers[camera.name]}
|
|
||||||
/>
|
/>
|
||||||
|
{isEditMode && showCircles && <CornerCircles />}
|
||||||
</GridLiveContextMenu>
|
</GridLiveContextMenu>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -863,7 +694,6 @@ export default function DraggableGridLayout({
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div
|
<div
|
||||||
data-testid="toggle-edit-layout"
|
|
||||||
className="cursor-pointer rounded-lg bg-secondary text-secondary-foreground opacity-60 transition-all duration-300 hover:bg-muted hover:opacity-100"
|
className="cursor-pointer rounded-lg bg-secondary text-secondary-foreground opacity-60 transition-all duration-300 hover:bg-muted hover:opacity-100"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setIsEditMode((prevIsEditMode) => !prevIsEditMode)
|
setIsEditMode((prevIsEditMode) => !prevIsEditMode)
|
||||||
@@ -932,6 +762,17 @@ export default function DraggableGridLayout({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CornerCircles() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="pointer-events-none absolute left-[-4px] top-[-4px] z-50 size-3 rounded-full bg-primary-variant p-2 text-background outline-2 outline-muted" />
|
||||||
|
<div className="pointer-events-none absolute right-[-4px] top-[-4px] z-50 size-3 rounded-full bg-primary-variant p-2 text-background outline-2 outline-muted" />
|
||||||
|
<div className="pointer-events-none absolute bottom-[-4px] right-[-4px] z-50 size-3 rounded-full bg-primary-variant p-2 text-background outline-2 outline-muted" />
|
||||||
|
<div className="pointer-events-none absolute bottom-[-4px] left-[-4px] z-50 size-3 rounded-full bg-primary-variant p-2 text-background outline-2 outline-muted" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type BirdseyeLivePlayerGridItemProps = {
|
type BirdseyeLivePlayerGridItemProps = {
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -940,7 +781,6 @@ type BirdseyeLivePlayerGridItemProps = {
|
|||||||
onTouchEnd?: React.TouchEventHandler<HTMLDivElement>;
|
onTouchEnd?: React.TouchEventHandler<HTMLDivElement>;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
birdseyeConfig: BirdseyeConfig;
|
birdseyeConfig: BirdseyeConfig;
|
||||||
aspectRatio: number;
|
|
||||||
liveMode: LivePlayerMode;
|
liveMode: LivePlayerMode;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
};
|
};
|
||||||
@@ -958,7 +798,6 @@ const BirdseyeLivePlayerGridItem = React.forwardRef<
|
|||||||
onTouchEnd,
|
onTouchEnd,
|
||||||
children,
|
children,
|
||||||
birdseyeConfig,
|
birdseyeConfig,
|
||||||
aspectRatio: cellAspect,
|
|
||||||
liveMode,
|
liveMode,
|
||||||
onClick,
|
onClick,
|
||||||
...props
|
...props
|
||||||
@@ -967,8 +806,7 @@ const BirdseyeLivePlayerGridItem = React.forwardRef<
|
|||||||
) => {
|
) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-center p-1 [container-type:size]"
|
style={{ ...style }}
|
||||||
style={{ ...style, "--ar": cellAspect } as React.CSSProperties}
|
|
||||||
ref={ref}
|
ref={ref}
|
||||||
onMouseDown={onMouseDown}
|
onMouseDown={onMouseDown}
|
||||||
onMouseUp={onMouseUp}
|
onMouseUp={onMouseUp}
|
||||||
@@ -976,7 +814,7 @@ const BirdseyeLivePlayerGridItem = React.forwardRef<
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<BirdseyeLivePlayer
|
<BirdseyeLivePlayer
|
||||||
className={cn(CARD_FIT, className)}
|
className={className}
|
||||||
birdseyeConfig={birdseyeConfig}
|
birdseyeConfig={birdseyeConfig}
|
||||||
liveMode={liveMode}
|
liveMode={liveMode}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
@@ -991,7 +829,6 @@ const BirdseyeLivePlayerGridItem = React.forwardRef<
|
|||||||
type GridLiveContextMenuProps = {
|
type GridLiveContextMenuProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
aspectRatio?: number;
|
|
||||||
onMouseDown?: React.MouseEventHandler<HTMLDivElement>;
|
onMouseDown?: React.MouseEventHandler<HTMLDivElement>;
|
||||||
onMouseUp?: React.MouseEventHandler<HTMLDivElement>;
|
onMouseUp?: React.MouseEventHandler<HTMLDivElement>;
|
||||||
onTouchEnd?: React.TouchEventHandler<HTMLDivElement>;
|
onTouchEnd?: React.TouchEventHandler<HTMLDivElement>;
|
||||||
@@ -1023,7 +860,6 @@ const GridLiveContextMenu = React.forwardRef<
|
|||||||
{
|
{
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
aspectRatio: cameraAspect,
|
|
||||||
onMouseDown,
|
onMouseDown,
|
||||||
onMouseUp,
|
onMouseUp,
|
||||||
onTouchEnd,
|
onTouchEnd,
|
||||||
@@ -1051,8 +887,7 @@ const GridLiveContextMenu = React.forwardRef<
|
|||||||
) => {
|
) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-center p-1 [container-type:size]"
|
style={{ ...style }}
|
||||||
style={{ ...style, "--ar": cameraAspect } as React.CSSProperties}
|
|
||||||
ref={ref}
|
ref={ref}
|
||||||
onMouseDown={onMouseDown}
|
onMouseDown={onMouseDown}
|
||||||
onMouseUp={onMouseUp}
|
onMouseUp={onMouseUp}
|
||||||
|
|||||||
@@ -295,7 +295,7 @@ export default function LiveBirdseyeView({
|
|||||||
onClick={handleOverlayClick}
|
onClick={handleOverlayClick}
|
||||||
>
|
>
|
||||||
<BirdseyeLivePlayer
|
<BirdseyeLivePlayer
|
||||||
className={fullscreen ? "rounded-none" : ""}
|
className={`${fullscreen ? "*:rounded-none" : ""}`}
|
||||||
birdseyeConfig={config.birdseye}
|
birdseyeConfig={config.birdseye}
|
||||||
liveMode={preferredLiveMode}
|
liveMode={preferredLiveMode}
|
||||||
containerRef={containerRef}
|
containerRef={containerRef}
|
||||||
|
|||||||
@@ -860,7 +860,7 @@ export default function LiveCameraView({
|
|||||||
)}
|
)}
|
||||||
<LivePlayer
|
<LivePlayer
|
||||||
key={camera.name}
|
key={camera.name}
|
||||||
className={fullscreen ? "rounded-none" : ""}
|
className={`${fullscreen ? "*:rounded-none" : ""}`}
|
||||||
windowVisible
|
windowVisible
|
||||||
showStillWithoutActivity={false}
|
showStillWithoutActivity={false}
|
||||||
alwaysShowCameraName={false}
|
alwaysShowCameraName={false}
|
||||||
|
|||||||
@@ -410,7 +410,7 @@ export default function LiveDashboardView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="scrollbar-container size-full select-none overflow-y-auto px-1 pt-2 [scrollbar-gutter:stable] md:p-2"
|
className="scrollbar-container size-full select-none overflow-y-auto px-1 pt-2 md:p-2"
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
>
|
>
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
@@ -609,7 +609,7 @@ export default function LiveDashboardView({
|
|||||||
<LivePlayer
|
<LivePlayer
|
||||||
cameraRef={cameraRef}
|
cameraRef={cameraRef}
|
||||||
key={camera.name}
|
key={camera.name}
|
||||||
className={`${grow} bg-black`}
|
className={`${grow} rounded-lg bg-black md:rounded-2xl`}
|
||||||
windowVisible={
|
windowVisible={
|
||||||
windowVisible && visibleCameras.includes(camera.name)
|
windowVisible && visibleCameras.includes(camera.name)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button, buttonVariants } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import {
|
import {
|
||||||
useUserPersistence,
|
useUserPersistence,
|
||||||
deleteUserNamespacedKey,
|
deleteUserNamespacedKey,
|
||||||
} from "@/hooks/use-user-persistence";
|
} from "@/hooks/use-user-persistence";
|
||||||
import { isMobileOnly } from "react-device-detect";
|
import { isSafari } from "react-device-detect";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -34,17 +34,6 @@ import {
|
|||||||
CONTROL_COLUMN_CLASS_NAME,
|
CONTROL_COLUMN_CLASS_NAME,
|
||||||
} from "@/components/card/SettingsGroupCard";
|
} from "@/components/card/SettingsGroupCard";
|
||||||
import Heading from "@/components/ui/heading";
|
import Heading from "@/components/ui/heading";
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import ImportUiSettingsDialog from "@/components/overlay/dialog/ImportUiSettingsDialog";
|
import ImportUiSettingsDialog from "@/components/overlay/dialog/ImportUiSettingsDialog";
|
||||||
import {
|
import {
|
||||||
applyImportPayload,
|
applyImportPayload,
|
||||||
@@ -63,8 +52,6 @@ import {
|
|||||||
const WEEK_STARTS_ON = ["Sunday", "Monday"];
|
const WEEK_STARTS_ON = ["Sunday", "Monday"];
|
||||||
const IMPORT_FAILED_FLAG = "frigate-ui-settings-import-failed";
|
const IMPORT_FAILED_FLAG = "frigate-ui-settings-import-failed";
|
||||||
|
|
||||||
type ConfirmTarget = "layouts" | "streaming" | "naturalAspect";
|
|
||||||
|
|
||||||
type SwitchSettingRowProps = {
|
type SwitchSettingRowProps = {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -145,7 +132,7 @@ export default function UiSettingsView() {
|
|||||||
const { auth } = useContext(AuthContext);
|
const { auth } = useContext(AuthContext);
|
||||||
const username = auth?.user?.username;
|
const username = auth?.user?.username;
|
||||||
|
|
||||||
const PLAYBACK_RATE_DEFAULT = [0.5, 1, 2, 4, 8, 16];
|
const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16];
|
||||||
|
|
||||||
const clearStoredLayouts = useCallback(() => {
|
const clearStoredLayouts = useCallback(() => {
|
||||||
if (!config) {
|
if (!config) {
|
||||||
@@ -335,10 +322,6 @@ export default function UiSettingsView() {
|
|||||||
"displayCameraNames",
|
"displayCameraNames",
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
const [naturalAspect, setNaturalAspect] = useUserPersistence(
|
|
||||||
"naturalAspectLayout",
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
const [playbackRate, setPlaybackRate] = useUserPersistence("playbackRate", 1);
|
const [playbackRate, setPlaybackRate] = useUserPersistence("playbackRate", 1);
|
||||||
const [weekStartsOn, setWeekStartsOn] = useUserPersistence("weekStartsOn", 0);
|
const [weekStartsOn, setWeekStartsOn] = useUserPersistence("weekStartsOn", 0);
|
||||||
const [alertVideos, setAlertVideos] = useUserPersistence("alertVideos", true);
|
const [alertVideos, setAlertVideos] = useUserPersistence("alertVideos", true);
|
||||||
@@ -347,66 +330,6 @@ export default function UiSettingsView() {
|
|||||||
3,
|
3,
|
||||||
);
|
);
|
||||||
|
|
||||||
const [pendingConfirm, setPendingConfirm] = useState<ConfirmTarget | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const confirmCopy = useCallback(
|
|
||||||
(target: ConfirmTarget) => {
|
|
||||||
// literal keys per branch: a template key would be invisible to
|
|
||||||
// npm run i18n:extract, which CI verifies
|
|
||||||
switch (target) {
|
|
||||||
case "layouts":
|
|
||||||
return {
|
|
||||||
title: t("general.storedLayouts.clearAll"),
|
|
||||||
description: t("general.storedLayouts.clearConfirm"),
|
|
||||||
action: t("button.clear", { ns: "common" }),
|
|
||||||
};
|
|
||||||
case "streaming":
|
|
||||||
return {
|
|
||||||
title: t("general.cameraGroupStreaming.clearAll"),
|
|
||||||
description: t("general.cameraGroupStreaming.clearConfirm"),
|
|
||||||
action: t("button.clear", { ns: "common" }),
|
|
||||||
};
|
|
||||||
case "naturalAspect":
|
|
||||||
return {
|
|
||||||
title: t("general.liveDashboard.naturalAspectLayout.label"),
|
|
||||||
description: t(
|
|
||||||
"general.liveDashboard.naturalAspectLayout.descNote",
|
|
||||||
),
|
|
||||||
action: naturalAspect
|
|
||||||
? t("button.disable", { ns: "common" })
|
|
||||||
: t("button.enable", { ns: "common" }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[naturalAspect, t],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleConfirm = useCallback(() => {
|
|
||||||
switch (pendingConfirm) {
|
|
||||||
case "layouts":
|
|
||||||
clearStoredLayouts();
|
|
||||||
break;
|
|
||||||
case "streaming":
|
|
||||||
clearStreamingSettings();
|
|
||||||
break;
|
|
||||||
case "naturalAspect":
|
|
||||||
// a layout only renders in the mode that built it
|
|
||||||
setNaturalAspect(!naturalAspect);
|
|
||||||
clearStoredLayouts();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
setPendingConfirm(null);
|
|
||||||
}, [
|
|
||||||
pendingConfirm,
|
|
||||||
clearStoredLayouts,
|
|
||||||
clearStreamingSettings,
|
|
||||||
naturalAspect,
|
|
||||||
setNaturalAspect,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const liveDashboardSwitchRows = [
|
const liveDashboardSwitchRows = [
|
||||||
{
|
{
|
||||||
id: "auto-live",
|
id: "auto-live",
|
||||||
@@ -429,18 +352,6 @@ export default function UiSettingsView() {
|
|||||||
checked: cameraNames,
|
checked: cameraNames,
|
||||||
onCheckedChange: setCameraName,
|
onCheckedChange: setCameraName,
|
||||||
},
|
},
|
||||||
// phones use the static grid, so tile sizing has nothing to affect there
|
|
||||||
...(isMobileOnly
|
|
||||||
? []
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
id: "natural-aspect",
|
|
||||||
label: t("general.liveDashboard.naturalAspectLayout.label"),
|
|
||||||
description: t("general.liveDashboard.naturalAspectLayout.desc"),
|
|
||||||
checked: naturalAspect,
|
|
||||||
onCheckedChange: () => setPendingConfirm("naturalAspect"),
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -509,7 +420,7 @@ export default function UiSettingsView() {
|
|||||||
id="stored-layouts-clear"
|
id="stored-layouts-clear"
|
||||||
aria-label={t("general.storedLayouts.clearAll")}
|
aria-label={t("general.storedLayouts.clearAll")}
|
||||||
className="w-full md:w-auto"
|
className="w-full md:w-auto"
|
||||||
onClick={() => setPendingConfirm("layouts")}
|
onClick={clearStoredLayouts}
|
||||||
>
|
>
|
||||||
{t("general.storedLayouts.clearAll")}
|
{t("general.storedLayouts.clearAll")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -525,7 +436,7 @@ export default function UiSettingsView() {
|
|||||||
id="camera-group-streaming-clear"
|
id="camera-group-streaming-clear"
|
||||||
aria-label={t("general.cameraGroupStreaming.clearAll")}
|
aria-label={t("general.cameraGroupStreaming.clearAll")}
|
||||||
className="w-full md:w-auto"
|
className="w-full md:w-auto"
|
||||||
onClick={() => setPendingConfirm("streaming")}
|
onClick={clearStreamingSettings}
|
||||||
>
|
>
|
||||||
{t("general.cameraGroupStreaming.clearAll")}
|
{t("general.cameraGroupStreaming.clearAll")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -659,41 +570,9 @@ export default function UiSettingsView() {
|
|||||||
fileName={pendingImport.name}
|
fileName={pendingImport.name}
|
||||||
file={pendingImport.file}
|
file={pendingImport.file}
|
||||||
summary={pendingImport.summary}
|
summary={pendingImport.summary}
|
||||||
currentNaturalAspect={naturalAspect ?? false}
|
|
||||||
onConfirm={handleImportConfirm}
|
onConfirm={handleImportConfirm}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<AlertDialog
|
|
||||||
open={pendingConfirm != null}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
setPendingConfirm(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>
|
|
||||||
{pendingConfirm && confirmCopy(pendingConfirm).title}
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
{pendingConfirm && confirmCopy(pendingConfirm).description}
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>
|
|
||||||
{t("button.cancel", { ns: "common" })}
|
|
||||||
</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
className={cn(buttonVariants({ variant: "destructive" }))}
|
|
||||||
onClick={handleConfirm}
|
|
||||||
>
|
|
||||||
{pendingConfirm && confirmCopy(pendingConfirm).action}
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user