Merge remote-tracking branch 'upstream/dev' into feature/share-review-timestamp

This commit is contained in:
0x464e 2026-04-08 17:39:16 +03:00
commit a785ea2e67
No known key found for this signature in database
GPG Key ID: E6D221DF6CBFBFFA
78 changed files with 3844 additions and 198 deletions

View File

@ -16,7 +16,7 @@ jobs:
uses: actions/github-script@v7 uses: actions/github-script@v7
with: with:
script: | script: |
const maintainers = ['blakeblackshear', 'NickM-27', 'hawkeye217', 'dependabot[bot]']; const maintainers = ['blakeblackshear', 'NickM-27', 'hawkeye217', 'dependabot[bot]', 'weblate'];
const author = context.payload.pull_request.user.login; const author = context.payload.pull_request.user.login;
if (maintainers.includes(author)) { if (maintainers.includes(author)) {

View File

@ -50,6 +50,37 @@ jobs:
# run: npm run test # run: npm run test
# working-directory: ./web # working-directory: ./web
web_e2e:
name: Web - E2E Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version: 20.x
- run: npm install
working-directory: ./web
- name: Install Playwright Chromium
run: npx playwright install chromium --with-deps
working-directory: ./web
- name: Build web for E2E
run: npm run e2e:build
working-directory: ./web
- name: Run E2E tests
run: npm run e2e
working-directory: ./web
- name: Upload test artifacts
uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: |
web/test-results/
web/playwright-report/
retention-days: 7
python_checks: python_checks:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: Python Checks name: Python Checks

View File

@ -9,6 +9,7 @@ from typing import Any
from ruamel.yaml import YAML from ruamel.yaml import YAML
sys.path.insert(0, "/opt/frigate") sys.path.insert(0, "/opt/frigate")
from frigate.config.env import substitute_frigate_vars
from frigate.const import ( from frigate.const import (
BIRDSEYE_PIPE, BIRDSEYE_PIPE,
DEFAULT_FFMPEG_VERSION, DEFAULT_FFMPEG_VERSION,
@ -47,14 +48,6 @@ ALLOW_ARBITRARY_EXEC = allow_arbitrary_exec is not None and str(
allow_arbitrary_exec allow_arbitrary_exec
).lower() in ("true", "1", "yes") ).lower() in ("true", "1", "yes")
FRIGATE_ENV_VARS = {k: v for k, v in os.environ.items() if k.startswith("FRIGATE_")}
# read docker secret files as env vars too
if os.path.isdir("/run/secrets"):
for secret_file in os.listdir("/run/secrets"):
if secret_file.startswith("FRIGATE_"):
FRIGATE_ENV_VARS[secret_file] = (
Path(os.path.join("/run/secrets", secret_file)).read_text().strip()
)
config_file = find_config_file() config_file = find_config_file()
@ -103,13 +96,13 @@ if go2rtc_config["webrtc"].get("candidates") is None:
go2rtc_config["webrtc"]["candidates"] = default_candidates go2rtc_config["webrtc"]["candidates"] = default_candidates
if go2rtc_config.get("rtsp", {}).get("username") is not None: if go2rtc_config.get("rtsp", {}).get("username") is not None:
go2rtc_config["rtsp"]["username"] = go2rtc_config["rtsp"]["username"].format( go2rtc_config["rtsp"]["username"] = substitute_frigate_vars(
**FRIGATE_ENV_VARS go2rtc_config["rtsp"]["username"]
) )
if go2rtc_config.get("rtsp", {}).get("password") is not None: if go2rtc_config.get("rtsp", {}).get("password") is not None:
go2rtc_config["rtsp"]["password"] = go2rtc_config["rtsp"]["password"].format( go2rtc_config["rtsp"]["password"] = substitute_frigate_vars(
**FRIGATE_ENV_VARS go2rtc_config["rtsp"]["password"]
) )
# ensure ffmpeg path is set correctly # ensure ffmpeg path is set correctly
@ -145,7 +138,7 @@ for name in list(go2rtc_config.get("streams", {})):
if isinstance(stream, str): if isinstance(stream, str):
try: try:
formatted_stream = stream.format(**FRIGATE_ENV_VARS) formatted_stream = substitute_frigate_vars(stream)
if not ALLOW_ARBITRARY_EXEC and is_restricted_source(formatted_stream): if not ALLOW_ARBITRARY_EXEC and is_restricted_source(formatted_stream):
print( print(
f"[ERROR] Stream '{name}' uses a restricted source (echo/expr/exec) which is disabled by default for security. " f"[ERROR] Stream '{name}' uses a restricted source (echo/expr/exec) which is disabled by default for security. "
@ -164,7 +157,7 @@ for name in list(go2rtc_config.get("streams", {})):
filtered_streams = [] filtered_streams = []
for i, stream_item in enumerate(stream): for i, stream_item in enumerate(stream):
try: try:
formatted_stream = stream_item.format(**FRIGATE_ENV_VARS) formatted_stream = substitute_frigate_vars(stream_item)
if not ALLOW_ARBITRARY_EXEC and is_restricted_source(formatted_stream): if not ALLOW_ARBITRARY_EXEC and is_restricted_source(formatted_stream):
print( print(
f"[ERROR] Stream '{name}' item {i + 1} uses a restricted source (echo/expr/exec) which is disabled by default for security. " f"[ERROR] Stream '{name}' item {i + 1} uses a restricted source (echo/expr/exec) which is disabled by default for security. "

View File

@ -158,7 +158,7 @@ Enable debug logs for classification models by adding `frigate.data_processing.r
Navigate to <NavPath path="Settings > System > Logging" />. Navigate to <NavPath path="Settings > System > Logging" />.
- Set **Logging level** to `debug` - Set **Logging level** to `debug`
- Set **Per-process log level > Frigate.Data Processing.Real Time.Custom Classification** to `debug` for verbose classification logging - Set **Per-process log level > `frigate.data_processing.real_time.custom_classification`** to `debug` for verbose classification logging
</TabItem> </TabItem>
<TabItem value="yaml"> <TabItem value="yaml">

View File

@ -24,6 +24,12 @@ For object filters, any single detection below `min_score` will be ignored as a
In frame 2, the score is below the `min_score` value, so Frigate ignores it and it becomes a 0.0. The computed score is the median of the score history (padding to at least 3 values), and only when that computed score crosses the `threshold` is the object marked as a true positive. That happens in frame 4 in the example. In frame 2, the score is below the `min_score` value, so Frigate ignores it and it becomes a 0.0. The computed score is the median of the score history (padding to at least 3 values), and only when that computed score crosses the `threshold` is the object marked as a true positive. That happens in frame 4 in the example.
The **top score** is the highest computed score the tracked object has ever reached during its lifetime. Because the computed score rises and falls as new frames come in, the top score can be thought of as the peak confidence Frigate had in the object. In Frigate's UI (such as the Tracking Details pane in Explore), you may see all three values:
- **Score** — the raw detector score for that single frame.
- **Computed Score** — the median of the most recent score history at that moment. This is the value compared against `threshold`.
- **Top Score** — the highest computed score reached so far for the tracked object.
### Minimum Score ### Minimum Score
Any detection below `min_score` will be immediately thrown out and never tracked because it is considered a false positive. If `min_score` is too low then false positives may be detected and tracked which can confuse the object tracker and may lead to wasted resources. If `min_score` is too high then lower scoring true positives like objects that are further away or partially occluded may be thrown out which can also confuse the tracker and cause valid tracked objects to be lost or disjointed. Any detection below `min_score` will be immediately thrown out and never tracked because it is considered a false positive. If `min_score` is too low then false positives may be detected and tracked which can confuse the object tracker and may lead to wasted resources. If `min_score` is too high then lower scoring true positives like objects that are further away or partially occluded may be thrown out which can also confuse the tracker and cause valid tracked objects to be lost or disjointed.

View File

@ -20,7 +20,7 @@ When a profile is activated, Frigate merges each camera's profile overrides on t
:::info :::info
Profile changes are applied in-memory and take effect immediately — no restart is required. The active profile is persisted across Frigate restarts (stored in the `/config/.active_profile` file). Profile changes are applied in-memory and take effect immediately — no restart is required. The active profile is persisted across Frigate restarts (stored in the `/config/.profiles` file).
::: :::
@ -130,14 +130,14 @@ Profiles can be activated and deactivated from the Frigate UI. Open the Settings
## Example: Home / Away Setup ## Example: Home / Away Setup
A common use case is having different detection and notification settings based on whether you are home or away. A common use case is having different detection and notification settings based on whether you are home or away. This example below is for a system with two cameras, `front_door` and `indoor_cam`.
<ConfigTabs> <ConfigTabs>
<TabItem value="ui"> <TabItem value="ui">
1. Navigate to <NavPath path="Settings > Camera configuration > Profiles" /> and create two profiles: **Home** and **Away**. 1. Navigate to <NavPath path="Settings > Camera configuration > Profiles" /> and create two profiles: **Home** and **Away**.
2. For the **front_door** camera, configure the **Away** profile to enable notifications and set alert labels to `person` and `car`. Configure the **Home** profile to disable notifications. 2. From to the Camera configuration section in Settings, choose the **front_door** camera, and select the **Away** profile from the profile dropdown. Then, enable notifications from the Notifications pane, and set alert labels to `person` and `car` from the Review pane. Then, from the profile dropdown choose **Home** profile, then navigate to Notifications to disable notifications.
3. For the **indoor_cam** camera, configure the **Away** profile to enable the camera, detection, and recording. Configure the **Home** profile to disable the camera entirely for privacy. 3. For the **indoor_cam** camera, perform similar steps - configure the **Away** profile to enable the camera, detection, and recording. Configure the **Home** profile to disable the camera entirely for privacy.
4. Activate the desired profile from <NavPath path="Settings > Camera configuration > Profiles" /> or from the **Profiles** option in Frigate's main menu. 4. Activate the desired profile from <NavPath path="Settings > Camera configuration > Profiles" /> or from the **Profiles** option in Frigate's main menu.
</TabItem> </TabItem>

View File

@ -123,6 +123,76 @@ record:
</TabItem> </TabItem>
</ConfigTabs> </ConfigTabs>
## Pre-capture and Post-capture
The `pre_capture` and `post_capture` settings control how many seconds of video are included before and after an alert or detection. These can be configured independently for alerts and detections, and can be set globally or overridden per camera.
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > Global configuration > Recording" /> for global defaults, or <NavPath path="Settings > Camera configuration > (select camera) > Recording" /> to override for a specific camera.
| Field | Description |
| ---------------------------------------------- | ---------------------------------------------------- |
| **Alert retention > Pre-capture seconds** | Seconds of video to include before an alert event |
| **Alert retention > Post-capture seconds** | Seconds of video to include after an alert event |
| **Detection retention > Pre-capture seconds** | Seconds of video to include before a detection event |
| **Detection retention > Post-capture seconds** | Seconds of video to include after a detection event |
</TabItem>
<TabItem value="yaml">
```yaml
record:
enabled: True
alerts:
pre_capture: 5 # seconds before the alert to include
post_capture: 5 # seconds after the alert to include
detections:
pre_capture: 5 # seconds before the detection to include
post_capture: 5 # seconds after the detection to include
```
</TabItem>
</ConfigTabs>
- **Default**: 5 seconds for both pre and post capture.
- **Pre-capture maximum**: 60 seconds.
- These settings apply per review category (alerts and detections), not per object type.
### How pre/post capture interacts with retention mode
The `pre_capture` and `post_capture` values define the **time window** around a review item, but only recording segments that also match the configured **retention mode** are actually kept on disk.
- **`mode: all`** — Retains every segment within the capture window, regardless of whether motion was detected.
- **`mode: motion`** (default) — Only retains segments within the capture window that contain motion. This includes segments with active tracked objects, since object motion implies motion. Segments without any motion are discarded even if they fall within the pre/post capture range.
- **`mode: active_objects`** — Only retains segments within the capture window where tracked objects were actively moving. Segments with general motion but no active objects are discarded.
This means that with the default `motion` mode, you may see less footage than the configured pre/post capture duration if parts of the capture window had no motion.
To guarantee the full pre/post capture duration is always retained:
```yaml
record:
enabled: True
alerts:
pre_capture: 10
post_capture: 10
retain:
days: 30
mode: all # retains all segments within the capture window
```
:::note
Because recording segments are written in 10 second chunks, pre-capture timing depends on segment boundaries. The actual pre-capture footage may be slightly shorter or longer than the exact configured value.
:::
### Where to view pre/post capture footage
Pre and post capture footage is included in the **recording timeline**, visible in the History view. Note that pre/post capture settings only affect which recording segments are **retained on disk** — they do not change the start and end points shown in the UI. The History view will still center on the review item's actual time range, but you can scrub backward and forward through the retained pre/post capture footage on the timeline. The Explore view shows object-specific clips that are trimmed to when the tracked object was actually visible, so pre/post capture time will not be reflected there.
## Will Frigate delete old recordings if my storage runs out? ## Will Frigate delete old recordings if my storage runs out?
As of Frigate 0.12 if there is less than an hour left of storage, the oldest 2 hours of recordings will be deleted. As of Frigate 0.12 if there is less than an hour left of storage, the oldest 2 hours of recordings will be deleted.

View File

@ -694,6 +694,9 @@ def config_set(request: Request, body: AppConfigSetBody):
if request.app.stats_emitter is not None: if request.app.stats_emitter is not None:
request.app.stats_emitter.config = config request.app.stats_emitter.config = config
if request.app.dispatcher is not None:
request.app.dispatcher.config = config
if body.update_topic: if body.update_topic:
if body.update_topic.startswith("config/cameras/"): if body.update_topic.startswith("config/cameras/"):
_, _, camera, field = body.update_topic.split("/") _, _, camera, field = body.update_topic.split("/")

View File

@ -30,7 +30,7 @@ from frigate.config.camera.updater import (
CameraConfigUpdateEnum, CameraConfigUpdateEnum,
CameraConfigUpdateTopic, CameraConfigUpdateTopic,
) )
from frigate.config.env import FRIGATE_ENV_VARS from frigate.config.env import substitute_frigate_vars
from frigate.util.builtin import clean_camera_user_pass from frigate.util.builtin import clean_camera_user_pass
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
from frigate.util.config import find_config_file from frigate.util.config import find_config_file
@ -126,7 +126,7 @@ def go2rtc_add_stream(request: Request, stream_name: str, src: str = ""):
params = {"name": stream_name} params = {"name": stream_name}
if src: if src:
try: try:
params["src"] = src.format(**FRIGATE_ENV_VARS) params["src"] = substitute_frigate_vars(src)
except KeyError: except KeyError:
params["src"] = src params["src"] = src

View File

@ -407,7 +407,7 @@ async def _execute_search_objects(
query_params = EventsQueryParams( query_params = EventsQueryParams(
cameras=arguments.get("camera", "all"), cameras=arguments.get("camera", "all"),
labels=arguments.get("label", "all"), labels=arguments.get("label", "all"),
sub_labels=arguments.get("sub_label", "all").lower(), sub_labels=arguments.get("sub_label", "all"), # case-insensitive on the backend
zones=zones, zones=zones,
zone=zones, zone=zones,
after=after, after=after,

View File

@ -199,13 +199,18 @@ def events(
sub_label_clauses.append((Event.sub_label.is_null())) sub_label_clauses.append((Event.sub_label.is_null()))
for label in filtered_sub_labels: for label in filtered_sub_labels:
lowered = label.lower()
sub_label_clauses.append( sub_label_clauses.append(
(Event.sub_label.cast("text") == label) (fn.LOWER(Event.sub_label.cast("text")) == lowered)
) # include exact matches ) # include exact matches (case-insensitive)
# include this label when part of a list # include this label when part of a list (LIKE is case-insensitive in sqlite for ASCII)
sub_label_clauses.append((Event.sub_label.cast("text") % f"*{label},*")) sub_label_clauses.append(
sub_label_clauses.append((Event.sub_label.cast("text") % f"*, {label}*")) (fn.LOWER(Event.sub_label.cast("text")) % f"*{lowered},*")
)
sub_label_clauses.append(
(fn.LOWER(Event.sub_label.cast("text")) % f"*, {lowered}*")
)
sub_label_clause = reduce(operator.or_, sub_label_clauses) sub_label_clause = reduce(operator.or_, sub_label_clauses)
clauses.append((sub_label_clause)) clauses.append((sub_label_clause))
@ -609,13 +614,18 @@ def events_search(
sub_label_clauses.append((Event.sub_label.is_null())) sub_label_clauses.append((Event.sub_label.is_null()))
for label in filtered_sub_labels: for label in filtered_sub_labels:
lowered = label.lower()
sub_label_clauses.append( sub_label_clauses.append(
(Event.sub_label.cast("text") == label) (fn.LOWER(Event.sub_label.cast("text")) == lowered)
) # include exact matches ) # include exact matches (case-insensitive)
# include this label when part of a list # include this label when part of a list (LIKE is case-insensitive in sqlite for ASCII)
sub_label_clauses.append((Event.sub_label.cast("text") % f"*{label},*")) sub_label_clauses.append(
sub_label_clauses.append((Event.sub_label.cast("text") % f"*, {label}*")) (fn.LOWER(Event.sub_label.cast("text")) % f"*{lowered},*")
)
sub_label_clauses.append(
(fn.LOWER(Event.sub_label.cast("text")) % f"*, {lowered}*")
)
event_filters.append((reduce(operator.or_, sub_label_clauses))) event_filters.append((reduce(operator.or_, sub_label_clauses)))

View File

@ -548,23 +548,27 @@ def export_recording_custom(
export_id = f"{camera_name}_{''.join(random.choices(string.ascii_lowercase + string.digits, k=6))}" export_id = f"{camera_name}_{''.join(random.choices(string.ascii_lowercase + string.digits, k=6))}"
# Validate user-provided ffmpeg args to prevent injection # Validate user-provided ffmpeg args to prevent injection.
for args_label, args_value in [ # Admin users are trusted and skip validation.
("input", ffmpeg_input_args), is_admin = request.headers.get("remote-role", "") == "admin"
("output", ffmpeg_output_args),
]: if not is_admin:
if args_value is not None: for args_label, args_value in [
valid, message = validate_ffmpeg_args(args_value) ("input", ffmpeg_input_args),
if not valid: ("output", ffmpeg_output_args),
return JSONResponse( ]:
content=( if args_value is not None:
{ valid, message = validate_ffmpeg_args(args_value)
"success": False, if not valid:
"message": f"Invalid ffmpeg {args_label} arguments: {message}", return JSONResponse(
} content=(
), {
status_code=400, "success": False,
) "message": f"Invalid ffmpeg {args_label} arguments: {message}",
}
),
status_code=400,
)
# Set default values if not provided (timelapse defaults) # Set default values if not provided (timelapse defaults)
if ffmpeg_input_args is None: if ffmpeg_input_args is None:

View File

@ -1,4 +1,5 @@
import os import os
import re
from pathlib import Path from pathlib import Path
from typing import Annotated from typing import Annotated
@ -15,8 +16,77 @@ if os.path.isdir(secrets_dir) and os.access(secrets_dir, os.R_OK):
) )
# Matches a FRIGATE_* identifier following an opening brace.
_FRIGATE_IDENT_RE = re.compile(r"FRIGATE_[A-Za-z0-9_]+")
def substitute_frigate_vars(value: str) -> str:
"""Substitute `{FRIGATE_*}` placeholders in *value*.
Reproduces the subset of `str.format()` brace semantics that Frigate's
config has historically supported, while leaving unrelated brace content
(e.g. ffmpeg `%{localtime\\:...}` expressions) untouched:
* `{{` and `}}` collapse to literal `{` / `}` (the documented escape).
* `{FRIGATE_NAME}` is replaced from `FRIGATE_ENV_VARS`; an unknown name
raises `KeyError` to preserve the existing "Invalid substitution"
error path.
* A `{` that begins `{FRIGATE_` but is not a well-formed
`{FRIGATE_NAME}` placeholder raises `ValueError` (malformed
placeholder). Callers that catch `KeyError` to allow unknown-var
passthrough will still surface malformed syntax as an error.
* Any other `{` or `}` is treated as a literal and passed through.
"""
out: list[str] = []
i = 0
n = len(value)
while i < n:
ch = value[i]
if ch == "{":
# Escaped literal `{{`.
if i + 1 < n and value[i + 1] == "{":
out.append("{")
i += 2
continue
# Possible `{FRIGATE_*}` placeholder.
if value.startswith("{FRIGATE_", i):
ident_match = _FRIGATE_IDENT_RE.match(value, i + 1)
if (
ident_match is not None
and ident_match.end() < n
and value[ident_match.end()] == "}"
):
key = ident_match.group(0)
if key not in FRIGATE_ENV_VARS:
raise KeyError(key)
out.append(FRIGATE_ENV_VARS[key])
i = ident_match.end() + 1
continue
# Looks like a FRIGATE placeholder but is malformed
# (no closing brace, illegal char, format spec, etc.).
raise ValueError(
f"Malformed FRIGATE_ placeholder near {value[i : i + 32]!r}"
)
# Plain `{` — pass through (e.g. `%{localtime\:...}`).
out.append("{")
i += 1
continue
if ch == "}":
# Escaped literal `}}`.
if i + 1 < n and value[i + 1] == "}":
out.append("}")
i += 2
continue
out.append("}")
i += 1
continue
out.append(ch)
i += 1
return "".join(out)
def validate_env_string(v: str) -> str: def validate_env_string(v: str) -> str:
return v.format(**FRIGATE_ENV_VARS) return substitute_frigate_vars(v)
EnvString = Annotated[str, AfterValidator(validate_env_string)] EnvString = Annotated[str, AfterValidator(validate_env_string)]

View File

@ -44,6 +44,22 @@ DEFAULT_ATTRIBUTE_LABEL_MAP = {
], ],
"motorcycle": ["license_plate"], "motorcycle": ["license_plate"],
} }
ATTRIBUTE_LABEL_DISPLAY_MAP = {
"amazon": "Amazon",
"an_post": "An Post",
"canada_post": "Canada Post",
"dhl": "DHL",
"dpd": "DPD",
"fedex": "FedEx",
"gls": "GLS",
"nzpost": "NZ Post",
"postnl": "PostNL",
"postnord": "PostNord",
"purolator": "Purolator",
"royal_mail": "Royal Mail",
"ups": "UPS",
"usps": "USPS",
}
LABEL_CONSOLIDATION_MAP = { LABEL_CONSOLIDATION_MAP = {
"car": 0.8, "car": 0.8,
"face": 0.5, "face": 0.5,

View File

@ -19,7 +19,12 @@ from frigate.comms.inter_process import InterProcessRequestor
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.config.camera import CameraConfig from frigate.config.camera import CameraConfig
from frigate.config.camera.review import GenAIReviewConfig, ImageSourceEnum from frigate.config.camera.review import GenAIReviewConfig, ImageSourceEnum
from frigate.const import CACHE_DIR, CLIPS_DIR, UPDATE_REVIEW_DESCRIPTION from frigate.const import (
ATTRIBUTE_LABEL_DISPLAY_MAP,
CACHE_DIR,
CLIPS_DIR,
UPDATE_REVIEW_DESCRIPTION,
)
from frigate.data_processing.types import PostProcessDataEnum from frigate.data_processing.types import PostProcessDataEnum
from frigate.genai import GenAIClient from frigate.genai import GenAIClient
from frigate.genai.manager import GenAIClientManager from frigate.genai.manager import GenAIClientManager
@ -556,10 +561,11 @@ def run_analysis(
if "-verified" in label: if "-verified" in label:
continue continue
elif label in labelmap_objects: elif label in labelmap_objects:
object_type = titlecase(label.replace("_", " ")) object_type = label.replace("_", " ")
if label in attribute_labels: if label in attribute_labels:
unified_objects.append(f"{object_type} (delivery/service)") display_name = ATTRIBUTE_LABEL_DISPLAY_MAP.get(label, object_type)
unified_objects.append(f"{display_name} (delivery/service)")
else: else:
unified_objects.append(object_type) unified_objects.append(object_type)

View File

@ -92,6 +92,7 @@ class EmbeddingMaintainer(threading.Thread):
CameraConfigUpdateEnum.add, CameraConfigUpdateEnum.add,
CameraConfigUpdateEnum.remove, CameraConfigUpdateEnum.remove,
CameraConfigUpdateEnum.object_genai, CameraConfigUpdateEnum.object_genai,
CameraConfigUpdateEnum.review,
CameraConfigUpdateEnum.review_genai, CameraConfigUpdateEnum.review_genai,
CameraConfigUpdateEnum.semantic_search, CameraConfigUpdateEnum.semantic_search,
], ],

View File

@ -106,8 +106,8 @@ When forming your description:
## Response Field Guidelines ## Response Field Guidelines
Respond with a JSON object matching the provided schema. Field-specific guidance: Respond with a JSON object matching the provided schema. Field-specific guidance:
- `scene`: Describe how the sequence begins, then the progression of events all significant movements and actions in order. For example, if a vehicle arrives and then a person exits, describe both sequentially. Always use subject names from "Objects in Scene" do not replace named subjects with generic terms like "a person" or "the individual". Your description should align with and support the threat level you assign. - `scene`: Describe how the sequence begins, then the progression of events all significant movements and actions in order. For example, if a vehicle arrives and then a person exits, describe both sequentially. For named subjects (those with a `` separator in "Objects in Scene"), always use their name do not replace them with generic terms. For unnamed objects (e.g., "person", "car"), refer to them naturally with articles (e.g., "a person", "the car"). Your description should align with and support the threat level you assign.
- `title`: Characterize **what took place and where** interpret the overall purpose or outcome, do not simply compress the scene description into fewer words. Include the relevant location (zone, area, or entry point). Always include subject names from "Objects in Scene" do not replace named subjects with generic terms. No editorial qualifiers like "routine" or "suspicious." - `title`: Characterize **what took place and where** interpret the overall purpose or outcome, do not simply compress the scene description into fewer words. Include the relevant location (zone, area, or entry point). For named subjects, always use their name. For unnamed objects, refer to them naturally with articles. No editorial qualifiers like "routine" or "suspicious."
- `potential_threat_level`: Must be consistent with your scene description and the activity patterns above. - `potential_threat_level`: Must be consistent with your scene description and the activity patterns above.
{get_concern_prompt()} {get_concern_prompt()}
@ -190,6 +190,7 @@ Each line represents a detection state, not necessarily unique individuals. The
if any("" in obj for obj in review_data["unified_objects"]): if any("" in obj for obj in review_data["unified_objects"]):
metadata.potential_threat_level = 0 metadata.potential_threat_level = 0
metadata.title = metadata.title[0].upper() + metadata.title[1:]
metadata.time = review_data["start"] metadata.time = review_data["start"]
return metadata return metadata
except Exception as e: except Exception as e:
@ -199,6 +200,9 @@ Each line represents a detection state, not necessarily unique individuals. The
) )
return None return None
else: else:
logger.debug(
f"Invalid response received from GenAI provider for review description on {review_data['camera']}. Response: {response}",
)
return None return None
def generate_review_summary( def generate_review_summary(

View File

@ -238,10 +238,15 @@ class LlamaCppClient(GenAIClient):
def list_models(self) -> list[str]: def list_models(self) -> list[str]:
"""Return available model IDs from the llama.cpp server.""" """Return available model IDs from the llama.cpp server."""
if self.provider is None: base_url = self.provider or (
self.genai_config.base_url.rstrip("/")
if self.genai_config.base_url
else None
)
if base_url is None:
return [] return []
try: try:
response = requests.get(f"{self.provider}/v1/models", timeout=10) response = requests.get(f"{base_url}/v1/models", timeout=10)
response.raise_for_status() response.raise_for_status()
models = [] models = []
for m in response.json().get("data", []): for m in response.json().get("data", []):

View File

@ -134,10 +134,20 @@ class OllamaClient(GenAIClient):
def list_models(self) -> list[str]: def list_models(self) -> list[str]:
"""Return available model names from the Ollama server.""" """Return available model names from the Ollama server."""
if self.provider is None: client = self.provider
return [] if client is None:
# Provider init may have failed due to invalid model, but we can
# still list available models with a fresh client.
if not self.genai_config.base_url:
return []
try:
client = ApiClient(
host=self.genai_config.base_url, timeout=self.timeout
)
except Exception:
return []
try: try:
response = self.provider.list() response = client.list()
return sorted( return sorted(
m.get("name", m.get("model", "")) for m in response.get("models", []) m.get("name", m.get("model", "")) for m in response.get("models", [])
) )

View File

@ -36,22 +36,20 @@ logger = logging.getLogger(__name__)
DEFAULT_TIME_LAPSE_FFMPEG_ARGS = "-vf setpts=0.04*PTS -r 30" DEFAULT_TIME_LAPSE_FFMPEG_ARGS = "-vf setpts=0.04*PTS -r 30"
TIMELAPSE_DATA_INPUT_ARGS = "-an -skip_frame nokey" TIMELAPSE_DATA_INPUT_ARGS = "-an -skip_frame nokey"
# ffmpeg flags that can read from or write to arbitrary files. # ffmpeg flags that can read from or write to arbitrary files
# filter flags are blocked because source filters like movie= and
# amovie= can read arbitrary files from the filesystem.
BLOCKED_FFMPEG_ARGS = frozenset( BLOCKED_FFMPEG_ARGS = frozenset(
{ {
"-i", "-i",
"-filter_script", "-filter_script",
"-vstats_file",
"-passlogfile",
"-sdp_file",
"-dump_attachment",
"-filter_complex", "-filter_complex",
"-lavfi", "-lavfi",
"-vf", "-vf",
"-af", "-af",
"-filter", "-filter",
"-vstats_file",
"-passlogfile",
"-sdp_file",
"-dump_attachment",
"-attach", "-attach",
} }
) )
@ -62,8 +60,11 @@ def validate_ffmpeg_args(args: str) -> tuple[bool, str]:
Blocks: Blocks:
- The -i flag and other flags that read/write arbitrary files - The -i flag and other flags that read/write arbitrary files
- Filter flags (can read files via movie=/amovie= source filters)
- Absolute/relative file paths (potential extra outputs) - Absolute/relative file paths (potential extra outputs)
- URLs and ffmpeg protocol references (data exfiltration) - URLs and ffmpeg protocol references (data exfiltration)
Admin users skip this validation entirely since they are trusted.
""" """
if not args or not args.strip(): if not args or not args.strip():
return True, "" return True, ""

View File

@ -2,6 +2,7 @@
import os import os
import unittest import unittest
from unittest.mock import MagicMock, patch
from frigate.config.env import ( from frigate.config.env import (
FRIGATE_ENV_VARS, FRIGATE_ENV_VARS,
@ -10,6 +11,71 @@ from frigate.config.env import (
) )
class TestGo2RtcAddStreamSubstitution(unittest.TestCase):
"""Covers the API path: PUT /go2rtc/streams/{stream_name}.
The route shells out to go2rtc via `requests.put`; we mock the HTTP call
and assert that the substituted `src` parameter handles the same mixed
{FRIGATE_*} + literal-brace strings as the config-loading path.
"""
def setUp(self):
self._original_env_vars = dict(FRIGATE_ENV_VARS)
def tearDown(self):
FRIGATE_ENV_VARS.clear()
FRIGATE_ENV_VARS.update(self._original_env_vars)
def _call_route(self, src: str) -> str:
"""Invoke go2rtc_add_stream and return the substituted src param."""
from frigate.api import camera as camera_api
captured = {}
def fake_put(url, params=None, timeout=None):
captured["params"] = params
resp = MagicMock()
resp.ok = True
resp.text = ""
resp.status_code = 200
return resp
with patch.object(camera_api.requests, "put", side_effect=fake_put):
camera_api.go2rtc_add_stream(
request=MagicMock(), stream_name="cam1", src=src
)
return captured["params"]["src"]
def test_mixed_localtime_and_frigate_var(self):
"""%{localtime\\:...} alongside {FRIGATE_USER} substitutes only the var."""
FRIGATE_ENV_VARS["FRIGATE_USER"] = "admin"
src = (
"ffmpeg:rtsp://host/s#raw=-vf "
"drawtext=text=%{localtime\\:%Y-%m-%d}:user={FRIGATE_USER}"
)
self.assertEqual(
self._call_route(src),
"ffmpeg:rtsp://host/s#raw=-vf "
"drawtext=text=%{localtime\\:%Y-%m-%d}:user=admin",
)
def test_unknown_var_falls_back_to_raw_src(self):
"""Existing route behavior: unknown {FRIGATE_*} keeps raw src."""
src = "rtsp://host/{FRIGATE_NONEXISTENT}/stream"
self.assertEqual(self._call_route(src), src)
def test_malformed_placeholder_rejected_via_api(self):
"""Malformed FRIGATE placeholders raise (not silently passed through).
Regression: previously camera.py caught any KeyError and fell back
to the raw src, so `{FRIGATE_FOO:>5}` was silently accepted via the
API while config loading rejected it. The helper now raises
ValueError for malformed syntax to keep the two paths consistent.
"""
with self.assertRaises(ValueError):
self._call_route("rtsp://host/{FRIGATE_FOO:>5}/stream")
class TestEnvString(unittest.TestCase): class TestEnvString(unittest.TestCase):
def setUp(self): def setUp(self):
self._original_env_vars = dict(FRIGATE_ENV_VARS) self._original_env_vars = dict(FRIGATE_ENV_VARS)
@ -43,6 +109,72 @@ class TestEnvString(unittest.TestCase):
with self.assertRaises(KeyError): with self.assertRaises(KeyError):
validate_env_string("{FRIGATE_NONEXISTENT_VAR}") validate_env_string("{FRIGATE_NONEXISTENT_VAR}")
def test_non_frigate_braces_passthrough(self):
"""Braces that are not {FRIGATE_*} placeholders pass through untouched.
Regression test for ffmpeg drawtext expressions like
"%{localtime\\:%Y-%m-%d}" being mangled by str.format().
"""
expr = (
"ffmpeg:rtsp://127.0.0.1/src#raw=-vf "
"drawtext=text=%{localtime\\:%Y-%m-%d_%H\\:%M\\:%S}"
":x=5:fontcolor=white"
)
self.assertEqual(validate_env_string(expr), expr)
def test_double_brace_escape_preserved(self):
"""`{{output}}` collapses to `{output}` (documented go2rtc escape)."""
result = validate_env_string(
"exec:ffmpeg -i /media/file.mp4 -f rtsp {{output}}"
)
self.assertEqual(result, "exec:ffmpeg -i /media/file.mp4 -f rtsp {output}")
def test_double_brace_around_frigate_var(self):
"""`{{FRIGATE_FOO}}` stays literal — escape takes precedence."""
FRIGATE_ENV_VARS["FRIGATE_FOO"] = "bar"
self.assertEqual(validate_env_string("{{FRIGATE_FOO}}"), "{FRIGATE_FOO}")
def test_mixed_frigate_var_and_braces(self):
"""A FRIGATE_ var alongside literal single braces substitutes only the var."""
FRIGATE_ENV_VARS["FRIGATE_USER"] = "admin"
result = validate_env_string(
"drawtext=text=%{localtime}:user={FRIGATE_USER}:x=5"
)
self.assertEqual(result, "drawtext=text=%{localtime}:user=admin:x=5")
def test_triple_braces_around_frigate_var(self):
"""`{{{FRIGATE_FOO}}}` collapses like str.format(): `{bar}`."""
FRIGATE_ENV_VARS["FRIGATE_FOO"] = "bar"
self.assertEqual(validate_env_string("{{{FRIGATE_FOO}}}"), "{bar}")
def test_trailing_double_brace_after_var(self):
"""`{FRIGATE_FOO}}}` collapses like str.format(): `bar}`."""
FRIGATE_ENV_VARS["FRIGATE_FOO"] = "bar"
self.assertEqual(validate_env_string("{FRIGATE_FOO}}}"), "bar}")
def test_leading_double_brace_then_var(self):
"""`{{{FRIGATE_FOO}` collapses like str.format(): `{bar`."""
FRIGATE_ENV_VARS["FRIGATE_FOO"] = "bar"
self.assertEqual(validate_env_string("{{{FRIGATE_FOO}"), "{bar")
def test_malformed_unterminated_placeholder_raises(self):
"""`{FRIGATE_FOO` (no closing brace) raises like str.format() did."""
FRIGATE_ENV_VARS["FRIGATE_FOO"] = "bar"
with self.assertRaises(ValueError):
validate_env_string("prefix-{FRIGATE_FOO")
def test_malformed_format_spec_raises(self):
"""`{FRIGATE_FOO:>5}` (format spec) raises like str.format() did."""
FRIGATE_ENV_VARS["FRIGATE_FOO"] = "bar"
with self.assertRaises(ValueError):
validate_env_string("{FRIGATE_FOO:>5}")
def test_malformed_conversion_raises(self):
"""`{FRIGATE_FOO!r}` (conversion) raises like str.format() did."""
FRIGATE_ENV_VARS["FRIGATE_FOO"] = "bar"
with self.assertRaises(ValueError):
validate_env_string("{FRIGATE_FOO!r}")
class TestEnvVars(unittest.TestCase): class TestEnvVars(unittest.TestCase):
def setUp(self): def setUp(self):

View File

@ -116,6 +116,8 @@ class TimelineProcessor(threading.Thread):
), ),
"attribute": "", "attribute": "",
"score": event_data["score"], "score": event_data["score"],
"computed_score": event_data.get("computed_score"),
"top_score": event_data.get("top_score"),
}, },
} }

View File

@ -400,6 +400,7 @@ class TrackedObject:
"start_time": self.obj_data["start_time"], "start_time": self.obj_data["start_time"],
"end_time": self.obj_data.get("end_time", None), "end_time": self.obj_data.get("end_time", None),
"score": self.obj_data["score"], "score": self.obj_data["score"],
"computed_score": self.computed_score,
"box": self.obj_data["box"], "box": self.obj_data["box"],
"area": self.obj_data["area"], "area": self.obj_data["area"],
"ratio": self.obj_data["ratio"], "ratio": self.obj_data["ratio"],

View File

@ -471,8 +471,16 @@ class CameraWatchdog(threading.Thread):
p["cmd"], self.logger, p["logpipe"], ffmpeg_process=p["process"] p["cmd"], self.logger, p["logpipe"], ffmpeg_process=p["process"]
) )
# Update stall metrics based on last processed frame timestamp # Prune expired reconnect timestamps
now = datetime.now().timestamp() now = datetime.now().timestamp()
while (
self.reconnect_timestamps and self.reconnect_timestamps[0] < now - 3600
):
self.reconnect_timestamps.popleft()
if self.reconnects:
self.reconnects.value = len(self.reconnect_timestamps)
# Update stall metrics based on last processed frame timestamp
processed_ts = ( processed_ts = (
float(self.detection_frame.value) if self.detection_frame else 0.0 float(self.detection_frame.value) if self.detection_frame else 0.0
) )

View File

@ -0,0 +1,80 @@
/* eslint-disable react-hooks/rules-of-hooks */
/**
* Extended Playwright test fixture with FrigateApp.
*
* Every test imports `test` and `expect` from this file instead of
* @playwright/test directly. The `frigateApp` fixture provides a
* fully mocked Frigate frontend ready for interaction.
*
* CRITICAL: All route/WS handlers are registered before page.goto()
* to prevent AuthProvider from redirecting to login.html.
*/
import { test as base, expect, type Page } from "@playwright/test";
import {
ApiMocker,
MediaMocker,
type ApiMockOverrides,
} from "../helpers/api-mocker";
import { WsMocker } from "../helpers/ws-mocker";
export class FrigateApp {
public api: ApiMocker;
public media: MediaMocker;
public ws: WsMocker;
public page: Page;
private isDesktop: boolean;
constructor(page: Page, projectName: string) {
this.page = page;
this.api = new ApiMocker(page);
this.media = new MediaMocker(page);
this.ws = new WsMocker();
this.isDesktop = projectName === "desktop";
}
get isMobile() {
return !this.isDesktop;
}
/** Install all mocks with default data. Call before goto(). */
async installDefaults(overrides?: ApiMockOverrides) {
// Mock i18n locale files to prevent 404s
await this.page.route("**/locales/**", async (route) => {
// Let the request through to the built files
return route.fallback();
});
await this.ws.install(this.page);
await this.media.install();
await this.api.install(overrides);
}
/** Navigate to a page. Always call installDefaults() first. */
async goto(path: string) {
await this.page.goto(path);
// Wait for the app to render past the loading indicator
await this.page.waitForSelector("#pageRoot", { timeout: 10_000 });
}
/** Navigate to a page that may show a loading indicator */
async gotoAndWait(path: string, selector: string) {
await this.page.goto(path);
await this.page.waitForSelector(selector, { timeout: 10_000 });
}
}
type FrigateFixtures = {
frigateApp: FrigateApp;
};
export const test = base.extend<FrigateFixtures>({
frigateApp: async ({ page }, use, testInfo) => {
const app = new FrigateApp(page, testInfo.project.name);
await app.installDefaults();
await use(app);
},
});
export { expect };

View File

@ -0,0 +1,77 @@
/**
* Camera activity WebSocket payload factory.
*
* The camera_activity topic payload is double-serialized:
* the WS message contains { topic: "camera_activity", payload: JSON.stringify(activityMap) }
*/
export interface CameraActivityState {
config: {
enabled: boolean;
detect: boolean;
record: boolean;
snapshots: boolean;
audio: boolean;
audio_transcription: boolean;
notifications: boolean;
notifications_suspended: number;
autotracking: boolean;
alerts: boolean;
detections: boolean;
object_descriptions: boolean;
review_descriptions: boolean;
};
motion: boolean;
objects: Array<{
label: string;
score: number;
box: [number, number, number, number];
area: number;
ratio: number;
region: [number, number, number, number];
current_zones: string[];
id: string;
}>;
audio_detections: Array<{
label: string;
score: number;
}>;
}
function defaultCameraActivity(): CameraActivityState {
return {
config: {
enabled: true,
detect: true,
record: true,
snapshots: true,
audio: false,
audio_transcription: false,
notifications: false,
notifications_suspended: 0,
autotracking: false,
alerts: true,
detections: true,
object_descriptions: false,
review_descriptions: false,
},
motion: false,
objects: [],
audio_detections: [],
};
}
export function cameraActivityPayload(
cameras: string[],
overrides?: Partial<Record<string, Partial<CameraActivityState>>>,
): string {
const activity: Record<string, CameraActivityState> = {};
for (const name of cameras) {
activity[name] = {
...defaultCameraActivity(),
...overrides?.[name],
} as CameraActivityState;
}
// Double-serialize: the WS payload is a JSON string
return JSON.stringify(activity);
}

View File

@ -0,0 +1 @@
[{"id": "case-001", "name": "Package Theft Investigation", "description": "Review of suspicious activity near the front porch", "created_at": 1775407931.3863528, "updated_at": 1775483531.3863528}]

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,76 @@
/**
* FrigateConfig factory for E2E tests.
*
* Uses a real config snapshot generated from the Python backend's FrigateConfig
* model. This guarantees all fields are present and match what the app expects.
* Tests override specific fields via DeepPartial.
*/
import { readFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const configSnapshot = JSON.parse(
readFileSync(resolve(__dirname, "config-snapshot.json"), "utf-8"),
);
export type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};
function deepMerge<T extends Record<string, unknown>>(
base: T,
overrides?: DeepPartial<T>,
): T {
if (!overrides) return base;
const result = { ...base };
for (const key of Object.keys(overrides) as (keyof T)[]) {
const val = overrides[key];
if (
val !== undefined &&
typeof val === "object" &&
val !== null &&
!Array.isArray(val) &&
typeof base[key] === "object" &&
base[key] !== null &&
!Array.isArray(base[key])
) {
result[key] = deepMerge(
base[key] as Record<string, unknown>,
val as DeepPartial<Record<string, unknown>>,
) as T[keyof T];
} else if (val !== undefined) {
result[key] = val as T[keyof T];
}
}
return result;
}
// The base config is a real snapshot from the Python backend.
// Apply test-specific overrides: friendly names, camera groups, version.
export const BASE_CONFIG = {
...configSnapshot,
version: "0.15.0-test",
cameras: {
...configSnapshot.cameras,
front_door: {
...configSnapshot.cameras.front_door,
friendly_name: "Front Door",
},
backyard: {
...configSnapshot.cameras.backyard,
friendly_name: "Backyard",
},
garage: {
...configSnapshot.cameras.garage,
friendly_name: "Garage",
},
},
};
export function configFactory(
overrides?: DeepPartial<typeof BASE_CONFIG>,
): typeof BASE_CONFIG {
return deepMerge(BASE_CONFIG, overrides);
}

View File

@ -0,0 +1 @@
[{"id": "event-person-001", "label": "person", "sub_label": null, "camera": "front_door", "start_time": 1775487131.3863528, "end_time": 1775487161.3863528, "false_positive": false, "zones": ["front_yard"], "thumbnail": null, "has_clip": true, "has_snapshot": true, "retain_indefinitely": false, "plus_id": null, "model_hash": "abc123", "detector_type": "cpu", "model_type": "ssd", "data": {"top_score": 0.92, "score": 0.92, "region": [0.1, 0.1, 0.5, 0.8], "box": [0.2, 0.15, 0.45, 0.75], "area": 0.18, "ratio": 0.6, "type": "object", "description": "A person walking toward the front door", "average_estimated_speed": 1.2, "velocity_angle": 45.0, "path_data": [[[0.2, 0.5], 0.0], [[0.3, 0.5], 1.0]]}}, {"id": "event-car-001", "label": "car", "sub_label": null, "camera": "backyard", "start_time": 1775483531.3863528, "end_time": 1775483576.3863528, "false_positive": false, "zones": ["driveway"], "thumbnail": null, "has_clip": true, "has_snapshot": true, "retain_indefinitely": false, "plus_id": null, "model_hash": "def456", "detector_type": "cpu", "model_type": "ssd", "data": {"top_score": 0.87, "score": 0.87, "region": [0.3, 0.2, 0.9, 0.7], "box": [0.35, 0.25, 0.85, 0.65], "area": 0.2, "ratio": 1.25, "type": "object", "description": "A car parked in the driveway", "average_estimated_speed": 0.0, "velocity_angle": 0.0, "path_data": []}}, {"id": "event-person-002", "label": "person", "sub_label": null, "camera": "garage", "start_time": 1775479931.3863528, "end_time": 1775479951.3863528, "false_positive": false, "zones": [], "thumbnail": null, "has_clip": false, "has_snapshot": true, "retain_indefinitely": false, "plus_id": null, "model_hash": "ghi789", "detector_type": "cpu", "model_type": "ssd", "data": {"top_score": 0.78, "score": 0.78, "region": [0.0, 0.0, 0.6, 0.9], "box": [0.1, 0.05, 0.5, 0.85], "area": 0.32, "ratio": 0.5, "type": "object", "description": null, "average_estimated_speed": 0.5, "velocity_angle": 90.0, "path_data": [[[0.1, 0.4], 0.0]]}}]

View File

@ -0,0 +1 @@
[{"id": "export-001", "camera": "front_door", "name": "Front Door - Person Alert", "date": 1775490731.3863528, "video_path": "/exports/export-001.mp4", "thumb_path": "/exports/export-001-thumb.jpg", "in_progress": false, "export_case_id": null}, {"id": "export-002", "camera": "backyard", "name": "Backyard - Car Detection", "date": 1775483531.3863528, "video_path": "/exports/export-002.mp4", "thumb_path": "/exports/export-002-thumb.jpg", "in_progress": false, "export_case_id": "case-001"}, {"id": "export-003", "camera": "garage", "name": "Garage - In Progress", "date": 1775492531.3863528, "video_path": "/exports/export-003.mp4", "thumb_path": "/exports/export-003-thumb.jpg", "in_progress": true, "export_case_id": null}]

View File

@ -0,0 +1,426 @@
#!/usr/bin/env python3
"""Generate E2E mock data from backend Pydantic and Peewee models.
Run from the repo root:
PYTHONPATH=/workspace/frigate python3 web/e2e/fixtures/mock-data/generate-mock-data.py
Strategy:
- FrigateConfig: instantiate the Pydantic config model, then model_dump()
- API responses: instantiate Pydantic response models (ReviewSegmentResponse,
EventResponse, ExportModel, ExportCaseModel) to validate all required fields
- If the backend adds a required field, this script fails at instantiation time
- The Peewee model field list is checked to detect new columns that would
appear in .dicts() API responses but aren't in our mock data
"""
import json
import sys
import time
import warnings
from datetime import datetime, timedelta
from pathlib import Path
warnings.filterwarnings("ignore")
OUTPUT_DIR = Path(__file__).parent
NOW = time.time()
HOUR = 3600
CAMERAS = ["front_door", "backyard", "garage"]
def check_pydantic_fields(pydantic_class, mock_keys, model_name):
"""Verify mock data covers all fields declared in the Pydantic response model.
The Pydantic response model is what the frontend actually receives.
Peewee models may have extra legacy columns that are filtered out by
FastAPI's response_model validation.
"""
required_fields = set()
for name, field_info in pydantic_class.model_fields.items():
required_fields.add(name)
missing = required_fields - mock_keys
if missing:
print(
f" ERROR: {model_name} response model has fields not in mock data: {missing}",
file=sys.stderr,
)
print(
f" Add these fields to the mock data in this script.",
file=sys.stderr,
)
sys.exit(1)
extra = mock_keys - required_fields
if extra:
print(
f" NOTE: {model_name} mock data has extra fields (not in response model): {extra}",
)
def generate_config():
"""Generate FrigateConfig from the Python backend model."""
from frigate.config import FrigateConfig
config = FrigateConfig.model_validate_json(
json.dumps(
{
"mqtt": {"host": "mqtt"},
"cameras": {
cam: {
"ffmpeg": {
"inputs": [
{
"path": f"rtsp://10.0.0.{i+1}:554/video",
"roles": ["detect"],
}
]
},
"detect": {"height": 720, "width": 1280, "fps": 5},
}
for i, cam in enumerate(CAMERAS)
},
"camera_groups": {
"default": {
"cameras": CAMERAS,
"icon": "generic",
"order": 0,
},
"outdoor": {
"cameras": ["front_door", "backyard"],
"icon": "generic",
"order": 1,
},
},
}
)
)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
snapshot = config.model_dump()
# Runtime-computed fields not in the Pydantic dump
all_attrs = set()
for attrs in snapshot.get("model", {}).get("attributes_map", {}).values():
all_attrs.update(attrs)
snapshot["model"]["all_attributes"] = sorted(all_attrs)
snapshot["model"]["colormap"] = {}
return snapshot
def generate_reviews():
"""Generate ReviewSegmentResponse[] validated against Pydantic + Peewee."""
from frigate.api.defs.response.review_response import ReviewSegmentResponse
reviews = [
ReviewSegmentResponse(
id="review-alert-001",
camera="front_door",
severity="alert",
start_time=datetime.fromtimestamp(NOW - 2 * HOUR),
end_time=datetime.fromtimestamp(NOW - 2 * HOUR + 30),
has_been_reviewed=False,
thumb_path="/clips/front_door/review-alert-001-thumb.jpg",
data=json.dumps(
{
"audio": [],
"detections": ["person-abc123"],
"objects": ["person"],
"sub_labels": [],
"significant_motion_areas": [],
"zones": ["front_yard"],
}
),
),
ReviewSegmentResponse(
id="review-alert-002",
camera="backyard",
severity="alert",
start_time=datetime.fromtimestamp(NOW - 3 * HOUR),
end_time=datetime.fromtimestamp(NOW - 3 * HOUR + 45),
has_been_reviewed=True,
thumb_path="/clips/backyard/review-alert-002-thumb.jpg",
data=json.dumps(
{
"audio": [],
"detections": ["car-def456"],
"objects": ["car"],
"sub_labels": [],
"significant_motion_areas": [],
"zones": ["driveway"],
}
),
),
ReviewSegmentResponse(
id="review-detect-001",
camera="garage",
severity="detection",
start_time=datetime.fromtimestamp(NOW - 4 * HOUR),
end_time=datetime.fromtimestamp(NOW - 4 * HOUR + 20),
has_been_reviewed=False,
thumb_path="/clips/garage/review-detect-001-thumb.jpg",
data=json.dumps(
{
"audio": [],
"detections": ["person-ghi789"],
"objects": ["person"],
"sub_labels": [],
"significant_motion_areas": [],
"zones": [],
}
),
),
ReviewSegmentResponse(
id="review-detect-002",
camera="front_door",
severity="detection",
start_time=datetime.fromtimestamp(NOW - 5 * HOUR),
end_time=datetime.fromtimestamp(NOW - 5 * HOUR + 15),
has_been_reviewed=False,
thumb_path="/clips/front_door/review-detect-002-thumb.jpg",
data=json.dumps(
{
"audio": [],
"detections": ["car-jkl012"],
"objects": ["car"],
"sub_labels": [],
"significant_motion_areas": [],
"zones": ["front_yard"],
}
),
),
]
result = [r.model_dump(mode="json") for r in reviews]
# Verify mock data covers all Pydantic response model fields
check_pydantic_fields(
ReviewSegmentResponse, set(result[0].keys()), "ReviewSegment"
)
return result
def generate_events():
"""Generate EventResponse[] validated against Pydantic + Peewee."""
from frigate.api.defs.response.event_response import EventResponse
events = [
EventResponse(
id="event-person-001",
label="person",
sub_label=None,
camera="front_door",
start_time=NOW - 2 * HOUR,
end_time=NOW - 2 * HOUR + 30,
false_positive=False,
zones=["front_yard"],
thumbnail=None,
has_clip=True,
has_snapshot=True,
retain_indefinitely=False,
plus_id=None,
model_hash="abc123",
detector_type="cpu",
model_type="ssd",
data={
"top_score": 0.92,
"score": 0.92,
"region": [0.1, 0.1, 0.5, 0.8],
"box": [0.2, 0.15, 0.45, 0.75],
"area": 0.18,
"ratio": 0.6,
"type": "object",
"description": "A person walking toward the front door",
"average_estimated_speed": 1.2,
"velocity_angle": 45.0,
"path_data": [[[0.2, 0.5], 0.0], [[0.3, 0.5], 1.0]],
},
),
EventResponse(
id="event-car-001",
label="car",
sub_label=None,
camera="backyard",
start_time=NOW - 3 * HOUR,
end_time=NOW - 3 * HOUR + 45,
false_positive=False,
zones=["driveway"],
thumbnail=None,
has_clip=True,
has_snapshot=True,
retain_indefinitely=False,
plus_id=None,
model_hash="def456",
detector_type="cpu",
model_type="ssd",
data={
"top_score": 0.87,
"score": 0.87,
"region": [0.3, 0.2, 0.9, 0.7],
"box": [0.35, 0.25, 0.85, 0.65],
"area": 0.2,
"ratio": 1.25,
"type": "object",
"description": "A car parked in the driveway",
"average_estimated_speed": 0.0,
"velocity_angle": 0.0,
"path_data": [],
},
),
EventResponse(
id="event-person-002",
label="person",
sub_label=None,
camera="garage",
start_time=NOW - 4 * HOUR,
end_time=NOW - 4 * HOUR + 20,
false_positive=False,
zones=[],
thumbnail=None,
has_clip=False,
has_snapshot=True,
retain_indefinitely=False,
plus_id=None,
model_hash="ghi789",
detector_type="cpu",
model_type="ssd",
data={
"top_score": 0.78,
"score": 0.78,
"region": [0.0, 0.0, 0.6, 0.9],
"box": [0.1, 0.05, 0.5, 0.85],
"area": 0.32,
"ratio": 0.5,
"type": "object",
"description": None,
"average_estimated_speed": 0.5,
"velocity_angle": 90.0,
"path_data": [[[0.1, 0.4], 0.0]],
},
),
]
result = [e.model_dump(mode="json") for e in events]
check_pydantic_fields(EventResponse, set(result[0].keys()), "Event")
return result
def generate_exports():
"""Generate ExportModel[] validated against Pydantic + Peewee."""
from frigate.api.defs.response.export_response import ExportModel
exports = [
ExportModel(
id="export-001",
camera="front_door",
name="Front Door - Person Alert",
date=NOW - 1 * HOUR,
video_path="/exports/export-001.mp4",
thumb_path="/exports/export-001-thumb.jpg",
in_progress=False,
export_case_id=None,
),
ExportModel(
id="export-002",
camera="backyard",
name="Backyard - Car Detection",
date=NOW - 3 * HOUR,
video_path="/exports/export-002.mp4",
thumb_path="/exports/export-002-thumb.jpg",
in_progress=False,
export_case_id="case-001",
),
ExportModel(
id="export-003",
camera="garage",
name="Garage - In Progress",
date=NOW - 0.5 * HOUR,
video_path="/exports/export-003.mp4",
thumb_path="/exports/export-003-thumb.jpg",
in_progress=True,
export_case_id=None,
),
]
result = [e.model_dump(mode="json") for e in exports]
check_pydantic_fields(ExportModel, set(result[0].keys()), "Export")
return result
def generate_cases():
"""Generate ExportCaseModel[] validated against Pydantic + Peewee."""
from frigate.api.defs.response.export_case_response import ExportCaseModel
cases = [
ExportCaseModel(
id="case-001",
name="Package Theft Investigation",
description="Review of suspicious activity near the front porch",
created_at=NOW - 24 * HOUR,
updated_at=NOW - 3 * HOUR,
),
]
result = [c.model_dump(mode="json") for c in cases]
check_pydantic_fields(ExportCaseModel, set(result[0].keys()), "ExportCase")
return result
def generate_review_summary():
"""Generate ReviewSummary for the calendar filter."""
today = datetime.now().strftime("%Y-%m-%d")
yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
return {
today: {
"day": today,
"reviewed_alert": 1,
"reviewed_detection": 0,
"total_alert": 2,
"total_detection": 2,
},
yesterday: {
"day": yesterday,
"reviewed_alert": 3,
"reviewed_detection": 2,
"total_alert": 3,
"total_detection": 4,
},
}
def write_json(filename, data):
path = OUTPUT_DIR / filename
path.write_text(json.dumps(data, default=str))
print(f" {path.name} ({path.stat().st_size} bytes)")
def main():
print("Generating E2E mock data from backend models...")
print(" Validating against Pydantic response models + Peewee DB columns")
print()
write_json("config-snapshot.json", generate_config())
write_json("reviews.json", generate_reviews())
write_json("events.json", generate_events())
write_json("exports.json", generate_exports())
write_json("cases.json", generate_cases())
write_json("review-summary.json", generate_review_summary())
print()
print("All mock data validated against backend schemas.")
print("If this script fails, update the mock data to match the new schema.")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,39 @@
/**
* User profile factories for E2E tests.
*/
export interface UserProfile {
username: string;
role: string;
allowed_cameras: string[] | null;
}
export function adminProfile(overrides?: Partial<UserProfile>): UserProfile {
return {
username: "admin",
role: "admin",
allowed_cameras: null,
...overrides,
};
}
export function viewerProfile(overrides?: Partial<UserProfile>): UserProfile {
return {
username: "viewer",
role: "viewer",
allowed_cameras: null,
...overrides,
};
}
export function restrictedProfile(
cameras: string[],
overrides?: Partial<UserProfile>,
): UserProfile {
return {
username: "restricted",
role: "viewer",
allowed_cameras: cameras,
...overrides,
};
}

View File

@ -0,0 +1 @@
{"2026-04-06": {"day": "2026-04-06", "reviewed_alert": 1, "reviewed_detection": 0, "total_alert": 2, "total_detection": 2}, "2026-04-05": {"day": "2026-04-05", "reviewed_alert": 3, "reviewed_detection": 2, "total_alert": 3, "total_detection": 4}}

View File

@ -0,0 +1 @@
[{"id": "review-alert-001", "camera": "front_door", "start_time": "2026-04-06T09:52:11.386353", "end_time": "2026-04-06T09:52:41.386353", "has_been_reviewed": false, "severity": "alert", "thumb_path": "/clips/front_door/review-alert-001-thumb.jpg", "data": {"audio": [], "detections": ["person-abc123"], "objects": ["person"], "sub_labels": [], "significant_motion_areas": [], "zones": ["front_yard"]}}, {"id": "review-alert-002", "camera": "backyard", "start_time": "2026-04-06T08:52:11.386353", "end_time": "2026-04-06T08:52:56.386353", "has_been_reviewed": true, "severity": "alert", "thumb_path": "/clips/backyard/review-alert-002-thumb.jpg", "data": {"audio": [], "detections": ["car-def456"], "objects": ["car"], "sub_labels": [], "significant_motion_areas": [], "zones": ["driveway"]}}, {"id": "review-detect-001", "camera": "garage", "start_time": "2026-04-06T07:52:11.386353", "end_time": "2026-04-06T07:52:31.386353", "has_been_reviewed": false, "severity": "detection", "thumb_path": "/clips/garage/review-detect-001-thumb.jpg", "data": {"audio": [], "detections": ["person-ghi789"], "objects": ["person"], "sub_labels": [], "significant_motion_areas": [], "zones": []}}, {"id": "review-detect-002", "camera": "front_door", "start_time": "2026-04-06T06:52:11.386353", "end_time": "2026-04-06T06:52:26.386353", "has_been_reviewed": false, "severity": "detection", "thumb_path": "/clips/front_door/review-detect-002-thumb.jpg", "data": {"audio": [], "detections": ["car-jkl012"], "objects": ["car"], "sub_labels": [], "significant_motion_areas": [], "zones": ["front_yard"]}}]

View File

@ -0,0 +1,76 @@
/**
* FrigateStats factory for E2E tests.
*/
import type { DeepPartial } from "./config";
function cameraStats(_name: string) {
return {
audio_dBFPS: 0,
audio_rms: 0,
camera_fps: 5.0,
capture_pid: 100,
detection_enabled: 1,
detection_fps: 5.0,
ffmpeg_pid: 101,
pid: 102,
process_fps: 5.0,
skipped_fps: 0,
connection_quality: "excellent" as const,
expected_fps: 5,
reconnects_last_hour: 0,
stalls_last_hour: 0,
};
}
export const BASE_STATS = {
cameras: {
front_door: cameraStats("front_door"),
backyard: cameraStats("backyard"),
garage: cameraStats("garage"),
},
cpu_usages: {
"1": { cmdline: "frigate.app", cpu: "5.0", cpu_average: "4.5", mem: "2.1" },
},
detectors: {
cpu: {
detection_start: 0,
inference_speed: 75.5,
pid: 200,
},
},
gpu_usages: {},
npu_usages: {},
processes: {},
service: {
last_updated: Date.now() / 1000,
storage: {
"/media/frigate/recordings": {
free: 50000000000,
total: 100000000000,
used: 50000000000,
mount_type: "ext4",
},
"/tmp/cache": {
free: 500000000,
total: 1000000000,
used: 500000000,
mount_type: "tmpfs",
},
},
uptime: 86400,
latest_version: "0.15.0",
version: "0.15.0-test",
},
camera_fps: 15.0,
process_fps: 15.0,
skipped_fps: 0,
detection_fps: 15.0,
};
export function statsFactory(
overrides?: DeepPartial<typeof BASE_STATS>,
): typeof BASE_STATS {
if (!overrides) return BASE_STATS;
return { ...BASE_STATS, ...overrides } as typeof BASE_STATS;
}

7
web/e2e/global-setup.ts Normal file
View File

@ -0,0 +1,7 @@
import { execSync } from "child_process";
import path from "path";
export default function globalSetup() {
const webDir = path.resolve(__dirname, "..");
execSync("npm run e2e:build", { cwd: webDir, stdio: "inherit" });
}

View File

@ -0,0 +1,271 @@
/**
* REST API mock using Playwright's page.route().
*
* Intercepts all /api/* requests and returns factory-generated responses.
* Must be installed BEFORE page.goto() to prevent auth redirects.
*/
import type { Page } from "@playwright/test";
import { readFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import {
BASE_CONFIG,
type DeepPartial,
configFactory,
} from "../fixtures/mock-data/config";
import { adminProfile, type UserProfile } from "../fixtures/mock-data/profile";
import { BASE_STATS, statsFactory } from "../fixtures/mock-data/stats";
const __dirname = dirname(fileURLToPath(import.meta.url));
const MOCK_DATA_DIR = resolve(__dirname, "../fixtures/mock-data");
function loadMockJson(filename: string): unknown {
return JSON.parse(readFileSync(resolve(MOCK_DATA_DIR, filename), "utf-8"));
}
// 1x1 transparent PNG
const PLACEHOLDER_PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"base64",
);
export interface ApiMockOverrides {
config?: DeepPartial<typeof BASE_CONFIG>;
profile?: UserProfile;
stats?: DeepPartial<typeof BASE_STATS>;
reviews?: unknown[];
events?: unknown[];
exports?: unknown[];
cases?: unknown[];
faces?: Record<string, unknown>;
configRaw?: string;
configSchema?: Record<string, unknown>;
}
export class ApiMocker {
private page: Page;
constructor(page: Page) {
this.page = page;
}
async install(overrides?: ApiMockOverrides) {
const config = configFactory(overrides?.config);
const profile = overrides?.profile ?? adminProfile();
const stats = statsFactory(overrides?.stats);
const reviews =
overrides?.reviews ?? (loadMockJson("reviews.json") as unknown[]);
const events =
overrides?.events ?? (loadMockJson("events.json") as unknown[]);
const exports =
overrides?.exports ?? (loadMockJson("exports.json") as unknown[]);
const cases = overrides?.cases ?? (loadMockJson("cases.json") as unknown[]);
const reviewSummary = loadMockJson("review-summary.json");
// Config endpoint
await this.page.route("**/api/config", (route) => {
if (route.request().method() === "GET") {
return route.fulfill({ json: config });
}
return route.fulfill({ json: { success: true } });
});
// Profile endpoint (AuthProvider fetches /profile directly via axios,
// which resolves to /api/profile due to axios.defaults.baseURL)
await this.page.route("**/profile", (route) =>
route.fulfill({ json: profile }),
);
// Stats endpoint
await this.page.route("**/api/stats", (route) =>
route.fulfill({ json: stats }),
);
// Reviews
await this.page.route("**/api/reviews**", (route) => {
const url = route.request().url();
if (url.includes("summary")) {
return route.fulfill({ json: reviewSummary });
}
return route.fulfill({ json: reviews });
});
// Recordings summary
await this.page.route("**/api/recordings/summary**", (route) =>
route.fulfill({ json: {} }),
);
// Previews (needed for review page event cards)
await this.page.route("**/api/preview/**", (route) =>
route.fulfill({ json: [] }),
);
// Sub-labels and attributes (for explore filters)
await this.page.route("**/api/sub_labels", (route) =>
route.fulfill({ json: [] }),
);
await this.page.route("**/api/labels", (route) =>
route.fulfill({ json: ["person", "car"] }),
);
await this.page.route("**/api/*/attributes", (route) =>
route.fulfill({ json: [] }),
);
await this.page.route("**/api/recognized_license_plates", (route) =>
route.fulfill({ json: [] }),
);
// Events / search
await this.page.route("**/api/events**", (route) =>
route.fulfill({ json: events }),
);
// Exports
await this.page.route("**/api/export**", (route) =>
route.fulfill({ json: exports }),
);
// Cases
await this.page.route("**/api/cases", (route) =>
route.fulfill({ json: cases }),
);
// Faces
await this.page.route("**/api/faces", (route) =>
route.fulfill({ json: overrides?.faces ?? {} }),
);
// Logs
await this.page.route("**/api/logs/**", (route) =>
route.fulfill({
contentType: "text/plain",
body: "[2026-04-06 10:00:00] INFO: Frigate started\n[2026-04-06 10:00:01] INFO: Cameras loaded\n",
}),
);
// Config raw
await this.page.route("**/api/config/raw", (route) =>
route.fulfill({
contentType: "text/plain",
body:
overrides?.configRaw ??
"mqtt:\n host: mqtt\ncameras:\n front_door:\n enabled: true\n",
}),
);
// Config schema
await this.page.route("**/api/config/schema.json", (route) =>
route.fulfill({
json: overrides?.configSchema ?? { type: "object", properties: {} },
}),
);
// Config set (mutation)
await this.page.route("**/api/config/set", (route) =>
route.fulfill({ json: { success: true, require_restart: false } }),
);
// Go2RTC streams
await this.page.route("**/api/go2rtc/streams**", (route) =>
route.fulfill({ json: {} }),
);
// Profiles
await this.page.route("**/api/profiles**", (route) =>
route.fulfill({
json: { profiles: [], active_profile: null, last_activated: {} },
}),
);
// Motion search
await this.page.route("**/api/motion_search**", (route) =>
route.fulfill({ json: { job_id: "test-job" } }),
);
// Region grid
await this.page.route("**/api/*/region_grid", (route) =>
route.fulfill({ json: {} }),
);
// Debug replay
await this.page.route("**/api/debug_replay/**", (route) =>
route.fulfill({ json: {} }),
);
// Generic mutation catch-all for remaining endpoints.
// Uses route.fallback() to defer to more specific routes registered above.
// Playwright matches routes in reverse registration order (last wins),
// so this catch-all must use fallback() to let specific routes take precedence.
await this.page.route("**/api/**", (route) => {
const method = route.request().method();
if (
method === "POST" ||
method === "PUT" ||
method === "PATCH" ||
method === "DELETE"
) {
return route.fulfill({ json: { success: true } });
}
// Fall through to more specific routes for GET requests
return route.fallback();
});
}
}
export class MediaMocker {
private page: Page;
constructor(page: Page) {
this.page = page;
}
async install() {
// Camera snapshots
await this.page.route("**/api/*/latest.jpg**", (route) =>
route.fulfill({
contentType: "image/png",
body: PLACEHOLDER_PNG,
}),
);
// Clips and thumbnails
await this.page.route("**/clips/**", (route) =>
route.fulfill({
contentType: "image/png",
body: PLACEHOLDER_PNG,
}),
);
// Event thumbnails
await this.page.route("**/api/events/*/thumbnail.jpg**", (route) =>
route.fulfill({
contentType: "image/png",
body: PLACEHOLDER_PNG,
}),
);
// Event snapshots
await this.page.route("**/api/events/*/snapshot.jpg**", (route) =>
route.fulfill({
contentType: "image/png",
body: PLACEHOLDER_PNG,
}),
);
// VOD / recordings
await this.page.route("**/vod/**", (route) =>
route.fulfill({
contentType: "application/vnd.apple.mpegurl",
body: "#EXTM3U\n#EXT-X-ENDLIST\n",
}),
);
// Live streams
await this.page.route("**/live/**", (route) =>
route.fulfill({
contentType: "application/vnd.apple.mpegurl",
body: "#EXTM3U\n#EXT-X-ENDLIST\n",
}),
);
}
}

View File

@ -0,0 +1,125 @@
/**
* WebSocket mock using Playwright's native page.routeWebSocket().
*
* Intercepts the app's WebSocket connection and simulates the Frigate
* WS protocol: onConnect handshake, camera_activity expansion, and
* topic-based state updates.
*/
import type { Page, WebSocketRoute } from "@playwright/test";
import { cameraActivityPayload } from "../fixtures/mock-data/camera-activity";
export class WsMocker {
private mockWs: WebSocketRoute | null = null;
private cameras: string[];
constructor(cameras: string[] = ["front_door", "backyard", "garage"]) {
this.cameras = cameras;
}
async install(page: Page) {
await page.routeWebSocket("**/ws", (ws) => {
this.mockWs = ws;
ws.onMessage((msg) => {
this.handleClientMessage(msg.toString());
});
});
}
private handleClientMessage(raw: string) {
let data: { topic: string; payload?: unknown; message?: string };
try {
data = JSON.parse(raw);
} catch {
return;
}
if (data.topic === "onConnect") {
// Send initial camera_activity state
this.sendCameraActivity();
// Send initial stats
this.send(
"stats",
JSON.stringify({
cameras: Object.fromEntries(
this.cameras.map((c) => [
c,
{
camera_fps: 5,
detection_fps: 5,
process_fps: 5,
skipped_fps: 0,
detection_enabled: 1,
connection_quality: "excellent",
},
]),
),
service: {
last_updated: Date.now() / 1000,
uptime: 86400,
version: "0.15.0-test",
latest_version: "0.15.0",
storage: {},
},
detectors: {},
cpu_usages: {},
gpu_usages: {},
camera_fps: 15,
process_fps: 15,
skipped_fps: 0,
detection_fps: 15,
}),
);
}
// Echo back state commands (e.g., modelState, jobState, etc.)
if (data.topic === "modelState") {
this.send("model_state", JSON.stringify({}));
}
if (data.topic === "embeddingsReindexProgress") {
this.send("embeddings_reindex_progress", JSON.stringify(null));
}
if (data.topic === "birdseyeLayout") {
this.send("birdseye_layout", JSON.stringify(null));
}
if (data.topic === "jobState") {
this.send("job_state", JSON.stringify({}));
}
if (data.topic === "audioTranscriptionState") {
this.send("audio_transcription_state", JSON.stringify("idle"));
}
// Camera toggle commands: echo back the new state
const toggleMatch = data.topic?.match(
/^(.+)\/(detect|recordings|snapshots|audio|enabled|notifications|ptz_autotracker|review_alerts|review_detections|object_descriptions|review_descriptions|audio_transcription)\/set$/,
);
if (toggleMatch) {
const [, camera, feature] = toggleMatch;
this.send(`${camera}/${feature}/state`, data.payload);
}
}
/** Send a raw WS message to the app */
send(topic: string, payload: unknown) {
if (!this.mockWs) return;
this.mockWs.send(JSON.stringify({ topic, payload }));
}
/** Send camera_activity with default or custom state */
sendCameraActivity(overrides?: Parameters<typeof cameraActivityPayload>[1]) {
const payload = cameraActivityPayload(this.cameras, overrides);
this.send("camera_activity", payload);
}
/** Send a review update */
sendReview(review: unknown) {
this.send("reviews", JSON.stringify(review));
}
/** Send an event update */
sendEvent(event: unknown) {
this.send("events", JSON.stringify(event));
}
}

View File

@ -0,0 +1,82 @@
/**
* Base page object with viewport-aware navigation helpers.
*
* Desktop: clicks sidebar NavLink elements.
* Mobile: clicks bottombar NavLink elements.
*/
import type { Page, Locator } from "@playwright/test";
export class BasePage {
constructor(
protected page: Page,
public isDesktop: boolean,
) {}
get isMobile() {
return !this.isDesktop;
}
/** The sidebar (desktop only) */
get sidebar(): Locator {
return this.page.locator("aside");
}
/** The bottombar (mobile only) */
get bottombar(): Locator {
return this.page
.locator('[data-bottombar="true"]')
.or(this.page.locator(".absolute.inset-x-4.bottom-0").first());
}
/** The main page content area */
get pageRoot(): Locator {
return this.page.locator("#pageRoot");
}
/** Navigate using a NavLink by its href */
async navigateTo(path: string) {
// Wait for any in-progress React renders to settle before clicking
await this.page.waitForLoadState("domcontentloaded");
// Use page.click with a CSS selector to avoid stale element issues
// when React re-renders the nav during route transitions.
// force: true bypasses actionability checks that fail when React
// detaches and reattaches nav elements during re-renders.
const selector = this.isDesktop
? `aside a[href="${path}"]`
: `a[href="${path}"]`;
// Use dispatchEvent to bypass actionability checks that fail when
// React tooltip wrappers detach/reattach nav elements during re-renders
await this.page.locator(selector).first().dispatchEvent("click");
// React Router navigates client-side, wait for URL update
if (path !== "/") {
const escaped = path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
await this.page.waitForURL(new RegExp(escaped), { timeout: 10_000 });
}
}
/** Navigate to Live page */
async goToLive() {
await this.navigateTo("/");
}
/** Navigate to Review page */
async goToReview() {
await this.navigateTo("/review");
}
/** Navigate to Explore page */
async goToExplore() {
await this.navigateTo("/explore");
}
/** Navigate to Export page */
async goToExport() {
await this.navigateTo("/export");
}
/** Check if the page has loaded */
async waitForPageLoad() {
await this.page.waitForSelector("#pageRoot", { timeout: 10_000 });
}
}

View File

@ -0,0 +1,56 @@
import { defineConfig, devices } from "@playwright/test";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const webRoot = resolve(__dirname, "..");
const DESKTOP_UA =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
const MOBILE_UA =
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
export default defineConfig({
testDir: "./specs",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: 4,
reporter: process.env.CI ? [["json"], ["html"]] : [["html"]],
timeout: 30_000,
expect: { timeout: 5_000 },
use: {
baseURL: "http://localhost:4173",
trace: "on-first-retry",
screenshot: "only-on-failure",
},
webServer: {
command: "npx vite preview --port 4173",
port: 4173,
cwd: webRoot,
reuseExistingServer: !process.env.CI,
},
projects: [
{
name: "desktop",
use: {
...devices["Desktop Chrome"],
viewport: { width: 1920, height: 1080 },
userAgent: DESKTOP_UA,
},
},
{
name: "mobile",
use: {
...devices["Desktop Chrome"],
viewport: { width: 390, height: 844 },
userAgent: MOBILE_UA,
isMobile: true,
hasTouch: true,
},
},
],
});

147
web/e2e/specs/auth.spec.ts Normal file
View File

@ -0,0 +1,147 @@
/**
* Auth and cross-cutting tests -- HIGH tier.
*
* Tests protected route access for admin/viewer roles,
* access denied page rendering, viewer nav restrictions,
* and all routes smoke test.
*/
import { test, expect } from "../fixtures/frigate-test";
import { viewerProfile } from "../fixtures/mock-data/profile";
test.describe("Auth - Admin Access @high", () => {
test("admin can access /system and sees system tabs", async ({
frigateApp,
}) => {
await frigateApp.goto("/system");
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
await frigateApp.page.waitForTimeout(3000);
// System page should have named tab buttons
await expect(frigateApp.page.getByLabel("Select general")).toBeVisible({
timeout: 5_000,
});
});
test("admin can access /config and Monaco editor loads", async ({
frigateApp,
}) => {
await frigateApp.goto("/config");
await frigateApp.page.waitForTimeout(5000);
const editor = frigateApp.page.locator(
".monaco-editor, [data-keybinding-context]",
);
await expect(editor.first()).toBeVisible({ timeout: 10_000 });
});
test("admin can access /logs and sees service tabs", async ({
frigateApp,
}) => {
await frigateApp.goto("/logs");
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
await expect(frigateApp.page.getByLabel("Select frigate")).toBeVisible({
timeout: 5_000,
});
});
test("admin sees Classification nav on desktop", async ({ frigateApp }) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/");
await expect(
frigateApp.page.locator('a[href="/classification"]'),
).toBeVisible();
});
});
test.describe("Auth - Viewer Restrictions @high", () => {
test("viewer sees Access Denied on /system", async ({ frigateApp, page }) => {
await frigateApp.installDefaults({ profile: viewerProfile() });
await page.goto("/system");
await page.waitForTimeout(2000);
// Should show "Access Denied" text
await expect(page.getByText("Access Denied")).toBeVisible({
timeout: 5_000,
});
});
test("viewer sees Access Denied on /config", async ({ frigateApp, page }) => {
await frigateApp.installDefaults({ profile: viewerProfile() });
await page.goto("/config");
await page.waitForTimeout(2000);
await expect(page.getByText("Access Denied")).toBeVisible({
timeout: 5_000,
});
});
test("viewer sees Access Denied on /logs", async ({ frigateApp, page }) => {
await frigateApp.installDefaults({ profile: viewerProfile() });
await page.goto("/logs");
await page.waitForTimeout(2000);
await expect(page.getByText("Access Denied")).toBeVisible({
timeout: 5_000,
});
});
test("viewer can access Live page and sees cameras", async ({
frigateApp,
page,
}) => {
await frigateApp.installDefaults({ profile: viewerProfile() });
await page.goto("/");
await page.waitForSelector("#pageRoot", { timeout: 10_000 });
await expect(page.locator("[data-camera='front_door']")).toBeVisible({
timeout: 10_000,
});
});
test("viewer can access Review page and sees severity tabs", async ({
frigateApp,
page,
}) => {
await frigateApp.installDefaults({ profile: viewerProfile() });
await page.goto("/review");
await page.waitForSelector("#pageRoot", { timeout: 10_000 });
await expect(page.getByLabel("Alerts")).toBeVisible({ timeout: 5_000 });
});
test("viewer can access all main user routes without crash", async ({
frigateApp,
page,
}) => {
await frigateApp.installDefaults({ profile: viewerProfile() });
const routes = ["/", "/review", "/explore", "/export", "/settings"];
for (const route of routes) {
await page.goto(route);
await page.waitForSelector("#pageRoot", { timeout: 10_000 });
}
});
});
test.describe("Auth - All Routes Smoke @high", () => {
test("all user routes render without crash", async ({ frigateApp }) => {
const routes = ["/", "/review", "/explore", "/export", "/settings"];
for (const route of routes) {
await frigateApp.goto(route);
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible({
timeout: 10_000,
});
}
});
test("admin routes render with specific content", async ({ frigateApp }) => {
// System page should have tab controls
await frigateApp.goto("/system");
await frigateApp.page.waitForTimeout(3000);
await expect(frigateApp.page.getByLabel("Select general")).toBeVisible({
timeout: 5_000,
});
// Logs page should have service tabs
await frigateApp.goto("/logs");
await expect(frigateApp.page.getByLabel("Select frigate")).toBeVisible({
timeout: 5_000,
});
});
});

View File

@ -0,0 +1,34 @@
/**
* Chat page tests -- MEDIUM tier.
*
* Tests chat interface rendering, input area, and example prompt buttons.
*/
import { test, expect } from "../fixtures/frigate-test";
test.describe("Chat Page @medium", () => {
test("chat page renders without crash", async ({ frigateApp }) => {
await frigateApp.goto("/chat");
await frigateApp.page.waitForTimeout(2000);
await expect(frigateApp.page.locator("body")).toBeVisible();
});
test("chat page has interactive input or buttons", async ({ frigateApp }) => {
await frigateApp.goto("/chat");
await frigateApp.page.waitForTimeout(2000);
const interactive = frigateApp.page.locator("input, textarea, button");
const count = await interactive.count();
expect(count).toBeGreaterThan(0);
});
test("chat input accepts text", async ({ frigateApp }) => {
await frigateApp.goto("/chat");
await frigateApp.page.waitForTimeout(2000);
const input = frigateApp.page.locator("input, textarea").first();
if (await input.isVisible().catch(() => false)) {
await input.fill("What cameras detected a person today?");
const value = await input.inputValue();
expect(value.length).toBeGreaterThan(0);
}
});
});

View File

@ -0,0 +1,33 @@
/**
* Classification page tests -- MEDIUM tier.
*
* Tests model selection view rendering and interactive elements.
*/
import { test, expect } from "../fixtures/frigate-test";
test.describe("Classification @medium", () => {
test("classification page renders without crash", async ({ frigateApp }) => {
await frigateApp.goto("/classification");
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
test("classification page shows content and controls", async ({
frigateApp,
}) => {
await frigateApp.goto("/classification");
await frigateApp.page.waitForTimeout(2000);
const text = await frigateApp.page.textContent("#pageRoot");
expect(text?.length).toBeGreaterThan(0);
});
test("classification page has interactive elements", async ({
frigateApp,
}) => {
await frigateApp.goto("/classification");
await frigateApp.page.waitForTimeout(2000);
const buttons = frigateApp.page.locator("#pageRoot button");
const count = await buttons.count();
expect(count).toBeGreaterThanOrEqual(0);
});
});

View File

@ -0,0 +1,44 @@
/**
* Config Editor page tests -- MEDIUM tier.
*
* Tests Monaco editor loading, YAML content rendering,
* save button presence, and copy button interaction.
*/
import { test, expect } from "../fixtures/frigate-test";
test.describe("Config Editor @medium", () => {
test("config editor loads Monaco editor with content", async ({
frigateApp,
}) => {
await frigateApp.goto("/config");
await frigateApp.page.waitForTimeout(5000);
// Monaco editor should render with a specific class
const editor = frigateApp.page.locator(
".monaco-editor, [data-keybinding-context]",
);
await expect(editor.first()).toBeVisible({ timeout: 10_000 });
});
test("config editor has action buttons", async ({ frigateApp }) => {
await frigateApp.goto("/config");
await frigateApp.page.waitForTimeout(5000);
const buttons = frigateApp.page.locator("button");
const count = await buttons.count();
expect(count).toBeGreaterThan(0);
});
test("config editor button clicks do not crash", async ({ frigateApp }) => {
await frigateApp.goto("/config");
await frigateApp.page.waitForTimeout(5000);
// Find buttons with SVG icons (copy, save, etc.)
const iconButtons = frigateApp.page.locator("button:has(svg)");
const count = await iconButtons.count();
if (count > 0) {
// Click the first icon button (likely copy)
await iconButtons.first().click();
await frigateApp.page.waitForTimeout(500);
}
await expect(frigateApp.page.locator("body")).toBeVisible();
});
});

View File

@ -0,0 +1,97 @@
/**
* Explore page tests -- HIGH tier.
*
* Tests search input with text entry and clearing, camera filter popover
* opening with camera names, and content rendering with mock events.
*/
import { test, expect } from "../fixtures/frigate-test";
test.describe("Explore Page - Search @high", () => {
test("explore page renders with filter buttons", async ({ frigateApp }) => {
await frigateApp.goto("/explore");
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
const buttons = frigateApp.page.locator("#pageRoot button");
await expect(buttons.first()).toBeVisible({ timeout: 10_000 });
});
test("search input accepts text and can be cleared", async ({
frigateApp,
}) => {
await frigateApp.goto("/explore");
await frigateApp.page.waitForTimeout(1000);
const searchInput = frigateApp.page.locator("input").first();
if (await searchInput.isVisible()) {
await searchInput.fill("person");
await expect(searchInput).toHaveValue("person");
await searchInput.fill("");
await expect(searchInput).toHaveValue("");
}
});
test("search input submits on Enter", async ({ frigateApp }) => {
await frigateApp.goto("/explore");
await frigateApp.page.waitForTimeout(1000);
const searchInput = frigateApp.page.locator("input").first();
if (await searchInput.isVisible()) {
await searchInput.fill("car in driveway");
await searchInput.press("Enter");
await frigateApp.page.waitForTimeout(1000);
// Page should not crash after search submit
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
}
});
});
test.describe("Explore Page - Filters @high", () => {
test("camera filter button opens popover with camera names (desktop)", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/explore");
await frigateApp.page.waitForTimeout(1000);
const camerasBtn = frigateApp.page.getByRole("button", {
name: /cameras/i,
});
if (await camerasBtn.isVisible().catch(() => false)) {
await camerasBtn.click();
await frigateApp.page.waitForTimeout(500);
const popover = frigateApp.page.locator(
"[data-radix-popper-content-wrapper]",
);
await expect(popover.first()).toBeVisible({ timeout: 3_000 });
// Camera names from config should be in the popover
await expect(frigateApp.page.getByText("Front Door")).toBeVisible();
await frigateApp.page.keyboard.press("Escape");
}
});
test("filter button opens and closes overlay cleanly", async ({
frigateApp,
}) => {
await frigateApp.goto("/explore");
await frigateApp.page.waitForTimeout(1000);
const firstButton = frigateApp.page.locator("#pageRoot button").first();
await expect(firstButton).toBeVisible({ timeout: 5_000 });
await firstButton.click();
await frigateApp.page.waitForTimeout(500);
await frigateApp.page.keyboard.press("Escape");
await frigateApp.page.waitForTimeout(300);
// Page is still functional after open/close cycle
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
});
test.describe("Explore Page - Content @high", () => {
test("explore page shows content with mock events", async ({
frigateApp,
}) => {
await frigateApp.goto("/explore");
await frigateApp.page.waitForTimeout(3000);
const pageText = await frigateApp.page.textContent("#pageRoot");
expect(pageText?.length).toBeGreaterThan(0);
});
});

View File

@ -0,0 +1,74 @@
/**
* Export page tests -- HIGH tier.
*
* Tests export card rendering with mock data, search filtering,
* and delete confirmation dialog.
*/
import { test, expect } from "../fixtures/frigate-test";
test.describe("Export Page - Cards @high", () => {
test("export page renders export cards from mock data", async ({
frigateApp,
}) => {
await frigateApp.goto("/export");
await frigateApp.page.waitForTimeout(2000);
// Should show export names from our mock data
await expect(
frigateApp.page.getByText("Front Door - Person Alert"),
).toBeVisible({ timeout: 10_000 });
await expect(
frigateApp.page.getByText("Backyard - Car Detection"),
).toBeVisible();
});
test("export page shows in-progress indicator", async ({ frigateApp }) => {
await frigateApp.goto("/export");
await frigateApp.page.waitForTimeout(2000);
// "Garage - In Progress" export should be visible
await expect(frigateApp.page.getByText("Garage - In Progress")).toBeVisible(
{ timeout: 10_000 },
);
});
test("export page shows case grouping", async ({ frigateApp }) => {
await frigateApp.goto("/export");
await frigateApp.page.waitForTimeout(3000);
// Cases may render differently depending on API response shape
const pageText = await frigateApp.page.textContent("#pageRoot");
expect(pageText?.length).toBeGreaterThan(0);
});
});
test.describe("Export Page - Search @high", () => {
test("search input filters export list", async ({ frigateApp }) => {
await frigateApp.goto("/export");
await frigateApp.page.waitForTimeout(2000);
const searchInput = frigateApp.page.locator(
'#pageRoot input[type="text"], #pageRoot input',
);
if (
(await searchInput.count()) > 0 &&
(await searchInput.first().isVisible())
) {
// Type a search term that matches one export
await searchInput.first().fill("Front Door");
await frigateApp.page.waitForTimeout(500);
// "Front Door - Person Alert" should still be visible
await expect(
frigateApp.page.getByText("Front Door - Person Alert"),
).toBeVisible();
}
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
});
test.describe("Export Page - Controls @high", () => {
test("export page filter controls are present", async ({ frigateApp }) => {
await frigateApp.goto("/export");
await frigateApp.page.waitForTimeout(1000);
const buttons = frigateApp.page.locator("#pageRoot button");
const count = await buttons.count();
expect(count).toBeGreaterThan(0);
});
});

View File

@ -0,0 +1,32 @@
/**
* Face Library page tests -- MEDIUM tier.
*
* Tests face grid rendering, empty state, and interactive controls.
*/
import { test, expect } from "../fixtures/frigate-test";
test.describe("Face Library @medium", () => {
test("face library page renders without crash", async ({ frigateApp }) => {
await frigateApp.goto("/faces");
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
test("face library shows empty state with no faces", async ({
frigateApp,
}) => {
await frigateApp.goto("/faces");
await frigateApp.page.waitForTimeout(2000);
// With empty faces mock, should show empty state or content
const text = await frigateApp.page.textContent("#pageRoot");
expect(text?.length).toBeGreaterThan(0);
});
test("face library has interactive buttons", async ({ frigateApp }) => {
await frigateApp.goto("/faces");
await frigateApp.page.waitForTimeout(2000);
const buttons = frigateApp.page.locator("#pageRoot button");
const count = await buttons.count();
expect(count).toBeGreaterThanOrEqual(0);
});
});

253
web/e2e/specs/live.spec.ts Normal file
View File

@ -0,0 +1,253 @@
/**
* Live page tests -- CRITICAL tier.
*
* Tests camera dashboard rendering, camera card clicks, single camera view
* with named controls, feature toggle behavior, context menu, and mobile layout.
*/
import { test, expect } from "../fixtures/frigate-test";
test.describe("Live Dashboard @critical", () => {
test("dashboard renders all configured cameras by name", async ({
frigateApp,
}) => {
await frigateApp.goto("/");
for (const cam of ["front_door", "backyard", "garage"]) {
await expect(
frigateApp.page.locator(`[data-camera='${cam}']`),
).toBeVisible({ timeout: 10_000 });
}
});
test("clicking camera card opens single camera view via hash", async ({
frigateApp,
}) => {
await frigateApp.goto("/");
const card = frigateApp.page.locator("[data-camera='front_door']").first();
await card.click({ timeout: 10_000 });
await expect(frigateApp.page).toHaveURL(/#front_door/);
});
test("back button returns from single camera to dashboard", async ({
frigateApp,
}) => {
// First navigate to dashboard so there's history to go back to
await frigateApp.goto("/");
await frigateApp.page.waitForTimeout(1000);
// Click a camera to enter single view
const card = frigateApp.page.locator("[data-camera='front_door']").first();
await card.click({ timeout: 10_000 });
await frigateApp.page.waitForTimeout(2000);
// Now click Back to return to dashboard
const backBtn = frigateApp.page.getByText("Back", { exact: true });
if (await backBtn.isVisible().catch(() => false)) {
await backBtn.click();
await frigateApp.page.waitForTimeout(1000);
}
// Should be back on the dashboard with cameras visible
await expect(
frigateApp.page.locator("[data-camera='front_door']"),
).toBeVisible({ timeout: 10_000 });
});
test("birdseye view loads without crash", async ({ frigateApp }) => {
await frigateApp.goto("/#birdseye");
await frigateApp.page.waitForTimeout(2000);
await expect(frigateApp.page.locator("body")).toBeVisible();
});
test("empty group shows fallback content", async ({ frigateApp }) => {
await frigateApp.page.goto("/?group=nonexistent");
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
});
test.describe("Live Single Camera - Controls @critical", () => {
test("single camera view shows Back and History buttons (desktop)", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip(); // On mobile, buttons may show icons only
return;
}
await frigateApp.goto("/#front_door");
await frigateApp.page.waitForTimeout(2000);
// Back and History are visible text buttons in the header
await expect(
frigateApp.page.getByText("Back", { exact: true }),
).toBeVisible({ timeout: 5_000 });
await expect(
frigateApp.page.getByText("History", { exact: true }),
).toBeVisible();
});
test("single camera view shows feature toggle icons (desktop)", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/#front_door");
await frigateApp.page.waitForTimeout(2000);
// Feature toggles are CameraFeatureToggle components rendered as divs
// with bg-selected (active) or bg-secondary (inactive) classes
// Count the toggles - should have at least detect, recording, snapshots
const toggles = frigateApp.page.locator(
".flex.flex-col.items-center.justify-center.bg-selected, .flex.flex-col.items-center.justify-center.bg-secondary",
);
const count = await toggles.count();
expect(count).toBeGreaterThanOrEqual(3);
});
test("clicking a feature toggle changes its visual state (desktop)", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/#front_door");
await frigateApp.page.waitForTimeout(2000);
// Find active toggles (bg-selected class = feature is ON)
const activeToggles = frigateApp.page.locator(
".flex.flex-col.items-center.justify-center.bg-selected",
);
const initialCount = await activeToggles.count();
if (initialCount > 0) {
// Click the first active toggle to disable it
await activeToggles.first().click();
await frigateApp.page.waitForTimeout(1000);
// After WS mock echoes back new state, count should decrease
const newCount = await activeToggles.count();
expect(newCount).toBeLessThan(initialCount);
}
});
test("settings gear button opens dropdown (desktop)", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/#front_door");
await frigateApp.page.waitForTimeout(2000);
// Find the gear icon button (last button-like element in header)
// The settings gear opens a dropdown with Stream, Play in background, etc.
const gearButtons = frigateApp.page.locator("button:has(svg)");
const count = await gearButtons.count();
// Click the last one (gear icon is typically last in the header)
if (count > 0) {
await gearButtons.last().click();
await frigateApp.page.waitForTimeout(500);
// A dropdown or drawer should appear
const overlay = frigateApp.page.locator(
'[role="menu"], [data-radix-menu-content], [role="dialog"]',
);
const visible = await overlay
.first()
.isVisible()
.catch(() => false);
if (visible) {
await frigateApp.page.keyboard.press("Escape");
}
}
});
test("keyboard shortcut f does not crash on desktop", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/");
await frigateApp.page.keyboard.press("f");
await frigateApp.page.waitForTimeout(500);
await expect(frigateApp.page.locator("body")).toBeVisible();
});
});
test.describe("Live Single Camera - Mobile Controls @critical", () => {
test("mobile camera view has settings drawer trigger", async ({
frigateApp,
}) => {
if (!frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/#front_door");
await frigateApp.page.waitForTimeout(2000);
// On mobile, settings gear opens a drawer
// The button has aria-label with the camera name like "front_door Settings"
const buttons = frigateApp.page.locator("button:has(svg)");
const count = await buttons.count();
expect(count).toBeGreaterThan(0);
});
});
test.describe("Live Context Menu @critical", () => {
test("right-click on camera opens context menu on desktop", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/");
const card = frigateApp.page.locator("[data-camera='front_door']").first();
await card.waitFor({ state: "visible", timeout: 10_000 });
await card.click({ button: "right" });
const contextMenu = frigateApp.page.locator(
'[role="menu"], [data-radix-menu-content]',
);
await expect(contextMenu.first()).toBeVisible({ timeout: 5_000 });
});
test("context menu closes on escape", async ({ frigateApp }) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/");
const card = frigateApp.page.locator("[data-camera='front_door']").first();
await card.waitFor({ state: "visible", timeout: 10_000 });
await card.click({ button: "right" });
await frigateApp.page.waitForTimeout(500);
await frigateApp.page.keyboard.press("Escape");
await frigateApp.page.waitForTimeout(300);
const contextMenu = frigateApp.page.locator(
'[role="menu"], [data-radix-menu-content]',
);
await expect(contextMenu).not.toBeVisible();
});
});
test.describe("Live Mobile Layout @critical", () => {
test("mobile renders cameras without sidebar", async ({ frigateApp }) => {
if (!frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/");
await expect(frigateApp.page.locator("aside")).not.toBeVisible();
await expect(
frigateApp.page.locator("[data-camera='front_door']"),
).toBeVisible({ timeout: 10_000 });
});
test("mobile camera click opens single camera view", async ({
frigateApp,
}) => {
if (!frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/");
const card = frigateApp.page.locator("[data-camera='front_door']").first();
await card.click({ timeout: 10_000 });
await expect(frigateApp.page).toHaveURL(/#front_door/);
});
});

View File

@ -0,0 +1,75 @@
/**
* Logs page tests -- MEDIUM tier.
*
* Tests service tab switching by name, copy/download buttons,
* and websocket message feed tab.
*/
import { test, expect } from "../fixtures/frigate-test";
test.describe("Logs Page - Service Tabs @medium", () => {
test("logs page renders with named service tabs", async ({ frigateApp }) => {
await frigateApp.goto("/logs");
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
// Service tabs have aria-label="Select {service}"
await expect(frigateApp.page.getByLabel("Select frigate")).toBeVisible({
timeout: 5_000,
});
});
test("switching to go2rtc tab changes active tab", async ({ frigateApp }) => {
await frigateApp.goto("/logs");
await frigateApp.page.waitForTimeout(1000);
const go2rtcTab = frigateApp.page.getByLabel("Select go2rtc");
if (await go2rtcTab.isVisible().catch(() => false)) {
await go2rtcTab.click();
await frigateApp.page.waitForTimeout(1000);
await expect(go2rtcTab).toHaveAttribute("data-state", "on");
}
});
test("switching to websocket tab shows message feed", async ({
frigateApp,
}) => {
await frigateApp.goto("/logs");
await frigateApp.page.waitForTimeout(1000);
const wsTab = frigateApp.page.getByLabel("Select websocket");
if (await wsTab.isVisible().catch(() => false)) {
await wsTab.click();
await frigateApp.page.waitForTimeout(1000);
await expect(wsTab).toHaveAttribute("data-state", "on");
}
});
});
test.describe("Logs Page - Actions @medium", () => {
test("copy to clipboard button is present and clickable", async ({
frigateApp,
}) => {
await frigateApp.goto("/logs");
await frigateApp.page.waitForTimeout(1000);
const copyBtn = frigateApp.page.getByLabel("Copy to Clipboard");
if (await copyBtn.isVisible().catch(() => false)) {
await copyBtn.click();
await frigateApp.page.waitForTimeout(500);
// Should trigger clipboard copy (toast may appear)
}
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
test("download logs button is present", async ({ frigateApp }) => {
await frigateApp.goto("/logs");
await frigateApp.page.waitForTimeout(1000);
const downloadBtn = frigateApp.page.getByLabel("Download Logs");
if (await downloadBtn.isVisible().catch(() => false)) {
await expect(downloadBtn).toBeVisible();
}
});
test("logs page displays log content text", async ({ frigateApp }) => {
await frigateApp.goto("/logs");
await frigateApp.page.waitForTimeout(2000);
const text = await frigateApp.page.textContent("#pageRoot");
expect(text?.length).toBeGreaterThan(0);
});
});

View File

@ -0,0 +1,227 @@
/**
* Navigation tests -- CRITICAL tier.
*
* Tests sidebar (desktop) and bottombar (mobile) navigation,
* conditional nav items, settings menus, and their actual behaviors.
*/
import { test, expect } from "../fixtures/frigate-test";
import { BasePage } from "../pages/base.page";
test.describe("Navigation @critical", () => {
test("app loads and renders page root", async ({ frigateApp }) => {
await frigateApp.goto("/");
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
test("logo is visible and links to home", async ({ frigateApp }) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/");
const base = new BasePage(frigateApp.page, true);
const logo = base.sidebar.locator('a[href="/"]').first();
await expect(logo).toBeVisible();
});
test("all primary nav links are present and navigate", async ({
frigateApp,
}) => {
await frigateApp.goto("/");
const routes = ["/review", "/explore", "/export"];
for (const route of routes) {
await expect(
frigateApp.page.locator(`a[href="${route}"]`).first(),
).toBeVisible();
}
// Verify clicking each one actually navigates
const base = new BasePage(frigateApp.page, !frigateApp.isMobile);
for (const route of routes) {
await base.navigateTo(route);
await expect(frigateApp.page).toHaveURL(new RegExp(route));
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
}
});
test("desktop sidebar is visible, mobile bottombar is visible", async ({
frigateApp,
}) => {
await frigateApp.goto("/");
const base = new BasePage(frigateApp.page, !frigateApp.isMobile);
if (!frigateApp.isMobile) {
await expect(base.sidebar).toBeVisible();
} else {
await expect(base.sidebar).not.toBeVisible();
}
});
test("navigate between all main pages without crash", async ({
frigateApp,
}) => {
await frigateApp.goto("/");
const base = new BasePage(frigateApp.page, !frigateApp.isMobile);
const pageRoot = frigateApp.page.locator("#pageRoot");
await base.navigateTo("/review");
await expect(pageRoot).toBeVisible({ timeout: 10_000 });
await base.navigateTo("/explore");
await expect(pageRoot).toBeVisible({ timeout: 10_000 });
await base.navigateTo("/export");
await expect(pageRoot).toBeVisible({ timeout: 10_000 });
await base.navigateTo("/review");
await expect(pageRoot).toBeVisible({ timeout: 10_000 });
});
test("unknown route redirects to home", async ({ frigateApp }) => {
await frigateApp.page.goto("/nonexistent-route");
await frigateApp.page.waitForTimeout(2000);
const url = frigateApp.page.url();
const hasPageRoot = await frigateApp.page
.locator("#pageRoot")
.isVisible()
.catch(() => false);
expect(url.endsWith("/") || hasPageRoot).toBeTruthy();
});
});
test.describe("Navigation - Conditional Items @critical", () => {
test("Faces nav hidden when face_recognition disabled", async ({
frigateApp,
}) => {
await frigateApp.goto("/");
await expect(frigateApp.page.locator('a[href="/faces"]')).not.toBeVisible();
});
test("Chat nav hidden when genai model is none", async ({ frigateApp }) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.installDefaults({
config: {
genai: {
enabled: false,
provider: "ollama",
model: "none",
base_url: "",
},
},
});
await frigateApp.goto("/");
await expect(frigateApp.page.locator('a[href="/chat"]')).not.toBeVisible();
});
test("Faces nav visible when face_recognition enabled on desktop", async ({
frigateApp,
page,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.installDefaults({
config: { face_recognition: { enabled: true } },
});
await frigateApp.goto("/");
await expect(page.locator('a[href="/faces"]')).toBeVisible();
});
test("Chat nav visible when genai model set on desktop", async ({
frigateApp,
page,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.installDefaults({
config: { genai: { enabled: true, model: "llava" } },
});
await frigateApp.goto("/");
await expect(page.locator('a[href="/chat"]')).toBeVisible();
});
test("Classification nav visible for admin on desktop", async ({
frigateApp,
page,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/");
await expect(page.locator('a[href="/classification"]')).toBeVisible();
});
});
test.describe("Navigation - Settings Menu @critical", () => {
test("settings gear opens menu with navigation items (desktop)", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/");
// Settings gear is in the sidebar bottom section, a div with cursor-pointer
const sidebarBottom = frigateApp.page.locator("aside .mb-8");
const gearIcon = sidebarBottom
.locator("div[class*='cursor-pointer']")
.first();
await expect(gearIcon).toBeVisible({ timeout: 5_000 });
await gearIcon.click();
// Menu should open - look for the "Settings" menu item by aria-label
await expect(frigateApp.page.getByLabel("Settings")).toBeVisible({
timeout: 3_000,
});
});
test("settings menu items navigate to correct routes (desktop)", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
const targets = [
{ label: "Settings", url: "/settings" },
{ label: "System metrics", url: "/system" },
{ label: "System logs", url: "/logs" },
{ label: "Configuration Editor", url: "/config" },
];
for (const target of targets) {
await frigateApp.goto("/");
const gearIcon = frigateApp.page
.locator("aside .mb-8 div[class*='cursor-pointer']")
.first();
await gearIcon.click();
await frigateApp.page.waitForTimeout(300);
const menuItem = frigateApp.page.getByLabel(target.label);
if (await menuItem.isVisible().catch(() => false)) {
await menuItem.click();
await expect(frigateApp.page).toHaveURL(
new RegExp(target.url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")),
);
}
}
});
test("account button in sidebar is clickable (desktop)", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/");
const sidebarBottom = frigateApp.page.locator("aside .mb-8");
const items = sidebarBottom.locator("div[class*='cursor-pointer']");
const count = await items.count();
if (count >= 2) {
await items.nth(1).click();
await frigateApp.page.waitForTimeout(500);
}
await expect(frigateApp.page.locator("body")).toBeVisible();
});
});

View File

@ -0,0 +1,23 @@
/**
* Replay page tests -- LOW tier.
*
* Tests replay page rendering and basic interactivity.
*/
import { test, expect } from "../fixtures/frigate-test";
test.describe("Replay Page @low", () => {
test("replay page renders without crash", async ({ frigateApp }) => {
await frigateApp.goto("/replay");
await frigateApp.page.waitForTimeout(2000);
await expect(frigateApp.page.locator("body")).toBeVisible();
});
test("replay page has interactive controls", async ({ frigateApp }) => {
await frigateApp.goto("/replay");
await frigateApp.page.waitForTimeout(2000);
const buttons = frigateApp.page.locator("button");
const count = await buttons.count();
expect(count).toBeGreaterThan(0);
});
});

View File

@ -0,0 +1,200 @@
/**
* Review/Events page tests -- CRITICAL tier.
*
* Tests severity tab switching by name (Alerts/Detections/Motion),
* filter popover opening with camera names, show reviewed toggle,
* calendar button, and filter button interactions.
*/
import { test, expect } from "../fixtures/frigate-test";
import { BasePage } from "../pages/base.page";
test.describe("Review Page - Severity Tabs @critical", () => {
test("severity tabs render with Alerts, Detections, Motion", async ({
frigateApp,
}) => {
await frigateApp.goto("/review");
await expect(frigateApp.page.getByLabel("Alerts")).toBeVisible({
timeout: 10_000,
});
await expect(frigateApp.page.getByLabel("Detections")).toBeVisible();
// Motion uses role="radio" to distinguish from other Motion elements
await expect(
frigateApp.page.getByRole("radio", { name: "Motion" }),
).toBeVisible();
});
test("Alerts tab is active by default", async ({ frigateApp }) => {
await frigateApp.goto("/review");
await frigateApp.page.waitForTimeout(1000);
const alertsTab = frigateApp.page.getByLabel("Alerts");
await expect(alertsTab).toHaveAttribute("data-state", "on");
});
test("clicking Detections tab makes it active and deactivates Alerts", async ({
frigateApp,
}) => {
await frigateApp.goto("/review");
await frigateApp.page.waitForTimeout(1000);
const alertsTab = frigateApp.page.getByLabel("Alerts");
const detectionsTab = frigateApp.page.getByLabel("Detections");
await detectionsTab.click();
await frigateApp.page.waitForTimeout(500);
await expect(detectionsTab).toHaveAttribute("data-state", "on");
await expect(alertsTab).toHaveAttribute("data-state", "off");
});
test("clicking Motion tab makes it active", async ({ frigateApp }) => {
await frigateApp.goto("/review");
await frigateApp.page.waitForTimeout(1000);
const motionTab = frigateApp.page.getByRole("radio", { name: "Motion" });
await motionTab.click();
await frigateApp.page.waitForTimeout(500);
await expect(motionTab).toHaveAttribute("data-state", "on");
});
test("switching back to Alerts from Detections works", async ({
frigateApp,
}) => {
await frigateApp.goto("/review");
await frigateApp.page.waitForTimeout(1000);
await frigateApp.page.getByLabel("Detections").click();
await frigateApp.page.waitForTimeout(300);
await frigateApp.page.getByLabel("Alerts").click();
await frigateApp.page.waitForTimeout(300);
await expect(frigateApp.page.getByLabel("Alerts")).toHaveAttribute(
"data-state",
"on",
);
});
});
test.describe("Review Page - Filters @critical", () => {
test("All Cameras filter button opens popover with camera names", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/review");
await frigateApp.page.waitForTimeout(1000);
const camerasBtn = frigateApp.page.getByRole("button", {
name: /cameras/i,
});
await expect(camerasBtn).toBeVisible({ timeout: 5_000 });
await camerasBtn.click();
await frigateApp.page.waitForTimeout(500);
// Popover should open with camera names from config
const popover = frigateApp.page.locator(
"[data-radix-popper-content-wrapper]",
);
await expect(popover.first()).toBeVisible({ timeout: 3_000 });
// Camera names should be present
await expect(frigateApp.page.getByText("Front Door")).toBeVisible();
await frigateApp.page.keyboard.press("Escape");
});
test("Show Reviewed toggle is clickable", async ({ frigateApp }) => {
await frigateApp.goto("/review");
await frigateApp.page.waitForTimeout(1000);
const showReviewed = frigateApp.page.getByRole("button", {
name: /reviewed/i,
});
if (await showReviewed.isVisible().catch(() => false)) {
await showReviewed.click();
await frigateApp.page.waitForTimeout(500);
// Toggle should change state
await expect(frigateApp.page.locator("body")).toBeVisible();
}
});
test("Last 24 Hours calendar button opens date picker", async ({
frigateApp,
}) => {
await frigateApp.goto("/review");
await frigateApp.page.waitForTimeout(1000);
const calendarBtn = frigateApp.page.getByRole("button", {
name: /24 hours|calendar|date/i,
});
if (await calendarBtn.isVisible().catch(() => false)) {
await calendarBtn.click();
await frigateApp.page.waitForTimeout(500);
// Popover should open
const popover = frigateApp.page.locator(
"[data-radix-popper-content-wrapper]",
);
if (
await popover
.first()
.isVisible()
.catch(() => false)
) {
await frigateApp.page.keyboard.press("Escape");
}
}
});
test("Filter button opens filter popover", async ({ frigateApp }) => {
await frigateApp.goto("/review");
await frigateApp.page.waitForTimeout(1000);
const filterBtn = frigateApp.page.getByRole("button", {
name: /^filter$/i,
});
if (await filterBtn.isVisible().catch(() => false)) {
await filterBtn.click();
await frigateApp.page.waitForTimeout(500);
// Popover or dialog should open
const popover = frigateApp.page.locator(
"[data-radix-popper-content-wrapper], [role='dialog']",
);
if (
await popover
.first()
.isVisible()
.catch(() => false)
) {
await frigateApp.page.keyboard.press("Escape");
}
}
});
});
test.describe("Review Page - Timeline @critical", () => {
test("review page has timeline with time markers (desktop)", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/review");
await frigateApp.page.waitForTimeout(2000);
// Timeline renders time labels like "4:30 PM"
const pageText = await frigateApp.page.textContent("#pageRoot");
expect(pageText).toMatch(/[AP]M/);
});
});
test.describe("Review Page - Navigation @critical", () => {
test("navigate to review from live page works", async ({ frigateApp }) => {
await frigateApp.goto("/");
const base = new BasePage(frigateApp.page, !frigateApp.isMobile);
await base.navigateTo("/review");
await expect(frigateApp.page).toHaveURL(/\/review/);
// Severity tabs should be visible
await expect(frigateApp.page.getByLabel("Alerts")).toBeVisible({
timeout: 10_000,
});
});
});

View File

@ -0,0 +1,40 @@
/**
* Settings page tests -- HIGH tier.
*
* Tests settings page rendering with content, form controls,
* and section navigation.
*/
import { test, expect } from "../../fixtures/frigate-test";
test.describe("Settings Page @high", () => {
test("settings page renders with content", async ({ frigateApp }) => {
await frigateApp.goto("/settings");
await frigateApp.page.waitForTimeout(2000);
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
const text = await frigateApp.page.textContent("#pageRoot");
expect(text?.length).toBeGreaterThan(0);
});
test("settings page has clickable navigation items", async ({
frigateApp,
}) => {
await frigateApp.goto("/settings");
await frigateApp.page.waitForTimeout(2000);
const navItems = frigateApp.page.locator(
"#pageRoot button, #pageRoot [role='button'], #pageRoot a",
);
const count = await navItems.count();
expect(count).toBeGreaterThan(0);
});
test("settings page has form controls", async ({ frigateApp }) => {
await frigateApp.goto("/settings");
await frigateApp.page.waitForTimeout(2000);
const formElements = frigateApp.page.locator(
'#pageRoot input, #pageRoot button[role="switch"], #pageRoot button[role="combobox"]',
);
const count = await formElements.count();
expect(count).toBeGreaterThanOrEqual(0);
});
});

View File

@ -0,0 +1,90 @@
/**
* System page tests -- MEDIUM tier.
*
* Tests system page rendering with tabs and tab switching.
* Navigates to /system#general explicitly so useHashState resolves
* the tab state deterministically.
*/
import { test, expect } from "../fixtures/frigate-test";
test.describe("System Page @medium", () => {
test("system page renders with tab buttons", async ({ frigateApp }) => {
await frigateApp.goto("/system#general");
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
"data-state",
"on",
{ timeout: 15_000 },
);
await expect(frigateApp.page.getByLabel("Select storage")).toBeVisible();
await expect(frigateApp.page.getByLabel("Select cameras")).toBeVisible();
});
test("general tab is active when navigated via hash", async ({
frigateApp,
}) => {
await frigateApp.goto("/system#general");
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
"data-state",
"on",
{ timeout: 15_000 },
);
});
test("clicking Storage tab activates it and deactivates General", async ({
frigateApp,
}) => {
await frigateApp.goto("/system#general");
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
"data-state",
"on",
{ timeout: 15_000 },
);
await frigateApp.page.getByLabel("Select storage").click();
await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute(
"data-state",
"on",
{ timeout: 5_000 },
);
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
"data-state",
"off",
);
});
test("clicking Cameras tab activates it and deactivates General", async ({
frigateApp,
}) => {
await frigateApp.goto("/system#general");
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
"data-state",
"on",
{ timeout: 15_000 },
);
await frigateApp.page.getByLabel("Select cameras").click();
await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute(
"data-state",
"on",
{ timeout: 5_000 },
);
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
"data-state",
"off",
);
});
test("system page shows version and last refreshed", async ({
frigateApp,
}) => {
await frigateApp.goto("/system#general");
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
"data-state",
"on",
{ timeout: 15_000 },
);
await expect(frigateApp.page.getByText("0.15.0-test")).toBeVisible();
await expect(frigateApp.page.getByText(/Last refreshed/)).toBeVisible();
});
});

72
web/package-lock.json generated
View File

@ -89,6 +89,7 @@
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.59.1",
"@tailwindcss/forms": "^0.5.9", "@tailwindcss/forms": "^0.5.9",
"@testing-library/jest-dom": "^6.6.2", "@testing-library/jest-dom": "^6.6.2",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
@ -121,7 +122,7 @@
"prettier-plugin-tailwindcss": "^0.6.5", "prettier-plugin-tailwindcss": "^0.6.5",
"tailwindcss": "^3.4.9", "tailwindcss": "^3.4.9",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^6.4.1", "vite": "^6.4.2",
"vitest": "^3.0.7" "vitest": "^3.0.7"
} }
}, },
@ -1485,6 +1486,22 @@
"url": "https://opencollective.com/unts" "url": "https://opencollective.com/unts"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@radix-ui/number": { "node_modules/@radix-ui/number": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz",
@ -11336,6 +11353,53 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/playwright": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.8", "version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
@ -13793,9 +13857,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "6.4.1", "version": "6.4.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View File

@ -13,6 +13,10 @@
"prettier:write": "prettier -u -w --ignore-path .gitignore \"*.{ts,tsx,js,jsx,css,html}\"", "prettier:write": "prettier -u -w --ignore-path .gitignore \"*.{ts,tsx,js,jsx,css,html}\"",
"test": "vitest", "test": "vitest",
"coverage": "vitest run --coverage", "coverage": "vitest run --coverage",
"e2e:build": "tsc && vite build --base=/",
"e2e": "playwright test --config e2e/playwright.config.ts",
"e2e:ui": "playwright test --config e2e/playwright.config.ts --ui",
"e2e:headed": "playwright test --config e2e/playwright.config.ts --headed",
"i18n:extract": "i18next-cli extract", "i18n:extract": "i18next-cli extract",
"i18n:extract:ci": "i18next-cli extract --ci", "i18n:extract:ci": "i18next-cli extract --ci",
"i18n:status": "i18next-cli status" "i18n:status": "i18next-cli status"
@ -98,6 +102,7 @@
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.59.1",
"@tailwindcss/forms": "^0.5.9", "@tailwindcss/forms": "^0.5.9",
"@testing-library/jest-dom": "^6.6.2", "@testing-library/jest-dom": "^6.6.2",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
@ -130,7 +135,7 @@
"prettier-plugin-tailwindcss": "^0.6.5", "prettier-plugin-tailwindcss": "^0.6.5",
"tailwindcss": "^3.4.9", "tailwindcss": "^3.4.9",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^6.4.1", "vite": "^6.4.2",
"vitest": "^3.0.7" "vitest": "^3.0.7"
}, },
"overrides": { "overrides": {

View File

@ -62,7 +62,10 @@
"zones": "Zones", "zones": "Zones",
"ratio": "Ratio", "ratio": "Ratio",
"area": "Area", "area": "Area",
"score": "Score" "score": "Score",
"computedScore": "Computed Score",
"topScore": "Top Score",
"toggleAdvancedScores": "Toggle advanced scores"
} }
}, },
"annotationSettings": { "annotationSettings": {

View File

@ -1326,6 +1326,10 @@
"keyPlaceholder": "New key", "keyPlaceholder": "New key",
"remove": "Remove" "remove": "Remove"
}, },
"knownPlates": {
"namePlaceholder": "e.g., Wife's Car",
"platePlaceholder": "Plate number or regex"
},
"timezone": { "timezone": {
"defaultOption": "Use browser timezone" "defaultOption": "Use browser timezone"
}, },

View File

@ -275,7 +275,7 @@ export default function ReviewCard({
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
<ContextMenu key={event.id}> <ContextMenu key={event.id} modal={false}>
<ContextMenuTrigger asChild>{content}</ContextMenuTrigger> <ContextMenuTrigger asChild>{content}</ContextMenuTrigger>
<ContextMenuContent> <ContextMenuContent>
<ContextMenuItem> <ContextMenuItem>

View File

@ -67,6 +67,13 @@ const lpr: SectionConfigOverrides = {
format: { format: {
"ui:options": { size: "md" }, "ui:options": { size: "md" },
}, },
known_plates: {
"ui:field": "KnownPlatesField",
"ui:options": {
label: false,
suppressDescription: true,
},
},
replace_rules: { replace_rules: {
"ui:field": "ReplaceRulesField", "ui:field": "ReplaceRulesField",
"ui:options": { "ui:options": {

View File

@ -16,6 +16,16 @@ const record: SectionConfigOverrides = {
}, },
}, },
], ],
fieldDocs: {
"alerts.pre_capture":
"/configuration/record#pre-capture-and-post-capture",
"alerts.post_capture":
"/configuration/record#pre-capture-and-post-capture",
"detections.pre_capture":
"/configuration/record#pre-capture-and-post-capture",
"detections.post_capture":
"/configuration/record#pre-capture-and-post-capture",
},
restartRequired: [], restartRequired: [],
fieldOrder: [ fieldOrder: [
"enabled", "enabled",

View File

@ -1,4 +1,4 @@
import { useMemo } from "react"; import { useEffect, useMemo, useRef } from "react";
import useSWR from "swr"; import useSWR from "swr";
import { Trans } from "react-i18next"; import { Trans } from "react-i18next";
import Heading from "@/components/ui/heading"; import Heading from "@/components/ui/heading";
@ -37,6 +37,21 @@ export default function CameraReviewStatusToggles({
const { payload: revDescState, send: sendRevDesc } = const { payload: revDescState, send: sendRevDesc } =
useReviewDescriptionState(cameraId); useReviewDescriptionState(cameraId);
// Sync WS runtime state when review genai transitions from disabled to enabled in config
const prevRevGenaiEnabled = useRef(
cameraConfig?.review?.genai?.enabled_in_config,
);
useEffect(() => {
const wasEnabled = prevRevGenaiEnabled.current;
const isEnabled = cameraConfig?.review?.genai?.enabled_in_config;
prevRevGenaiEnabled.current = isEnabled;
if (!wasEnabled && isEnabled) {
sendRevDesc("ON");
}
}, [cameraConfig?.review?.genai?.enabled_in_config, sendRevDesc]);
if (!selectedCamera || !cameraConfig) { if (!selectedCamera || !cameraConfig) {
return null; return null;
} }

View File

@ -0,0 +1,277 @@
import type { FieldPathList, FieldProps, RJSFSchema } from "@rjsf/utils";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import {
LuChevronDown,
LuChevronRight,
LuPlus,
LuTrash2,
} from "react-icons/lu";
import type { ConfigFormContext } from "@/types/configForm";
import get from "lodash/get";
import { isSubtreeModified } from "../utils";
type KnownPlatesData = Record<string, string[]>;
export function KnownPlatesField(props: FieldProps) {
const { schema, formData, onChange, idSchema, disabled, readonly } = props;
const formContext = props.registry?.formContext as
| ConfigFormContext
| undefined;
const { t } = useTranslation(["views/settings", "common"]);
const data: KnownPlatesData = useMemo(() => {
if (!formData || typeof formData !== "object" || Array.isArray(formData)) {
return {};
}
return formData as KnownPlatesData;
}, [formData]);
const entries = useMemo(() => Object.entries(data), [data]);
const title = (schema as RJSFSchema).title;
const description = (schema as RJSFSchema).description;
const hasItems = entries.length > 0;
const emptyPath = useMemo(() => [] as FieldPathList, []);
const fieldPath =
(props as { fieldPathId?: { path?: FieldPathList } }).fieldPathId?.path ??
emptyPath;
const isModified = useMemo(() => {
const baselineRoot = formContext?.baselineFormData;
const baselineValue = baselineRoot
? get(baselineRoot, fieldPath)
: undefined;
return isSubtreeModified(
data,
baselineValue,
formContext?.overrides,
fieldPath,
formContext?.formData,
);
}, [fieldPath, formContext, data]);
const [open, setOpen] = useState(hasItems || isModified);
useEffect(() => {
if (isModified) {
setOpen(true);
}
}, [isModified]);
useEffect(() => {
if (hasItems) {
setOpen(true);
}
}, [hasItems]);
const handleAddEntry = useCallback(() => {
const next = { ...data, "": [""] };
onChange(next, fieldPath);
}, [data, fieldPath, onChange]);
const handleRemoveEntry = useCallback(
(key: string) => {
const next = { ...data };
delete next[key];
onChange(next, fieldPath);
},
[data, fieldPath, onChange],
);
const handleRenameKey = useCallback(
(oldKey: string, newKey: string) => {
if (oldKey === newKey) return;
// Preserve order by rebuilding the object
const next: KnownPlatesData = {};
for (const [k, v] of Object.entries(data)) {
if (k === oldKey) {
next[newKey] = v;
} else {
next[k] = v;
}
}
onChange(next, fieldPath);
},
[data, fieldPath, onChange],
);
const handleAddPlate = useCallback(
(key: string) => {
const next = { ...data, [key]: [...(data[key] || []), ""] };
onChange(next, fieldPath);
},
[data, fieldPath, onChange],
);
const handleRemovePlate = useCallback(
(key: string, plateIndex: number) => {
const plates = [...(data[key] || [])];
plates.splice(plateIndex, 1);
const next = { ...data, [key]: plates };
onChange(next, fieldPath);
},
[data, fieldPath, onChange],
);
const handleUpdatePlate = useCallback(
(key: string, plateIndex: number, value: string) => {
const plates = [...(data[key] || [])];
plates[plateIndex] = value;
const next = { ...data, [key]: plates };
onChange(next, fieldPath);
},
[data, fieldPath, onChange],
);
const baseId = idSchema?.$id || "known_plates";
const deleteLabel = t("button.delete", {
ns: "common",
defaultValue: "Delete",
});
const namePlaceholder = t("configForm.knownPlates.namePlaceholder", {
ns: "views/settings",
});
const platePlaceholder = t("configForm.knownPlates.platePlaceholder", {
ns: "views/settings",
});
return (
<Card className="w-full">
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer p-4 transition-colors hover:bg-muted/50">
<div className="flex items-center justify-between">
<div>
<CardTitle
className={cn("text-sm", isModified && "text-danger")}
>
{title}
</CardTitle>
{description && (
<p className="mt-1 text-xs text-muted-foreground">
{description}
</p>
)}
</div>
{open ? (
<LuChevronDown className="h-4 w-4" />
) : (
<LuChevronRight className="h-4 w-4" />
)}
</div>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-3 p-4 pt-0">
{entries.map(([key, plates], entryIndex) => {
const entryId = `${baseId}-${entryIndex}`;
return (
<div
key={entryIndex}
className="space-y-2 rounded-md border p-3"
>
<div className="flex items-center gap-2">
<Input
id={`${entryId}-key`}
defaultValue={key}
placeholder={namePlaceholder}
disabled={disabled || readonly}
onBlur={(e) => handleRenameKey(key, e.target.value)}
className="flex-1"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveEntry(key)}
disabled={disabled || readonly}
aria-label={deleteLabel}
title={deleteLabel}
className="shrink-0"
>
<LuTrash2 className="h-4 w-4" />
</Button>
</div>
<div className="ml-1 space-y-2 border-l-2 border-muted-foreground/20 pl-3">
{plates.map((plate, plateIndex) => (
<div key={plateIndex} className="flex items-center gap-2">
<Input
id={`${entryId}-plate-${plateIndex}`}
value={plate}
placeholder={platePlaceholder}
disabled={disabled || readonly}
onChange={(e) =>
handleUpdatePlate(key, plateIndex, e.target.value)
}
className="flex-1"
/>
{plates.length > 1 && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemovePlate(key, plateIndex)}
disabled={disabled || readonly}
aria-label={deleteLabel}
title={deleteLabel}
className="shrink-0"
>
<LuTrash2 className="h-4 w-4" />
</Button>
)}
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleAddPlate(key)}
disabled={disabled || readonly}
className="gap-2"
>
<LuPlus className="h-4 w-4" />
{t("button.add", {
ns: "common",
defaultValue: "Add",
})}
</Button>
</div>
</div>
);
})}
<div>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddEntry}
disabled={disabled || readonly}
className="gap-2"
>
<LuPlus className="h-4 w-4" />
{t("button.add", { ns: "common", defaultValue: "Add" })}
</Button>
</div>
</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
);
}
export default KnownPlatesField;

View File

@ -49,6 +49,7 @@ import { DetectorHardwareField } from "./fields/DetectorHardwareField";
import { ReplaceRulesField } from "./fields/ReplaceRulesField"; import { ReplaceRulesField } from "./fields/ReplaceRulesField";
import { CameraInputsField } from "./fields/CameraInputsField"; import { CameraInputsField } from "./fields/CameraInputsField";
import { DictAsYamlField } from "./fields/DictAsYamlField"; import { DictAsYamlField } from "./fields/DictAsYamlField";
import { KnownPlatesField } from "./fields/KnownPlatesField";
export interface FrigateTheme { export interface FrigateTheme {
widgets: RegistryWidgetsType; widgets: RegistryWidgetsType;
@ -105,5 +106,6 @@ export const frigateTheme: FrigateTheme = {
ReplaceRulesField: ReplaceRulesField, ReplaceRulesField: ReplaceRulesField,
CameraInputsField: CameraInputsField, CameraInputsField: CameraInputsField,
DictAsYamlField: DictAsYamlField, DictAsYamlField: DictAsYamlField,
KnownPlatesField: KnownPlatesField,
}, },
}; };

View File

@ -1,6 +1,6 @@
// Combobox widget for genai *.model fields. // Combobox widget for genai *.model fields.
// Fetches available models from the provider's backend and shows them in a dropdown. // Fetches available models from the provider's backend and shows them in a dropdown.
import { useState, useMemo } from "react"; import { useState, useMemo, useEffect, useRef } from "react";
import type { WidgetProps } from "@rjsf/utils"; import type { WidgetProps } from "@rjsf/utils";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import useSWR from "swr"; import useSWR from "swr";
@ -19,6 +19,7 @@ import {
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import type { ConfigFormContext } from "@/types/configForm";
import { getSizedFieldClassName } from "../utils"; import { getSizedFieldClassName } from "../utils";
/** /**
@ -37,17 +38,45 @@ function getProviderKey(widgetId: string): string | undefined {
} }
export function GenAIModelWidget(props: WidgetProps) { export function GenAIModelWidget(props: WidgetProps) {
const { id, value, disabled, readonly, onChange, options } = props; const { id, value, disabled, readonly, onChange, options, registry } = props;
const { t } = useTranslation(["views/settings"]); const { t } = useTranslation(["views/settings"]);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const fieldClassName = getSizedFieldClassName(options, "sm"); const fieldClassName = getSizedFieldClassName(options, "sm");
const providerKey = useMemo(() => getProviderKey(id), [id]); const providerKey = useMemo(() => getProviderKey(id), [id]);
const { data: allModels } = useSWR<Record<string, string[]>>("genai/models", { const formContext = registry?.formContext as ConfigFormContext | undefined;
// Build a fingerprint from the saved config's provider + base_url so the
// SWR key changes (and models are refetched) whenever those fields are saved.
const configFingerprint = useMemo(() => {
if (!providerKey) return "";
const genai = (
formContext?.fullConfig as Record<string, unknown> | undefined
)?.genai;
if (!genai || typeof genai !== "object" || Array.isArray(genai)) return "";
const entry = (genai as Record<string, unknown>)[providerKey];
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return "";
const e = entry as Record<string, unknown>;
return `${e.provider ?? ""}|${e.base_url ?? ""}`;
}, [providerKey, formContext?.fullConfig]);
const { data: allModels, mutate: mutateModels } = useSWR<
Record<string, string[]>
>("genai/models", {
revalidateOnFocus: false, revalidateOnFocus: false,
}); });
// Revalidate models when the saved config fingerprint changes (e.g. after
// switching provider or base_url and saving).
const prevFingerprint = useRef(configFingerprint);
useEffect(() => {
if (configFingerprint !== prevFingerprint.current) {
prevFingerprint.current = configFingerprint;
mutateModels();
}
}, [configFingerprint, mutateModels]);
const models = useMemo(() => { const models = useMemo(() => {
if (!allModels || !providerKey) return []; if (!allModels || !providerKey) return [];
return allModels[providerKey] ?? []; return allModels[providerKey] ?? [];

View File

@ -272,7 +272,7 @@ export default function LiveContextMenu({
return ( return (
<div className={cn("w-full", className)}> <div className={cn("w-full", className)}>
<ContextMenu key={camera} onOpenChange={handleOpenChange}> <ContextMenu key={camera} modal={false} onOpenChange={handleOpenChange}>
<ContextMenuTrigger>{children}</ContextMenuTrigger> <ContextMenuTrigger>{children}</ContextMenuTrigger>
<ContextMenuContent> <ContextMenuContent>
<div className="flex flex-col items-start gap-1 py-1 pl-2"> <div className="flex flex-col items-start gap-1 py-1 pl-2">

View File

@ -8,6 +8,7 @@ import {
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import Konva from "konva"; import Konva from "konva";
import { useResizeObserver } from "@/hooks/resize-observer"; import { useResizeObserver } from "@/hooks/resize-observer";
import { useTranslation } from "react-i18next";
type DebugDrawingLayerProps = { type DebugDrawingLayerProps = {
containerRef: React.RefObject<HTMLDivElement | null>; containerRef: React.RefObject<HTMLDivElement | null>;
@ -28,6 +29,7 @@ function DebugDrawingLayer({
} | null>(null); } | null>(null);
const [isDrawing, setIsDrawing] = useState(false); const [isDrawing, setIsDrawing] = useState(false);
const [showPopover, setShowPopover] = useState(false); const [showPopover, setShowPopover] = useState(false);
const { t } = useTranslation(["common"]);
const stageRef = useRef<Konva.Stage>(null); const stageRef = useRef<Konva.Stage>(null);
const [{ width: containerWidth }] = useResizeObserver(containerRef); const [{ width: containerWidth }] = useResizeObserver(containerRef);
@ -153,10 +155,13 @@ function DebugDrawingLayer({
<div className="flex flex-col text-primary"> <div className="flex flex-col text-primary">
Area:{" "} Area:{" "}
<span className="text-sm text-primary-variant"> <span className="text-sm text-primary-variant">
px: {calculateArea().toFixed(0)} {t("information.pixels", {
ns: "common",
area: calculateArea().toFixed(0),
})}
</span> </span>
<span className="text-sm text-primary-variant"> <span className="text-sm text-primary-variant">
%: {calculateAreaPercentage().toFixed(4)} {(calculateAreaPercentage() * 100).toFixed(2)}%
</span> </span>
</div> </div>
<div className="flex flex-col text-primary"> <div className="flex flex-col text-primary">

View File

@ -23,7 +23,7 @@ import { GeneralFilterContent } from "../filter/ReviewFilterGroup";
import { toast } from "sonner"; import { toast } from "sonner";
import axios, { AxiosError } from "axios"; import axios, { AxiosError } from "axios";
import SaveExportOverlay from "./SaveExportOverlay"; import SaveExportOverlay from "./SaveExportOverlay";
import { isIOS, isMobile } from "react-device-detect"; import { isMobile } from "react-device-detect";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { ShareTimestampContent } from "./ShareTimestampDialog"; import { ShareTimestampContent } from "./ShareTimestampDialog";
@ -564,7 +564,6 @@ export default function MobileReviewSettingsDrawer({
setShowPreview={setShowExportPreview} setShowPreview={setShowExportPreview}
/> />
<Drawer <Drawer
modal={!(isIOS && drawerMode == "export")}
open={drawerMode != "none"} open={drawerMode != "none"}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) { if (!open) {

View File

@ -9,7 +9,12 @@ import { FrigateConfig } from "@/types/frigateConfig";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { use24HourTime } from "@/hooks/use-date-utils"; import { use24HourTime } from "@/hooks/use-date-utils";
import { getIconForLabel } from "@/utils/iconUtil"; import { getIconForLabel } from "@/utils/iconUtil";
import { LuCircle, LuFolderX } from "react-icons/lu"; import {
LuChevronDown,
LuChevronRight,
LuCircle,
LuFolderX,
} from "react-icons/lu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import HlsVideoPlayer from "@/components/player/HlsVideoPlayer"; import HlsVideoPlayer from "@/components/player/HlsVideoPlayer";
import { baseUrl } from "@/api/baseUrl"; import { baseUrl } from "@/api/baseUrl";
@ -899,6 +904,7 @@ function LifecycleIconRow({
const { t } = useTranslation(["views/explore", "components/player"]); const { t } = useTranslation(["views/explore", "components/player"]);
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [showAdvancedScores, setShowAdvancedScores] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const isAdmin = useIsAdmin(); const isAdmin = useIsAdmin();
@ -993,12 +999,31 @@ function LifecycleIconRow({
[item.data.box], [item.data.box],
); );
const score = useMemo(() => { const currentScore = useMemo(
if (item.data.score !== undefined) { () =>
return (item.data.score * 100).toFixed(0) + "%"; item.data.score !== undefined
} ? (item.data.score * 100).toFixed(0) + "%"
return "N/A"; : null,
}, [item.data.score]); [item.data.score],
);
const computedScore = useMemo(
() =>
item.data.computed_score !== undefined &&
item.data.computed_score !== null &&
item.data.computed_score > 0
? (item.data.computed_score * 100).toFixed(0) + "%"
: null,
[item.data.computed_score],
);
const topScore = useMemo(
() =>
item.data.top_score !== undefined &&
item.data.top_score !== null &&
item.data.top_score > 0
? (item.data.top_score * 100).toFixed(0) + "%"
: null,
[item.data.top_score],
);
return ( return (
<div <div
@ -1034,8 +1059,50 @@ function LifecycleIconRow({
<span className="text-primary-variant"> <span className="text-primary-variant">
{t("trackingDetails.lifecycleItemDesc.header.score")} {t("trackingDetails.lifecycleItemDesc.header.score")}
</span> </span>
<span className="font-medium text-primary">{score}</span> <span className="font-medium text-primary">
{currentScore ?? "N/A"}
</span>
{(computedScore || topScore) && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setShowAdvancedScores((v) => !v);
}}
className="ml-1 inline-flex items-center text-primary-variant hover:text-primary"
aria-expanded={showAdvancedScores}
aria-label={t(
"trackingDetails.lifecycleItemDesc.header.toggleAdvancedScores",
)}
>
{showAdvancedScores ? (
<LuChevronDown className="size-3.5" />
) : (
<LuChevronRight className="size-3.5" />
)}
</button>
)}
</div> </div>
{showAdvancedScores && computedScore && (
<div className="flex items-center gap-1.5">
<span className="text-primary-variant">
{t(
"trackingDetails.lifecycleItemDesc.header.computedScore",
)}
</span>
<span className="font-medium text-primary">
{computedScore}
</span>
</div>
)}
{showAdvancedScores && topScore && (
<div className="flex items-center gap-1.5">
<span className="text-primary-variant">
{t("trackingDetails.lifecycleItemDesc.header.topScore")}
</span>
<span className="font-medium text-primary">{topScore}</span>
</div>
)}
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="text-primary-variant"> <span className="text-primary-variant">
{t("trackingDetails.lifecycleItemDesc.header.ratio")} {t("trackingDetails.lifecycleItemDesc.header.ratio")}

View File

@ -41,6 +41,7 @@ import { Toaster } from "@/components/ui/sonner";
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import { getIconForLabel } from "@/utils/iconUtil"; import { getIconForLabel } from "@/utils/iconUtil";
import { getTranslatedLabel } from "@/utils/i18n"; import { getTranslatedLabel } from "@/utils/i18n";
import { Card } from "@/components/ui/card";
import { ObjectType } from "@/types/ws"; import { ObjectType } from "@/types/ws";
import WsMessageFeed from "@/components/ws/WsMessageFeed"; import WsMessageFeed from "@/components/ws/WsMessageFeed";
import { ConfigSectionTemplate } from "@/components/config-form/sections/ConfigSectionTemplate"; import { ConfigSectionTemplate } from "@/components/config-form/sections/ConfigSectionTemplate";
@ -633,7 +634,7 @@ type ObjectListProps = {
}; };
function ObjectList({ cameraConfig, objects, config }: ObjectListProps) { function ObjectList({ cameraConfig, objects, config }: ObjectListProps) {
const { t } = useTranslation(["views/settings"]); const { t } = useTranslation(["views/settings", "common"]);
const colormap = useMemo(() => { const colormap = useMemo(() => {
if (!config) { if (!config) {
@ -660,73 +661,80 @@ function ObjectList({ cameraConfig, objects, config }: ObjectListProps) {
} }
return ( return (
<div className="flex w-full flex-col gap-2"> <div className="scrollbar-container relative flex w-full flex-col overflow-y-auto">
{objects.map((obj: ObjectType) => { {objects.map((obj: ObjectType) => {
return ( return (
<div <Card className="mb-1 p-2 text-sm" key={obj.id}>
key={obj.id}
className="flex flex-col rounded-lg bg-secondary/30 p-2"
>
<div className="flex flex-row items-center gap-3 pb-1"> <div className="flex flex-row items-center gap-3 pb-1">
<div <div className="flex flex-1 flex-row items-center justify-start p-3 pl-1">
className="rounded-lg p-2" <div
style={{ className="rounded-lg p-2"
backgroundColor: obj.stationary style={{
? "rgb(110,110,110)" backgroundColor: obj.stationary
: getColorForObjectName(obj.label), ? "rgb(110,110,110)"
}} : getColorForObjectName(obj.label),
> }}
{getIconForLabel(obj.label, "object", "size-4 text-white")} >
{getIconForLabel(obj.label, "object", "size-5 text-white")}
</div>
<div className="ml-3 text-lg">
{getTranslatedLabel(obj.label)}
</div>
</div> </div>
<div className="text-sm font-medium"> <div className="flex w-8/12 flex-row items-center justify-end">
{getTranslatedLabel(obj.label)} <div className="text-md mr-2 w-1/3">
<div className="flex flex-col items-end justify-end">
<p className="mb-1.5 text-sm text-primary-variant">
{t("debug.objectShapeFilterDrawing.score", {
ns: "views/settings",
})}
</p>
{obj.score ? (obj.score * 100).toFixed(1).toString() : "-"}%
</div>
</div>
<div className="text-md mr-2 w-1/3">
<div className="flex flex-col items-end justify-end">
<p className="mb-1.5 text-sm text-primary-variant">
{t("debug.objectShapeFilterDrawing.ratio", {
ns: "views/settings",
})}
</p>
{obj.ratio ? obj.ratio.toFixed(2).toString() : "-"}
</div>
</div>
<div className="text-md mr-2 w-1/3">
<div className="flex flex-col items-end justify-end">
<p className="mb-1.5 text-sm text-primary-variant">
{t("debug.objectShapeFilterDrawing.area", {
ns: "views/settings",
})}
</p>
{obj.area && cameraConfig ? (
<div className="text-end">
<div className="text-xs">
{t("information.pixels", {
ns: "common",
area: obj.area,
})}
</div>
<div className="text-xs">
{(
(obj.area /
(cameraConfig.detect.width *
cameraConfig.detect.height)) *
100
).toFixed(2)}
%
</div>
</div>
) : (
"-"
)}
</div>
</div>
</div> </div>
</div> </div>
<div className="flex flex-col gap-1 pl-1 text-xs text-primary-variant"> </Card>
<div className="flex items-center justify-between">
<span>
{t("debug.objectShapeFilterDrawing.score", {
ns: "views/settings",
})}
:
</span>
<span className="text-primary">
{obj.score ? (obj.score * 100).toFixed(1) : "-"}%
</span>
</div>
{obj.ratio && (
<div className="flex items-center justify-between">
<span>
{t("debug.objectShapeFilterDrawing.ratio", {
ns: "views/settings",
})}
:
</span>
<span className="text-primary">{obj.ratio.toFixed(2)}</span>
</div>
)}
{obj.area && cameraConfig && (
<div className="flex items-center justify-between">
<span>
{t("debug.objectShapeFilterDrawing.area", {
ns: "views/settings",
})}
:
</span>
<span className="text-primary">
{obj.area} px (
{(
(obj.area /
(cameraConfig.detect.width *
cameraConfig.detect.height)) *
100
).toFixed(2)}
%)
</span>
</div>
)}
</div>
</div>
); );
})} })}
</div> </div>

View File

@ -818,6 +818,27 @@ export default function Settings() {
[], [],
); );
// Show save/undo all buttons only when changes span multiple sections
// or the single changed section is not the one currently being viewed
const showSaveAllButtons = useMemo(() => {
const pendingKeys = Object.keys(pendingDataBySection);
if (pendingKeys.length === 0) return false;
if (pendingKeys.length >= 2) return true;
// Exactly one pending section — check if it matches the current view
const key = pendingKeys[0];
const menuKey = pendingKeyToMenuKey(key);
if (menuKey !== pageToggle) return true;
// For camera-scoped keys, also check if the camera matches
if (key.includes("::")) {
const cameraName = key.slice(0, key.indexOf("::"));
return cameraName !== selectedCamera;
}
return false;
}, [pendingDataBySection, pendingKeyToMenuKey, pageToggle, selectedCamera]);
const handleSaveAll = useCallback(async () => { const handleSaveAll = useCallback(async () => {
if ( if (
!config || !config ||
@ -1491,7 +1512,7 @@ export default function Settings() {
); );
})} })}
</div> </div>
{hasPendingChanges && ( {showSaveAllButtons && (
<div className="sticky bottom-0 z-50 mt-2 bg-background p-4"> <div className="sticky bottom-0 z-50 mt-2 bg-background p-4">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -1667,7 +1688,7 @@ export default function Settings() {
</Heading> </Heading>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{hasPendingChanges && ( {showSaveAllButtons && (
<div <div
className={cn( className={cn(
"flex flex-row items-center gap-2", "flex flex-row items-center gap-2",

View File

@ -17,6 +17,8 @@ export type TrackingDetailsSequence = {
camera: string; camera: string;
label: string; label: string;
score: number; score: number;
computed_score?: number;
top_score?: number;
sub_label: string; sub_label: string;
box?: [number, number, number, number]; box?: [number, number, number, number];
region: [number, number, number, number]; region: [number, number, number, number];

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { isDesktop, isIOS, isMobile } from "react-device-detect"; import { isDesktop, isIOS, isMobile } from "react-device-detect";
import { FaArrowRight, FaCalendarAlt, FaCheckCircle } from "react-icons/fa"; import { FaArrowRight, FaCalendarAlt, FaCheckCircle } from "react-icons/fa";
@ -42,7 +42,6 @@ import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
import { TimezoneAwareCalendar } from "@/components/overlay/ReviewActivityCalendar"; import { TimezoneAwareCalendar } from "@/components/overlay/ReviewActivityCalendar";
import { useApiHost } from "@/api"; import { useApiHost } from "@/api";
import { useResizeObserver } from "@/hooks/resize-observer";
import { useFormattedTimestamp, use24HourTime } from "@/hooks/use-date-utils"; import { useFormattedTimestamp, use24HourTime } from "@/hooks/use-date-utils";
import { getUTCOffset } from "@/utils/dateUtil"; import { getUTCOffset } from "@/utils/dateUtil";
import useSWR from "swr"; import useSWR from "swr";
@ -113,11 +112,35 @@ export default function MotionSearchDialog({
}: MotionSearchDialogProps) { }: MotionSearchDialogProps) {
const { t } = useTranslation(["views/motionSearch", "common"]); const { t } = useTranslation(["views/motionSearch", "common"]);
const apiHost = useApiHost(); const apiHost = useApiHost();
const containerRef = useRef<HTMLDivElement>(null); const [containerNode, setContainerNode] = useState<HTMLDivElement | null>(
const [{ width: containerWidth, height: containerHeight }] = null,
useResizeObserver(containerRef); );
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
const containerWidth = containerSize.width;
const containerHeight = containerSize.height;
const [imageLoaded, setImageLoaded] = useState(false); const [imageLoaded, setImageLoaded] = useState(false);
useEffect(() => {
if (!containerNode) {
return;
}
const measure = () => {
const rect = containerNode.getBoundingClientRect();
setContainerSize((prev) =>
prev.width === rect.width && prev.height === rect.height
? prev
: { width: rect.width, height: rect.height },
);
};
measure();
const observer = new ResizeObserver(() => measure());
observer.observe(containerNode);
return () => observer.disconnect();
}, [containerNode]);
const cameraConfig = useMemo(() => { const cameraConfig = useMemo(() => {
if (!selectedCamera) return undefined; if (!selectedCamera) return undefined;
return config.cameras[selectedCamera]; return config.cameras[selectedCamera];
@ -258,16 +281,16 @@ export default function MotionSearchDialog({
}} }}
> >
<div <div
ref={containerRef} ref={setContainerNode}
className="relative flex w-full items-center justify-center overflow-hidden rounded-lg border bg-secondary" className="relative flex w-full items-center justify-center overflow-hidden rounded-lg border bg-secondary"
style={{ aspectRatio: "16 / 9" }} style={{ aspectRatio: "16 / 9" }}
> >
{selectedCamera && cameraConfig && imageSize.width > 0 ? ( {selectedCamera && cameraConfig ? (
<div <div
className="relative" className="relative"
style={{ style={{
width: imageSize.width, width: imageSize.width || "100%",
height: imageSize.height, height: imageSize.height || "100%",
}} }}
> >
<img <img
@ -277,6 +300,11 @@ export default function MotionSearchDialog({
src={`${apiHost}api/${selectedCamera}/latest.jpg?h=500`} src={`${apiHost}api/${selectedCamera}/latest.jpg?h=500`}
className="h-full w-full object-contain" className="h-full w-full object-contain"
onLoad={() => setImageLoaded(true)} onLoad={() => setImageLoaded(true)}
ref={(node) => {
if (node?.complete && node.naturalWidth > 0) {
setImageLoaded(true);
}
}}
/> />
{!imageLoaded && ( {!imageLoaded && (
<div className="absolute inset-0 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center">

View File

@ -1,10 +1,9 @@
import { useCallback, useMemo, useRef } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Stage, Layer, Line, Circle, Image } from "react-konva"; import { Stage, Layer, Line, Circle, Image } from "react-konva";
import Konva from "konva"; import Konva from "konva";
import type { KonvaEventObject } from "konva/lib/Node"; import type { KonvaEventObject } from "konva/lib/Node";
import { flattenPoints } from "@/utils/canvasUtil"; import { flattenPoints } from "@/utils/canvasUtil";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useResizeObserver } from "@/hooks/resize-observer";
type MotionSearchROICanvasProps = { type MotionSearchROICanvasProps = {
camera: string; camera: string;
@ -30,18 +29,40 @@ export default function MotionSearchROICanvas({
motionHeatmap, motionHeatmap,
showMotionHeatmap = false, showMotionHeatmap = false,
}: MotionSearchROICanvasProps) { }: MotionSearchROICanvasProps) {
const containerRef = useRef<HTMLDivElement>(null);
const stageRef = useRef<Konva.Stage>(null); const stageRef = useRef<Konva.Stage>(null);
const [{ width: containerWidth, height: containerHeight }] = const [containerNode, setContainerNode] = useState<HTMLDivElement | null>(
useResizeObserver(containerRef); null,
const stageSize = useMemo(
() => ({
width: containerWidth > 0 ? Math.ceil(containerWidth) : 0,
height: containerHeight > 0 ? Math.ceil(containerHeight) : 0,
}),
[containerHeight, containerWidth],
); );
const [stageSize, setStageSize] = useState({ width: 0, height: 0 });
useEffect(() => {
if (!containerNode) {
return;
}
const apply = (width: number, height: number) => {
setStageSize((prev) => {
const next = {
width: width > 0 ? Math.ceil(width) : 0,
height: height > 0 ? Math.ceil(height) : 0,
};
if (prev.width === next.width && prev.height === next.height) {
return prev;
}
return next;
});
};
apply(containerNode.clientWidth, containerNode.clientHeight);
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
apply(entry.contentRect.width, entry.contentRect.height);
});
observer.observe(containerNode);
return () => observer.disconnect();
}, [containerNode]);
const videoRect = useMemo(() => { const videoRect = useMemo(() => {
const stageWidth = stageSize.width; const stageWidth = stageSize.width;
@ -317,7 +338,7 @@ export default function MotionSearchROICanvas({
return ( return (
<div <div
ref={containerRef} ref={setContainerNode}
className={cn( className={cn(
"absolute inset-0 z-10", "absolute inset-0 z-10",
isInteractive ? "pointer-events-auto" : "pointer-events-none", isInteractive ? "pointer-events-auto" : "pointer-events-none",
@ -385,6 +406,8 @@ export default function MotionSearchROICanvas({
stroke="white" stroke="white"
strokeWidth={2} strokeWidth={2}
draggable={!isDrawing} draggable={!isDrawing}
onMouseDown={(e) => e.evt.stopPropagation()}
onTouchStart={(e) => e.evt.stopPropagation()}
onDragMove={(e) => handlePointDragMove(e, index)} onDragMove={(e) => handlePointDragMove(e, index)}
onMouseOver={(e) => handleMouseOverPoint(e, index)} onMouseOver={(e) => handleMouseOverPoint(e, index)}
onMouseOut={(e) => handleMouseOutPoint(e, index)} onMouseOut={(e) => handleMouseOutPoint(e, index)}

View File

@ -994,15 +994,20 @@ export default function MotionSearchView({
); );
const progressMetrics = jobStatus?.metrics ?? searchMetrics; const progressMetrics = jobStatus?.metrics ?? searchMetrics;
const progressValue = const progressValue = (() => {
progressMetrics && progressMetrics.segments_scanned > 0 if (!progressMetrics || progressMetrics.segments_scanned <= 0) {
? Math.min( return 0;
100, }
(progressMetrics.segments_processed / const skipped =
progressMetrics.segments_scanned) * progressMetrics.heatmap_roi_skip_segments +
100, progressMetrics.metadata_inactive_segments;
) const totalWork = progressMetrics.segments_scanned - skipped;
: 0; const doneWork = progressMetrics.segments_processed - skipped;
if (totalWork <= 0) {
return 100;
}
return Math.min(100, Math.max(0, (doneWork / totalWork) * 100));
})();
const resultsPanel = ( const resultsPanel = (
<> <>
@ -1036,8 +1041,8 @@ export default function MotionSearchView({
<Progress className="h-1" value={progressValue} /> <Progress className="h-1" value={progressValue} />
</div> </div>
)} )}
{searchMetrics && searchResults.length > 0 && ( {searchMetrics && (isSearching || searchResults.length > 0) && (
<div className="mx-2 rounded-lg border bg-secondary p-2"> <div className="mx-2 my-3 rounded-lg border bg-secondary p-2">
<div className="space-y-0.5 text-xs text-muted-foreground"> <div className="space-y-0.5 text-xs text-muted-foreground">
<div className="flex justify-between"> <div className="flex justify-between">
<span>{t("metrics.segmentsScanned")}</span> <span>{t("metrics.segmentsScanned")}</span>

View File

@ -370,7 +370,7 @@ type ObjectListProps = {
}; };
function ObjectList({ cameraConfig, objects }: ObjectListProps) { function ObjectList({ cameraConfig, objects }: ObjectListProps) {
const { t } = useTranslation(["views/settings"]); const { t } = useTranslation(["views/settings", "common"]);
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const colormap = useMemo(() => { const colormap = useMemo(() => {
@ -440,17 +440,21 @@ function ObjectList({ cameraConfig, objects }: ObjectListProps) {
{obj.area ? ( {obj.area ? (
<div className="text-end"> <div className="text-end">
<div className="text-xs"> <div className="text-xs">
px: {obj.area.toString()} {t("information.pixels", {
ns: "common",
area: obj.area,
})}
</div> </div>
<div className="text-xs"> <div className="text-xs">
%:{" "}
{( {(
obj.area / (obj.area /
(cameraConfig.detect.width * (cameraConfig.detect.width *
cameraConfig.detect.height) cameraConfig.detect.height)) *
100
) )
.toFixed(4) .toFixed(2)
.toString()} .toString()}
%
</div> </div>
</div> </div>
) : ( ) : (

View File

@ -22,6 +22,7 @@ import { Link } from "react-router-dom";
import { useDocDomain } from "@/hooks/use-doc-domain"; import { useDocDomain } from "@/hooks/use-doc-domain";
import { LuExternalLink } from "react-icons/lu"; import { LuExternalLink } from "react-icons/lu";
import { FaExclamationTriangle } from "react-icons/fa"; import { FaExclamationTriangle } from "react-icons/fa";
import ActivityIndicator from "@/components/indicators/activity-indicator";
type CameraStorage = { type CameraStorage = {
[key: string]: { [key: string]: {
@ -128,7 +129,11 @@ export default function StorageMetrics({
}, [stats, config]); }, [stats, config]);
if (!cameraStorage || !stats || !totalStorage || !config) { if (!cameraStorage || !stats || !totalStorage || !config) {
return; return (
<div className="flex size-full items-center justify-center">
<ActivityIndicator />
</div>
);
} }
return ( return (