mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-28 23:31:52 +03:00
Compare commits
11 Commits
3473ee80ce
...
5d118cd100
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d118cd100 | ||
|
|
b79ad9871a | ||
|
|
8be7a97fa6 | ||
|
|
f00dd5c7af | ||
|
|
51dd890eb6 | ||
|
|
8f6e083420 | ||
|
|
bf25560067 | ||
|
|
df40d9e2b5 | ||
|
|
263554a5f6 | ||
|
|
597a9f9fb4 | ||
|
|
0d05f0feaa |
0
.devcontainer/post_create.sh
Executable file → Normal file
0
.devcontainer/post_create.sh
Executable file → Normal file
2
.github/workflows/pull_request.yml
vendored
2
.github/workflows/pull_request.yml
vendored
@ -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"
|
||||
|
||||
10
AGENTS.md
10
AGENTS.md
@ -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
|
||||
|
||||
4105
docs/static/frigate-api.yaml
vendored
4105
docs/static/frigate-api.yaml
vendored
File diff suppressed because it is too large
Load Diff
@ -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]])}"
|
||||
|
||||
91
frigate/test/test_norfair_distance.py
Normal file
91
frigate/test/test_norfair_distance.py
Normal 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()
|
||||
@ -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
606
generate_api_auth_spec.py
Normal 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())
|
||||
@ -21,6 +21,7 @@
|
||||
"1hour": "1 hour",
|
||||
"12hours": "12 hours",
|
||||
"24hours": "24 hours",
|
||||
"custom": "Custom...",
|
||||
"pm": "pm",
|
||||
"am": "am",
|
||||
"yr": "{{time}}yr",
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
167
web/src/components/overlay/dialog/CustomSuspensionDialog.tsx
Normal file
167
web/src/components/overlay/dialog/CustomSuspensionDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user