Compare commits

...

8 Commits

Author SHA1 Message Date
Weblate (bot)
e49da8301b
Merge c04a0841e6 into cd606ad240 2025-11-26 20:46:58 -08:00
Josh Hawkins
cd606ad240
Enforce default admin role requirement for API endpoints (#21065)
Some checks are pending
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* require admin role by default

* update all endpoint access guards

* explicit paths and prefixes exception lists

* fix tests to use mock auth

* add helper and simplify auth conditions

* add missing exempt path

* fix test

* make metrics endpoint require auth
2025-11-26 15:07:28 -06:00
Nicolas Mowen
de2144f158
Miscellaneous Fixes (#21050)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* Don't add to history when opening search dialog

* Update caniuse

* Revamp the history handling for dialog components

* clarify audio transcription docs

* Use titlecase helper

* Allow running object clasasification on stationary objects

* small spacing tweaks for tablets

* require admin role to delete users

* explicitly prevent deletion of admin user

---------

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
2025-11-26 07:23:51 -06:00
Hosted Weblate
c04a0841e6
Translated using Weblate (Norwegian Bokmål)
Currently translated at 100.0% (72 of 72 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 100.0% (639 of 639 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 100.0% (52 of 52 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 100.0% (116 of 116 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 100.0% (55 of 55 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 100.0% (128 of 128 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 100.0% (48 of 48 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: OverTheHillsAndFarAway <prosjektx@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-filter/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-classificationmodel/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-explore/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-facelibrary/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-search/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/nb_NO/
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/components-filter
Translation: Frigate NVR/views-classificationmodel
Translation: Frigate NVR/views-explore
Translation: Frigate NVR/views-facelibrary
Translation: Frigate NVR/views-search
Translation: Frigate NVR/views-settings
2025-11-25 20:34:29 +01:00
Hosted Weblate
5b17a826ef
Translated using Weblate (Dutch)
Currently translated at 100.0% (55 of 55 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (128 of 128 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Marijn <168113859+Marijn0@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/nl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-explore/nl/
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/views-explore
2025-11-25 20:34:28 +01:00
Hosted Weblate
7a5933e37f
Translated using Weblate (Catalan)
Currently translated at 100.0% (128 of 128 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (55 of 55 strings)

Co-authored-by: Eduardo Pastor Fernández <123eduardoneko123@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/ca/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-explore/ca/
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/views-explore
2025-11-25 20:34:28 +01:00
Hosted Weblate
5b4db83f38
Translated using Weblate (Ukrainian)
Currently translated at 100.0% (55 of 55 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (128 of 128 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Максим Горпиніч <gorpinicmaksim0@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/uk/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-explore/uk/
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/views-explore
2025-11-25 20:34:28 +01:00
Hosted Weblate
c88b36ed9f
Translated using Weblate (German)
Currently translated at 29.3% (34 of 116 strings)

Translated using Weblate (German)

Currently translated at 96.0% (123 of 128 strings)

Translated using Weblate (German)

Currently translated at 78.0% (499 of 639 strings)

Co-authored-by: Fuxle <moritz.hofmann2005@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-classificationmodel/de/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-explore/de/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/de/
Translation: Frigate NVR/views-classificationmodel
Translation: Frigate NVR/views-explore
Translation: Frigate NVR/views-settings
2025-11-25 20:34:28 +01:00
41 changed files with 732 additions and 348 deletions

View File

@ -75,7 +75,13 @@ audio:
### Audio Transcription ### Audio Transcription
Frigate supports fully local audio transcription using either `sherpa-onnx` or OpenAIs open-source Whisper models via `faster-whisper`. To enable transcription, enable it in your config. Note that audio detection must also be enabled as described above in order to use audio transcription features. Frigate supports fully local audio transcription using either `sherpa-onnx` or OpenAIs open-source Whisper models via `faster-whisper`. The goal of this feature is to support Semantic Search for `speech` audio events. Frigate is not intended to act as a continuous, fully-automatic speech transcription service — automatically transcribing all speech (or queuing many audio events for transcription) requires substantial CPU (or GPU) resources and is impractical on most systems. For this reason, transcriptions for events are initiated manually from the UI or the API rather than being run continuously in the background.
Transcription accuracy also depends heavily on the quality of your camera's microphone and recording conditions. Many cameras use inexpensive microphones, and distance to the speaker, low audio bitrate, or background noise can significantly reduce transcription quality. If you need higher accuracy, more robust long-running queues, or large-scale automatic transcription, consider using the HTTP API in combination with an automation platform and a cloud transcription service.
#### Configuration
To enable transcription, enable it in your config. Note that audio detection must also be enabled as described above in order to use audio transcription features.
```yaml ```yaml
audio_transcription: audio_transcription:

View File

@ -23,7 +23,7 @@ from markupsafe import escape
from peewee import SQL, fn, operator from peewee import SQL, fn, operator
from pydantic import ValidationError from pydantic import ValidationError
from frigate.api.auth import require_role from frigate.api.auth import allow_any_authenticated, allow_public, require_role
from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryParameters from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryParameters
from frigate.api.defs.request.app_body import AppConfigSetBody from frigate.api.defs.request.app_body import AppConfigSetBody
from frigate.api.defs.tags import Tags from frigate.api.defs.tags import Tags
@ -56,29 +56,33 @@ logger = logging.getLogger(__name__)
router = APIRouter(tags=[Tags.app]) router = APIRouter(tags=[Tags.app])
@router.get("/", response_class=PlainTextResponse) @router.get(
"/", response_class=PlainTextResponse, dependencies=[Depends(allow_public())]
)
def is_healthy(): def is_healthy():
return "Frigate is running. Alive and healthy!" return "Frigate is running. Alive and healthy!"
@router.get("/config/schema.json") @router.get("/config/schema.json", dependencies=[Depends(allow_public())])
def config_schema(request: Request): def config_schema(request: Request):
return Response( return Response(
content=request.app.frigate_config.schema_json(), media_type="application/json" content=request.app.frigate_config.schema_json(), media_type="application/json"
) )
@router.get("/version", response_class=PlainTextResponse) @router.get(
"/version", response_class=PlainTextResponse, dependencies=[Depends(allow_public())]
)
def version(): def version():
return VERSION return VERSION
@router.get("/stats") @router.get("/stats", dependencies=[Depends(allow_any_authenticated())])
def stats(request: Request): def stats(request: Request):
return JSONResponse(content=request.app.stats_emitter.get_latest_stats()) return JSONResponse(content=request.app.stats_emitter.get_latest_stats())
@router.get("/stats/history") @router.get("/stats/history", dependencies=[Depends(allow_any_authenticated())])
def stats_history(request: Request, keys: str = None): def stats_history(request: Request, keys: str = None):
if keys: if keys:
keys = keys.split(",") keys = keys.split(",")
@ -86,7 +90,7 @@ def stats_history(request: Request, keys: str = None):
return JSONResponse(content=request.app.stats_emitter.get_stats_history(keys)) return JSONResponse(content=request.app.stats_emitter.get_stats_history(keys))
@router.get("/metrics") @router.get("/metrics", dependencies=[Depends(allow_any_authenticated())])
def metrics(request: Request): def metrics(request: Request):
"""Expose Prometheus metrics endpoint and update metrics with latest stats""" """Expose Prometheus metrics endpoint and update metrics with latest stats"""
# Retrieve the latest statistics and update the Prometheus metrics # Retrieve the latest statistics and update the Prometheus metrics
@ -103,7 +107,7 @@ def metrics(request: Request):
return Response(content=content, media_type=content_type) return Response(content=content, media_type=content_type)
@router.get("/config") @router.get("/config", dependencies=[Depends(allow_any_authenticated())])
def config(request: Request): def config(request: Request):
config_obj: FrigateConfig = request.app.frigate_config config_obj: FrigateConfig = request.app.frigate_config
config: dict[str, dict[str, Any]] = config_obj.model_dump( config: dict[str, dict[str, Any]] = config_obj.model_dump(
@ -209,7 +213,7 @@ def config_raw_paths(request: Request):
return JSONResponse(content=raw_paths) return JSONResponse(content=raw_paths)
@router.get("/config/raw") @router.get("/config/raw", dependencies=[Depends(allow_any_authenticated())])
def config_raw(): def config_raw():
config_file = find_config_file() config_file = find_config_file()
@ -452,7 +456,7 @@ def config_set(request: Request, body: AppConfigSetBody):
) )
@router.get("/vainfo") @router.get("/vainfo", dependencies=[Depends(allow_any_authenticated())])
def vainfo(): def vainfo():
vainfo = vainfo_hwaccel() vainfo = vainfo_hwaccel()
return JSONResponse( return JSONResponse(
@ -472,12 +476,16 @@ def vainfo():
) )
@router.get("/nvinfo") @router.get("/nvinfo", dependencies=[Depends(allow_any_authenticated())])
def nvinfo(): def nvinfo():
return JSONResponse(content=get_nvidia_driver_info()) return JSONResponse(content=get_nvidia_driver_info())
@router.get("/logs/{service}", tags=[Tags.logs]) @router.get(
"/logs/{service}",
tags=[Tags.logs],
dependencies=[Depends(allow_any_authenticated())],
)
async def logs( async def logs(
service: str = Path(enum=["frigate", "nginx", "go2rtc"]), service: str = Path(enum=["frigate", "nginx", "go2rtc"]),
download: Optional[str] = None, download: Optional[str] = None,
@ -585,7 +593,7 @@ def restart():
) )
@router.get("/labels") @router.get("/labels", dependencies=[Depends(allow_any_authenticated())])
def get_labels(camera: str = ""): def get_labels(camera: str = ""):
try: try:
if camera: if camera:
@ -603,7 +611,7 @@ def get_labels(camera: str = ""):
return JSONResponse(content=labels) return JSONResponse(content=labels)
@router.get("/sub_labels") @router.get("/sub_labels", dependencies=[Depends(allow_any_authenticated())])
def get_sub_labels(split_joined: Optional[int] = None): def get_sub_labels(split_joined: Optional[int] = None):
try: try:
events = Event.select(Event.sub_label).distinct() events = Event.select(Event.sub_label).distinct()
@ -634,7 +642,7 @@ def get_sub_labels(split_joined: Optional[int] = None):
return JSONResponse(content=sub_labels) return JSONResponse(content=sub_labels)
@router.get("/plus/models") @router.get("/plus/models", dependencies=[Depends(allow_any_authenticated())])
def plusModels(request: Request, filterByCurrentModelDetector: bool = False): def plusModels(request: Request, filterByCurrentModelDetector: bool = False):
if not request.app.frigate_config.plus_api.is_active(): if not request.app.frigate_config.plus_api.is_active():
return JSONResponse( return JSONResponse(
@ -676,7 +684,9 @@ def plusModels(request: Request, filterByCurrentModelDetector: bool = False):
return JSONResponse(content=validModels) return JSONResponse(content=validModels)
@router.get("/recognized_license_plates") @router.get(
"/recognized_license_plates", dependencies=[Depends(allow_any_authenticated())]
)
def get_recognized_license_plates(split_joined: Optional[int] = None): def get_recognized_license_plates(split_joined: Optional[int] = None):
try: try:
query = ( query = (
@ -710,7 +720,7 @@ def get_recognized_license_plates(split_joined: Optional[int] = None):
return JSONResponse(content=recognized_license_plates) return JSONResponse(content=recognized_license_plates)
@router.get("/timeline") @router.get("/timeline", dependencies=[Depends(allow_any_authenticated())])
def timeline(camera: str = "all", limit: int = 100, source_id: Optional[str] = None): def timeline(camera: str = "all", limit: int = 100, source_id: Optional[str] = None):
clauses = [] clauses = []
@ -747,7 +757,7 @@ def timeline(camera: str = "all", limit: int = 100, source_id: Optional[str] = N
return JSONResponse(content=[t for t in timeline]) return JSONResponse(content=[t for t in timeline])
@router.get("/timeline/hourly") @router.get("/timeline/hourly", dependencies=[Depends(allow_any_authenticated())])
def hourly_timeline(params: AppTimelineHourlyQueryParameters = Depends()): def hourly_timeline(params: AppTimelineHourlyQueryParameters = Depends()):
"""Get hourly summary for timeline.""" """Get hourly summary for timeline."""
cameras = params.cameras cameras = params.cameras

View File

@ -32,10 +32,154 @@ from frigate.models import User
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def require_admin_by_default():
"""
Global admin requirement dependency for all endpoints by default.
This is set as the default dependency on the FastAPI app to ensure all
endpoints require admin access unless explicitly overridden with
allow_public(), allow_any_authenticated(), or require_role().
Port 5000 (internal) always has admin role set by the /auth endpoint,
so this check passes automatically for internal requests.
Certain paths are exempted from the global admin check because they must
be accessible before authentication (login, auth) or they have their own
route-level authorization dependencies that handle access control.
"""
# Paths that have route-level auth dependencies and should bypass global admin check
# These paths still have authorization - it's handled by their route-level dependencies
EXEMPT_PATHS = {
# Public auth endpoints (allow_public)
"/auth",
"/auth/first_time_login",
"/login",
# Authenticated user endpoints (allow_any_authenticated)
"/logout",
"/profile",
# Public info endpoints (allow_public)
"/",
"/version",
"/config/schema.json",
"/metrics",
# Authenticated user endpoints (allow_any_authenticated)
"/stats",
"/stats/history",
"/config",
"/config/raw",
"/vainfo",
"/nvinfo",
"/labels",
"/sub_labels",
"/plus/models",
"/recognized_license_plates",
"/timeline",
"/timeline/hourly",
"/events/summary",
"/recordings/storage",
"/recordings/summary",
"/recordings/unavailable",
"/go2rtc/streams",
}
# Path prefixes that should be exempt (for paths with parameters)
EXEMPT_PREFIXES = (
"/logs/", # /logs/{service}
"/review", # /review, /review/{id}, /review_ids, /review/summary, etc.
"/reviews/", # /reviews/viewed, /reviews/delete
"/events/", # /events/{id}/thumbnail, etc. (camera-scoped)
"/go2rtc/streams/", # /go2rtc/streams/{camera}
"/users/", # /users/{username}/password (has own auth)
"/preview/", # /preview/{file}/thumbnail.jpg
)
async def admin_checker(request: Request):
path = request.url.path
# Check exact path matches
if path in EXEMPT_PATHS:
return
# Check prefix matches for parameterized paths
if path.startswith(EXEMPT_PREFIXES):
return
# For all other paths, require admin role
# Port 5000 (internal) requests have admin role set automatically
role = request.headers.get("remote-role")
if role == "admin":
return
raise HTTPException(
status_code=403,
detail="Admin role required for this endpoint",
)
return admin_checker
def _is_authenticated(request: Request) -> bool:
"""
Helper to determine if a request is from an authenticated user.
Returns True if the request has a valid authenticated user (not anonymous).
Port 5000 internal requests are considered anonymous despite having admin role.
"""
username = request.headers.get("remote-user")
return username is not None and username != "anonymous"
def allow_public():
"""
Override dependency to allow unauthenticated access to an endpoint.
Use this for endpoints that should be publicly accessible without
authentication, such as login page, health checks, or pre-auth info.
Example:
@router.get("/public-endpoint", dependencies=[Depends(allow_public())])
"""
async def public_checker(request: Request):
return # Always allow
return public_checker
def allow_any_authenticated():
"""
Override dependency to allow any authenticated user (bypass admin requirement).
Allows:
- Port 5000 internal requests (have admin role despite anonymous user)
- Any authenticated user with a real username (not "anonymous")
Rejects:
- Port 8971 requests with anonymous user (auth disabled, no proxy auth)
Example:
@router.get("/authenticated-endpoint", dependencies=[Depends(allow_any_authenticated())])
"""
async def auth_checker(request: Request):
# Port 5000 requests have admin role and should be allowed
role = request.headers.get("remote-role")
if role == "admin":
return
# Otherwise require a real authenticated user (not anonymous)
if not _is_authenticated(request):
raise HTTPException(status_code=401, detail="Authentication required")
return
return auth_checker
router = APIRouter(tags=[Tags.auth]) router = APIRouter(tags=[Tags.auth])
@router.get("/auth/first_time_login") @router.get("/auth/first_time_login", dependencies=[Depends(allow_public())])
def first_time_login(request: Request): def first_time_login(request: Request):
"""Return whether the admin first-time login help flag is set in config. """Return whether the admin first-time login help flag is set in config.
@ -352,7 +496,7 @@ def resolve_role(
# Endpoints # Endpoints
@router.get("/auth") @router.get("/auth", dependencies=[Depends(allow_public())])
def auth(request: Request): def auth(request: Request):
auth_config: AuthConfig = request.app.frigate_config.auth auth_config: AuthConfig = request.app.frigate_config.auth
proxy_config: ProxyConfig = request.app.frigate_config.proxy proxy_config: ProxyConfig = request.app.frigate_config.proxy
@ -478,7 +622,7 @@ def auth(request: Request):
return fail_response return fail_response
@router.get("/profile") @router.get("/profile", dependencies=[Depends(allow_any_authenticated())])
def profile(request: Request): def profile(request: Request):
username = request.headers.get("remote-user", "anonymous") username = request.headers.get("remote-user", "anonymous")
role = request.headers.get("remote-role", "viewer") role = request.headers.get("remote-role", "viewer")
@ -492,7 +636,7 @@ def profile(request: Request):
) )
@router.get("/logout") @router.get("/logout", dependencies=[Depends(allow_any_authenticated())])
def logout(request: Request): def logout(request: Request):
auth_config: AuthConfig = request.app.frigate_config.auth auth_config: AuthConfig = request.app.frigate_config.auth
response = RedirectResponse("/login", status_code=303) response = RedirectResponse("/login", status_code=303)
@ -503,7 +647,7 @@ def logout(request: Request):
limiter = Limiter(key_func=get_remote_addr) limiter = Limiter(key_func=get_remote_addr)
@router.post("/login") @router.post("/login", dependencies=[Depends(allow_public())])
@limiter.limit(limit_value=rateLimiter.get_limit) @limiter.limit(limit_value=rateLimiter.get_limit)
def login(request: Request, body: AppPostLoginBody): def login(request: Request, body: AppPostLoginBody):
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
@ -578,13 +722,21 @@ def create_user(
return JSONResponse(content={"username": body.username}) return JSONResponse(content={"username": body.username})
@router.delete("/users/{username}") @router.delete("/users/{username}", dependencies=[Depends(require_role(["admin"]))])
def delete_user(username: str): def delete_user(request: Request, username: str):
# Prevent deletion of the built-in admin user
if username == "admin":
return JSONResponse(
content={"message": "Cannot delete admin user"}, status_code=403
)
User.delete_by_id(username) User.delete_by_id(username)
return JSONResponse(content={"success": True}) return JSONResponse(content={"success": True})
@router.put("/users/{username}/password") @router.put(
"/users/{username}/password", dependencies=[Depends(allow_any_authenticated())]
)
async def update_password( async def update_password(
request: Request, request: Request,
username: str, username: str,

View File

@ -15,7 +15,11 @@ from onvif import ONVIFCamera, ONVIFError
from zeep.exceptions import Fault, TransportError from zeep.exceptions import Fault, TransportError
from zeep.transports import AsyncTransport from zeep.transports import AsyncTransport
from frigate.api.auth import require_role from frigate.api.auth import (
allow_any_authenticated,
require_camera_access,
require_role,
)
from frigate.api.defs.tags import Tags from frigate.api.defs.tags import Tags
from frigate.config.config import FrigateConfig from frigate.config.config import FrigateConfig
from frigate.util.builtin import clean_camera_user_pass from frigate.util.builtin import clean_camera_user_pass
@ -50,7 +54,7 @@ def _is_valid_host(host: str) -> bool:
return False return False
@router.get("/go2rtc/streams") @router.get("/go2rtc/streams", dependencies=[Depends(allow_any_authenticated())])
def go2rtc_streams(): def go2rtc_streams():
r = requests.get("http://127.0.0.1:1984/api/streams") r = requests.get("http://127.0.0.1:1984/api/streams")
if not r.ok: if not r.ok:
@ -66,7 +70,9 @@ def go2rtc_streams():
return JSONResponse(content=stream_data) return JSONResponse(content=stream_data)
@router.get("/go2rtc/streams/{camera_name}") @router.get(
"/go2rtc/streams/{camera_name}", dependencies=[Depends(require_camera_access)]
)
def go2rtc_camera_stream(request: Request, camera_name: str): def go2rtc_camera_stream(request: Request, camera_name: str):
r = requests.get( r = requests.get(
f"http://127.0.0.1:1984/api/streams?src={camera_name}&video=all&audio=all&microphone" f"http://127.0.0.1:1984/api/streams?src={camera_name}&video=all&audio=all&microphone"
@ -161,7 +167,7 @@ def go2rtc_delete_stream(stream_name: str):
) )
@router.get("/ffprobe") @router.get("/ffprobe", dependencies=[Depends(require_role(["admin"]))])
def ffprobe(request: Request, paths: str = "", detailed: bool = False): def ffprobe(request: Request, paths: str = "", detailed: bool = False):
path_param = paths path_param = paths

View File

@ -22,6 +22,7 @@ from peewee import JOIN, DoesNotExist, fn, operator
from playhouse.shortcuts import model_to_dict from playhouse.shortcuts import model_to_dict
from frigate.api.auth import ( from frigate.api.auth import (
allow_any_authenticated,
get_allowed_cameras_for_filter, get_allowed_cameras_for_filter,
require_camera_access, require_camera_access,
require_role, require_role,
@ -808,7 +809,7 @@ def events_search(
return JSONResponse(content=processed_events) return JSONResponse(content=processed_events)
@router.get("/events/summary") @router.get("/events/summary", dependencies=[Depends(allow_any_authenticated())])
def events_summary( def events_summary(
params: EventsSummaryQueryParams = Depends(), params: EventsSummaryQueryParams = Depends(),
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),

View File

@ -2,7 +2,7 @@ import logging
import re import re
from typing import Optional from typing import Optional
from fastapi import FastAPI, Request from fastapi import Depends, FastAPI, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from joserfc.jwk import OctKey from joserfc.jwk import OctKey
from playhouse.sqliteq import SqliteQueueDatabase from playhouse.sqliteq import SqliteQueueDatabase
@ -24,7 +24,7 @@ from frigate.api import (
preview, preview,
review, review,
) )
from frigate.api.auth import get_jwt_secret, limiter from frigate.api.auth import get_jwt_secret, limiter, require_admin_by_default
from frigate.comms.event_metadata_updater import ( from frigate.comms.event_metadata_updater import (
EventMetadataPublisher, EventMetadataPublisher,
) )
@ -62,11 +62,15 @@ def create_fastapi_app(
stats_emitter: StatsEmitter, stats_emitter: StatsEmitter,
event_metadata_updater: EventMetadataPublisher, event_metadata_updater: EventMetadataPublisher,
config_publisher: CameraConfigUpdatePublisher, config_publisher: CameraConfigUpdatePublisher,
enforce_default_admin: bool = True,
): ):
logger.info("Starting FastAPI app") logger.info("Starting FastAPI app")
app = FastAPI( app = FastAPI(
debug=False, debug=False,
swagger_ui_parameters={"apisSorter": "alpha", "operationsSorter": "alpha"}, swagger_ui_parameters={"apisSorter": "alpha", "operationsSorter": "alpha"},
dependencies=[Depends(require_admin_by_default())]
if enforce_default_admin
else [],
) )
# update the request_address with the x-forwarded-for header from nginx # update the request_address with the x-forwarded-for header from nginx

View File

@ -22,7 +22,11 @@ from pathvalidate import sanitize_filename
from peewee import DoesNotExist, fn, operator from peewee import DoesNotExist, fn, operator
from tzlocal import get_localzone_name from tzlocal import get_localzone_name
from frigate.api.auth import get_allowed_cameras_for_filter, require_camera_access from frigate.api.auth import (
allow_any_authenticated,
get_allowed_cameras_for_filter,
require_camera_access,
)
from frigate.api.defs.query.media_query_parameters import ( from frigate.api.defs.query.media_query_parameters import (
Extension, Extension,
MediaEventsSnapshotQueryParams, MediaEventsSnapshotQueryParams,
@ -393,7 +397,7 @@ async def submit_recording_snapshot_to_plus(
) )
@router.get("/recordings/storage") @router.get("/recordings/storage", dependencies=[Depends(allow_any_authenticated())])
def get_recordings_storage_usage(request: Request): def get_recordings_storage_usage(request: Request):
recording_stats = request.app.stats_emitter.get_latest_stats()["service"][ recording_stats = request.app.stats_emitter.get_latest_stats()["service"][
"storage" "storage"
@ -417,7 +421,7 @@ def get_recordings_storage_usage(request: Request):
return JSONResponse(content=camera_usages) return JSONResponse(content=camera_usages)
@router.get("/recordings/summary") @router.get("/recordings/summary", dependencies=[Depends(allow_any_authenticated())])
def all_recordings_summary( def all_recordings_summary(
request: Request, request: Request,
params: MediaRecordingsSummaryQueryParams = Depends(), params: MediaRecordingsSummaryQueryParams = Depends(),
@ -635,7 +639,11 @@ async def recordings(
return JSONResponse(content=list(recordings)) return JSONResponse(content=list(recordings))
@router.get("/recordings/unavailable", response_model=list[dict]) @router.get(
"/recordings/unavailable",
response_model=list[dict],
dependencies=[Depends(allow_any_authenticated())],
)
async def no_recordings( async def no_recordings(
request: Request, request: Request,
params: MediaRecordingsAvailabilityQueryParams = Depends(), params: MediaRecordingsAvailabilityQueryParams = Depends(),
@ -1053,7 +1061,10 @@ async def event_snapshot(
) )
@router.get("/events/{event_id}/thumbnail.{extension}") @router.get(
"/events/{event_id}/thumbnail.{extension}",
dependencies=[Depends(require_camera_access)],
)
async def event_thumbnail( async def event_thumbnail(
request: Request, request: Request,
event_id: str, event_id: str,
@ -1251,7 +1262,10 @@ def grid_snapshot(
) )
@router.get("/events/{event_id}/snapshot-clean.webp") @router.get(
"/events/{event_id}/snapshot-clean.webp",
dependencies=[Depends(require_camera_access)],
)
def event_snapshot_clean(request: Request, event_id: str, download: bool = False): def event_snapshot_clean(request: Request, event_id: str, download: bool = False):
webp_bytes = None webp_bytes = None
try: try:
@ -1375,7 +1389,9 @@ def event_snapshot_clean(request: Request, event_id: str, download: bool = False
) )
@router.get("/events/{event_id}/clip.mp4") @router.get(
"/events/{event_id}/clip.mp4", dependencies=[Depends(require_camera_access)]
)
async def event_clip( async def event_clip(
request: Request, request: Request,
event_id: str, event_id: str,
@ -1403,7 +1419,9 @@ async def event_clip(
) )
@router.get("/events/{event_id}/preview.gif") @router.get(
"/events/{event_id}/preview.gif", dependencies=[Depends(require_camera_access)]
)
def event_preview(request: Request, event_id: str): def event_preview(request: Request, event_id: str):
try: try:
event: Event = Event.get(Event.id == event_id) event: Event = Event.get(Event.id == event_id)
@ -1756,7 +1774,7 @@ def preview_mp4(
) )
@router.get("/review/{event_id}/preview") @router.get("/review/{event_id}/preview", dependencies=[Depends(require_camera_access)])
def review_preview( def review_preview(
request: Request, request: Request,
event_id: str, event_id: str,
@ -1782,8 +1800,12 @@ def review_preview(
return preview_mp4(request, review.camera, start_ts, end_ts) return preview_mp4(request, review.camera, start_ts, end_ts)
@router.get("/preview/{file_name}/thumbnail.jpg") @router.get(
@router.get("/preview/{file_name}/thumbnail.webp") "/preview/{file_name}/thumbnail.jpg", dependencies=[Depends(require_camera_access)]
)
@router.get(
"/preview/{file_name}/thumbnail.webp", dependencies=[Depends(require_camera_access)]
)
def preview_thumbnail(file_name: str): def preview_thumbnail(file_name: str):
"""Get a thumbnail from the cached preview frames.""" """Get a thumbnail from the cached preview frames."""
if len(file_name) > 1000: if len(file_name) > 1000:

View File

@ -14,6 +14,7 @@ from peewee import Case, DoesNotExist, IntegrityError, fn, operator
from playhouse.shortcuts import model_to_dict from playhouse.shortcuts import model_to_dict
from frigate.api.auth import ( from frigate.api.auth import (
allow_any_authenticated,
get_allowed_cameras_for_filter, get_allowed_cameras_for_filter,
get_current_user, get_current_user,
require_camera_access, require_camera_access,
@ -43,7 +44,11 @@ logger = logging.getLogger(__name__)
router = APIRouter(tags=[Tags.review]) router = APIRouter(tags=[Tags.review])
@router.get("/review", response_model=list[ReviewSegmentResponse]) @router.get(
"/review",
response_model=list[ReviewSegmentResponse],
dependencies=[Depends(allow_any_authenticated())],
)
async def review( async def review(
params: ReviewQueryParams = Depends(), params: ReviewQueryParams = Depends(),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
@ -152,7 +157,11 @@ async def review(
return JSONResponse(content=[r for r in review_query]) return JSONResponse(content=[r for r in review_query])
@router.get("/review_ids", response_model=list[ReviewSegmentResponse]) @router.get(
"/review_ids",
response_model=list[ReviewSegmentResponse],
dependencies=[Depends(allow_any_authenticated())],
)
async def review_ids(request: Request, ids: str): async def review_ids(request: Request, ids: str):
ids = ids.split(",") ids = ids.split(",")
@ -186,7 +195,11 @@ async def review_ids(request: Request, ids: str):
) )
@router.get("/review/summary", response_model=ReviewSummaryResponse) @router.get(
"/review/summary",
response_model=ReviewSummaryResponse,
dependencies=[Depends(allow_any_authenticated())],
)
async def review_summary( async def review_summary(
params: ReviewSummaryQueryParams = Depends(), params: ReviewSummaryQueryParams = Depends(),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
@ -461,7 +474,11 @@ async def review_summary(
return JSONResponse(content=data) return JSONResponse(content=data)
@router.post("/reviews/viewed", response_model=GenericResponse) @router.post(
"/reviews/viewed",
response_model=GenericResponse,
dependencies=[Depends(allow_any_authenticated())],
)
async def set_multiple_reviewed( async def set_multiple_reviewed(
request: Request, request: Request,
body: ReviewModifyMultipleBody, body: ReviewModifyMultipleBody,
@ -644,7 +661,11 @@ def motion_activity(
return JSONResponse(content=normalized) return JSONResponse(content=normalized)
@router.get("/review/event/{event_id}", response_model=ReviewSegmentResponse) @router.get(
"/review/event/{event_id}",
response_model=ReviewSegmentResponse,
dependencies=[Depends(allow_any_authenticated())],
)
async def get_review_from_event(request: Request, event_id: str): async def get_review_from_event(request: Request, event_id: str):
try: try:
review = ReviewSegment.get( review = ReviewSegment.get(
@ -659,7 +680,11 @@ async def get_review_from_event(request: Request, event_id: str):
) )
@router.get("/review/{review_id}", response_model=ReviewSegmentResponse) @router.get(
"/review/{review_id}",
response_model=ReviewSegmentResponse,
dependencies=[Depends(allow_any_authenticated())],
)
async def get_review(request: Request, review_id: str): async def get_review(request: Request, review_id: str):
try: try:
review = ReviewSegment.get(ReviewSegment.id == review_id) review = ReviewSegment.get(ReviewSegment.id == review_id)
@ -672,7 +697,11 @@ async def get_review(request: Request, review_id: str):
) )
@router.delete("/review/{review_id}/viewed", response_model=GenericResponse) @router.delete(
"/review/{review_id}/viewed",
response_model=GenericResponse,
dependencies=[Depends(allow_any_authenticated())],
)
async def set_not_reviewed( async def set_not_reviewed(
review_id: str, review_id: str,
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),

View File

@ -12,6 +12,7 @@ from typing import Any
import cv2 import cv2
from peewee import DoesNotExist from peewee import DoesNotExist
from titlecase import titlecase
from frigate.comms.embeddings_updater import EmbeddingsRequestEnum from frigate.comms.embeddings_updater import EmbeddingsRequestEnum
from frigate.comms.inter_process import InterProcessRequestor from frigate.comms.inter_process import InterProcessRequestor
@ -455,14 +456,14 @@ def run_analysis(
for i, verified_label in enumerate(final_data["data"]["verified_objects"]): for i, verified_label in enumerate(final_data["data"]["verified_objects"]):
object_type = verified_label.replace("-verified", "").replace("_", " ") object_type = verified_label.replace("-verified", "").replace("_", " ")
name = sub_labels_list[i].replace("_", " ").title() name = titlecase(sub_labels_list[i].replace("_", " "))
unified_objects.append(f"{name} ({object_type})") unified_objects.append(f"{name} ({object_type})")
for label in objects_list: for label in objects_list:
if "-verified" in label: if "-verified" in label:
continue continue
elif label in labelmap_objects: elif label in labelmap_objects:
object_type = label.replace("_", " ").title() object_type = titlecase(label.replace("_", " "))
if label in attribute_labels: if label in attribute_labels:
unified_objects.append(f"{object_type} (delivery/service)") unified_objects.append(f"{object_type} (delivery/service)")

View File

@ -405,9 +405,6 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
if obj_data.get("end_time") is not None: if obj_data.get("end_time") is not None:
return return
if obj_data.get("stationary"):
return
object_id = obj_data["id"] object_id = obj_data["id"]
if ( if (

View File

@ -3,6 +3,8 @@ import logging
import os import os
import unittest import unittest
from fastapi import Request
from fastapi.testclient import TestClient
from peewee_migrate import Router from peewee_migrate import Router
from playhouse.sqlite_ext import SqliteExtDatabase from playhouse.sqlite_ext import SqliteExtDatabase
from playhouse.sqliteq import SqliteQueueDatabase from playhouse.sqliteq import SqliteQueueDatabase
@ -16,6 +18,20 @@ from frigate.review.types import SeverityEnum
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
class AuthTestClient(TestClient):
"""TestClient that automatically adds auth headers to all requests."""
def request(self, *args, **kwargs):
# Add default auth headers if not already present
headers = kwargs.get("headers") or {}
if "remote-user" not in headers:
headers["remote-user"] = "admin"
if "remote-role" not in headers:
headers["remote-role"] = "admin"
kwargs["headers"] = headers
return super().request(*args, **kwargs)
class BaseTestHttp(unittest.TestCase): class BaseTestHttp(unittest.TestCase):
def setUp(self, models): def setUp(self, models):
# setup clean database for each test run # setup clean database for each test run
@ -113,7 +129,9 @@ class BaseTestHttp(unittest.TestCase):
pass pass
def create_app(self, stats=None, event_metadata_publisher=None): def create_app(self, stats=None, event_metadata_publisher=None):
return create_fastapi_app( from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
app = create_fastapi_app(
FrigateConfig(**self.minimal_config), FrigateConfig(**self.minimal_config),
self.db, self.db,
None, None,
@ -123,8 +141,33 @@ class BaseTestHttp(unittest.TestCase):
stats, stats,
event_metadata_publisher, event_metadata_publisher,
None, None,
enforce_default_admin=False,
) )
# Default test mocks for authentication
# Tests can override these in their setUp if needed
# This mock uses headers set by AuthTestClient
async def mock_get_current_user(request: Request):
username = request.headers.get("remote-user")
role = request.headers.get("remote-role")
if not username or not role:
from fastapi.responses import JSONResponse
return JSONResponse(
content={"message": "No authorization headers."}, status_code=401
)
return {"username": username, "role": role}
async def mock_get_allowed_cameras_for_filter(request: Request):
return list(self.minimal_config.get("cameras", {}).keys())
app.dependency_overrides[get_current_user] = mock_get_current_user
app.dependency_overrides[get_allowed_cameras_for_filter] = (
mock_get_allowed_cameras_for_filter
)
return app
def insert_mock_event( def insert_mock_event(
self, self,
id: str, id: str,

View File

@ -1,10 +1,8 @@
from unittest.mock import Mock from unittest.mock import Mock
from fastapi.testclient import TestClient
from frigate.models import Event, Recordings, ReviewSegment from frigate.models import Event, Recordings, ReviewSegment
from frigate.stats.emitter import StatsEmitter from frigate.stats.emitter import StatsEmitter
from frigate.test.http_api.base_http_test import BaseTestHttp from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
class TestHttpApp(BaseTestHttp): class TestHttpApp(BaseTestHttp):
@ -20,7 +18,7 @@ class TestHttpApp(BaseTestHttp):
stats.get_latest_stats.return_value = self.test_stats stats.get_latest_stats.return_value = self.test_stats
app = super().create_app(stats) app = super().create_app(stats)
with TestClient(app) as client: with AuthTestClient(app) as client:
response = client.get("/stats") response = client.get("/stats")
response_json = response.json() response_json = response.json()
assert response_json == self.test_stats assert response_json == self.test_stats

View File

@ -1,14 +1,13 @@
from unittest.mock import patch from unittest.mock import patch
from fastapi import HTTPException, Request from fastapi import HTTPException, Request
from fastapi.testclient import TestClient
from frigate.api.auth import ( from frigate.api.auth import (
get_allowed_cameras_for_filter, get_allowed_cameras_for_filter,
get_current_user, get_current_user,
) )
from frigate.models import Event, Recordings, ReviewSegment from frigate.models import Event, Recordings, ReviewSegment
from frigate.test.http_api.base_http_test import BaseTestHttp from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
class TestCameraAccessEventReview(BaseTestHttp): class TestCameraAccessEventReview(BaseTestHttp):
@ -16,9 +15,17 @@ class TestCameraAccessEventReview(BaseTestHttp):
super().setUp([Event, ReviewSegment, Recordings]) super().setUp([Event, ReviewSegment, Recordings])
self.app = super().create_app() self.app = super().create_app()
# Mock get_current_user to return valid user for all tests # Mock get_current_user for all tests
async def mock_get_current_user(): async def mock_get_current_user(request: Request):
return {"username": "test_user", "role": "user"} username = request.headers.get("remote-user")
role = request.headers.get("remote-role")
if not username or not role:
from fastapi.responses import JSONResponse
return JSONResponse(
content={"message": "No authorization headers."}, status_code=401
)
return {"username": username, "role": role}
self.app.dependency_overrides[get_current_user] = mock_get_current_user self.app.dependency_overrides[get_current_user] = mock_get_current_user
@ -30,21 +37,25 @@ class TestCameraAccessEventReview(BaseTestHttp):
super().insert_mock_event("event1", camera="front_door") super().insert_mock_event("event1", camera="front_door")
super().insert_mock_event("event2", camera="back_door") super().insert_mock_event("event2", camera="back_door")
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [ async def mock_cameras(request: Request):
"front_door" return ["front_door"]
]
with TestClient(self.app) as client: self.app.dependency_overrides[get_allowed_cameras_for_filter] = mock_cameras
with AuthTestClient(self.app) as client:
resp = client.get("/events") resp = client.get("/events")
assert resp.status_code == 200 assert resp.status_code == 200
ids = [e["id"] for e in resp.json()] ids = [e["id"] for e in resp.json()]
assert "event1" in ids assert "event1" in ids
assert "event2" not in ids assert "event2" not in ids
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [ async def mock_cameras(request: Request):
"front_door", return [
"back_door", "front_door",
] "back_door",
with TestClient(self.app) as client: ]
self.app.dependency_overrides[get_allowed_cameras_for_filter] = mock_cameras
with AuthTestClient(self.app) as client:
resp = client.get("/events") resp = client.get("/events")
assert resp.status_code == 200 assert resp.status_code == 200
ids = [e["id"] for e in resp.json()] ids = [e["id"] for e in resp.json()]
@ -54,21 +65,25 @@ class TestCameraAccessEventReview(BaseTestHttp):
super().insert_mock_review_segment("rev1", camera="front_door") super().insert_mock_review_segment("rev1", camera="front_door")
super().insert_mock_review_segment("rev2", camera="back_door") super().insert_mock_review_segment("rev2", camera="back_door")
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [ async def mock_cameras(request: Request):
"front_door" return ["front_door"]
]
with TestClient(self.app) as client: self.app.dependency_overrides[get_allowed_cameras_for_filter] = mock_cameras
with AuthTestClient(self.app) as client:
resp = client.get("/review") resp = client.get("/review")
assert resp.status_code == 200 assert resp.status_code == 200
ids = [r["id"] for r in resp.json()] ids = [r["id"] for r in resp.json()]
assert "rev1" in ids assert "rev1" in ids
assert "rev2" not in ids assert "rev2" not in ids
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [ async def mock_cameras(request: Request):
"front_door", return [
"back_door", "front_door",
] "back_door",
with TestClient(self.app) as client: ]
self.app.dependency_overrides[get_allowed_cameras_for_filter] = mock_cameras
with AuthTestClient(self.app) as client:
resp = client.get("/review") resp = client.get("/review")
assert resp.status_code == 200 assert resp.status_code == 200
ids = [r["id"] for r in resp.json()] ids = [r["id"] for r in resp.json()]
@ -84,7 +99,7 @@ class TestCameraAccessEventReview(BaseTestHttp):
raise HTTPException(status_code=403, detail="Access denied") raise HTTPException(status_code=403, detail="Access denied")
with patch("frigate.api.event.require_camera_access", mock_require_allowed): with patch("frigate.api.event.require_camera_access", mock_require_allowed):
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
resp = client.get("/events/event1") resp = client.get("/events/event1")
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["id"] == "event1" assert resp.json()["id"] == "event1"
@ -94,7 +109,7 @@ class TestCameraAccessEventReview(BaseTestHttp):
raise HTTPException(status_code=403, detail="Access denied") raise HTTPException(status_code=403, detail="Access denied")
with patch("frigate.api.event.require_camera_access", mock_require_disallowed): with patch("frigate.api.event.require_camera_access", mock_require_disallowed):
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
resp = client.get("/events/event1") resp = client.get("/events/event1")
assert resp.status_code == 403 assert resp.status_code == 403
@ -108,7 +123,7 @@ class TestCameraAccessEventReview(BaseTestHttp):
raise HTTPException(status_code=403, detail="Access denied") raise HTTPException(status_code=403, detail="Access denied")
with patch("frigate.api.review.require_camera_access", mock_require_allowed): with patch("frigate.api.review.require_camera_access", mock_require_allowed):
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
resp = client.get("/review/rev1") resp = client.get("/review/rev1")
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["id"] == "rev1" assert resp.json()["id"] == "rev1"
@ -118,7 +133,7 @@ class TestCameraAccessEventReview(BaseTestHttp):
raise HTTPException(status_code=403, detail="Access denied") raise HTTPException(status_code=403, detail="Access denied")
with patch("frigate.api.review.require_camera_access", mock_require_disallowed): with patch("frigate.api.review.require_camera_access", mock_require_disallowed):
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
resp = client.get("/review/rev1") resp = client.get("/review/rev1")
assert resp.status_code == 403 assert resp.status_code == 403
@ -126,21 +141,25 @@ class TestCameraAccessEventReview(BaseTestHttp):
super().insert_mock_event("event1", camera="front_door") super().insert_mock_event("event1", camera="front_door")
super().insert_mock_event("event2", camera="back_door") super().insert_mock_event("event2", camera="back_door")
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [ async def mock_cameras(request: Request):
"front_door" return ["front_door"]
]
with TestClient(self.app) as client: self.app.dependency_overrides[get_allowed_cameras_for_filter] = mock_cameras
with AuthTestClient(self.app) as client:
resp = client.get("/events", params={"cameras": "all"}) resp = client.get("/events", params={"cameras": "all"})
assert resp.status_code == 200 assert resp.status_code == 200
ids = [e["id"] for e in resp.json()] ids = [e["id"] for e in resp.json()]
assert "event1" in ids assert "event1" in ids
assert "event2" not in ids assert "event2" not in ids
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [ async def mock_cameras(request: Request):
"front_door", return [
"back_door", "front_door",
] "back_door",
with TestClient(self.app) as client: ]
self.app.dependency_overrides[get_allowed_cameras_for_filter] = mock_cameras
with AuthTestClient(self.app) as client:
resp = client.get("/events", params={"cameras": "all"}) resp = client.get("/events", params={"cameras": "all"})
assert resp.status_code == 200 assert resp.status_code == 200
ids = [e["id"] for e in resp.json()] ids = [e["id"] for e in resp.json()]
@ -150,20 +169,24 @@ class TestCameraAccessEventReview(BaseTestHttp):
super().insert_mock_event("event1", camera="front_door") super().insert_mock_event("event1", camera="front_door")
super().insert_mock_event("event2", camera="back_door") super().insert_mock_event("event2", camera="back_door")
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [ async def mock_cameras(request: Request):
"front_door" return ["front_door"]
]
with TestClient(self.app) as client: self.app.dependency_overrides[get_allowed_cameras_for_filter] = mock_cameras
with AuthTestClient(self.app) as client:
resp = client.get("/events/summary") resp = client.get("/events/summary")
assert resp.status_code == 200 assert resp.status_code == 200
summary_list = resp.json() summary_list = resp.json()
assert len(summary_list) == 1 assert len(summary_list) == 1
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [ async def mock_cameras(request: Request):
"front_door", return [
"back_door", "front_door",
] "back_door",
with TestClient(self.app) as client: ]
self.app.dependency_overrides[get_allowed_cameras_for_filter] = mock_cameras
with AuthTestClient(self.app) as client:
resp = client.get("/events/summary") resp = client.get("/events/summary")
summary_list = resp.json() summary_list = resp.json()
assert len(summary_list) == 2 assert len(summary_list) == 2

View File

@ -2,14 +2,13 @@ from datetime import datetime
from typing import Any from typing import Any
from unittest.mock import Mock from unittest.mock import Mock
from fastapi.testclient import TestClient
from playhouse.shortcuts import model_to_dict from playhouse.shortcuts import model_to_dict
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
from frigate.comms.event_metadata_updater import EventMetadataPublisher from frigate.comms.event_metadata_updater import EventMetadataPublisher
from frigate.models import Event, Recordings, ReviewSegment, Timeline from frigate.models import Event, Recordings, ReviewSegment, Timeline
from frigate.stats.emitter import StatsEmitter from frigate.stats.emitter import StatsEmitter
from frigate.test.http_api.base_http_test import BaseTestHttp from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp, Request
from frigate.test.test_storage import _insert_mock_event from frigate.test.test_storage import _insert_mock_event
@ -18,14 +17,26 @@ class TestHttpApp(BaseTestHttp):
super().setUp([Event, Recordings, ReviewSegment, Timeline]) super().setUp([Event, Recordings, ReviewSegment, Timeline])
self.app = super().create_app() self.app = super().create_app()
# Mock auth to bypass camera access for tests # Mock get_current_user for all tests
async def mock_get_current_user(request: Any): async def mock_get_current_user(request: Request):
return {"username": "test_user", "role": "admin"} username = request.headers.get("remote-user")
role = request.headers.get("remote-role")
if not username or not role:
from fastapi.responses import JSONResponse
return JSONResponse(
content={"message": "No authorization headers."}, status_code=401
)
return {"username": username, "role": role}
self.app.dependency_overrides[get_current_user] = mock_get_current_user self.app.dependency_overrides[get_current_user] = mock_get_current_user
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [
"front_door" async def mock_get_allowed_cameras_for_filter(request: Request):
] return ["front_door"]
self.app.dependency_overrides[get_allowed_cameras_for_filter] = (
mock_get_allowed_cameras_for_filter
)
def tearDown(self): def tearDown(self):
self.app.dependency_overrides.clear() self.app.dependency_overrides.clear()
@ -35,20 +46,20 @@ class TestHttpApp(BaseTestHttp):
################################### GET /events Endpoint ######################################################### ################################### GET /events Endpoint #########################################################
#################################################################################################################### ####################################################################################################################
def test_get_event_list_no_events(self): def test_get_event_list_no_events(self):
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
events = client.get("/events").json() events = client.get("/events").json()
assert len(events) == 0 assert len(events) == 0
def test_get_event_list_no_match_event_id(self): def test_get_event_list_no_match_event_id(self):
id = "123456.random" id = "123456.random"
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
super().insert_mock_event(id) super().insert_mock_event(id)
events = client.get("/events", params={"event_id": "abc"}).json() events = client.get("/events", params={"event_id": "abc"}).json()
assert len(events) == 0 assert len(events) == 0
def test_get_event_list_match_event_id(self): def test_get_event_list_match_event_id(self):
id = "123456.random" id = "123456.random"
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
super().insert_mock_event(id) super().insert_mock_event(id)
events = client.get("/events", params={"event_id": id}).json() events = client.get("/events", params={"event_id": id}).json()
assert len(events) == 1 assert len(events) == 1
@ -58,7 +69,7 @@ class TestHttpApp(BaseTestHttp):
now = int(datetime.now().timestamp()) now = int(datetime.now().timestamp())
id = "123456.random" id = "123456.random"
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
super().insert_mock_event(id, now, now + 1) super().insert_mock_event(id, now, now + 1)
events = client.get( events = client.get(
"/events", params={"max_length": 1, "min_length": 1} "/events", params={"max_length": 1, "min_length": 1}
@ -69,7 +80,7 @@ class TestHttpApp(BaseTestHttp):
def test_get_event_list_no_match_max_length(self): def test_get_event_list_no_match_max_length(self):
now = int(datetime.now().timestamp()) now = int(datetime.now().timestamp())
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
id = "123456.random" id = "123456.random"
super().insert_mock_event(id, now, now + 2) super().insert_mock_event(id, now, now + 2)
events = client.get("/events", params={"max_length": 1}).json() events = client.get("/events", params={"max_length": 1}).json()
@ -78,7 +89,7 @@ class TestHttpApp(BaseTestHttp):
def test_get_event_list_no_match_min_length(self): def test_get_event_list_no_match_min_length(self):
now = int(datetime.now().timestamp()) now = int(datetime.now().timestamp())
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
id = "123456.random" id = "123456.random"
super().insert_mock_event(id, now, now + 2) super().insert_mock_event(id, now, now + 2)
events = client.get("/events", params={"min_length": 3}).json() events = client.get("/events", params={"min_length": 3}).json()
@ -88,7 +99,7 @@ class TestHttpApp(BaseTestHttp):
id = "123456.random" id = "123456.random"
id2 = "54321.random" id2 = "54321.random"
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
super().insert_mock_event(id) super().insert_mock_event(id)
events = client.get("/events").json() events = client.get("/events").json()
assert len(events) == 1 assert len(events) == 1
@ -108,14 +119,14 @@ class TestHttpApp(BaseTestHttp):
def test_get_event_list_no_match_has_clip(self): def test_get_event_list_no_match_has_clip(self):
now = int(datetime.now().timestamp()) now = int(datetime.now().timestamp())
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
id = "123456.random" id = "123456.random"
super().insert_mock_event(id, now, now + 2) super().insert_mock_event(id, now, now + 2)
events = client.get("/events", params={"has_clip": 0}).json() events = client.get("/events", params={"has_clip": 0}).json()
assert len(events) == 0 assert len(events) == 0
def test_get_event_list_has_clip(self): def test_get_event_list_has_clip(self):
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
id = "123456.random" id = "123456.random"
super().insert_mock_event(id, has_clip=True) super().insert_mock_event(id, has_clip=True)
events = client.get("/events", params={"has_clip": 1}).json() events = client.get("/events", params={"has_clip": 1}).json()
@ -123,7 +134,7 @@ class TestHttpApp(BaseTestHttp):
assert events[0]["id"] == id assert events[0]["id"] == id
def test_get_event_list_sort_score(self): def test_get_event_list_sort_score(self):
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
id = "123456.random" id = "123456.random"
id2 = "54321.random" id2 = "54321.random"
super().insert_mock_event(id, top_score=37, score=37, data={"score": 50}) super().insert_mock_event(id, top_score=37, score=37, data={"score": 50})
@ -141,7 +152,7 @@ class TestHttpApp(BaseTestHttp):
def test_get_event_list_sort_start_time(self): def test_get_event_list_sort_start_time(self):
now = int(datetime.now().timestamp()) now = int(datetime.now().timestamp())
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
id = "123456.random" id = "123456.random"
id2 = "54321.random" id2 = "54321.random"
super().insert_mock_event(id, start_time=now + 3) super().insert_mock_event(id, start_time=now + 3)
@ -159,7 +170,7 @@ class TestHttpApp(BaseTestHttp):
def test_get_good_event(self): def test_get_good_event(self):
id = "123456.random" id = "123456.random"
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
super().insert_mock_event(id) super().insert_mock_event(id)
event = client.get(f"/events/{id}").json() event = client.get(f"/events/{id}").json()
@ -171,7 +182,7 @@ class TestHttpApp(BaseTestHttp):
id = "123456.random" id = "123456.random"
bad_id = "654321.other" bad_id = "654321.other"
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
super().insert_mock_event(id) super().insert_mock_event(id)
event_response = client.get(f"/events/{bad_id}") event_response = client.get(f"/events/{bad_id}")
assert event_response.status_code == 404 assert event_response.status_code == 404
@ -180,7 +191,7 @@ class TestHttpApp(BaseTestHttp):
def test_delete_event(self): def test_delete_event(self):
id = "123456.random" id = "123456.random"
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
super().insert_mock_event(id) super().insert_mock_event(id)
event = client.get(f"/events/{id}").json() event = client.get(f"/events/{id}").json()
assert event assert event
@ -193,7 +204,7 @@ class TestHttpApp(BaseTestHttp):
def test_event_retention(self): def test_event_retention(self):
id = "123456.random" id = "123456.random"
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
super().insert_mock_event(id) super().insert_mock_event(id)
client.post(f"/events/{id}/retain", headers={"remote-role": "admin"}) client.post(f"/events/{id}/retain", headers={"remote-role": "admin"})
event = client.get(f"/events/{id}").json() event = client.get(f"/events/{id}").json()
@ -212,12 +223,11 @@ class TestHttpApp(BaseTestHttp):
morning = 1656590400 # 06/30/2022 6 am (GMT) morning = 1656590400 # 06/30/2022 6 am (GMT)
evening = 1656633600 # 06/30/2022 6 pm (GMT) evening = 1656633600 # 06/30/2022 6 pm (GMT)
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
super().insert_mock_event(morning_id, morning) super().insert_mock_event(morning_id, morning)
super().insert_mock_event(evening_id, evening) super().insert_mock_event(evening_id, evening)
# both events come back # both events come back
events = client.get("/events").json() events = client.get("/events").json()
print("events!!!", events)
assert events assert events
assert len(events) == 2 assert len(events) == 2
# morning event is excluded # morning event is excluded
@ -248,7 +258,7 @@ class TestHttpApp(BaseTestHttp):
mock_event_updater.publish.side_effect = update_event mock_event_updater.publish.side_effect = update_event
with TestClient(app) as client: with AuthTestClient(app) as client:
super().insert_mock_event(id) super().insert_mock_event(id)
new_sub_label_response = client.post( new_sub_label_response = client.post(
f"/events/{id}/sub_label", f"/events/{id}/sub_label",
@ -285,7 +295,7 @@ class TestHttpApp(BaseTestHttp):
mock_event_updater.publish.side_effect = update_event mock_event_updater.publish.side_effect = update_event
with TestClient(app) as client: with AuthTestClient(app) as client:
super().insert_mock_event(id) super().insert_mock_event(id)
client.post( client.post(
f"/events/{id}/sub_label", f"/events/{id}/sub_label",
@ -301,7 +311,7 @@ class TestHttpApp(BaseTestHttp):
#################################################################################################################### ####################################################################################################################
def test_get_metrics(self): def test_get_metrics(self):
"""ensure correct prometheus metrics api response""" """ensure correct prometheus metrics api response"""
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
ts_start = datetime.now().timestamp() ts_start = datetime.now().timestamp()
ts_end = ts_start + 30 ts_end = ts_start + 30
_insert_mock_event( _insert_mock_event(

View File

@ -1,14 +1,13 @@
"""Unit tests for recordings/media API endpoints.""" """Unit tests for recordings/media API endpoints."""
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any
import pytz import pytz
from fastapi.testclient import TestClient from fastapi import Request
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
from frigate.models import Recordings from frigate.models import Recordings
from frigate.test.http_api.base_http_test import BaseTestHttp from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
class TestHttpMedia(BaseTestHttp): class TestHttpMedia(BaseTestHttp):
@ -19,15 +18,26 @@ class TestHttpMedia(BaseTestHttp):
super().setUp([Recordings]) super().setUp([Recordings])
self.app = super().create_app() self.app = super().create_app()
# Mock auth to bypass camera access for tests # Mock get_current_user for all tests
async def mock_get_current_user(request: Any): async def mock_get_current_user(request: Request):
return {"username": "test_user", "role": "admin"} username = request.headers.get("remote-user")
role = request.headers.get("remote-role")
if not username or not role:
from fastapi.responses import JSONResponse
return JSONResponse(
content={"message": "No authorization headers."}, status_code=401
)
return {"username": username, "role": role}
self.app.dependency_overrides[get_current_user] = mock_get_current_user self.app.dependency_overrides[get_current_user] = mock_get_current_user
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [
"front_door", async def mock_get_allowed_cameras_for_filter(request: Request):
"back_door", return ["front_door"]
]
self.app.dependency_overrides[get_allowed_cameras_for_filter] = (
mock_get_allowed_cameras_for_filter
)
def tearDown(self): def tearDown(self):
"""Clean up after tests.""" """Clean up after tests."""
@ -52,7 +62,7 @@ class TestHttpMedia(BaseTestHttp):
# March 11, 2024 at 12:00 PM EDT (after DST) # March 11, 2024 at 12:00 PM EDT (after DST)
march_11_noon = tz.localize(datetime(2024, 3, 11, 12, 0, 0)).timestamp() march_11_noon = tz.localize(datetime(2024, 3, 11, 12, 0, 0)).timestamp()
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
# Insert recordings for each day # Insert recordings for each day
Recordings.insert( Recordings.insert(
id="recording_march_9", id="recording_march_9",
@ -128,7 +138,7 @@ class TestHttpMedia(BaseTestHttp):
# November 4, 2024 at 12:00 PM EST (after DST) # November 4, 2024 at 12:00 PM EST (after DST)
nov_4_noon = tz.localize(datetime(2024, 11, 4, 12, 0, 0)).timestamp() nov_4_noon = tz.localize(datetime(2024, 11, 4, 12, 0, 0)).timestamp()
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
# Insert recordings for each day # Insert recordings for each day
Recordings.insert( Recordings.insert(
id="recording_nov_2", id="recording_nov_2",
@ -195,7 +205,15 @@ class TestHttpMedia(BaseTestHttp):
# March 10, 2024 at 3:00 PM EDT (after DST transition) # March 10, 2024 at 3:00 PM EDT (after DST transition)
march_10_afternoon = tz.localize(datetime(2024, 3, 10, 15, 0, 0)).timestamp() march_10_afternoon = tz.localize(datetime(2024, 3, 10, 15, 0, 0)).timestamp()
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
# Override allowed cameras for this test to include both
async def mock_get_allowed_cameras_for_filter(_request: Request):
return ["front_door", "back_door"]
self.app.dependency_overrides[get_allowed_cameras_for_filter] = (
mock_get_allowed_cameras_for_filter
)
# Insert recordings for front_door on March 9 # Insert recordings for front_door on March 9
Recordings.insert( Recordings.insert(
id="front_march_9", id="front_march_9",
@ -236,6 +254,14 @@ class TestHttpMedia(BaseTestHttp):
assert summary["2024-03-09"] is True assert summary["2024-03-09"] is True
assert summary["2024-03-10"] is True assert summary["2024-03-10"] is True
# Reset dependency override back to default single camera for other tests
async def reset_allowed_cameras(_request: Request):
return ["front_door"]
self.app.dependency_overrides[get_allowed_cameras_for_filter] = (
reset_allowed_cameras
)
def test_recordings_summary_at_dst_transition_time(self): def test_recordings_summary_at_dst_transition_time(self):
""" """
Test recordings that span the exact DST transition time. Test recordings that span the exact DST transition time.
@ -250,7 +276,7 @@ class TestHttpMedia(BaseTestHttp):
# This is 1.5 hours of actual time but spans the "missing" hour # This is 1.5 hours of actual time but spans the "missing" hour
after_transition = tz.localize(datetime(2024, 3, 10, 3, 30, 0)).timestamp() after_transition = tz.localize(datetime(2024, 3, 10, 3, 30, 0)).timestamp()
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
Recordings.insert( Recordings.insert(
id="recording_during_transition", id="recording_during_transition",
path="/media/recordings/transition.mp4", path="/media/recordings/transition.mp4",
@ -283,7 +309,7 @@ class TestHttpMedia(BaseTestHttp):
march_9_utc = datetime(2024, 3, 9, 17, 0, 0, tzinfo=timezone.utc).timestamp() march_9_utc = datetime(2024, 3, 9, 17, 0, 0, tzinfo=timezone.utc).timestamp()
march_10_utc = datetime(2024, 3, 10, 17, 0, 0, tzinfo=timezone.utc).timestamp() march_10_utc = datetime(2024, 3, 10, 17, 0, 0, tzinfo=timezone.utc).timestamp()
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
Recordings.insert( Recordings.insert(
id="recording_march_9_utc", id="recording_march_9_utc",
path="/media/recordings/march_9_utc.mp4", path="/media/recordings/march_9_utc.mp4",
@ -325,7 +351,7 @@ class TestHttpMedia(BaseTestHttp):
""" """
Test recordings summary when no recordings exist. Test recordings summary when no recordings exist.
""" """
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
response = client.get( response = client.get(
"/recordings/summary", "/recordings/summary",
params={"timezone": "America/New_York", "cameras": "all"}, params={"timezone": "America/New_York", "cameras": "all"},
@ -342,7 +368,7 @@ class TestHttpMedia(BaseTestHttp):
tz = pytz.timezone("America/New_York") tz = pytz.timezone("America/New_York")
march_10_noon = tz.localize(datetime(2024, 3, 10, 12, 0, 0)).timestamp() march_10_noon = tz.localize(datetime(2024, 3, 10, 12, 0, 0)).timestamp()
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
# Insert recordings for both cameras # Insert recordings for both cameras
Recordings.insert( Recordings.insert(
id="front_recording", id="front_recording",

View File

@ -1,12 +1,12 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from fastapi.testclient import TestClient from fastapi import Request
from peewee import DoesNotExist from peewee import DoesNotExist
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
from frigate.models import Event, Recordings, ReviewSegment, UserReviewStatus from frigate.models import Event, Recordings, ReviewSegment, UserReviewStatus
from frigate.review.types import SeverityEnum from frigate.review.types import SeverityEnum
from frigate.test.http_api.base_http_test import BaseTestHttp from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
class TestHttpReview(BaseTestHttp): class TestHttpReview(BaseTestHttp):
@ -16,14 +16,26 @@ class TestHttpReview(BaseTestHttp):
self.user_id = "admin" self.user_id = "admin"
# Mock get_current_user for all tests # Mock get_current_user for all tests
async def mock_get_current_user(): # This mock uses headers set by AuthTestClient
return {"username": self.user_id, "role": "admin"} async def mock_get_current_user(request: Request):
username = request.headers.get("remote-user")
role = request.headers.get("remote-role")
if not username or not role:
from fastapi.responses import JSONResponse
return JSONResponse(
content={"message": "No authorization headers."}, status_code=401
)
return {"username": username, "role": role}
self.app.dependency_overrides[get_current_user] = mock_get_current_user self.app.dependency_overrides[get_current_user] = mock_get_current_user
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [ async def mock_get_allowed_cameras_for_filter(request: Request):
"front_door" return ["front_door"]
]
self.app.dependency_overrides[get_allowed_cameras_for_filter] = (
mock_get_allowed_cameras_for_filter
)
def tearDown(self): def tearDown(self):
self.app.dependency_overrides.clear() self.app.dependency_overrides.clear()
@ -57,7 +69,7 @@ class TestHttpReview(BaseTestHttp):
but ends after is included in the results.""" but ends after is included in the results."""
now = datetime.now().timestamp() now = datetime.now().timestamp()
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
super().insert_mock_review_segment("123456.random", now, now + 2) super().insert_mock_review_segment("123456.random", now, now + 2)
response = client.get("/review") response = client.get("/review")
assert response.status_code == 200 assert response.status_code == 200
@ -67,7 +79,7 @@ class TestHttpReview(BaseTestHttp):
def test_get_review_no_filters(self): def test_get_review_no_filters(self):
now = datetime.now().timestamp() now = datetime.now().timestamp()
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
id = "123456.random" id = "123456.random"
super().insert_mock_review_segment(id, now - 2, now - 1) super().insert_mock_review_segment(id, now - 2, now - 1)
response = client.get("/review") response = client.get("/review")
@ -81,7 +93,7 @@ class TestHttpReview(BaseTestHttp):
"""Test that review items outside the range are not returned.""" """Test that review items outside the range are not returned."""
now = datetime.now().timestamp() now = datetime.now().timestamp()
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
id = "123456.random" id = "123456.random"
super().insert_mock_review_segment(id, now - 2, now - 1) super().insert_mock_review_segment(id, now - 2, now - 1)
super().insert_mock_review_segment(f"{id}2", now + 4, now + 5) super().insert_mock_review_segment(f"{id}2", now + 4, now + 5)
@ -97,7 +109,7 @@ class TestHttpReview(BaseTestHttp):
def test_get_review_with_time_filter(self): def test_get_review_with_time_filter(self):
now = datetime.now().timestamp() now = datetime.now().timestamp()
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
id = "123456.random" id = "123456.random"
super().insert_mock_review_segment(id, now, now + 2) super().insert_mock_review_segment(id, now, now + 2)
params = { params = {
@ -113,7 +125,7 @@ class TestHttpReview(BaseTestHttp):
def test_get_review_with_limit_filter(self): def test_get_review_with_limit_filter(self):
now = datetime.now().timestamp() now = datetime.now().timestamp()
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
id = "123456.random" id = "123456.random"
id2 = "654321.random" id2 = "654321.random"
super().insert_mock_review_segment(id, now, now + 2) super().insert_mock_review_segment(id, now, now + 2)
@ -132,7 +144,7 @@ class TestHttpReview(BaseTestHttp):
def test_get_review_with_severity_filters_no_matches(self): def test_get_review_with_severity_filters_no_matches(self):
now = datetime.now().timestamp() now = datetime.now().timestamp()
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
id = "123456.random" id = "123456.random"
super().insert_mock_review_segment(id, now, now + 2, SeverityEnum.detection) super().insert_mock_review_segment(id, now, now + 2, SeverityEnum.detection)
params = { params = {
@ -149,7 +161,7 @@ class TestHttpReview(BaseTestHttp):
def test_get_review_with_severity_filters(self): def test_get_review_with_severity_filters(self):
now = datetime.now().timestamp() now = datetime.now().timestamp()
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
id = "123456.random" id = "123456.random"
super().insert_mock_review_segment(id, now, now + 2, SeverityEnum.detection) super().insert_mock_review_segment(id, now, now + 2, SeverityEnum.detection)
params = { params = {
@ -165,7 +177,7 @@ class TestHttpReview(BaseTestHttp):
def test_get_review_with_all_filters(self): def test_get_review_with_all_filters(self):
now = datetime.now().timestamp() now = datetime.now().timestamp()
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
id = "123456.random" id = "123456.random"
super().insert_mock_review_segment(id, now, now + 2) super().insert_mock_review_segment(id, now, now + 2)
params = { params = {
@ -188,7 +200,7 @@ class TestHttpReview(BaseTestHttp):
################################### GET /review/summary Endpoint ################################################# ################################### GET /review/summary Endpoint #################################################
#################################################################################################################### ####################################################################################################################
def test_get_review_summary_all_filters(self): def test_get_review_summary_all_filters(self):
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
super().insert_mock_review_segment("123456.random") super().insert_mock_review_segment("123456.random")
params = { params = {
"cameras": "front_door", "cameras": "front_door",
@ -219,7 +231,7 @@ class TestHttpReview(BaseTestHttp):
self.assertEqual(response_json, expected_response) self.assertEqual(response_json, expected_response)
def test_get_review_summary_no_filters(self): def test_get_review_summary_no_filters(self):
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
super().insert_mock_review_segment("123456.random") super().insert_mock_review_segment("123456.random")
response = client.get("/review/summary") response = client.get("/review/summary")
assert response.status_code == 200 assert response.status_code == 200
@ -247,7 +259,7 @@ class TestHttpReview(BaseTestHttp):
now = datetime.now() now = datetime.now()
five_days_ago = datetime.today() - timedelta(days=5) five_days_ago = datetime.today() - timedelta(days=5)
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
super().insert_mock_review_segment( super().insert_mock_review_segment(
"123456.random", now.timestamp() - 2, now.timestamp() - 1 "123456.random", now.timestamp() - 2, now.timestamp() - 1
) )
@ -291,7 +303,7 @@ class TestHttpReview(BaseTestHttp):
now = datetime.now() now = datetime.now()
five_days_ago = datetime.today() - timedelta(days=5) five_days_ago = datetime.today() - timedelta(days=5)
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
super().insert_mock_review_segment("123456.random", now.timestamp()) super().insert_mock_review_segment("123456.random", now.timestamp())
five_days_ago_ts = five_days_ago.timestamp() five_days_ago_ts = five_days_ago.timestamp()
for i in range(20): for i in range(20):
@ -342,7 +354,7 @@ class TestHttpReview(BaseTestHttp):
def test_get_review_summary_multiple_in_same_day_with_reviewed(self): def test_get_review_summary_multiple_in_same_day_with_reviewed(self):
five_days_ago = datetime.today() - timedelta(days=5) five_days_ago = datetime.today() - timedelta(days=5)
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
five_days_ago_ts = five_days_ago.timestamp() five_days_ago_ts = five_days_ago.timestamp()
for i in range(10): for i in range(10):
id = f"123456_{i}.random_alert_not_reviewed" id = f"123456_{i}.random_alert_not_reviewed"
@ -393,14 +405,14 @@ class TestHttpReview(BaseTestHttp):
#################################################################################################################### ####################################################################################################################
def test_post_reviews_viewed_no_body(self): def test_post_reviews_viewed_no_body(self):
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
super().insert_mock_review_segment("123456.random") super().insert_mock_review_segment("123456.random")
response = client.post("/reviews/viewed") response = client.post("/reviews/viewed")
# Missing ids # Missing ids
assert response.status_code == 422 assert response.status_code == 422
def test_post_reviews_viewed_no_body_ids(self): def test_post_reviews_viewed_no_body_ids(self):
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
super().insert_mock_review_segment("123456.random") super().insert_mock_review_segment("123456.random")
body = {"ids": [""]} body = {"ids": [""]}
response = client.post("/reviews/viewed", json=body) response = client.post("/reviews/viewed", json=body)
@ -408,7 +420,7 @@ class TestHttpReview(BaseTestHttp):
assert response.status_code == 422 assert response.status_code == 422
def test_post_reviews_viewed_non_existent_id(self): def test_post_reviews_viewed_non_existent_id(self):
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
id = "123456.random" id = "123456.random"
super().insert_mock_review_segment(id) super().insert_mock_review_segment(id)
body = {"ids": ["1"]} body = {"ids": ["1"]}
@ -425,7 +437,7 @@ class TestHttpReview(BaseTestHttp):
) )
def test_post_reviews_viewed(self): def test_post_reviews_viewed(self):
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
id = "123456.random" id = "123456.random"
super().insert_mock_review_segment(id) super().insert_mock_review_segment(id)
body = {"ids": [id]} body = {"ids": [id]}
@ -445,14 +457,14 @@ class TestHttpReview(BaseTestHttp):
################################### POST reviews/delete Endpoint ################################################ ################################### POST reviews/delete Endpoint ################################################
#################################################################################################################### ####################################################################################################################
def test_post_reviews_delete_no_body(self): def test_post_reviews_delete_no_body(self):
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
super().insert_mock_review_segment("123456.random") super().insert_mock_review_segment("123456.random")
response = client.post("/reviews/delete", headers={"remote-role": "admin"}) response = client.post("/reviews/delete", headers={"remote-role": "admin"})
# Missing ids # Missing ids
assert response.status_code == 422 assert response.status_code == 422
def test_post_reviews_delete_no_body_ids(self): def test_post_reviews_delete_no_body_ids(self):
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
super().insert_mock_review_segment("123456.random") super().insert_mock_review_segment("123456.random")
body = {"ids": [""]} body = {"ids": [""]}
response = client.post( response = client.post(
@ -462,7 +474,7 @@ class TestHttpReview(BaseTestHttp):
assert response.status_code == 422 assert response.status_code == 422
def test_post_reviews_delete_non_existent_id(self): def test_post_reviews_delete_non_existent_id(self):
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
id = "123456.random" id = "123456.random"
super().insert_mock_review_segment(id) super().insert_mock_review_segment(id)
body = {"ids": ["1"]} body = {"ids": ["1"]}
@ -479,7 +491,7 @@ class TestHttpReview(BaseTestHttp):
assert review_ids_in_db_after[0].id == id assert review_ids_in_db_after[0].id == id
def test_post_reviews_delete(self): def test_post_reviews_delete(self):
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
id = "123456.random" id = "123456.random"
super().insert_mock_review_segment(id) super().insert_mock_review_segment(id)
body = {"ids": [id]} body = {"ids": [id]}
@ -495,7 +507,7 @@ class TestHttpReview(BaseTestHttp):
assert len(review_ids_in_db_after) == 0 assert len(review_ids_in_db_after) == 0
def test_post_reviews_delete_many(self): def test_post_reviews_delete_many(self):
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
ids = ["123456.random", "654321.random"] ids = ["123456.random", "654321.random"]
for id in ids: for id in ids:
super().insert_mock_review_segment(id) super().insert_mock_review_segment(id)
@ -527,7 +539,7 @@ class TestHttpReview(BaseTestHttp):
def test_review_activity_motion_no_data_for_time_range(self): def test_review_activity_motion_no_data_for_time_range(self):
now = datetime.now().timestamp() now = datetime.now().timestamp()
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
params = { params = {
"after": now, "after": now,
"before": now + 3, "before": now + 3,
@ -540,7 +552,7 @@ class TestHttpReview(BaseTestHttp):
def test_review_activity_motion(self): def test_review_activity_motion(self):
now = int(datetime.now().timestamp()) now = int(datetime.now().timestamp())
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
one_m = int((datetime.now() + timedelta(minutes=1)).timestamp()) one_m = int((datetime.now() + timedelta(minutes=1)).timestamp())
id = "123456.random" id = "123456.random"
id2 = "123451.random" id2 = "123451.random"
@ -573,7 +585,7 @@ class TestHttpReview(BaseTestHttp):
################################### GET /review/event/{event_id} Endpoint ####################################### ################################### GET /review/event/{event_id} Endpoint #######################################
#################################################################################################################### ####################################################################################################################
def test_review_event_not_found(self): def test_review_event_not_found(self):
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
response = client.get("/review/event/123456.random") response = client.get("/review/event/123456.random")
assert response.status_code == 404 assert response.status_code == 404
response_json = response.json() response_json = response.json()
@ -585,7 +597,7 @@ class TestHttpReview(BaseTestHttp):
def test_review_event_not_found_in_data(self): def test_review_event_not_found_in_data(self):
now = datetime.now().timestamp() now = datetime.now().timestamp()
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
id = "123456.random" id = "123456.random"
super().insert_mock_review_segment(id, now + 1, now + 2) super().insert_mock_review_segment(id, now + 1, now + 2)
response = client.get(f"/review/event/{id}") response = client.get(f"/review/event/{id}")
@ -599,7 +611,7 @@ class TestHttpReview(BaseTestHttp):
def test_review_get_specific_event(self): def test_review_get_specific_event(self):
now = datetime.now().timestamp() now = datetime.now().timestamp()
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
event_id = "123456.event.random" event_id = "123456.event.random"
super().insert_mock_event(event_id) super().insert_mock_event(event_id)
review_id = "123456.review.random" review_id = "123456.review.random"
@ -626,7 +638,7 @@ class TestHttpReview(BaseTestHttp):
################################### GET /review/{review_id} Endpoint ####################################### ################################### GET /review/{review_id} Endpoint #######################################
#################################################################################################################### ####################################################################################################################
def test_review_not_found(self): def test_review_not_found(self):
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
response = client.get("/review/123456.random") response = client.get("/review/123456.random")
assert response.status_code == 404 assert response.status_code == 404
response_json = response.json() response_json = response.json()
@ -638,7 +650,7 @@ class TestHttpReview(BaseTestHttp):
def test_get_review(self): def test_get_review(self):
now = datetime.now().timestamp() now = datetime.now().timestamp()
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
review_id = "123456.review.random" review_id = "123456.review.random"
super().insert_mock_review_segment(review_id, now + 1, now + 2) super().insert_mock_review_segment(review_id, now + 1, now + 2)
response = client.get(f"/review/{review_id}") response = client.get(f"/review/{review_id}")
@ -662,7 +674,7 @@ class TestHttpReview(BaseTestHttp):
#################################################################################################################### ####################################################################################################################
def test_delete_review_viewed_review_not_found(self): def test_delete_review_viewed_review_not_found(self):
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
review_id = "123456.random" review_id = "123456.random"
response = client.delete(f"/review/{review_id}/viewed") response = client.delete(f"/review/{review_id}/viewed")
assert response.status_code == 404 assert response.status_code == 404
@ -675,7 +687,7 @@ class TestHttpReview(BaseTestHttp):
def test_delete_review_viewed(self): def test_delete_review_viewed(self):
now = datetime.now().timestamp() now = datetime.now().timestamp()
with TestClient(self.app) as client: with AuthTestClient(self.app) as client:
review_id = "123456.review.random" review_id = "123456.review.random"
super().insert_mock_review_segment(review_id, now + 1, now + 2) super().insert_mock_review_segment(review_id, now + 1, now + 2)
self._insert_user_review_status(review_id, reviewed=True) self._insert_user_review_status(review_id, reviewed=True)

6
web/package-lock.json generated
View File

@ -4702,9 +4702,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001651", "version": "1.0.30001757",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001651.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz",
"integrity": "sha512-9Cf+Xv1jJNe1xPZLGuUXLNkE1BoDkqRqYyFJ9TDYSqhduqA4hu4oR9HluGoWYQC/aj8WHjsGVV+bwkh0+tegRg==", "integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {

View File

@ -58,7 +58,8 @@
"endTimeMustAfterStartTime": "L'hora de finalització ha de ser posterior a l'hora d'inici", "endTimeMustAfterStartTime": "L'hora de finalització ha de ser posterior a l'hora d'inici",
"noVaildTimeSelected": "No s'ha seleccionat un rang de temps vàlid", "noVaildTimeSelected": "No s'ha seleccionat un rang de temps vàlid",
"failed": "No s'ha pogut inciar l'exportació: {{error}}" "failed": "No s'ha pogut inciar l'exportació: {{error}}"
} },
"view": "Vista"
}, },
"fromTimeline": { "fromTimeline": {
"saveExport": "Guardar exportació", "saveExport": "Guardar exportació",

View File

@ -263,7 +263,8 @@
"header": { "header": {
"zones": "Zones", "zones": "Zones",
"ratio": "Ràtio", "ratio": "Ràtio",
"area": "Àrea" "area": "Àrea",
"score": "Puntuació"
} }
}, },
"annotationSettings": { "annotationSettings": {

View File

@ -34,7 +34,20 @@
"error": { "error": {
"deleteImageFailed": "Löschen fehlgeschlagen: {{errorMessage}}", "deleteImageFailed": "Löschen fehlgeschlagen: {{errorMessage}}",
"deleteCategoryFailed": "Klasse konnte nicht gelöscht werden: {{errorMessage}}", "deleteCategoryFailed": "Klasse konnte nicht gelöscht werden: {{errorMessage}}",
"deleteModelFailed": "Model konnte nicht gelöscht werden: {{errorMessage}}" "deleteModelFailed": "Model konnte nicht gelöscht werden: {{errorMessage}}",
"trainingFailedToStart": "Modelltraining konnte nicht gestartet werden: {{errorMessage}}",
"updateModelFailed": "Aktualisierung des Modells fehlgeschlagen: {{errorMessage}}",
"renameCategoryFailed": "Umbenennung der Klasse fehlgeschlagen: {{errorMessage}}"
} }
},
"deleteCategory": {
"title": "Klasse löschen",
"desc": "Möchten Sie die Klasse {{name}} wirklich löschen? Dadurch werden alle zugehörigen Bilder dauerhaft gelöscht und das Modell muss neu trainiert werden.",
"minClassesTitle": "Klasse kann nicht gelöscht werden",
"minClassesDesc": "Ein Klassifizierungsmodell benötigt mindestens zwei Klassen. Fügen Sie eine weitere Klasse hinzu, bevor Sie diese löschen."
},
"deleteModel": {
"title": "Klassifizierungsmodell löschen",
"single": "Möchten Sie {{name}} wirklich löschen? Dadurch werden alle zugehörigen Daten, einschließlich Bilder und Trainingsdaten, dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden."
} }
} }

View File

@ -278,6 +278,10 @@
"previous": "Vorherige Anzeige", "previous": "Vorherige Anzeige",
"next": "Nächste Anzeige" "next": "Nächste Anzeige"
}, },
"title": "Verfolgungsdetails" "title": "Verfolgungsdetails",
"adjustAnnotationSettings": "Anmerkungseinstellungen anpassen",
"autoTrackingTips": "Die Positionen der Begrenzungsrahmen sind bei Kameras mit automatischer Verfolgung ungenau.",
"count": "{{first}} von {{second}}",
"trackedPoint": "Verfolgter Punkt"
} }
} }

View File

@ -41,7 +41,7 @@
"noCamera": "Keine Kamera" "noCamera": "Keine Kamera"
}, },
"general": { "general": {
"title": "Allgemeine Einstellungen", "title": "Einstellungen der Benutzeroberfläche",
"liveDashboard": { "liveDashboard": {
"title": "Live Übersicht", "title": "Live Übersicht",
"playAlertVideos": { "playAlertVideos": {
@ -51,6 +51,10 @@
"automaticLiveView": { "automaticLiveView": {
"desc": "Wechsle automatisch zur Live Ansicht der Kamera, wenn einen Aktivität erkannt wurde. Wenn du diese Option deaktivierst, werden die statischen Kamerabilder auf der Liveübersicht nur einmal pro Minute aktualisiert.", "desc": "Wechsle automatisch zur Live Ansicht der Kamera, wenn einen Aktivität erkannt wurde. Wenn du diese Option deaktivierst, werden die statischen Kamerabilder auf der Liveübersicht nur einmal pro Minute aktualisiert.",
"label": "Automatische Live Ansicht" "label": "Automatische Live Ansicht"
},
"displayCameraNames": {
"label": "Immer Namen der Kamera anzeigen",
"desc": "Kameranamen immer in einem Chip im Live-View-Dashboard für mehrere Kameras anzeigen."
} }
}, },
"storedLayouts": { "storedLayouts": {

View File

@ -61,7 +61,8 @@
"failed": "Klarte ikke å starte eksport: {{error}}", "failed": "Klarte ikke å starte eksport: {{error}}",
"noVaildTimeSelected": "Ingen gyldig tidsperiode valgt", "noVaildTimeSelected": "Ingen gyldig tidsperiode valgt",
"endTimeMustAfterStartTime": "Sluttid må være etter starttid" "endTimeMustAfterStartTime": "Sluttid må være etter starttid"
} },
"view": "Vis"
}, },
"fromTimeline": { "fromTimeline": {
"previewExport": "Forhåndsvis eksport", "previewExport": "Forhåndsvis eksport",

View File

@ -23,8 +23,8 @@
"label": "Sorter", "label": "Sorter",
"dateAsc": "Dato (Stigende)", "dateAsc": "Dato (Stigende)",
"dateDesc": "Dato (Synkende)", "dateDesc": "Dato (Synkende)",
"scoreAsc": "Objektpoengsum (Stigende)", "scoreAsc": "Objektscore (Stigende)",
"scoreDesc": "Objektpoengsum (Synkende)", "scoreDesc": "Objektscore (Synkende)",
"speedAsc": "Estimert hastighet (Stigende)", "speedAsc": "Estimert hastighet (Stigende)",
"speedDesc": "Estimert hastighet (Synkende)", "speedDesc": "Estimert hastighet (Synkende)",
"relevance": "Relevans" "relevance": "Relevans"
@ -104,7 +104,7 @@
"label": "Underetiketter", "label": "Underetiketter",
"all": "Alle underetiketter" "all": "Alle underetiketter"
}, },
"score": "Poengsum", "score": "Score",
"estimatedSpeed": "Estimert hastighet ({{unit}})", "estimatedSpeed": "Estimert hastighet ({{unit}})",
"cameras": { "cameras": {
"all": { "all": {

View File

@ -12,19 +12,19 @@
}, },
"toast": { "toast": {
"success": { "success": {
"deletedCategory": "Kategori slettet", "deletedCategory": "Klasse slettet",
"deletedImage": "Bilder slettet", "deletedImage": "Bilder slettet",
"categorizedImage": "Bildet ble klassifisert", "categorizedImage": "Klassifiserte bildet",
"trainedModel": "Modellen ble trent.", "trainedModel": "Modellen ble trent.",
"trainingModel": "Modelltrening startet.", "trainingModel": "Modelltrening startet.",
"deletedModel_one": "{{count}} modell ble slettet", "deletedModel_one": "{{count}} modell ble slettet",
"deletedModel_other": "{{count}} modeller ble slettet", "deletedModel_other": "{{count}} modeller ble slettet",
"updatedModel": "Modellkonfigurasjonen ble oppdatert", "updatedModel": "Modellkonfigurasjonen ble oppdatert",
"renamedCategory": "Kategorien ble omdøpt til {{name}}" "renamedCategory": "Klassen ble omdøpt til {{name}}"
}, },
"error": { "error": {
"deleteImageFailed": "Kunne ikke slette: {{errorMessage}}", "deleteImageFailed": "Kunne ikke slette: {{errorMessage}}",
"deleteCategoryFailed": "Kunne ikke slette kategori: {{errorMessage}}", "deleteCategoryFailed": "Kunne ikke slette klasse: {{errorMessage}}",
"categorizeFailed": "Kunne ikke klassifisere bilde: {{errorMessage}}", "categorizeFailed": "Kunne ikke klassifisere bilde: {{errorMessage}}",
"trainingFailed": "Modelltrening mislyktes. Sjekk Frigate-loggene for detaljer.", "trainingFailed": "Modelltrening mislyktes. Sjekk Frigate-loggene for detaljer.",
"deleteModelFailed": "Kunne ikke slette modell: {{errorMessage}}", "deleteModelFailed": "Kunne ikke slette modell: {{errorMessage}}",
@ -34,10 +34,10 @@
} }
}, },
"deleteCategory": { "deleteCategory": {
"title": "Slett kategori", "title": "Slett klasse",
"desc": "Er du sikker på at du vil slette kategorien {{name}}? Dette vil permanent slette alle tilknyttede bilder og kreve at modellen trenes på nytt.", "desc": "Er du sikker på at du vil slette klassen {{name}}? Dette vil permanent slette alle tilknyttede bilder og kreve at modellen trenes på nytt.",
"minClassesTitle": "Kan ikke slette klasse", "minClassesTitle": "Kan ikke slette klasse",
"minClassesDesc": "En klassifiseringsmodell må ha minst 2 kategorier. Legg til en ny kategori før du sletter denne." "minClassesDesc": "En klassifiseringsmodell må ha minst 2 klasser. Legg til en ny klasse før du sletter denne."
}, },
"deleteDatasetImages": { "deleteDatasetImages": {
"title": "Slett datasettbilder", "title": "Slett datasettbilder",
@ -48,7 +48,7 @@
"desc": "Er du sikker på at du vil slette {{count}} bilder? Denne handlingen kan ikke angres." "desc": "Er du sikker på at du vil slette {{count}} bilder? Denne handlingen kan ikke angres."
}, },
"renameCategory": { "renameCategory": {
"title": "Gi nytt navn til kategori", "title": "Omdøp klasse",
"desc": "Skriv inn et nytt navn for {{name}}. Du må trene modellen på nytt for at navneendringen skal tre i kraft." "desc": "Skriv inn et nytt navn for {{name}}. Du må trene modellen på nytt for at navneendringen skal tre i kraft."
}, },
"description": { "description": {
@ -59,9 +59,9 @@
"aria": "Velg nylige klassifiseringer", "aria": "Velg nylige klassifiseringer",
"titleShort": "Nylig" "titleShort": "Nylig"
}, },
"categories": "Kategorier", "categories": "Klasser",
"createCategory": { "createCategory": {
"new": "Opprett ny kategori" "new": "Opprett ny klasse"
}, },
"categorizeImageAs": "Klassifiser bilde som:", "categorizeImageAs": "Klassifiser bilde som:",
"categorizeImage": "Klassifiser bilde", "categorizeImage": "Klassifiser bilde",
@ -98,18 +98,18 @@
"classificationTypeDesc": "Underetiketter legger til ekstra tekst på objektetiketten (f.eks. 'Person: Posten'). Attributter er søkbare metadata som lagres separat i objektets metadata.", "classificationTypeDesc": "Underetiketter legger til ekstra tekst på objektetiketten (f.eks. 'Person: Posten'). Attributter er søkbare metadata som lagres separat i objektets metadata.",
"classificationSubLabel": "Underetikett", "classificationSubLabel": "Underetikett",
"classificationAttribute": "Attributt", "classificationAttribute": "Attributt",
"classes": "Kategorier", "classes": "Klasser",
"classesTip": "Lær om kategorier", "classesTip": "Lær om klasser",
"classesStateDesc": "Definer de ulike tilstandene kamerasonen kan være i. For eksempel: 'åpen' og 'lukket' for en garasjeport.", "classesStateDesc": "Definer de ulike tilstandene kamerasonen kan være i. For eksempel: 'åpen' og 'lukket' for en garasjeport.",
"classesObjectDesc": "Definer kategoriene du vil klassifisere oppdagede objekter i. For eksempel: 'bud', 'beboer', 'fremmed' for personklassifisering.", "classesObjectDesc": "Definer klassene du vil klassifisere oppdagede objekter i. For eksempel: 'bud', 'beboer', 'fremmed' for personklassifisering.",
"classPlaceholder": "Skriv inn tilstandsnavn...", "classPlaceholder": "Skriv inn klassenavn...",
"errors": { "errors": {
"nameRequired": "Modellnavn er påkrevd", "nameRequired": "Modellnavn er påkrevd",
"nameLength": "Modellnavn må være på 64 tegn eller mindre", "nameLength": "Modellnavn må være på 64 tegn eller mindre",
"nameOnlyNumbers": "Modellnavn kan ikke bare inneholde tall", "nameOnlyNumbers": "Modellnavn kan ikke bare inneholde tall",
"classRequired": "Minst én kategori er påkrevd", "classRequired": "Minst én klasse er påkrevd",
"classesUnique": "Kategorinavn må være unike", "classesUnique": "Klassenavn må være unike",
"stateRequiresTwoClasses": "Tilstandsmodeller krever minst to kategorier", "stateRequiresTwoClasses": "Tilstandsmodeller krever minst to klasser",
"objectLabelRequired": "Velg en objektetikett", "objectLabelRequired": "Velg en objektetikett",
"objectTypeRequired": "Velg en klassifiseringstype" "objectTypeRequired": "Velg en klassifiseringstype"
}, },
@ -124,7 +124,7 @@
}, },
"step3": { "step3": {
"selectImagesPrompt": "Velg alle bilder med: {{className}}", "selectImagesPrompt": "Velg alle bilder med: {{className}}",
"selectImagesDescription": "Klikk på bilder for å velge dem. Klikk Fortsett når du er ferdig med denne kategorien.", "selectImagesDescription": "Klikk på bilder for å velge dem. Klikk Fortsett når du er ferdig med denne klassen.",
"generating": { "generating": {
"title": "Genererer eksempelbilder", "title": "Genererer eksempelbilder",
"description": "Frigate henter representative bilder fra opptakene dine. Dette kan ta litt tid..." "description": "Frigate henter representative bilder fra opptakene dine. Dette kan ta litt tid..."
@ -159,7 +159,7 @@
"states": "Tilstander" "states": "Tilstander"
}, },
"details": { "details": {
"scoreInfo": "Poengsummen representerer gjennomsnittlig klassifiseringskonfidens på tvers av alle deteksjoner av dette objektet." "scoreInfo": "Score representerer gjennomsnittlig klassifiseringskonfidens på tvers av alle deteksjoner av dette objektet."
}, },
"tooltip": { "tooltip": {
"trainingInProgress": "Modellen trenes for øyeblikket", "trainingInProgress": "Modellen trenes for øyeblikket",

View File

@ -90,7 +90,7 @@
"updatedSublabel": "Underetikett ble oppdatert.", "updatedSublabel": "Underetikett ble oppdatert.",
"updatedLPR": "Vellykket oppdatering av kjennemerke.", "updatedLPR": "Vellykket oppdatering av kjennemerke.",
"regenerate": "En ny beskrivelse har blitt anmodet fra {{provider}}. Avhengig av hastigheten til leverandøren din, kan den nye beskrivelsen ta litt tid å regenerere.", "regenerate": "En ny beskrivelse har blitt anmodet fra {{provider}}. Avhengig av hastigheten til leverandøren din, kan den nye beskrivelsen ta litt tid å regenerere.",
"audioTranscription": "Lydtranskripsjon ble forespurt." "audioTranscription": "Lydtranskripsjon ble forespurt. Avhengig av ytelsen på din Frigate server kan transkripsjonen ta noe tid å fullføre."
}, },
"error": { "error": {
"regenerate": "Feil ved anrop til {{provider}} for en ny beskrivelse: {{errorMessage}}", "regenerate": "Feil ved anrop til {{provider}} for en ny beskrivelse: {{errorMessage}}",
@ -107,8 +107,8 @@
} }
}, },
"topScore": { "topScore": {
"info": "Den høyeste poengsummen er den høyeste medianverdi for det sporede objektet, så denne kan avvike fra poengsummen som vises på miniatyrbildet for søkeresultatet.", "info": "Toppscoren er den høyeste medianverdien for det sporede objektet, så denne kan avvike fra scoren som vises på miniatyrbildet i søkeresultatet.",
"label": "Høyeste poengsum" "label": "Toppscore"
}, },
"estimatedSpeed": "Estimert hastighet", "estimatedSpeed": "Estimert hastighet",
"objects": "Objekter", "objects": "Objekter",
@ -147,10 +147,10 @@
"descNoLabel": "Angi en ny underetikett for dette sporede objektet" "descNoLabel": "Angi en ny underetikett for dette sporede objektet"
}, },
"snapshotScore": { "snapshotScore": {
"label": "Øyeblikksbilde poengsum" "label": "Øyeblikksbilde score"
}, },
"score": { "score": {
"label": "Poengsum" "label": "Score"
} }
}, },
"itemMenu": { "itemMenu": {
@ -261,7 +261,8 @@
"header": { "header": {
"zones": "Soner", "zones": "Soner",
"ratio": "Forhold", "ratio": "Forhold",
"area": "Område" "area": "Område",
"score": "Score"
} }
}, },
"annotationSettings": { "annotationSettings": {

View File

@ -11,7 +11,7 @@
"face": "Ansiktsdetaljer", "face": "Ansiktsdetaljer",
"faceDesc": "Detaljer for sporet objekt som genererte dette ansiktet", "faceDesc": "Detaljer for sporet objekt som genererte dette ansiktet",
"timestamp": "Tidsstempel", "timestamp": "Tidsstempel",
"scoreInfo": "Under-merkelappens poengsum er basert på en vektet sum ut ifra hvor sikre gjenkjenningene av ansiktene er, så den kan avvike fra poengsummen som vises på øyeblikksbildet.", "scoreInfo": "Score er et vektet gjennomsnitt av alle ansiktsscorer, vektet etter størrelsen på ansiktet i hvert bilde.",
"subLabelScore": "Poengsum for under-merkelapp", "subLabelScore": "Poengsum for under-merkelapp",
"unknown": "Ukjent" "unknown": "Ukjent"
}, },
@ -38,7 +38,7 @@
"deleteFaceFailed": "Kunne ikke slette: {{errorMessage}}", "deleteFaceFailed": "Kunne ikke slette: {{errorMessage}}",
"uploadingImageFailed": "Kunne ikke laste opp bilde: {{errorMessage}}", "uploadingImageFailed": "Kunne ikke laste opp bilde: {{errorMessage}}",
"trainFailed": "Kunne ikke trene: {{errorMessage}}", "trainFailed": "Kunne ikke trene: {{errorMessage}}",
"updateFaceScoreFailed": "Kunne ikke oppdatere ansiktsskåring: {{errorMessage}}", "updateFaceScoreFailed": "Kunne ikke oppdatere ansiktsscore: {{errorMessage}}",
"addFaceLibraryFailed": "Kunne ikke angi ansiktsnavn: {{errorMessage}}", "addFaceLibraryFailed": "Kunne ikke angi ansiktsnavn: {{errorMessage}}",
"deleteNameFailed": "Kunne ikke slette navn: {{errorMessage}}", "deleteNameFailed": "Kunne ikke slette navn: {{errorMessage}}",
"renameFaceFailed": "Kunne ikke gi nytt navn til ansikt: {{errorMessage}}" "renameFaceFailed": "Kunne ikke gi nytt navn til ansikt: {{errorMessage}}"
@ -49,7 +49,7 @@
"deletedName_one": "{{count}} ansikt ble slettet.", "deletedName_one": "{{count}} ansikt ble slettet.",
"deletedName_other": "{{count}} ansikter ble slettet.", "deletedName_other": "{{count}} ansikter ble slettet.",
"trainedFace": "Ansiktet ble trent.", "trainedFace": "Ansiktet ble trent.",
"updatedFaceScore": "Ansiktsskåring ble oppdatert til {{name}} ({{score}}).", "updatedFaceScore": "Oppdaterte ansiktsscore for {{name}} ({{score}}).",
"uploadedImage": "Bildet ble lastet opp.", "uploadedImage": "Bildet ble lastet opp.",
"addFaceLibrary": "{{name}} ble lagt til i ansiktsbiblioteket!", "addFaceLibrary": "{{name}} ble lagt til i ansiktsbiblioteket!",
"renamedFace": "Nytt navn ble gitt til ansikt {{name}}" "renamedFace": "Nytt navn ble gitt til ansikt {{name}}"

View File

@ -15,8 +15,8 @@
"labels": "Etiketter", "labels": "Etiketter",
"search_type": "Søketype", "search_type": "Søketype",
"after": "Etter", "after": "Etter",
"min_score": "Min. poengsum", "min_score": "Min. score",
"max_score": "Maks. poengsum", "max_score": "Maks. score",
"min_speed": "Min. hastighet", "min_speed": "Min. hastighet",
"zones": "Soner", "zones": "Soner",
"sub_labels": "Underetiketter", "sub_labels": "Underetiketter",
@ -36,8 +36,8 @@
"minSpeedMustBeLessOrEqualMaxSpeed": "Minimum hastighet 'min_speed' må være mindre enn eller lik maksimum hastighet 'max_speed'.", "minSpeedMustBeLessOrEqualMaxSpeed": "Minimum hastighet 'min_speed' må være mindre enn eller lik maksimum hastighet 'max_speed'.",
"beforeDateBeLaterAfter": "Før-datoen 'before' må være senere enn etter-datoen 'after'.", "beforeDateBeLaterAfter": "Før-datoen 'before' må være senere enn etter-datoen 'after'.",
"afterDatebeEarlierBefore": "Etter-datoen 'after' må være tidligere enn før-datoen 'before'.", "afterDatebeEarlierBefore": "Etter-datoen 'after' må være tidligere enn før-datoen 'before'.",
"minScoreMustBeLessOrEqualMaxScore": "Minimum poengsum 'min_score' må være mindre enn eller lik maksimum poengsum 'max_score'.", "minScoreMustBeLessOrEqualMaxScore": "Minimum score 'min_score' må være mindre enn eller lik maksimum score 'max_score'.",
"maxScoreMustBeGreaterOrEqualMinScore": "Maksimum poengsum 'max_score' må være større enn eller lik minimum poengsum 'min_score'.", "maxScoreMustBeGreaterOrEqualMinScore": "Maksimum score 'max_score' må være større enn eller lik minimum score 'min_score'.",
"maxSpeedMustBeGreaterOrEqualMinSpeed": "Maksimum hastighet 'max_speed' må være større enn eller lik minimum hastighet 'min_speed'." "maxSpeedMustBeGreaterOrEqualMinSpeed": "Maksimum hastighet 'max_speed' må være større enn eller lik minimum hastighet 'min_speed'."
} }
}, },

View File

@ -460,7 +460,7 @@
}, },
"objectShapeFilterDrawing": { "objectShapeFilterDrawing": {
"document": "Se dokumentasjonen ", "document": "Se dokumentasjonen ",
"score": "Poengsum", "score": "Score",
"ratio": "Forhold", "ratio": "Forhold",
"area": "Areal", "area": "Areal",
"title": "Tegning av objektformfilter", "title": "Tegning av objektformfilter",
@ -478,7 +478,7 @@
"audio": { "audio": {
"title": "Lyd", "title": "Lyd",
"noAudioDetections": "Ingen lyddeteksjoner", "noAudioDetections": "Ingen lyddeteksjoner",
"score": "poengsum", "score": "score",
"currentRMS": "Nåværende RMS", "currentRMS": "Nåværende RMS",
"currentdbFS": "Nåværende dbFS" "currentdbFS": "Nåværende dbFS"
}, },

View File

@ -65,7 +65,8 @@
"noVaildTimeSelected": "Geen geldig tijdsbereik geselecteerd", "noVaildTimeSelected": "Geen geldig tijdsbereik geselecteerd",
"endTimeMustAfterStartTime": "Eindtijd moet na starttijd zijn" "endTimeMustAfterStartTime": "Eindtijd moet na starttijd zijn"
}, },
"success": "Export is succesvol gestart. Bekijk het bestand op de exportpagina." "success": "Export is succesvol gestart. Bekijk het bestand op de exportpagina.",
"view": "Weergeven"
}, },
"fromTimeline": { "fromTimeline": {
"saveExport": "Export opslaan", "saveExport": "Export opslaan",

View File

@ -261,7 +261,8 @@
"header": { "header": {
"zones": "Zones", "zones": "Zones",
"ratio": "Verhouding", "ratio": "Verhouding",
"area": "Gebied" "area": "Gebied",
"score": "Score"
} }
}, },
"annotationSettings": { "annotationSettings": {

View File

@ -57,7 +57,8 @@
"endTimeMustAfterStartTime": "Час закінчення повинен бути після часу початку", "endTimeMustAfterStartTime": "Час закінчення повинен бути після часу початку",
"noVaildTimeSelected": "Не вибрано допустимий діапазон часу" "noVaildTimeSelected": "Не вибрано допустимий діапазон часу"
}, },
"success": "Експорт успішно розпочато. Перегляньте файл на сторінці експорту." "success": "Експорт успішно розпочато. Перегляньте файл на сторінці експорту.",
"view": "Переглянути"
}, },
"fromTimeline": { "fromTimeline": {
"saveExport": "Зберегти експорт", "saveExport": "Зберегти експорт",

View File

@ -263,7 +263,8 @@
"header": { "header": {
"zones": "Зони", "zones": "Зони",
"ratio": "Співвідношення", "ratio": "Співвідношення",
"area": "Площа" "area": "Площа",
"score": "Рахунок"
} }
}, },
"annotationSettings": { "annotationSettings": {

View File

@ -13,7 +13,7 @@ import { cn } from "@/lib/utils";
import { isPWA } from "@/utils/isPWA"; import { isPWA } from "@/utils/isPWA";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom"; import { useHistoryBack } from "@/hooks/use-history-back";
const MobilePageContext = createContext<{ const MobilePageContext = createContext<{
open: boolean; open: boolean;
@ -24,15 +24,16 @@ type MobilePageProps = {
children: React.ReactNode; children: React.ReactNode;
open?: boolean; open?: boolean;
onOpenChange?: (open: boolean) => void; onOpenChange?: (open: boolean) => void;
enableHistoryBack?: boolean;
}; };
export function MobilePage({ export function MobilePage({
children, children,
open: controlledOpen, open: controlledOpen,
onOpenChange, onOpenChange,
enableHistoryBack = true,
}: MobilePageProps) { }: MobilePageProps) {
const [uncontrolledOpen, setUncontrolledOpen] = useState(false); const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
const location = useLocation();
const open = controlledOpen ?? uncontrolledOpen; const open = controlledOpen ?? uncontrolledOpen;
const setOpen = useCallback( const setOpen = useCallback(
@ -46,33 +47,12 @@ export function MobilePage({
[onOpenChange, setUncontrolledOpen], [onOpenChange, setUncontrolledOpen],
); );
useEffect(() => { // Handle browser back button to close mobile page
let isActive = true; useHistoryBack({
enabled: enableHistoryBack,
if (open && isActive) { open,
window.history.pushState({ isMobilePage: true }, "", location.pathname); onClose: () => setOpen(false),
} });
const handlePopState = (event: PopStateEvent) => {
if (open && isActive) {
event.preventDefault();
setOpen(false);
// Delay replaceState to ensure state updates are processed
setTimeout(() => {
if (isActive) {
window.history.replaceState(null, "", location.pathname);
}
}, 0);
}
};
window.addEventListener("popstate", handlePopState);
return () => {
isActive = false;
window.removeEventListener("popstate", handlePopState);
};
}, [open, setOpen, location.pathname]);
return ( return (
<MobilePageContext.Provider value={{ open, onOpenChange: setOpen }}> <MobilePageContext.Provider value={{ open, onOpenChange: setOpen }}>

View File

@ -113,7 +113,12 @@ export function PlatformAwareSheet({
} }
return ( return (
<Sheet open={open} onOpenChange={onOpenChange} modal={false}> <Sheet
open={open}
onOpenChange={onOpenChange}
modal={false}
enableHistoryBack
>
<SheetTrigger asChild className={triggerClassName}> <SheetTrigger asChild className={triggerClassName}>
{trigger} {trigger}
</SheetTrigger> </SheetTrigger>

View File

@ -2,6 +2,7 @@ import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog"; import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useHistoryBack } from "@/hooks/use-history-back";
// Enhanced Dialog with History Support // Enhanced Dialog with History Support
interface HistoryDialogProps extends DialogPrimitive.DialogProps { interface HistoryDialogProps extends DialogPrimitive.DialogProps {
@ -15,51 +16,28 @@ const Dialog = ({
...props ...props
}: HistoryDialogProps) => { }: HistoryDialogProps) => {
const [internalOpen, setInternalOpen] = React.useState(open || false); const [internalOpen, setInternalOpen] = React.useState(open || false);
const historyStateRef = React.useRef<null | {
listener: (e: PopStateEvent) => void;
}>(null);
// Sync internal state with controlled open prop
React.useEffect(() => { React.useEffect(() => {
if (open !== undefined) { if (open !== undefined) {
setInternalOpen(open); setInternalOpen(open);
} }
}, [open]); }, [open]);
React.useEffect(() => { const handleOpenChange = React.useCallback(
if (enableHistoryBack) { (newOpen: boolean) => {
if (internalOpen) { setInternalOpen(newOpen);
window.history.pushState({ dialogOpen: true }, ""); onOpenChange?.(newOpen);
},
[onOpenChange],
);
const listener = () => { // Handle browser back button to close dialog
setInternalOpen(false); useHistoryBack({
if (onOpenChange) onOpenChange(false); enabled: enableHistoryBack,
}; open: internalOpen,
onClose: () => handleOpenChange(false),
historyStateRef.current = { listener }; });
window.addEventListener("popstate", listener);
return () => {
if (internalOpen) {
window.removeEventListener("popstate", listener);
historyStateRef.current = null;
}
};
} else if (historyStateRef.current) {
window.removeEventListener(
"popstate",
historyStateRef.current.listener,
);
historyStateRef.current = null;
}
}
}, [enableHistoryBack, internalOpen, onOpenChange]);
const handleOpenChange = (open: boolean) => {
setInternalOpen(open);
if (onOpenChange) {
onOpenChange(open);
}
};
return ( return (
<DialogPrimitive.Root <DialogPrimitive.Root

View File

@ -4,6 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useHistoryBack } from "@/hooks/use-history-back";
// Enhanced Sheet with History Support // Enhanced Sheet with History Support
interface HistorySheetProps extends SheetPrimitive.DialogProps { interface HistorySheetProps extends SheetPrimitive.DialogProps {
@ -17,51 +18,28 @@ const Sheet = ({
...props ...props
}: HistorySheetProps) => { }: HistorySheetProps) => {
const [internalOpen, setInternalOpen] = React.useState(open || false); const [internalOpen, setInternalOpen] = React.useState(open || false);
const historyStateRef = React.useRef<null | {
listener: (e: PopStateEvent) => void;
}>(null);
// Sync internal state with controlled open prop
React.useEffect(() => { React.useEffect(() => {
if (open !== undefined) { if (open !== undefined) {
setInternalOpen(open); setInternalOpen(open);
} }
}, [open]); }, [open]);
React.useEffect(() => { const handleOpenChange = React.useCallback(
if (enableHistoryBack) { (newOpen: boolean) => {
if (internalOpen) { setInternalOpen(newOpen);
window.history.pushState({ sheetOpen: true }, ""); onOpenChange?.(newOpen);
},
[onOpenChange],
);
const listener = () => { // Handle browser back button to close sheet
setInternalOpen(false); useHistoryBack({
if (onOpenChange) onOpenChange(false); enabled: enableHistoryBack,
}; open: internalOpen,
onClose: () => handleOpenChange(false),
historyStateRef.current = { listener }; });
window.addEventListener("popstate", listener);
return () => {
if (internalOpen) {
window.removeEventListener("popstate", listener);
historyStateRef.current = null;
}
};
} else if (historyStateRef.current) {
window.removeEventListener(
"popstate",
historyStateRef.current.listener,
);
historyStateRef.current = null;
}
}
}, [enableHistoryBack, internalOpen, onOpenChange]);
const handleOpenChange = (open: boolean) => {
setInternalOpen(open);
if (onOpenChange) {
onOpenChange(open);
}
};
return ( return (
<SheetPrimitive.Root <SheetPrimitive.Root

View File

@ -0,0 +1,57 @@
import * as React from "react";
interface UseHistoryBackOptions {
enabled: boolean;
open: boolean;
onClose: () => void;
}
/**
* Hook that manages browser history for overlay components (dialogs, sheets, etc.)
* When enabled, pressing the browser back button will close the overlay instead of navigating away.
*/
export function useHistoryBack({
enabled,
open,
onClose,
}: UseHistoryBackOptions): void {
const historyPushedRef = React.useRef(false);
const closedByBackRef = React.useRef(false);
// Keep onClose in a ref to avoid effect re-runs that cause multiple history pushes
const onCloseRef = React.useRef(onClose);
React.useLayoutEffect(() => {
onCloseRef.current = onClose;
});
React.useEffect(() => {
if (!enabled) return;
if (open) {
// Only push history state if we haven't already (prevents duplicates in strict mode)
if (!historyPushedRef.current) {
window.history.pushState({ overlayOpen: true }, "");
historyPushedRef.current = true;
}
const handlePopState = () => {
closedByBackRef.current = true;
historyPushedRef.current = false;
onCloseRef.current();
};
window.addEventListener("popstate", handlePopState);
return () => {
window.removeEventListener("popstate", handlePopState);
};
} else {
// Overlay is closing - clean up history if we pushed and it wasn't via back button
if (historyPushedRef.current && !closedByBackRef.current) {
window.history.back();
}
historyPushedRef.current = false;
closedByBackRef.current = false;
}
}, [enabled, open]);
}

View File

@ -35,6 +35,7 @@ import { useTranslation } from "react-i18next";
import { useDocDomain } from "@/hooks/use-doc-domain"; import { useDocDomain } from "@/hooks/use-doc-domain";
import { getTranslatedLabel } from "@/utils/i18n"; import { getTranslatedLabel } from "@/utils/i18n";
import { cn } from "@/lib/utils";
type MasksAndZoneViewProps = { type MasksAndZoneViewProps = {
selectedCamera: string; selectedCamera: string;
@ -697,7 +698,10 @@ export default function MasksAndZonesView({
</div> </div>
<div <div
ref={containerRef} ref={containerRef}
className="flex max-h-[50%] md:mr-3 md:h-dvh md:max-h-full md:w-7/12 md:grow" className={cn(
"flex max-h-[50%] md:h-dvh md:max-h-full md:w-7/12 md:grow",
isDesktop && "md:mr-3",
)}
> >
<div className="mx-auto flex size-full flex-row justify-center"> <div className="mx-auto flex size-full flex-row justify-center">
{cameraConfig && {cameraConfig &&

View File

@ -23,6 +23,8 @@ import { LuExternalLink } from "react-icons/lu";
import { StatusBarMessagesContext } from "@/context/statusbar-provider"; import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { useDocDomain } from "@/hooks/use-doc-domain"; import { useDocDomain } from "@/hooks/use-doc-domain";
import { cn } from "@/lib/utils";
import { isDesktop } from "react-device-detect";
type MotionTunerViewProps = { type MotionTunerViewProps = {
selectedCamera: string; selectedCamera: string;
@ -325,7 +327,12 @@ export default function MotionTunerView({
</div> </div>
{cameraConfig ? ( {cameraConfig ? (
<div className="flex max-h-[70%] md:mr-3 md:h-dvh md:max-h-full md:w-7/12 md:grow"> <div
className={cn(
"flex max-h-[70%] md:h-dvh md:max-h-full md:w-7/12 md:grow",
isDesktop && "md:mr-3",
)}
>
<div className="size-full min-h-10"> <div className="size-full min-h-10">
<AutoUpdatingCameraImage <AutoUpdatingCameraImage
camera={cameraConfig.name} camera={cameraConfig.name}

View File

@ -43,6 +43,7 @@ import { useTriggers } from "@/api/ws";
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name"; import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
import { CiCircleAlert } from "react-icons/ci"; import { CiCircleAlert } from "react-icons/ci";
import { useDocDomain } from "@/hooks/use-doc-domain"; import { useDocDomain } from "@/hooks/use-doc-domain";
import { isDesktop } from "react-device-detect";
type ConfigSetBody = { type ConfigSetBody = {
requires_restart: number; requires_restart: number;
@ -440,7 +441,12 @@ export default function TriggerView({
return ( return (
<div className="flex size-full flex-col md:flex-row"> <div className="flex size-full flex-col md:flex-row">
<Toaster position="top-center" closeButton={true} /> <Toaster position="top-center" closeButton={true} />
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none md:mr-3 md:mt-0"> <div
className={cn(
"scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2",
isDesktop && "order-none mr-3 mt-0",
)}
>
{!isSemanticSearchEnabled ? ( {!isSemanticSearchEnabled ? (
<div className="mb-5 flex flex-row items-center justify-between gap-2"> <div className="mb-5 flex flex-row items-center justify-between gap-2">
<div className="flex flex-col items-start"> <div className="flex flex-col items-start">
@ -651,7 +657,7 @@ export default function TriggerView({
</div> </div>
{/* Desktop Table View */} {/* Desktop Table View */}
<div className="scrollbar-container hidden flex-1 overflow-hidden rounded-lg border border-border bg-background_alt md:mr-3 md:block"> <div className="scrollbar-container hidden flex-1 overflow-hidden rounded-lg border border-border bg-background_alt md:block">
<div className="h-full overflow-auto"> <div className="h-full overflow-auto">
<Table> <Table>
<TableHeader className="sticky top-0 bg-muted/50"> <TableHeader className="sticky top-0 bg-muted/50">