Compare commits

...

11 Commits

Author SHA1 Message Date
Dmytro Marchuk
5d118cd100
Merge f00dd5c7af into b79ad9871a 2026-06-13 23:11:54 +01:00
Josh Hawkins
b79ad9871a
generate the API docs OpenAPI spec from the app with per-endpoint auth requirements (#23476) 2026-06-13 16:04:22 -06:00
Josh Hawkins
8be7a97fa6
guard norfair distance against non-finite and zero-area boxes that could crash autotracking cameras (#23475) 2026-06-13 16:23:30 -05:00
Dmytro Marchuk
f00dd5c7af
Merge branch 'dev' into dev 2026-06-07 08:10:15 +03:00
Dmitry Marchuk
51dd890eb6 Remove accidential changes 2026-06-07 08:07:02 +03:00
Dmitry Marchuk
8f6e083420 Review changes 2026-05-24 07:45:04 +03:00
Dmitry Marchuk
bf25560067 Make Dropdown look like Select 2026-05-23 22:20:04 +03:00
Dmitry Marchuk
df40d9e2b5 Review changes 2026-05-23 21:32:02 +03:00
Dmitry Marchuk
263554a5f6 Improve on the dialog, fix some bugs 2026-05-22 12:18:35 +03:00
Dmytro Marchuk
597a9f9fb4
Merge branch 'blakeblackshear:dev' into dev 2026-05-17 08:50:35 +03:00
Dmitry Marchuk
0d05f0feaa Add custom notification suspension dialog 2026-05-17 08:50:19 +03:00
14 changed files with 3642 additions and 1496 deletions

0
.devcontainer/post_create.sh Executable file → Normal file
View File

View File

@ -125,5 +125,7 @@ jobs:
run: devcontainer up --workspace-folder .
- name: Run mypy in devcontainer
run: devcontainer exec --workspace-folder . bash -lc "python3 -u -m mypy --config-file frigate/mypy.ini frigate"
- name: Check API spec is up to date
run: devcontainer exec --workspace-folder . bash -lc "python3 generate_api_auth_spec.py --check"
- name: Run unit tests in devcontainer
run: devcontainer exec --workspace-folder . bash -lc "python3 -u -m unittest"

View File

@ -235,6 +235,14 @@ ruff check frigate/
# Type check
python3 -u -m mypy --config-file frigate/mypy.ini frigate
# Regenerate the OpenAPI spec after adding, changing, or removing an API
# endpoint or its auth dependency — outputs docs/static/frigate-api.yaml,
# annotated with each endpoint's auth requirement (admin / any / camera /
# public). NEVER edit that file by hand. CI runs the --check variant and fails
# if it is out of date. (from repo root)
python3 generate_api_auth_spec.py
python3 generate_api_auth_spec.py --check
```
### Frontend (from web/ directory)
@ -316,6 +324,8 @@ async def get_events(request: Request, limit: int = 100):
# Implementation
```
After adding, changing, or removing an endpoint (or its auth dependency), regenerate the OpenAPI spec with `python3 generate_api_auth_spec.py` so `docs/static/frigate-api.yaml` stays in sync and the endpoint's auth requirement is documented. CI enforces this via the `--check` variant; never edit that file by hand.
### Configuration Access
```python

File diff suppressed because it is too large Load Diff

View File

@ -48,6 +48,22 @@ def ptz_moving_at_frame_time(frame_time, ptz_start_time, ptz_stop_time):
)
def transform_is_finite(coord_transformations) -> bool:
"""Return True if a norfair coordinate transform contains only finite values.
A near-singular homography (common when the motion estimator can't find
enough stable features during zoom on a low-texture scene) can produce
inf/nan matrix entries. norfair accumulates the homography across frames, so
a single bad transform poisons every subsequent one and propagates nan into
the tracker's distance function, crashing the camera process.
"""
for attr in ("homography_matrix", "inverse_homography_matrix", "movement_vector"):
value = getattr(coord_transformations, attr, None)
if value is not None and not np.all(np.isfinite(value)):
return False
return True
class PtzMotionEstimator:
def __init__(self, config: CameraConfig, ptz_metrics: PTZMetrics) -> None:
self.frame_manager = SharedMemoryFrameManager()
@ -135,6 +151,19 @@ class PtzMotionEstimator:
)
self.coord_transformations = None
# A degenerate homography can yield non-finite transform values that
# norfair would accumulate and feed to the tracker as nan estimates.
# Drop the bad transform and request a reset so the estimator rebuilds
# a fresh reference frame instead of poisoning every following frame.
if self.coord_transformations is not None and not transform_is_finite(
self.coord_transformations
):
logger.warning(
f"Autotracker: motion estimator produced a non-finite transform for {camera} at frame time {frame_time}, resetting"
)
self.coord_transformations = None
self.ptz_metrics.reset.set()
try:
logger.debug(
f"{camera}: Motion estimator transformation: {self.coord_transformations.rel_to_abs([[0, 0]])}"

View File

@ -0,0 +1,91 @@
import math
import unittest
import numpy as np
from norfair.camera_motion import (
HomographyTransformation,
TranslationTransformation,
)
from frigate.ptz.autotrack import transform_is_finite
from frigate.track.norfair_tracker import distance
class TestNorfairDistance(unittest.TestCase):
"""Regression tests for the tracker distance guard.
norfair raises a hard ValueError on any nan distance, which kills the camera
process. During autotracking, an ill-conditioned homography can hand the
tracker a non-finite or degenerate estimate box, so distance() must never
return nan for any input.
"""
def setUp(self) -> None:
# boxes are [[x1, y1], [x2, y2]]
self.detection = np.array([[805.0, 402.0], [864.0, 521.0]])
self.estimate = np.array([[800.0, 400.0], [860.0, 520.0]])
def test_finite_boxes_give_finite_distance(self) -> None:
d = distance(self.detection, self.estimate)
self.assertTrue(math.isfinite(d))
def test_inf_estimate_corner_does_not_return_nan(self) -> None:
estimate = np.array([[np.inf, 400.0], [860.0, 520.0]])
d = distance(self.detection, estimate)
self.assertFalse(math.isnan(d))
self.assertEqual(d, float("inf"))
def test_nan_estimate_corner_does_not_return_nan(self) -> None:
# the actual autotracking crash: a positive-only guard would miss this
# because nan <= 0 is False
estimate = np.array([[np.nan, 400.0], [860.0, 520.0]])
d = distance(self.detection, estimate)
self.assertFalse(math.isnan(d))
self.assertEqual(d, float("inf"))
def test_zero_area_estimate_does_not_return_nan(self) -> None:
estimate = np.array([[900.0, 500.0], [900.0, 500.0]])
d = distance(self.detection, estimate)
self.assertFalse(math.isnan(d))
self.assertEqual(d, float("inf"))
def test_zero_area_detection_does_not_return_nan(self) -> None:
detection = np.array([[805.0, 402.0], [805.0, 521.0]])
d = distance(detection, self.estimate)
self.assertFalse(math.isnan(d))
self.assertEqual(d, float("inf"))
def test_inverted_estimate_corners_do_not_return_nan(self) -> None:
# Kalman estimates can occasionally cross corners (x2 < x1)
estimate = np.array([[860.0, 520.0], [800.0, 400.0]])
d = distance(self.detection, estimate)
self.assertFalse(math.isnan(d))
self.assertEqual(d, float("inf"))
class TestTransformIsFinite(unittest.TestCase):
def test_finite_homography_is_finite(self) -> None:
matrix = np.array([[1.0, 0.0, 5.0], [0.0, 1.0, 3.0], [0.0, 0.0, 1.0]])
self.assertTrue(transform_is_finite(HomographyTransformation(matrix)))
def test_finite_translation_is_finite(self) -> None:
self.assertTrue(
transform_is_finite(TranslationTransformation(np.array([12.0, -4.0])))
)
def test_non_finite_homography_is_not_finite(self) -> None:
transform = HomographyTransformation(np.eye(3))
# simulate accumulation overflowing to a non-finite matrix
transform.homography_matrix = np.array(
[[1.0, 0.0, np.inf], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]
)
self.assertFalse(transform_is_finite(transform))
def test_nan_translation_is_not_finite(self) -> None:
self.assertFalse(
transform_is_finite(TranslationTransformation(np.array([np.nan, 0.0])))
)
if __name__ == "__main__":
unittest.main()

View File

@ -45,6 +45,17 @@ def distance(detection: np.ndarray, estimate: np.ndarray) -> float:
estimate_dim = np.diff(estimate, axis=0).flatten()
detection_dim = np.diff(detection, axis=0).flatten()
# Guard against degenerate or non-finite boxes
if (
not np.all(np.isfinite(estimate_dim))
or not np.all(np.isfinite(detection_dim))
or estimate_dim[0] <= 0
or estimate_dim[1] <= 0
or detection_dim[0] <= 0
or detection_dim[1] <= 0
):
return float("inf")
# get bottom center positions
detection_position = np.array(
[np.average(detection[:, 0]), np.max(detection[:, 1])]

606
generate_api_auth_spec.py Normal file
View File

@ -0,0 +1,606 @@
"""Generate the OpenAPI spec from the app, annotated with auth requirements.
This generator builds the FastAPI application, exports its OpenAPI document via
``app.openapi()``, and enriches every operation with authentication metadata:
* a ``components.securitySchemes`` block,
* a per-operation ``security`` requirement (so the docs render a lock badge),
* an ``x-required-role`` extension for machine readers, and
* a short bold ``Access:`` note prepended to each operation description.
The committed docs/static/frigate-api.yaml is the output of this script. It is
generated rather than hand-maintained so it stays complete and current; the docs
build (docusaurus-plugin-openapi-docs) consumes it as-is.
The access level for an endpoint is determined by BOTH its route-level
dependency (``require_role``/``allow_any_authenticated``/``allow_public``/
``require_camera_access``) AND the global "secure by default" admin dependency,
which is bypassed only for the paths listed in ``require_admin_by_default``.
Those exempt lists are read directly from the function's closure so this script
stays in lockstep with ``frigate/api/auth.py`` instead of duplicating them.
Many handlers enforce per-camera access by calling ``require_camera_access``
inside the handler body rather than as a route dependency, which dependency
introspection cannot see. We recover those from the handler's bytecode (see
``_handler_enforces_camera``) and promote an otherwise "any authenticated"
operation to camera-scoped.
Usage (from the repository root):
python3 generate_api_auth_spec.py # write the spec
python3 generate_api_auth_spec.py --check # CI guard: fail if stale
The process exits non-zero if the generated document fails structural
validation, or (in --check mode) if the committed spec is out of date.
"""
import argparse
import difflib
import inspect
import io
import logging
import sys
from pathlib import Path
from fastapi import FastAPI
from fastapi.routing import APIRoute
from ruamel.yaml import YAML
from ruamel.yaml.scalarstring import LiteralScalarString
from frigate.api import app as main_app
from frigate.api import (
auth,
camera,
chat,
classification,
debug_replay,
event,
export,
media,
motion_search,
notification,
preview,
record,
review,
)
from frigate.api.auth import require_admin_by_default
logging.basicConfig(level=logging.INFO, format="%(message)s")
logger = logging.getLogger("generate_api_auth_spec")
REPO_ROOT = Path(__file__).resolve().parent
OUTPUT_SPEC = REPO_ROOT / "docs" / "static" / "frigate-api.yaml"
HTTP_METHODS = {"get", "post", "put", "delete", "patch"}
# Banner written at the top of the generated spec.
HEADER = (
"# Generated by generate_api_auth_spec.py — do not edit by hand.\n"
"# Regenerate with: python3 generate_api_auth_spec.py\n"
"# The empty info.title is intentional: a docusaurus-openapi-docs convention\n"
"# that suppresses the generated API introduction page.\n"
)
# Post-processing applied on top of the raw app.openapi() export. These live
# only in the published spec, not in the app, so they are reproduced here.
SPEC_TITLE = ""
SPEC_SERVERS = [
{"url": "https://demo.frigate.video/api"},
{"url": "http://localhost:5001/api"},
]
# Access levels, ordered from least to most privileged. The string values are
# also what we emit as ``x-required-role``.
PUBLIC = "public"
AUTHENTICATED = "any"
CAMERA = "camera"
ADMIN = "admin"
ADMIN_SCHEME = "frigateAdminAuth"
USER_SCHEME = "frigateUserAuth"
SECURITY_SCHEMES = {
ADMIN_SCHEME: {
"type": "apiKey",
"in": "cookie",
"name": "frigate_token",
"description": (
"Authenticated session whose resolved role is 'admin'. The session "
"is established via the JWT cookie issued by POST /login, or via "
"proxy auth headers (remote-user / remote-role) when Frigate runs "
"behind an authenticating reverse proxy."
),
},
USER_SCHEME: {
"type": "apiKey",
"in": "cookie",
"name": "frigate_token",
"description": (
"Any authenticated session (role 'viewer' or higher), established "
"via the JWT cookie issued by POST /login, or via proxy auth "
"headers when Frigate runs behind an authenticating reverse proxy."
),
},
}
# How each access level maps to a rendered note.
ACCESS_NOTES = {
PUBLIC: "**Access:** Public — no authentication required.",
AUTHENTICATED: "**Access:** Any authenticated user.",
CAMERA: "**Access:** Authenticated user with access to the referenced camera.",
ADMIN: "**Access:** Admin role required.",
}
def build_app() -> FastAPI:
"""Build a bare app with every router mounted.
This mirrors the router set wired up in frigate.api.fastapi_app. It omits
the global admin dependency and all runtime state; the OpenAPI route table
and the per-route dependencies are all we need to export and classify.
"""
app = FastAPI()
routers = [
auth.router,
camera.router,
chat.router,
classification.router,
review.router,
main_app.router,
preview.router,
notification.router,
export.router,
event.router,
media.router,
motion_search.router,
record.router,
debug_replay.router,
]
for router in routers:
app.include_router(router)
return app
def read_exempt_rules() -> tuple[set[str], tuple[str, ...]]:
"""Read the admin-exemption lists straight from the auth dependency closure.
Reading them here (rather than copying) keeps this generator in sync with
frigate/api/auth.py automatically.
"""
closure = inspect.getclosurevars(require_admin_by_default()).nonlocals
exempt_paths = set(closure["EXEMPT_PATHS"])
exempt_prefixes = tuple(closure["EXEMPT_PREFIXES"])
return exempt_paths, exempt_prefixes
def _first_segment(path: str) -> str:
return path.split("/", 2)[1] if path.startswith("/") and len(path) > 1 else ""
def _route_markers(route: APIRoute) -> tuple[set[str], list[str] | None]:
"""Return the set of recognized auth markers on a route's dependencies."""
markers: set[str] = set()
admin_roles: list[str] | None = None
for dep in route.dependant.dependencies:
call = dep.call
qualname = getattr(call, "__qualname__", "") or ""
name = getattr(call, "__name__", "") or ""
if "role_checker" in qualname:
markers.add(ADMIN)
try:
roles = inspect.getclosurevars(call).nonlocals.get("required_roles")
if roles:
admin_roles = list(roles)
except (TypeError, ValueError):
pass
elif name in ("require_camera_access", "require_go2rtc_stream_access"):
markers.add(CAMERA)
elif "auth_checker" in qualname:
markers.add(AUTHENTICATED)
elif "public_checker" in qualname:
markers.add(PUBLIC)
return markers, admin_roles
def _handler_enforces_camera(route: APIRoute) -> bool:
"""True if the route handler calls require_camera_access in its body.
Such calls are invisible to dependency introspection. We detect them from
the handler's compiled bytecode: a global name referenced anywhere in the
function appears in ``__code__.co_names``. This catches direct calls (all of
them, currently); a call hidden behind a helper function would be missed.
"""
code = getattr(route.endpoint, "__code__", None)
return bool(code and "require_camera_access" in code.co_names)
def classify_route(
route: APIRoute,
exempt_paths: set[str],
exempt_prefixes: tuple[str, ...],
) -> tuple[str, list[str] | None, str | None]:
"""Resolve the effective access level for a route.
Returns (access_level, roles, flag). ``flag`` is a human-readable note when
the result needed inference or revealed a possible inconsistency.
"""
level, roles, flag = _classify_base(route, exempt_paths, exempt_prefixes)
# In-body require_camera_access enforcement is invisible to dependency
# introspection. When the effective access would otherwise be "any
# authenticated", the handler's per-camera check is the real constraint, so
# promote it to camera-scoped. Admin/public are left alone: for admin the
# role is the binding requirement and the camera check is only defensive.
if level == AUTHENTICATED and _handler_enforces_camera(route):
return CAMERA, None, None
return level, roles, flag
def _classify_base(
route: APIRoute,
exempt_paths: set[str],
exempt_prefixes: tuple[str, ...],
) -> tuple[str, list[str] | None, str | None]:
"""Resolve the access level from route-level dependencies and exempt rules."""
markers, admin_roles = _route_markers(route)
path = route.path
is_camera_path = _first_segment(path) == "{camera_name}"
exempt = path in exempt_paths or path.startswith(exempt_prefixes) or is_camera_path
# Explicit route-level markers win, in order of specificity.
if ADMIN in markers:
return ADMIN, admin_roles or ["admin"], None
if CAMERA in markers:
return CAMERA, None, None
if AUTHENTICATED in markers:
if exempt:
return AUTHENTICATED, None, None
# The route opts in to any-authenticated, but the global admin check is
# not bypassed for this path, so admin is what actually gets enforced.
return (
ADMIN,
["admin"],
(
"route declares allow_any_authenticated but path is not exempt from "
"the global admin check; admin is effectively enforced"
),
)
if PUBLIC in markers:
if exempt:
return PUBLIC, None, None
return (
ADMIN,
["admin"],
(
"route declares allow_public but path is not exempt from the global "
"admin check; admin is effectively enforced"
),
)
# No explicit auth marker: governed purely by the global default.
if not exempt:
return ADMIN, ["admin"], None
# Exempt with no route dependency: the global admin check is bypassed and
# there is no route-level gate, so authorization (if any) happens inside the
# handler. Infer from the path shape and flag for confirmation.
if is_camera_path:
return (
CAMERA,
None,
(
"no route-level dependency; camera-scoped path, authorization "
"assumed to be enforced in the handler"
),
)
return (
AUTHENTICATED,
None,
(
"path is exempt from the global admin check but has no route-level "
"dependency; confirm authorization is enforced in the handler"
),
)
def build_access_map(
app: FastAPI,
exempt_paths: set[str],
exempt_prefixes: tuple[str, ...],
) -> dict[tuple[str, str], dict]:
"""Map (path, lowercase method) -> classification details."""
access_map: dict[tuple[str, str], dict] = {}
for route in app.routes:
if not isinstance(route, APIRoute):
continue
level, roles, flag = classify_route(route, exempt_paths, exempt_prefixes)
for method in route.methods:
if method in ("HEAD", "OPTIONS"):
continue
access_map[(route.path, method.lower())] = {
"level": level,
"roles": roles,
"flag": flag,
"path": route.path,
"method": method,
}
return access_map
def security_for(level: str) -> list:
"""Build the OpenAPI ``security`` value for an access level."""
if level == PUBLIC:
return []
if level == ADMIN:
return [{ADMIN_SCHEME: []}]
# AUTHENTICATED and CAMERA both require any authenticated session; the
# camera-specific scoping is conveyed in the note and x-required-role.
return [{USER_SCHEME: []}]
def required_role_value(level: str, roles: list[str] | None):
if level == ADMIN and roles and roles != ["admin"]:
return roles
return level
def annotate_description(operation: dict, note: str) -> None:
existing = operation.get("description")
if not existing:
operation["description"] = note
return
operation["description"] = LiteralScalarString(
f"{note}\n\n{str(existing).rstrip()}"
)
def base_document(raw: dict) -> dict:
"""Apply the docs pipeline post-processing with a stable top-level order."""
info = dict(raw.get("info", {}))
info["title"] = SPEC_TITLE
return {
"openapi": raw["openapi"],
"info": info,
"servers": [dict(server) for server in SPEC_SERVERS],
"paths": raw["paths"],
"components": raw.get("components", {}),
}
def enrich(spec: dict, access_map: dict) -> tuple[dict, list, list]:
"""Add security schemes and per-operation auth metadata in place."""
components = spec.setdefault("components", {})
components["securitySchemes"] = dict(SECURITY_SCHEMES)
counts: dict[str, int] = {}
flagged: list[dict] = []
unmatched: list[tuple[str, str]] = []
for path, path_item in spec["paths"].items():
for method, operation in path_item.items():
if method.lower() not in HTTP_METHODS:
continue
details = access_map.get((path, method.lower()))
if details is None:
unmatched.append((method.upper(), path))
continue
level = details["level"]
counts[level] = counts.get(level, 0) + 1
operation["security"] = security_for(level)
operation["x-required-role"] = required_role_value(level, details["roles"])
annotate_description(operation, ACCESS_NOTES[level])
if details["flag"]:
flagged.append(details)
return counts, flagged, unmatched
# Numeric defaults at or above this magnitude are treated as live Unix
# timestamps baked into the schema at import time (e.g. the /{camera_name}
# /recordings after/before params default to datetime.now()). They make the
# export non-deterministic and document a meaningless frozen epoch, so they are
# stripped. The proper fix is to default those route params to None and resolve
# "now" inside the handler.
VOLATILE_DEFAULT_THRESHOLD = 1_000_000_000
def strip_volatile_defaults(node, trail: str = "") -> list[tuple[str, float]]:
"""Remove epoch-like numeric ``default`` values so the export is stable.
Returns the (location, value) pairs that were removed, for reporting.
"""
removed: list[tuple[str, float]] = []
if isinstance(node, dict):
default = node.get("default")
if (
isinstance(default, (int, float))
and not isinstance(default, bool)
and default >= VOLATILE_DEFAULT_THRESHOLD
):
removed.append((trail, default))
del node["default"]
for key, value in node.items():
removed.extend(strip_volatile_defaults(value, f"{trail}/{key}"))
elif isinstance(node, list):
for index, value in enumerate(node):
removed.extend(strip_volatile_defaults(value, f"{trail}[{index}]"))
return removed
def to_block_scalars(node):
"""Recursively render multi-line strings as literal block scalars.
Produces readable, deterministic YAML (``|-`` blocks) instead of long
double-quoted lines with escaped newlines.
"""
if isinstance(node, dict):
return {key: to_block_scalars(value) for key, value in node.items()}
if isinstance(node, list):
return [to_block_scalars(value) for value in node]
if isinstance(node, str) and "\n" in node:
return LiteralScalarString(node)
return node
def _iter_refs(node):
if isinstance(node, dict):
for key, value in node.items():
if key == "$ref" and isinstance(value, str):
yield value
else:
yield from _iter_refs(value)
elif isinstance(node, list):
for value in node:
yield from _iter_refs(value)
def validate(spec: dict) -> list[str]:
"""Structural sanity checks on the generated document."""
problems: list[str] = []
schemas = set(spec.get("components", {}).get("schemas", {}))
defined_schemes = set(spec.get("components", {}).get("securitySchemes", {}))
for ref in _iter_refs(spec):
if ref.startswith("#/components/schemas/"):
name = ref.rsplit("/", 1)[-1]
if name not in schemas:
problems.append(f"dangling $ref: {ref}")
for path, path_item in spec.get("paths", {}).items():
for method, operation in path_item.items():
if method.lower() not in HTTP_METHODS or not isinstance(operation, dict):
continue
location = f"{method.upper()} {path}"
if "x-required-role" not in operation:
problems.append(f"missing x-required-role: {location}")
if "security" not in operation:
problems.append(f"missing security: {location}")
continue
for requirement in operation["security"]:
for scheme in requirement:
if scheme not in defined_schemes:
problems.append(
f"undefined security scheme {scheme}: {location}"
)
return sorted(set(problems))
def render(spec: dict) -> str:
"""Serialize the spec to the canonical YAML string (with the header)."""
yaml = YAML()
yaml.width = 80
yaml.indent(mapping=2, sequence=4, offset=2)
stream = io.StringIO()
yaml.dump(spec, stream)
return HEADER + stream.getvalue()
def build_spec() -> tuple[dict, dict, list, list, list]:
app = build_app()
exempt_paths, exempt_prefixes = read_exempt_rules()
access_map = build_access_map(app, exempt_paths, exempt_prefixes)
spec = base_document(app.openapi())
normalized = strip_volatile_defaults(spec)
counts, flagged, unmatched = enrich(spec, access_map)
spec = to_block_scalars(spec)
return spec, counts, flagged, unmatched, normalized
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Generate the annotated OpenAPI spec.")
parser.add_argument(
"--check",
action="store_true",
help="verify the committed spec is up to date without writing; "
"exit non-zero if it would change",
)
args = parser.parse_args(argv)
spec, counts, flagged, unmatched, normalized = build_spec()
problems = validate(spec)
rendered = render(spec)
if args.check:
return _check(rendered, problems)
if problems:
logger.error("Refusing to write — generated spec failed validation:")
for problem in problems:
logger.error(" %s", problem)
return 1
OUTPUT_SPEC.write_text(rendered)
_report(counts, flagged, unmatched, normalized)
logger.info("\nWrote %s", OUTPUT_SPEC.relative_to(REPO_ROOT))
return 0
def _check(rendered: str, problems: list[str]) -> int:
name = OUTPUT_SPEC.relative_to(REPO_ROOT)
if problems:
logger.error("Generated spec failed validation:")
for problem in problems:
logger.error(" %s", problem)
return 1
current = OUTPUT_SPEC.read_text() if OUTPUT_SPEC.exists() else ""
if current == rendered:
logger.info("%s is up to date", name)
return 0
logger.error(
"%s is out of date. Regenerate with: python3 %s",
name,
Path(__file__).name,
)
diff = difflib.unified_diff(
current.splitlines(),
rendered.splitlines(),
fromfile=f"{name} (committed)",
tofile=f"{name} (generated)",
lineterm="",
n=2,
)
for shown, line in enumerate(diff):
if shown >= 60:
logger.error(" ... (diff truncated)")
break
logger.error(" %s", line)
return 1
def _report(counts, flagged, unmatched, normalized) -> None:
logger.info("Access levels applied:")
for level in (PUBLIC, AUTHENTICATED, CAMERA, ADMIN):
logger.info(" %-14s %d", level, counts.get(level, 0))
logger.info(" %-14s %d", "total", sum(counts.values()))
if normalized:
logger.info("\nStripped volatile timestamp defaults (%d):", len(normalized))
for location, value in normalized:
logger.info(" %s = %s", location.lstrip("/"), value)
if flagged:
logger.info("\nFlagged for manual confirmation (%d):", len(flagged))
for item in flagged:
logger.info(" %-6s %s", item["method"], item["path"])
logger.info(" -> %s (%s)", item["level"], item["flag"])
if unmatched:
logger.info(
"\nOperations with no classification (%d) [unexpected]:", len(unmatched)
)
for method, path in unmatched:
logger.info(" %-6s %s", method, path)
if __name__ == "__main__":
sys.exit(main())

View File

@ -21,6 +21,7 @@
"1hour": "1 hour",
"12hours": "12 hours",
"24hours": "24 hours",
"custom": "Custom...",
"pm": "pm",
"am": "am",
"yr": "{{time}}yr",

View File

@ -1191,8 +1191,15 @@
"1hour": "Suspend for 1 hour",
"12hours": "Suspend for 12 hours",
"24hours": "Suspend for 24 hours",
"custom": "Suspend until...",
"untilRestart": "Suspend until restart"
},
"customSuspension": {
"title": "Custom suspension time",
"description": "Suspend notifications for this camera until the selected time.",
"untilLabel": "Suspend until",
"invalidTime": "Pick a time in the future."
},
"cancelSuspension": "Cancel Suspension",
"toast": {
"success": {

View File

@ -23,7 +23,7 @@ import {
useState,
} from "react";
import { useForm } from "react-hook-form";
import { LuCheck, LuExternalLink, LuX } from "react-icons/lu";
import { LuCheck, LuChevronDown, LuExternalLink, LuX } from "react-icons/lu";
import { CiCircleAlert } from "react-icons/ci";
import { Link } from "react-router-dom";
import { toast } from "sonner";
@ -35,12 +35,12 @@ import {
useNotificationTest,
} from "@/api/ws";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { use24HourTime } from "@/hooks/use-date-utils";
import FilterSwitch from "@/components/filter/FilterSwitch";
@ -51,6 +51,7 @@ import { useDocDomain } from "@/hooks/use-doc-domain";
import { isPWA } from "@/utils/isPWA";
import { isIOS } from "react-device-detect";
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
import CustomSuspensionDialog from "@/components/overlay/dialog/CustomSuspensionDialog";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { cn } from "@/lib/utils";
import cloneDeep from "lodash/cloneDeep";
@ -756,6 +757,8 @@ export function CameraNotificationSwitch({
}
}, [notificationSuspendUntil, notificationState]);
const [customDialogOpen, setCustomDialogOpen] = useState(false);
const handleSuspend = (duration: string) => {
setIsSuspended(true);
if (duration == "off") {
@ -765,6 +768,11 @@ export function CameraNotificationSwitch({
}
};
const handleCustomSuspend = (totalMinutes: number) => {
setIsSuspended(true);
sendNotificationSuspend(totalMinutes);
};
const handleCancelSuspension = () => {
sendNotification("ON");
sendNotificationSuspend(0);
@ -824,34 +832,41 @@ export function CameraNotificationSwitch({
</div>
{!isSuspended ? (
<Select onValueChange={handleSuspend}>
<SelectTrigger className="w-auto">
<SelectValue placeholder={t("notification.suspendTime.suspend")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline" className="flex gap-2">
{t("notification.suspendTime.suspend")}
<LuChevronDown className="h-4 w-4 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => handleSuspend("5")}>
{t("notification.suspendTime.5minutes")}
</SelectItem>
<SelectItem value="10">
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleSuspend("10")}>
{t("notification.suspendTime.10minutes")}
</SelectItem>
<SelectItem value="30">
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleSuspend("30")}>
{t("notification.suspendTime.30minutes")}
</SelectItem>
<SelectItem value="60">
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleSuspend("60")}>
{t("notification.suspendTime.1hour")}
</SelectItem>
<SelectItem value="840">
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleSuspend("840")}>
{t("notification.suspendTime.12hours")}
</SelectItem>
<SelectItem value="1440">
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleSuspend("1440")}>
{t("notification.suspendTime.24hours")}
</SelectItem>
<SelectItem value="off">
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleSuspend("off")}>
{t("notification.suspendTime.untilRestart")}
</SelectItem>
</SelectContent>
</Select>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setCustomDialogOpen(true)}>
{t("notification.suspendTime.custom")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button
variant="destructive"
@ -861,6 +876,12 @@ export function CameraNotificationSwitch({
{t("notification.cancelSuspension")}
</Button>
)}
<CustomSuspensionDialog
open={customDialogOpen}
onOpenChange={setCustomDialogOpen}
onConfirm={handleCustomSuspend}
/>
</div>
);
}

View File

@ -50,6 +50,7 @@ import { use24HourTime } from "@/hooks/use-date-utils";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { CameraNameLabel } from "../camera/FriendlyNameLabel";
import { LiveStreamMetadata } from "@/types/live";
import CustomSuspensionDialog from "@/components/overlay/dialog/CustomSuspensionDialog";
type LiveContextMenuProps = {
className?: string;
@ -238,6 +239,8 @@ export default function LiveContextMenu({
}
}, [notificationSuspendUntil, notificationState]);
const [customDialogOpen, setCustomDialogOpen] = useState(false);
const handleSuspend = (duration: string) => {
if (duration === "off") {
sendNotification("OFF");
@ -534,6 +537,16 @@ export default function LiveContextMenu({
>
{t("time.24hours", { ns: "common" })}
</ContextMenuItem>
<ContextMenuItem
disabled={!isEnabled}
onClick={
isEnabled
? () => setCustomDialogOpen(true)
: undefined
}
>
{t("time.custom", { ns: "common" })}
</ContextMenuItem>
<ContextMenuItem
disabled={!isEnabled}
onClick={
@ -566,6 +579,12 @@ export default function LiveContextMenu({
streamMetadata={streamMetadata}
/>
</Dialog>
<CustomSuspensionDialog
open={customDialogOpen}
onOpenChange={setCustomDialogOpen}
onConfirm={(minutes) => sendNotificationSuspend(minutes)}
/>
</div>
);
}

View File

@ -1,6 +1,12 @@
import { RecordingsSummary, ReviewSummary } from "@/types/review";
import { Calendar } from "../ui/calendar";
import { ButtonHTMLAttributes, useEffect, useMemo, useRef } from "react";
import {
ButtonHTMLAttributes,
ComponentProps,
useEffect,
useMemo,
useRef,
} from "react";
import { FaCircle } from "react-icons/fa";
import { getUTCOffset } from "@/utils/dateUtil";
import { type DayButtonProps } from "react-day-picker";
@ -156,12 +162,14 @@ type TimezoneAwareCalendarProps = {
timezone?: string;
selectedDay?: Date;
onSelect: (day?: Date) => void;
disabled?: ComponentProps<typeof Calendar>["disabled"];
recordingsSummary?: RecordingsSummary;
};
export function TimezoneAwareCalendar({
timezone,
selectedDay,
onSelect,
disabled,
recordingsSummary,
}: TimezoneAwareCalendarProps) {
const [weekStartsOn] = useUserPersistence("weekStartsOn", 0);
@ -187,7 +195,7 @@ export function TimezoneAwareCalendar({
timezone ? Math.round(getUTCOffset(new Date(), timezone)) : undefined,
[timezone],
);
const disabledDates = useMemo(() => {
const defaultDisabledDates = useMemo(() => {
const tomorrow = new Date();
if (timezoneOffset) {
@ -205,6 +213,7 @@ export function TimezoneAwareCalendar({
future.setFullYear(tomorrow.getFullYear() + 10);
return { from: tomorrow, to: future };
}, [timezoneOffset]);
const disabledDates = disabled ?? defaultDisabledDates;
const today = useMemo(() => {
if (!timezoneOffset) {

View File

@ -0,0 +1,167 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { isDesktop } from "react-device-detect";
import { FaCalendarAlt } from "react-icons/fa";
import useSWR from "swr";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Label } from "@/components/ui/label";
import { TimezoneAwareCalendar } from "@/components/overlay/ReviewActivityCalendar";
import { FrigateConfig } from "@/types/frigateConfig";
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
type CustomSuspensionDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: (minutes: number) => void;
};
const ONE_HOUR_MS = 60 * 60 * 1000;
function pad(n: number): string {
return n.toString().padStart(2, "0");
}
function isValidDate(d: Date): boolean {
return !Number.isNaN(d.getTime());
}
export default function CustomSuspensionDialog({
open,
onOpenChange,
onConfirm,
}: CustomSuspensionDialogProps) {
const { t } = useTranslation(["views/settings"]);
const { data: config } = useSWR<FrigateConfig>("config");
const [until, setUntil] = useState<Date>(
() => new Date(Date.now() + ONE_HOUR_MS),
);
const [calendarOpen, setCalendarOpen] = useState(false);
useEffect(() => {
if (open) setUntil(new Date(Date.now() + ONE_HOUR_MS));
}, [open]);
const formattedDate = useFormattedTimestamp(
isValidDate(until) ? Math.floor(until.getTime() / 1000) : 0,
t("time.formattedTimestampMonthDayYear.24hour", { ns: "common" }),
config?.ui.timezone,
);
const isFuture = isValidDate(until) && until.getTime() > Date.now();
const handleApply = () => {
if (!isFuture) return;
onConfirm(Math.ceil((until.getTime() - Date.now()) / 60_000));
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("notification.customSuspension.title")}</DialogTitle>
<DialogDescription>
{t("notification.customSuspension.description")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2">
<Label>{t("notification.customSuspension.untilLabel")}</Label>
<div className="flex items-center gap-2 rounded-lg bg-secondary p-2 text-secondary-foreground">
<FaCalendarAlt />
<Popover open={calendarOpen} onOpenChange={setCalendarOpen}>
<PopoverTrigger asChild>
<Button
className={`text-primary ${isDesktop ? "" : "text-xs"}`}
variant={calendarOpen ? "select" : "default"}
size="sm"
>
{isValidDate(until) ? formattedDate : "—"}
</Button>
</PopoverTrigger>
<PopoverContent
className="flex flex-col items-center"
disablePortal
>
<TimezoneAwareCalendar
timezone={config?.ui.timezone}
selectedDay={isValidDate(until) ? until : undefined}
disabled={{
before: new Date(new Date().setHours(0, 0, 0, 0)),
}}
onSelect={(day) => {
if (!day) return;
const next = new Date(day);
const carry = isValidDate(until) ? until : new Date();
next.setHours(
carry.getHours(),
carry.getMinutes(),
carry.getSeconds(),
0,
);
setUntil(next);
setCalendarOpen(false);
}}
/>
</PopoverContent>
</Popover>
<input
className="text-md border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
aria-label={t("notification.customSuspension.untilLabel")}
type="time"
value={
isValidDate(until)
? `${pad(until.getHours())}:${pad(until.getMinutes())}`
: ""
}
step="60"
onChange={(e) => {
const [h, m] = e.target.value.split(":");
const hh = Number.parseInt(h ?? "", 10);
const mm = Number.parseInt(m ?? "", 10);
if (Number.isNaN(hh) || Number.isNaN(mm)) return;
const base = isValidDate(until) ? until : new Date();
const next = new Date(base);
next.setHours(hh, mm, 0, 0);
setUntil(next);
}}
/>
</div>
{!isFuture && (
<p className="text-sm text-danger">
{t("notification.customSuspension.invalidTime")}
</p>
)}
</div>
<DialogFooter>
<Button type="button" onClick={() => onOpenChange(false)}>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
type="button"
disabled={!isFuture}
onClick={handleApply}
>
{t("button.apply", { ns: "common" })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}