mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-18 19:16:42 +03:00
Compare commits
1 Commits
b5911c1fa3
...
11b27d6b7d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11b27d6b7d |
@ -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 allow_any_authenticated, allow_public, require_role
|
from frigate.api.auth import 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,33 +56,29 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(tags=[Tags.app])
|
router = APIRouter(tags=[Tags.app])
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get("/", response_class=PlainTextResponse)
|
||||||
"/", 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", dependencies=[Depends(allow_public())])
|
@router.get("/config/schema.json")
|
||||||
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(
|
@router.get("/version", response_class=PlainTextResponse)
|
||||||
"/version", response_class=PlainTextResponse, dependencies=[Depends(allow_public())]
|
|
||||||
)
|
|
||||||
def version():
|
def version():
|
||||||
return VERSION
|
return VERSION
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats", dependencies=[Depends(allow_any_authenticated())])
|
@router.get("/stats")
|
||||||
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", dependencies=[Depends(allow_any_authenticated())])
|
@router.get("/stats/history")
|
||||||
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(",")
|
||||||
@ -90,7 +86,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", dependencies=[Depends(allow_any_authenticated())])
|
@router.get("/metrics")
|
||||||
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
|
||||||
@ -107,7 +103,7 @@ def metrics(request: Request):
|
|||||||
return Response(content=content, media_type=content_type)
|
return Response(content=content, media_type=content_type)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/config", dependencies=[Depends(allow_any_authenticated())])
|
@router.get("/config")
|
||||||
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(
|
||||||
@ -213,7 +209,7 @@ def config_raw_paths(request: Request):
|
|||||||
return JSONResponse(content=raw_paths)
|
return JSONResponse(content=raw_paths)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/config/raw", dependencies=[Depends(allow_any_authenticated())])
|
@router.get("/config/raw")
|
||||||
def config_raw():
|
def config_raw():
|
||||||
config_file = find_config_file()
|
config_file = find_config_file()
|
||||||
|
|
||||||
@ -456,7 +452,7 @@ def config_set(request: Request, body: AppConfigSetBody):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/vainfo", dependencies=[Depends(allow_any_authenticated())])
|
@router.get("/vainfo")
|
||||||
def vainfo():
|
def vainfo():
|
||||||
vainfo = vainfo_hwaccel()
|
vainfo = vainfo_hwaccel()
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@ -476,16 +472,12 @@ def vainfo():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/nvinfo", dependencies=[Depends(allow_any_authenticated())])
|
@router.get("/nvinfo")
|
||||||
def nvinfo():
|
def nvinfo():
|
||||||
return JSONResponse(content=get_nvidia_driver_info())
|
return JSONResponse(content=get_nvidia_driver_info())
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get("/logs/{service}", tags=[Tags.logs])
|
||||||
"/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,
|
||||||
@ -593,7 +585,7 @@ def restart():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/labels", dependencies=[Depends(allow_any_authenticated())])
|
@router.get("/labels")
|
||||||
def get_labels(camera: str = ""):
|
def get_labels(camera: str = ""):
|
||||||
try:
|
try:
|
||||||
if camera:
|
if camera:
|
||||||
@ -611,7 +603,7 @@ def get_labels(camera: str = ""):
|
|||||||
return JSONResponse(content=labels)
|
return JSONResponse(content=labels)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/sub_labels", dependencies=[Depends(allow_any_authenticated())])
|
@router.get("/sub_labels")
|
||||||
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()
|
||||||
@ -642,7 +634,7 @@ def get_sub_labels(split_joined: Optional[int] = None):
|
|||||||
return JSONResponse(content=sub_labels)
|
return JSONResponse(content=sub_labels)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/plus/models", dependencies=[Depends(allow_any_authenticated())])
|
@router.get("/plus/models")
|
||||||
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(
|
||||||
@ -684,9 +676,7 @@ def plusModels(request: Request, filterByCurrentModelDetector: bool = False):
|
|||||||
return JSONResponse(content=validModels)
|
return JSONResponse(content=validModels)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get("/recognized_license_plates")
|
||||||
"/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 = (
|
||||||
@ -720,7 +710,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", dependencies=[Depends(allow_any_authenticated())])
|
@router.get("/timeline")
|
||||||
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 = []
|
||||||
|
|
||||||
@ -757,7 +747,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", dependencies=[Depends(allow_any_authenticated())])
|
@router.get("/timeline/hourly")
|
||||||
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
|
||||||
|
|||||||
@ -32,154 +32,10 @@ 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", dependencies=[Depends(allow_public())])
|
@router.get("/auth/first_time_login")
|
||||||
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.
|
||||||
|
|
||||||
@ -496,7 +352,7 @@ def resolve_role(
|
|||||||
|
|
||||||
|
|
||||||
# Endpoints
|
# Endpoints
|
||||||
@router.get("/auth", dependencies=[Depends(allow_public())])
|
@router.get("/auth")
|
||||||
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
|
||||||
@ -622,7 +478,7 @@ def auth(request: Request):
|
|||||||
return fail_response
|
return fail_response
|
||||||
|
|
||||||
|
|
||||||
@router.get("/profile", dependencies=[Depends(allow_any_authenticated())])
|
@router.get("/profile")
|
||||||
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")
|
||||||
@ -636,7 +492,7 @@ def profile(request: Request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/logout", dependencies=[Depends(allow_any_authenticated())])
|
@router.get("/logout")
|
||||||
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)
|
||||||
@ -647,7 +503,7 @@ def logout(request: Request):
|
|||||||
limiter = Limiter(key_func=get_remote_addr)
|
limiter = Limiter(key_func=get_remote_addr)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", dependencies=[Depends(allow_public())])
|
@router.post("/login")
|
||||||
@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
|
||||||
@ -734,9 +590,7 @@ def delete_user(request: Request, username: str):
|
|||||||
return JSONResponse(content={"success": True})
|
return JSONResponse(content={"success": True})
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put("/users/{username}/password")
|
||||||
"/users/{username}/password", dependencies=[Depends(allow_any_authenticated())]
|
|
||||||
)
|
|
||||||
async def update_password(
|
async def update_password(
|
||||||
request: Request,
|
request: Request,
|
||||||
username: str,
|
username: str,
|
||||||
|
|||||||
@ -15,11 +15,7 @@ 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 (
|
from frigate.api.auth import require_role
|
||||||
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
|
||||||
@ -54,7 +50,7 @@ def _is_valid_host(host: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@router.get("/go2rtc/streams", dependencies=[Depends(allow_any_authenticated())])
|
@router.get("/go2rtc/streams")
|
||||||
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:
|
||||||
@ -70,9 +66,7 @@ def go2rtc_streams():
|
|||||||
return JSONResponse(content=stream_data)
|
return JSONResponse(content=stream_data)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get("/go2rtc/streams/{camera_name}")
|
||||||
"/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µphone"
|
f"http://127.0.0.1:1984/api/streams?src={camera_name}&video=all&audio=allµphone"
|
||||||
@ -167,7 +161,7 @@ def go2rtc_delete_stream(stream_name: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/ffprobe", dependencies=[Depends(require_role(["admin"]))])
|
@router.get("/ffprobe")
|
||||||
def ffprobe(request: Request, paths: str = "", detailed: bool = False):
|
def ffprobe(request: Request, paths: str = "", detailed: bool = False):
|
||||||
path_param = paths
|
path_param = paths
|
||||||
|
|
||||||
|
|||||||
@ -870,46 +870,6 @@ def categorize_classification_image(request: Request, name: str, body: dict = No
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/classification/{name}/dataset/{category}/create",
|
|
||||||
response_model=GenericResponse,
|
|
||||||
dependencies=[Depends(require_role(["admin"]))],
|
|
||||||
summary="Create an empty classification category folder",
|
|
||||||
description="""Creates an empty folder for a classification category.
|
|
||||||
This is used to create folders for categories that don't have images yet.
|
|
||||||
Returns a success message or an error if the name is invalid.""",
|
|
||||||
)
|
|
||||||
def create_classification_category(request: Request, name: str, category: str):
|
|
||||||
config: FrigateConfig = request.app.frigate_config
|
|
||||||
|
|
||||||
if name not in config.classification.custom:
|
|
||||||
return JSONResponse(
|
|
||||||
content=(
|
|
||||||
{
|
|
||||||
"success": False,
|
|
||||||
"message": f"{name} is not a known classification model.",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
status_code=404,
|
|
||||||
)
|
|
||||||
|
|
||||||
category_folder = os.path.join(
|
|
||||||
CLIPS_DIR, sanitize_filename(name), "dataset", sanitize_filename(category)
|
|
||||||
)
|
|
||||||
|
|
||||||
os.makedirs(category_folder, exist_ok=True)
|
|
||||||
|
|
||||||
return JSONResponse(
|
|
||||||
content=(
|
|
||||||
{
|
|
||||||
"success": True,
|
|
||||||
"message": f"Successfully created category folder: {category}",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
status_code=200,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/classification/{name}/train/delete",
|
"/classification/{name}/train/delete",
|
||||||
response_model=GenericResponse,
|
response_model=GenericResponse,
|
||||||
|
|||||||
@ -22,7 +22,6 @@ 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,
|
||||||
@ -809,7 +808,7 @@ def events_search(
|
|||||||
return JSONResponse(content=processed_events)
|
return JSONResponse(content=processed_events)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/events/summary", dependencies=[Depends(allow_any_authenticated())])
|
@router.get("/events/summary")
|
||||||
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),
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import logging
|
|||||||
import re
|
import re
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import Depends, FastAPI, Request
|
from fastapi import 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, require_admin_by_default
|
from frigate.api.auth import get_jwt_secret, limiter
|
||||||
from frigate.comms.event_metadata_updater import (
|
from frigate.comms.event_metadata_updater import (
|
||||||
EventMetadataPublisher,
|
EventMetadataPublisher,
|
||||||
)
|
)
|
||||||
@ -62,15 +62,11 @@ 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
|
||||||
|
|||||||
@ -22,11 +22,7 @@ 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 (
|
from frigate.api.auth import get_allowed_cameras_for_filter, require_camera_access
|
||||||
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,
|
||||||
@ -397,7 +393,7 @@ async def submit_recording_snapshot_to_plus(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/recordings/storage", dependencies=[Depends(allow_any_authenticated())])
|
@router.get("/recordings/storage")
|
||||||
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"
|
||||||
@ -421,7 +417,7 @@ def get_recordings_storage_usage(request: Request):
|
|||||||
return JSONResponse(content=camera_usages)
|
return JSONResponse(content=camera_usages)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/recordings/summary", dependencies=[Depends(allow_any_authenticated())])
|
@router.get("/recordings/summary")
|
||||||
def all_recordings_summary(
|
def all_recordings_summary(
|
||||||
request: Request,
|
request: Request,
|
||||||
params: MediaRecordingsSummaryQueryParams = Depends(),
|
params: MediaRecordingsSummaryQueryParams = Depends(),
|
||||||
@ -639,11 +635,7 @@ async def recordings(
|
|||||||
return JSONResponse(content=list(recordings))
|
return JSONResponse(content=list(recordings))
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get("/recordings/unavailable", response_model=list[dict])
|
||||||
"/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(),
|
||||||
@ -1061,10 +1053,7 @@ async def event_snapshot(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get("/events/{event_id}/thumbnail.{extension}")
|
||||||
"/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,
|
||||||
@ -1262,10 +1251,7 @@ def grid_snapshot(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get("/events/{event_id}/snapshot-clean.webp")
|
||||||
"/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:
|
||||||
@ -1389,9 +1375,7 @@ def event_snapshot_clean(request: Request, event_id: str, download: bool = False
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get("/events/{event_id}/clip.mp4")
|
||||||
"/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,
|
||||||
@ -1419,9 +1403,7 @@ async def event_clip(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get("/events/{event_id}/preview.gif")
|
||||||
"/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)
|
||||||
@ -1774,7 +1756,7 @@ def preview_mp4(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/review/{event_id}/preview", dependencies=[Depends(require_camera_access)])
|
@router.get("/review/{event_id}/preview")
|
||||||
def review_preview(
|
def review_preview(
|
||||||
request: Request,
|
request: Request,
|
||||||
event_id: str,
|
event_id: str,
|
||||||
@ -1800,12 +1782,8 @@ 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(
|
@router.get("/preview/{file_name}/thumbnail.jpg")
|
||||||
"/preview/{file_name}/thumbnail.jpg", dependencies=[Depends(require_camera_access)]
|
@router.get("/preview/{file_name}/thumbnail.webp")
|
||||||
)
|
|
||||||
@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:
|
||||||
|
|||||||
@ -14,7 +14,6 @@ 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,
|
||||||
@ -44,11 +43,7 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(tags=[Tags.review])
|
router = APIRouter(tags=[Tags.review])
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get("/review", response_model=list[ReviewSegmentResponse])
|
||||||
"/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),
|
||||||
@ -157,11 +152,7 @@ async def review(
|
|||||||
return JSONResponse(content=[r for r in review_query])
|
return JSONResponse(content=[r for r in review_query])
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get("/review_ids", response_model=list[ReviewSegmentResponse])
|
||||||
"/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(",")
|
||||||
|
|
||||||
@ -195,11 +186,7 @@ async def review_ids(request: Request, ids: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get("/review/summary", response_model=ReviewSummaryResponse)
|
||||||
"/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),
|
||||||
@ -474,11 +461,7 @@ async def review_summary(
|
|||||||
return JSONResponse(content=data)
|
return JSONResponse(content=data)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post("/reviews/viewed", response_model=GenericResponse)
|
||||||
"/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,
|
||||||
@ -661,11 +644,7 @@ def motion_activity(
|
|||||||
return JSONResponse(content=normalized)
|
return JSONResponse(content=normalized)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get("/review/event/{event_id}", response_model=ReviewSegmentResponse)
|
||||||
"/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(
|
||||||
@ -680,11 +659,7 @@ async def get_review_from_event(request: Request, event_id: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get("/review/{review_id}", response_model=ReviewSegmentResponse)
|
||||||
"/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)
|
||||||
@ -697,11 +672,7 @@ async def get_review(request: Request, review_id: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete("/review/{review_id}/viewed", response_model=GenericResponse)
|
||||||
"/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),
|
||||||
|
|||||||
@ -375,19 +375,7 @@ class WebPushClient(Communicator):
|
|||||||
ended = state == "end" or state == "genai"
|
ended = state == "end" or state == "genai"
|
||||||
|
|
||||||
if state == "genai" and payload["after"]["data"]["metadata"]:
|
if state == "genai" and payload["after"]["data"]["metadata"]:
|
||||||
base_title = payload["after"]["data"]["metadata"]["title"]
|
title = payload["after"]["data"]["metadata"]["title"]
|
||||||
threat_level = payload["after"]["data"]["metadata"].get(
|
|
||||||
"potential_threat_level", 0
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add prefix for threat levels 1 and 2
|
|
||||||
if threat_level == 1:
|
|
||||||
title = f"Needs Review: {base_title}"
|
|
||||||
elif threat_level == 2:
|
|
||||||
title = f"Security Concern: {base_title}"
|
|
||||||
else:
|
|
||||||
title = base_title
|
|
||||||
|
|
||||||
message = payload["after"]["data"]["metadata"]["scene"]
|
message = payload["after"]["data"]["metadata"]["scene"]
|
||||||
else:
|
else:
|
||||||
title = f"{titlecase(', '.join(sorted_objects).replace('_', ' '))}{' was' if state == 'end' else ''} detected in {titlecase(', '.join(payload['after']['data']['zones']).replace('_', ' '))}"
|
title = f"{titlecase(', '.join(sorted_objects).replace('_', ' '))}{' was' if state == 'end' else ''} detected in {titlecase(', '.join(payload['after']['data']['zones']).replace('_', ' '))}"
|
||||||
|
|||||||
@ -205,20 +205,14 @@ Rules for the report:
|
|||||||
- Group bullets under subheadings when multiple events fall into the same category (e.g., Vehicle Activity, Porch Activity, Unusual Behavior).
|
- Group bullets under subheadings when multiple events fall into the same category (e.g., Vehicle Activity, Porch Activity, Unusual Behavior).
|
||||||
|
|
||||||
- Threat levels
|
- Threat levels
|
||||||
- Always show the threat level for each event using these labels:
|
- Always show (threat level: X) for each event.
|
||||||
- Threat level 0: "Normal"
|
|
||||||
- Threat level 1: "Needs review"
|
|
||||||
- Threat level 2: "Security concern"
|
|
||||||
- Format as (threat level: Normal), (threat level: Needs review), or (threat level: Security concern).
|
|
||||||
- If multiple events at the same time share the same threat level, only state it once.
|
- If multiple events at the same time share the same threat level, only state it once.
|
||||||
|
|
||||||
- Final assessment
|
- Final assessment
|
||||||
- End with a Final Assessment section.
|
- End with a Final Assessment section.
|
||||||
- If all events are threat level 0:
|
- If all events are threat level 1 with no escalation:
|
||||||
Final assessment: Only normal residential activity observed during this period.
|
Final assessment: Only normal residential activity observed during this period.
|
||||||
- If threat level 1 events are present:
|
- If threat level 2+ events are present, clearly summarize them as Potential concerns requiring review.
|
||||||
Final assessment: Some activity requires review but no security concerns identified.
|
|
||||||
- If threat level 2 events are present, clearly summarize them as Security concerns requiring immediate attention.
|
|
||||||
|
|
||||||
- Conciseness
|
- Conciseness
|
||||||
- Do not repeat benign clothing/appearance details unless they distinguish individuals.
|
- Do not repeat benign clothing/appearance details unless they distinguish individuals.
|
||||||
|
|||||||
@ -3,8 +3,6 @@ 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
|
||||||
@ -18,20 +16,6 @@ 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
|
||||||
@ -129,9 +113,7 @@ 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):
|
||||||
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
|
return create_fastapi_app(
|
||||||
|
|
||||||
app = create_fastapi_app(
|
|
||||||
FrigateConfig(**self.minimal_config),
|
FrigateConfig(**self.minimal_config),
|
||||||
self.db,
|
self.db,
|
||||||
None,
|
None,
|
||||||
@ -141,33 +123,8 @@ 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,
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
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 AuthTestClient, BaseTestHttp
|
from frigate.test.http_api.base_http_test import BaseTestHttp
|
||||||
|
|
||||||
|
|
||||||
class TestHttpApp(BaseTestHttp):
|
class TestHttpApp(BaseTestHttp):
|
||||||
@ -18,7 +20,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 AuthTestClient(app) as client:
|
with TestClient(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
|
||||||
|
|||||||
@ -1,13 +1,14 @@
|
|||||||
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 AuthTestClient, BaseTestHttp
|
from frigate.test.http_api.base_http_test import BaseTestHttp
|
||||||
|
|
||||||
|
|
||||||
class TestCameraAccessEventReview(BaseTestHttp):
|
class TestCameraAccessEventReview(BaseTestHttp):
|
||||||
@ -15,17 +16,9 @@ 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 for all tests
|
# Mock get_current_user to return valid user for all tests
|
||||||
async def mock_get_current_user(request: Request):
|
async def mock_get_current_user():
|
||||||
username = request.headers.get("remote-user")
|
return {"username": "test_user", "role": "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
|
||||||
|
|
||||||
@ -37,25 +30,21 @@ 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")
|
||||||
|
|
||||||
async def mock_cameras(request: Request):
|
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [
|
||||||
return ["front_door"]
|
"front_door"
|
||||||
|
]
|
||||||
self.app.dependency_overrides[get_allowed_cameras_for_filter] = mock_cameras
|
with TestClient(self.app) as client:
|
||||||
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
|
||||||
|
|
||||||
async def mock_cameras(request: Request):
|
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [
|
||||||
return [
|
"front_door",
|
||||||
"front_door",
|
"back_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()]
|
||||||
@ -65,25 +54,21 @@ 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")
|
||||||
|
|
||||||
async def mock_cameras(request: Request):
|
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [
|
||||||
return ["front_door"]
|
"front_door"
|
||||||
|
]
|
||||||
self.app.dependency_overrides[get_allowed_cameras_for_filter] = mock_cameras
|
with TestClient(self.app) as client:
|
||||||
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
|
||||||
|
|
||||||
async def mock_cameras(request: Request):
|
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [
|
||||||
return [
|
"front_door",
|
||||||
"front_door",
|
"back_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()]
|
||||||
@ -99,7 +84,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 AuthTestClient(self.app) as client:
|
with TestClient(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"
|
||||||
@ -109,7 +94,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 AuthTestClient(self.app) as client:
|
with TestClient(self.app) as client:
|
||||||
resp = client.get("/events/event1")
|
resp = client.get("/events/event1")
|
||||||
assert resp.status_code == 403
|
assert resp.status_code == 403
|
||||||
|
|
||||||
@ -123,7 +108,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 AuthTestClient(self.app) as client:
|
with TestClient(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"
|
||||||
@ -133,7 +118,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 AuthTestClient(self.app) as client:
|
with TestClient(self.app) as client:
|
||||||
resp = client.get("/review/rev1")
|
resp = client.get("/review/rev1")
|
||||||
assert resp.status_code == 403
|
assert resp.status_code == 403
|
||||||
|
|
||||||
@ -141,25 +126,21 @@ 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")
|
||||||
|
|
||||||
async def mock_cameras(request: Request):
|
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [
|
||||||
return ["front_door"]
|
"front_door"
|
||||||
|
]
|
||||||
self.app.dependency_overrides[get_allowed_cameras_for_filter] = mock_cameras
|
with TestClient(self.app) as client:
|
||||||
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
|
||||||
|
|
||||||
async def mock_cameras(request: Request):
|
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [
|
||||||
return [
|
"front_door",
|
||||||
"front_door",
|
"back_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()]
|
||||||
@ -169,24 +150,20 @@ 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")
|
||||||
|
|
||||||
async def mock_cameras(request: Request):
|
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [
|
||||||
return ["front_door"]
|
"front_door"
|
||||||
|
]
|
||||||
self.app.dependency_overrides[get_allowed_cameras_for_filter] = mock_cameras
|
with TestClient(self.app) as client:
|
||||||
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
|
||||||
|
|
||||||
async def mock_cameras(request: Request):
|
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [
|
||||||
return [
|
"front_door",
|
||||||
"front_door",
|
"back_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
|
||||||
|
|||||||
@ -2,13 +2,14 @@ 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 AuthTestClient, BaseTestHttp, Request
|
from frigate.test.http_api.base_http_test import BaseTestHttp
|
||||||
from frigate.test.test_storage import _insert_mock_event
|
from frigate.test.test_storage import _insert_mock_event
|
||||||
|
|
||||||
|
|
||||||
@ -17,26 +18,14 @@ 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 get_current_user for all tests
|
# Mock auth to bypass camera access for tests
|
||||||
async def mock_get_current_user(request: Request):
|
async def mock_get_current_user(request: Any):
|
||||||
username = request.headers.get("remote-user")
|
return {"username": "test_user", "role": "admin"}
|
||||||
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()
|
||||||
@ -46,20 +35,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 AuthTestClient(self.app) as client:
|
with TestClient(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 AuthTestClient(self.app) as client:
|
with TestClient(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 AuthTestClient(self.app) as client:
|
with TestClient(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
|
||||||
@ -69,7 +58,7 @@ class TestHttpApp(BaseTestHttp):
|
|||||||
now = int(datetime.now().timestamp())
|
now = int(datetime.now().timestamp())
|
||||||
|
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
with AuthTestClient(self.app) as client:
|
with TestClient(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}
|
||||||
@ -80,7 +69,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 AuthTestClient(self.app) as client:
|
with TestClient(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()
|
||||||
@ -89,7 +78,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 AuthTestClient(self.app) as client:
|
with TestClient(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()
|
||||||
@ -99,7 +88,7 @@ class TestHttpApp(BaseTestHttp):
|
|||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
id2 = "54321.random"
|
id2 = "54321.random"
|
||||||
|
|
||||||
with AuthTestClient(self.app) as client:
|
with TestClient(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
|
||||||
@ -119,14 +108,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 AuthTestClient(self.app) as client:
|
with TestClient(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 AuthTestClient(self.app) as client:
|
with TestClient(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()
|
||||||
@ -134,7 +123,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 AuthTestClient(self.app) as client:
|
with TestClient(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})
|
||||||
@ -152,7 +141,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 AuthTestClient(self.app) as client:
|
with TestClient(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)
|
||||||
@ -170,7 +159,7 @@ class TestHttpApp(BaseTestHttp):
|
|||||||
def test_get_good_event(self):
|
def test_get_good_event(self):
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
|
|
||||||
with AuthTestClient(self.app) as client:
|
with TestClient(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()
|
||||||
|
|
||||||
@ -182,7 +171,7 @@ class TestHttpApp(BaseTestHttp):
|
|||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
bad_id = "654321.other"
|
bad_id = "654321.other"
|
||||||
|
|
||||||
with AuthTestClient(self.app) as client:
|
with TestClient(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
|
||||||
@ -191,7 +180,7 @@ class TestHttpApp(BaseTestHttp):
|
|||||||
def test_delete_event(self):
|
def test_delete_event(self):
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
|
|
||||||
with AuthTestClient(self.app) as client:
|
with TestClient(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
|
||||||
@ -204,7 +193,7 @@ class TestHttpApp(BaseTestHttp):
|
|||||||
def test_event_retention(self):
|
def test_event_retention(self):
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
|
|
||||||
with AuthTestClient(self.app) as client:
|
with TestClient(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()
|
||||||
@ -223,11 +212,12 @@ 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 AuthTestClient(self.app) as client:
|
with TestClient(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
|
||||||
@ -258,7 +248,7 @@ class TestHttpApp(BaseTestHttp):
|
|||||||
|
|
||||||
mock_event_updater.publish.side_effect = update_event
|
mock_event_updater.publish.side_effect = update_event
|
||||||
|
|
||||||
with AuthTestClient(app) as client:
|
with TestClient(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",
|
||||||
@ -295,7 +285,7 @@ class TestHttpApp(BaseTestHttp):
|
|||||||
|
|
||||||
mock_event_updater.publish.side_effect = update_event
|
mock_event_updater.publish.side_effect = update_event
|
||||||
|
|
||||||
with AuthTestClient(app) as client:
|
with TestClient(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",
|
||||||
@ -311,7 +301,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 AuthTestClient(self.app) as client:
|
with TestClient(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(
|
||||||
|
|||||||
@ -1,13 +1,14 @@
|
|||||||
"""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 import Request
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
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 AuthTestClient, BaseTestHttp
|
from frigate.test.http_api.base_http_test import BaseTestHttp
|
||||||
|
|
||||||
|
|
||||||
class TestHttpMedia(BaseTestHttp):
|
class TestHttpMedia(BaseTestHttp):
|
||||||
@ -18,26 +19,15 @@ class TestHttpMedia(BaseTestHttp):
|
|||||||
super().setUp([Recordings])
|
super().setUp([Recordings])
|
||||||
self.app = super().create_app()
|
self.app = super().create_app()
|
||||||
|
|
||||||
# Mock get_current_user for all tests
|
# Mock auth to bypass camera access for tests
|
||||||
async def mock_get_current_user(request: Request):
|
async def mock_get_current_user(request: Any):
|
||||||
username = request.headers.get("remote-user")
|
return {"username": "test_user", "role": "admin"}
|
||||||
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"]
|
"back_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."""
|
||||||
@ -62,7 +52,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 AuthTestClient(self.app) as client:
|
with TestClient(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",
|
||||||
@ -138,7 +128,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 AuthTestClient(self.app) as client:
|
with TestClient(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",
|
||||||
@ -205,15 +195,7 @@ 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 AuthTestClient(self.app) as client:
|
with TestClient(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",
|
||||||
@ -254,14 +236,6 @@ 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.
|
||||||
@ -276,7 +250,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 AuthTestClient(self.app) as client:
|
with TestClient(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",
|
||||||
@ -309,7 +283,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 AuthTestClient(self.app) as client:
|
with TestClient(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",
|
||||||
@ -351,7 +325,7 @@ class TestHttpMedia(BaseTestHttp):
|
|||||||
"""
|
"""
|
||||||
Test recordings summary when no recordings exist.
|
Test recordings summary when no recordings exist.
|
||||||
"""
|
"""
|
||||||
with AuthTestClient(self.app) as client:
|
with TestClient(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"},
|
||||||
@ -368,7 +342,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 AuthTestClient(self.app) as client:
|
with TestClient(self.app) as client:
|
||||||
# Insert recordings for both cameras
|
# Insert recordings for both cameras
|
||||||
Recordings.insert(
|
Recordings.insert(
|
||||||
id="front_recording",
|
id="front_recording",
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from fastapi import Request
|
from fastapi.testclient import TestClient
|
||||||
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 AuthTestClient, BaseTestHttp
|
from frigate.test.http_api.base_http_test import BaseTestHttp
|
||||||
|
|
||||||
|
|
||||||
class TestHttpReview(BaseTestHttp):
|
class TestHttpReview(BaseTestHttp):
|
||||||
@ -16,26 +16,14 @@ 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
|
||||||
# This mock uses headers set by AuthTestClient
|
async def mock_get_current_user():
|
||||||
async def mock_get_current_user(request: Request):
|
return {"username": self.user_id, "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
|
||||||
|
|
||||||
async def mock_get_allowed_cameras_for_filter(request: Request):
|
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [
|
||||||
return ["front_door"]
|
"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()
|
||||||
@ -69,7 +57,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 AuthTestClient(self.app) as client:
|
with TestClient(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
|
||||||
@ -79,7 +67,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 AuthTestClient(self.app) as client:
|
with TestClient(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")
|
||||||
@ -93,7 +81,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 AuthTestClient(self.app) as client:
|
with TestClient(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)
|
||||||
@ -109,7 +97,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 AuthTestClient(self.app) as client:
|
with TestClient(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 = {
|
||||||
@ -125,7 +113,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 AuthTestClient(self.app) as client:
|
with TestClient(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)
|
||||||
@ -144,7 +132,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 AuthTestClient(self.app) as client:
|
with TestClient(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 = {
|
||||||
@ -161,7 +149,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 AuthTestClient(self.app) as client:
|
with TestClient(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 = {
|
||||||
@ -177,7 +165,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 AuthTestClient(self.app) as client:
|
with TestClient(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 = {
|
||||||
@ -200,7 +188,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 AuthTestClient(self.app) as client:
|
with TestClient(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",
|
||||||
@ -231,7 +219,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 AuthTestClient(self.app) as client:
|
with TestClient(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
|
||||||
@ -259,7 +247,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 AuthTestClient(self.app) as client:
|
with TestClient(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
|
||||||
)
|
)
|
||||||
@ -303,7 +291,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 AuthTestClient(self.app) as client:
|
with TestClient(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):
|
||||||
@ -354,7 +342,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 AuthTestClient(self.app) as client:
|
with TestClient(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"
|
||||||
@ -405,14 +393,14 @@ class TestHttpReview(BaseTestHttp):
|
|||||||
####################################################################################################################
|
####################################################################################################################
|
||||||
|
|
||||||
def test_post_reviews_viewed_no_body(self):
|
def test_post_reviews_viewed_no_body(self):
|
||||||
with AuthTestClient(self.app) as client:
|
with TestClient(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 AuthTestClient(self.app) as client:
|
with TestClient(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)
|
||||||
@ -420,7 +408,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 AuthTestClient(self.app) as client:
|
with TestClient(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"]}
|
||||||
@ -437,7 +425,7 @@ class TestHttpReview(BaseTestHttp):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_post_reviews_viewed(self):
|
def test_post_reviews_viewed(self):
|
||||||
with AuthTestClient(self.app) as client:
|
with TestClient(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]}
|
||||||
@ -457,14 +445,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 AuthTestClient(self.app) as client:
|
with TestClient(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 AuthTestClient(self.app) as client:
|
with TestClient(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(
|
||||||
@ -474,7 +462,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 AuthTestClient(self.app) as client:
|
with TestClient(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"]}
|
||||||
@ -491,7 +479,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 AuthTestClient(self.app) as client:
|
with TestClient(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]}
|
||||||
@ -507,7 +495,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 AuthTestClient(self.app) as client:
|
with TestClient(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)
|
||||||
@ -539,7 +527,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 AuthTestClient(self.app) as client:
|
with TestClient(self.app) as client:
|
||||||
params = {
|
params = {
|
||||||
"after": now,
|
"after": now,
|
||||||
"before": now + 3,
|
"before": now + 3,
|
||||||
@ -552,7 +540,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 AuthTestClient(self.app) as client:
|
with TestClient(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"
|
||||||
@ -585,7 +573,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 AuthTestClient(self.app) as client:
|
with TestClient(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()
|
||||||
@ -597,7 +585,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 AuthTestClient(self.app) as client:
|
with TestClient(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}")
|
||||||
@ -611,7 +599,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 AuthTestClient(self.app) as client:
|
with TestClient(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"
|
||||||
@ -638,7 +626,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 AuthTestClient(self.app) as client:
|
with TestClient(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()
|
||||||
@ -650,7 +638,7 @@ class TestHttpReview(BaseTestHttp):
|
|||||||
def test_get_review(self):
|
def test_get_review(self):
|
||||||
now = datetime.now().timestamp()
|
now = datetime.now().timestamp()
|
||||||
|
|
||||||
with AuthTestClient(self.app) as client:
|
with TestClient(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}")
|
||||||
@ -674,7 +662,7 @@ class TestHttpReview(BaseTestHttp):
|
|||||||
####################################################################################################################
|
####################################################################################################################
|
||||||
|
|
||||||
def test_delete_review_viewed_review_not_found(self):
|
def test_delete_review_viewed_review_not_found(self):
|
||||||
with AuthTestClient(self.app) as client:
|
with TestClient(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
|
||||||
@ -687,7 +675,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 AuthTestClient(self.app) as client:
|
with TestClient(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)
|
||||||
|
|||||||
@ -348,7 +348,7 @@ def migrate_016_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]
|
|||||||
|
|
||||||
|
|
||||||
def migrate_017_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
def migrate_017_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
||||||
"""Handle migrating frigate config to 0.17-0"""
|
"""Handle migrating frigate config to 0.16-0"""
|
||||||
new_config = config.copy()
|
new_config = config.copy()
|
||||||
|
|
||||||
# migrate global to new recording configuration
|
# migrate global to new recording configuration
|
||||||
@ -380,7 +380,7 @@ def migrate_017_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]
|
|||||||
|
|
||||||
if global_genai:
|
if global_genai:
|
||||||
new_genai_config = {}
|
new_genai_config = {}
|
||||||
new_object_config = new_config.get("objects", {})
|
new_object_config = config.get("objects", {})
|
||||||
new_object_config["genai"] = {}
|
new_object_config["genai"] = {}
|
||||||
|
|
||||||
for key in global_genai.keys():
|
for key in global_genai.keys():
|
||||||
@ -389,8 +389,7 @@ def migrate_017_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]
|
|||||||
else:
|
else:
|
||||||
new_object_config["genai"][key] = global_genai[key]
|
new_object_config["genai"][key] = global_genai[key]
|
||||||
|
|
||||||
new_config["genai"] = new_genai_config
|
config["genai"] = new_genai_config
|
||||||
new_config["objects"] = new_object_config
|
|
||||||
|
|
||||||
for name, camera in config.get("cameras", {}).items():
|
for name, camera in config.get("cameras", {}).items():
|
||||||
camera_config: dict[str, dict[str, Any]] = camera.copy()
|
camera_config: dict[str, dict[str, Any]] = camera.copy()
|
||||||
@ -416,9 +415,8 @@ def migrate_017_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]
|
|||||||
camera_genai = camera_config.get("genai", {})
|
camera_genai = camera_config.get("genai", {})
|
||||||
|
|
||||||
if camera_genai:
|
if camera_genai:
|
||||||
camera_object_config = camera_config.get("objects", {})
|
new_object_config = config.get("objects", {})
|
||||||
camera_object_config["genai"] = camera_genai
|
new_object_config["genai"] = camera_genai
|
||||||
camera_config["objects"] = camera_object_config
|
|
||||||
del camera_config["genai"]
|
del camera_config["genai"]
|
||||||
|
|
||||||
new_config["cameras"][name] = camera_config
|
new_config["cameras"][name] = camera_config
|
||||||
|
|||||||
@ -166,7 +166,6 @@
|
|||||||
"noImages": "No sample images generated",
|
"noImages": "No sample images generated",
|
||||||
"classifying": "Classifying & Training...",
|
"classifying": "Classifying & Training...",
|
||||||
"trainingStarted": "Training started successfully",
|
"trainingStarted": "Training started successfully",
|
||||||
"modelCreated": "Model created successfully. Use the Recent Classifications view to add images for missing states, then train the model.",
|
|
||||||
"errors": {
|
"errors": {
|
||||||
"noCameras": "No cameras configured",
|
"noCameras": "No cameras configured",
|
||||||
"noObjectLabel": "No object label selected",
|
"noObjectLabel": "No object label selected",
|
||||||
@ -174,11 +173,7 @@
|
|||||||
"generationFailed": "Generation failed. Please try again.",
|
"generationFailed": "Generation failed. Please try again.",
|
||||||
"classifyFailed": "Failed to classify images: {{error}}"
|
"classifyFailed": "Failed to classify images: {{error}}"
|
||||||
},
|
},
|
||||||
"generateSuccess": "Successfully generated sample images",
|
"generateSuccess": "Successfully generated sample images"
|
||||||
"missingStatesWarning": {
|
|
||||||
"title": "Missing State Examples",
|
|
||||||
"description": "You haven't selected examples for all states. The model will not be trained until all states have images. After continuing, use the Recent Classifications view to classify images for the missing states, then train the model."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -54,7 +54,6 @@
|
|||||||
"selected_other": "{{count}} selected",
|
"selected_other": "{{count}} selected",
|
||||||
"camera": "Camera",
|
"camera": "Camera",
|
||||||
"detected": "detected",
|
"detected": "detected",
|
||||||
"normalActivity": "Normal",
|
"suspiciousActivity": "Suspicious Activity",
|
||||||
"needsReview": "Needs review",
|
"threateningActivity": "Threatening Activity"
|
||||||
"securityConcern": "Security concern"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,8 +10,12 @@ import useSWR from "swr";
|
|||||||
import { baseUrl } from "@/api/baseUrl";
|
import { baseUrl } from "@/api/baseUrl";
|
||||||
import { isMobile } from "react-device-detect";
|
import { isMobile } from "react-device-detect";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import {
|
||||||
import { IoIosWarning } from "react-icons/io";
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||||
|
|
||||||
export type Step3FormData = {
|
export type Step3FormData = {
|
||||||
examplesGenerated: boolean;
|
examplesGenerated: boolean;
|
||||||
@ -141,67 +145,20 @@ export default function Step3ChooseExamples({
|
|||||||
);
|
);
|
||||||
await Promise.all(categorizePromises);
|
await Promise.all(categorizePromises);
|
||||||
|
|
||||||
// Step 2.5: Create empty folders for classes that don't have any images
|
// Step 3: Kick off training
|
||||||
// This ensures all classes are available in the dataset view later
|
await axios.post(`/classification/${step1Data.modelName}/train`);
|
||||||
const classesWithImages = new Set(
|
|
||||||
Object.values(classifications).filter((c) => c && c !== "none"),
|
|
||||||
);
|
|
||||||
const emptyFolderPromises = step1Data.classes
|
|
||||||
.filter((className) => !classesWithImages.has(className))
|
|
||||||
.map((className) =>
|
|
||||||
axios.post(
|
|
||||||
`/classification/${step1Data.modelName}/dataset/${className}/create`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await Promise.all(emptyFolderPromises);
|
|
||||||
|
|
||||||
// Step 3: Determine if we should train
|
toast.success(t("wizard.step3.trainingStarted"), {
|
||||||
// For state models, we need ALL states to have examples
|
closeButton: true,
|
||||||
// For object models, we need at least 2 classes with images
|
});
|
||||||
const allStatesHaveExamplesForTraining =
|
setIsTraining(true);
|
||||||
step1Data.modelType !== "state" ||
|
|
||||||
step1Data.classes.every((className) =>
|
|
||||||
classesWithImages.has(className),
|
|
||||||
);
|
|
||||||
const shouldTrain =
|
|
||||||
allStatesHaveExamplesForTraining && classesWithImages.size >= 2;
|
|
||||||
|
|
||||||
// Step 4: Kick off training only if we have enough classes with images
|
|
||||||
if (shouldTrain) {
|
|
||||||
await axios.post(`/classification/${step1Data.modelName}/train`);
|
|
||||||
|
|
||||||
toast.success(t("wizard.step3.trainingStarted"), {
|
|
||||||
closeButton: true,
|
|
||||||
});
|
|
||||||
setIsTraining(true);
|
|
||||||
} else {
|
|
||||||
// Don't train - not all states have examples
|
|
||||||
toast.success(t("wizard.step3.modelCreated"), {
|
|
||||||
closeButton: true,
|
|
||||||
});
|
|
||||||
setIsTraining(false);
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[step1Data, step2Data, t, onClose],
|
[step1Data, step2Data, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleContinueClassification = useCallback(async () => {
|
const handleContinueClassification = useCallback(async () => {
|
||||||
// Mark selected images with current class
|
// Mark selected images with current class
|
||||||
const newClassifications = { ...imageClassifications };
|
const newClassifications = { ...imageClassifications };
|
||||||
|
|
||||||
// Handle user going back and de-selecting images
|
|
||||||
const imagesToCheck = unknownImages.slice(0, 24);
|
|
||||||
imagesToCheck.forEach((imageName) => {
|
|
||||||
if (
|
|
||||||
newClassifications[imageName] === currentClass &&
|
|
||||||
!selectedImages.has(imageName)
|
|
||||||
) {
|
|
||||||
delete newClassifications[imageName];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Then, add all currently selected images to the current class
|
|
||||||
selectedImages.forEach((imageName) => {
|
selectedImages.forEach((imageName) => {
|
||||||
newClassifications[imageName] = currentClass;
|
newClassifications[imageName] = currentClass;
|
||||||
});
|
});
|
||||||
@ -372,43 +329,8 @@ export default function Step3ChooseExamples({
|
|||||||
return unclassifiedImages.length === 0;
|
return unclassifiedImages.length === 0;
|
||||||
}, [unclassifiedImages]);
|
}, [unclassifiedImages]);
|
||||||
|
|
||||||
const isLastClass = currentClassIndex === allClasses.length - 1;
|
|
||||||
const statesWithExamples = useMemo(() => {
|
|
||||||
if (step1Data.modelType !== "state") return new Set<string>();
|
|
||||||
|
|
||||||
const states = new Set<string>();
|
|
||||||
const allImages = unknownImages.slice(0, 24);
|
|
||||||
|
|
||||||
// Check which states have at least one image classified
|
|
||||||
allImages.forEach((img) => {
|
|
||||||
let className: string | undefined;
|
|
||||||
if (selectedImages.has(img)) {
|
|
||||||
className = currentClass;
|
|
||||||
} else {
|
|
||||||
className = imageClassifications[img];
|
|
||||||
}
|
|
||||||
if (className && allClasses.includes(className)) {
|
|
||||||
states.add(className);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return states;
|
|
||||||
}, [
|
|
||||||
step1Data.modelType,
|
|
||||||
unknownImages,
|
|
||||||
imageClassifications,
|
|
||||||
selectedImages,
|
|
||||||
currentClass,
|
|
||||||
allClasses,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const allStatesHaveExamples = useMemo(() => {
|
|
||||||
if (step1Data.modelType !== "state") return true;
|
|
||||||
return allClasses.every((className) => statesWithExamples.has(className));
|
|
||||||
}, [step1Data.modelType, allClasses, statesWithExamples]);
|
|
||||||
|
|
||||||
// For state models on the last class, require all images to be classified
|
// For state models on the last class, require all images to be classified
|
||||||
// But allow proceeding even if not all states have examples (with warning)
|
const isLastClass = currentClassIndex === allClasses.length - 1;
|
||||||
const canProceed = useMemo(() => {
|
const canProceed = useMemo(() => {
|
||||||
if (step1Data.modelType === "state" && isLastClass) {
|
if (step1Data.modelType === "state" && isLastClass) {
|
||||||
// Check if all 24 images will be classified after current selections are applied
|
// Check if all 24 images will be classified after current selections are applied
|
||||||
@ -431,28 +353,6 @@ export default function Step3ChooseExamples({
|
|||||||
selectedImages,
|
selectedImages,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const hasUnclassifiedImages = useMemo(() => {
|
|
||||||
if (!unknownImages) return false;
|
|
||||||
const allImages = unknownImages.slice(0, 24);
|
|
||||||
return allImages.some((img) => !imageClassifications[img]);
|
|
||||||
}, [unknownImages, imageClassifications]);
|
|
||||||
|
|
||||||
const showMissingStatesWarning = useMemo(() => {
|
|
||||||
return (
|
|
||||||
step1Data.modelType === "state" &&
|
|
||||||
isLastClass &&
|
|
||||||
!allStatesHaveExamples &&
|
|
||||||
!hasUnclassifiedImages &&
|
|
||||||
hasGenerated
|
|
||||||
);
|
|
||||||
}, [
|
|
||||||
step1Data.modelType,
|
|
||||||
isLastClass,
|
|
||||||
allStatesHaveExamples,
|
|
||||||
hasUnclassifiedImages,
|
|
||||||
hasGenerated,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleBack = useCallback(() => {
|
const handleBack = useCallback(() => {
|
||||||
if (currentClassIndex > 0) {
|
if (currentClassIndex > 0) {
|
||||||
const previousClass = allClasses[currentClassIndex - 1];
|
const previousClass = allClasses[currentClassIndex - 1];
|
||||||
@ -499,17 +399,6 @@ export default function Step3ChooseExamples({
|
|||||||
</div>
|
</div>
|
||||||
) : hasGenerated ? (
|
) : hasGenerated ? (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{showMissingStatesWarning && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<IoIosWarning className="size-5" />
|
|
||||||
<AlertTitle>
|
|
||||||
{t("wizard.step3.missingStatesWarning.title")}
|
|
||||||
</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
{t("wizard.step3.missingStatesWarning.description")}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
{!allImagesClassified && (
|
{!allImagesClassified && (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-medium">
|
<h3 className="text-lg font-medium">
|
||||||
@ -585,22 +474,35 @@ export default function Step3ChooseExamples({
|
|||||||
<Button type="button" onClick={handleBack} className="sm:flex-1">
|
<Button type="button" onClick={handleBack} className="sm:flex-1">
|
||||||
{t("button.back", { ns: "common" })}
|
{t("button.back", { ns: "common" })}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Tooltip>
|
||||||
type="button"
|
<TooltipTrigger asChild>
|
||||||
onClick={
|
<Button
|
||||||
allImagesClassified
|
type="button"
|
||||||
? handleContinue
|
onClick={
|
||||||
: handleContinueClassification
|
allImagesClassified
|
||||||
}
|
? handleContinue
|
||||||
variant="select"
|
: handleContinueClassification
|
||||||
className="flex items-center justify-center gap-2 sm:flex-1"
|
}
|
||||||
disabled={
|
variant="select"
|
||||||
!hasGenerated || isGenerating || isProcessing || !canProceed
|
className="flex items-center justify-center gap-2 sm:flex-1"
|
||||||
}
|
disabled={
|
||||||
>
|
!hasGenerated || isGenerating || isProcessing || !canProceed
|
||||||
{isProcessing && <ActivityIndicator className="size-4" />}
|
}
|
||||||
{t("button.continue", { ns: "common" })}
|
>
|
||||||
</Button>
|
{isProcessing && <ActivityIndicator className="size-4" />}
|
||||||
|
{t("button.continue", { ns: "common" })}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
{!canProceed && (
|
||||||
|
<TooltipPortal>
|
||||||
|
<TooltipContent>
|
||||||
|
{t("wizard.step3.allImagesRequired", {
|
||||||
|
count: unclassifiedImages.length,
|
||||||
|
})}
|
||||||
|
</TooltipContent>
|
||||||
|
</TooltipPortal>
|
||||||
|
)}
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -78,7 +78,7 @@ import { useStreamingSettings } from "@/context/streaming-settings-provider";
|
|||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { CameraNameLabel } from "../camera/FriendlyNameLabel";
|
import { CameraNameLabel } from "../camera/FriendlyNameLabel";
|
||||||
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
|
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
|
||||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
import { useIsCustomRole } from "@/hooks/use-is-custom-role";
|
||||||
|
|
||||||
type CameraGroupSelectorProps = {
|
type CameraGroupSelectorProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -88,7 +88,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
|||||||
const { t } = useTranslation(["components/camera"]);
|
const { t } = useTranslation(["components/camera"]);
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
const allowedCameras = useAllowedCameras();
|
const allowedCameras = useAllowedCameras();
|
||||||
const isAdmin = useIsAdmin();
|
const isCustomRole = useIsCustomRole();
|
||||||
|
|
||||||
// tooltip
|
// tooltip
|
||||||
|
|
||||||
@ -124,7 +124,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
|||||||
const allGroups = Object.entries(config.camera_groups);
|
const allGroups = Object.entries(config.camera_groups);
|
||||||
|
|
||||||
// If custom role, filter out groups where user has no accessible cameras
|
// If custom role, filter out groups where user has no accessible cameras
|
||||||
if (!isAdmin) {
|
if (isCustomRole) {
|
||||||
return allGroups
|
return allGroups
|
||||||
.filter(([, groupConfig]) => {
|
.filter(([, groupConfig]) => {
|
||||||
// Check if user has access to at least one camera in this group
|
// Check if user has access to at least one camera in this group
|
||||||
@ -136,7 +136,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return allGroups.sort((a, b) => a[1].order - b[1].order);
|
return allGroups.sort((a, b) => a[1].order - b[1].order);
|
||||||
}, [config, allowedCameras, isAdmin]);
|
}, [config, allowedCameras, isCustomRole]);
|
||||||
|
|
||||||
// add group
|
// add group
|
||||||
|
|
||||||
@ -153,7 +153,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
|||||||
activeGroup={group}
|
activeGroup={group}
|
||||||
setGroup={setGroup}
|
setGroup={setGroup}
|
||||||
deleteGroup={deleteGroup}
|
deleteGroup={deleteGroup}
|
||||||
isAdmin={isAdmin}
|
isCustomRole={isCustomRole}
|
||||||
/>
|
/>
|
||||||
<Scroller className={`${isMobile ? "whitespace-nowrap" : ""}`}>
|
<Scroller className={`${isMobile ? "whitespace-nowrap" : ""}`}>
|
||||||
<div
|
<div
|
||||||
@ -221,7 +221,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{isAdmin && (
|
{!isCustomRole && (
|
||||||
<Button
|
<Button
|
||||||
className="bg-secondary text-muted-foreground"
|
className="bg-secondary text-muted-foreground"
|
||||||
aria-label={t("group.add")}
|
aria-label={t("group.add")}
|
||||||
@ -245,7 +245,7 @@ type NewGroupDialogProps = {
|
|||||||
activeGroup?: string;
|
activeGroup?: string;
|
||||||
setGroup: (value: string | undefined, replace?: boolean | undefined) => void;
|
setGroup: (value: string | undefined, replace?: boolean | undefined) => void;
|
||||||
deleteGroup: () => void;
|
deleteGroup: () => void;
|
||||||
isAdmin?: boolean;
|
isCustomRole?: boolean;
|
||||||
};
|
};
|
||||||
function NewGroupDialog({
|
function NewGroupDialog({
|
||||||
open,
|
open,
|
||||||
@ -254,7 +254,7 @@ function NewGroupDialog({
|
|||||||
activeGroup,
|
activeGroup,
|
||||||
setGroup,
|
setGroup,
|
||||||
deleteGroup,
|
deleteGroup,
|
||||||
isAdmin,
|
isCustomRole,
|
||||||
}: NewGroupDialogProps) {
|
}: NewGroupDialogProps) {
|
||||||
const { t } = useTranslation(["components/camera"]);
|
const { t } = useTranslation(["components/camera"]);
|
||||||
const { mutate: updateConfig } = useSWR<FrigateConfig>("config");
|
const { mutate: updateConfig } = useSWR<FrigateConfig>("config");
|
||||||
@ -390,7 +390,7 @@ function NewGroupDialog({
|
|||||||
>
|
>
|
||||||
<Title>{t("group.label")}</Title>
|
<Title>{t("group.label")}</Title>
|
||||||
<Description className="sr-only">{t("group.edit")}</Description>
|
<Description className="sr-only">{t("group.edit")}</Description>
|
||||||
{isAdmin && (
|
{!isCustomRole && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute",
|
"absolute",
|
||||||
@ -422,7 +422,7 @@ function NewGroupDialog({
|
|||||||
group={group}
|
group={group}
|
||||||
onDeleteGroup={() => onDeleteGroup(group[0])}
|
onDeleteGroup={() => onDeleteGroup(group[0])}
|
||||||
onEditGroup={() => onEditGroup(group)}
|
onEditGroup={() => onEditGroup(group)}
|
||||||
isReadOnly={!isAdmin}
|
isReadOnly={isCustomRole}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -677,7 +677,7 @@ export function CameraGroupEdit({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const allowedCameras = useAllowedCameras();
|
const allowedCameras = useAllowedCameras();
|
||||||
const isAdmin = useIsAdmin();
|
const isCustomRole = useIsCustomRole();
|
||||||
|
|
||||||
const [openCamera, setOpenCamera] = useState<string | null>();
|
const [openCamera, setOpenCamera] = useState<string | null>();
|
||||||
|
|
||||||
@ -867,7 +867,7 @@ export function CameraGroupEdit({
|
|||||||
<FormMessage />
|
<FormMessage />
|
||||||
{[
|
{[
|
||||||
...(birdseyeConfig?.enabled &&
|
...(birdseyeConfig?.enabled &&
|
||||||
(isAdmin || "birdseye" in allowedCameras)
|
(!isCustomRole || "birdseye" in allowedCameras)
|
||||||
? ["birdseye"]
|
? ["birdseye"]
|
||||||
: []),
|
: []),
|
||||||
...Object.keys(config?.cameras ?? {})
|
...Object.keys(config?.cameras ?? {})
|
||||||
|
|||||||
@ -1,11 +1,7 @@
|
|||||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||||
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
|
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import { ReviewSegment, ThreatLevel } from "@/types/review";
|
||||||
ReviewSegment,
|
|
||||||
ThreatLevel,
|
|
||||||
THREAT_LEVEL_LABELS,
|
|
||||||
} from "@/types/review";
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { isDesktop } from "react-device-detect";
|
import { isDesktop } from "react-device-detect";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -59,22 +55,13 @@ export function GenAISummaryDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
let concerns = "";
|
let concerns = "";
|
||||||
const threatLevel = aiAnalysis.potential_threat_level ?? 0;
|
switch (aiAnalysis.potential_threat_level) {
|
||||||
|
case ThreatLevel.SUSPICIOUS:
|
||||||
if (threatLevel > 0) {
|
concerns = `• ${t("suspiciousActivity", { ns: "views/events" })}\n`;
|
||||||
let label = "";
|
break;
|
||||||
|
case ThreatLevel.DANGER:
|
||||||
switch (threatLevel) {
|
concerns = `• ${t("threateningActivity", { ns: "views/events" })}\n`;
|
||||||
case ThreatLevel.NEEDS_REVIEW:
|
break;
|
||||||
label = t("needsReview", { ns: "views/events" });
|
|
||||||
break;
|
|
||||||
case ThreatLevel.SECURITY_CONCERN:
|
|
||||||
label = t("securityConcern", { ns: "views/events" });
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
label = THREAT_LEVEL_LABELS[threatLevel as ThreatLevel] || "Unknown";
|
|
||||||
}
|
|
||||||
concerns = `• ${label}\n`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
(aiAnalysis.other_concerns ?? []).forEach((c) => {
|
(aiAnalysis.other_concerns ?? []).forEach((c) => {
|
||||||
|
|||||||
@ -1,11 +1,7 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useApiHost } from "@/api";
|
import { useApiHost } from "@/api";
|
||||||
import { isCurrentHour } from "@/utils/dateUtil";
|
import { isCurrentHour } from "@/utils/dateUtil";
|
||||||
import {
|
import { ReviewSegment } from "@/types/review";
|
||||||
ReviewSegment,
|
|
||||||
ThreatLevel,
|
|
||||||
THREAT_LEVEL_LABELS,
|
|
||||||
} from "@/types/review";
|
|
||||||
import { getIconForLabel } from "@/utils/iconUtil";
|
import { getIconForLabel } from "@/utils/iconUtil";
|
||||||
import TimeAgo from "../dynamic/TimeAgo";
|
import TimeAgo from "../dynamic/TimeAgo";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
@ -48,7 +44,7 @@ export default function PreviewThumbnailPlayer({
|
|||||||
onClick,
|
onClick,
|
||||||
onTimeUpdate,
|
onTimeUpdate,
|
||||||
}: PreviewPlayerProps) {
|
}: PreviewPlayerProps) {
|
||||||
const { t } = useTranslation(["components/player", "views/events"]);
|
const { t } = useTranslation(["components/player"]);
|
||||||
const apiHost = useApiHost();
|
const apiHost = useApiHost();
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
const [imgRef, imgLoaded, onImgLoad] = useImageLoaded();
|
const [imgRef, imgLoaded, onImgLoad] = useImageLoaded();
|
||||||
@ -323,21 +319,11 @@ export default function PreviewThumbnailPlayer({
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
</div>
|
</div>
|
||||||
<TooltipContent className="smart-capitalize">
|
<TooltipContent className="smart-capitalize">
|
||||||
{(() => {
|
{review.data.metadata.potential_threat_level == 1 ? (
|
||||||
const threatLevel =
|
<>{t("suspiciousActivity", { ns: "views/events" })}</>
|
||||||
review.data.metadata.potential_threat_level ?? 0;
|
) : (
|
||||||
switch (threatLevel) {
|
<>{t("threateningActivity", { ns: "views/events" })}</>
|
||||||
case ThreatLevel.NEEDS_REVIEW:
|
)}
|
||||||
return t("needsReview", { ns: "views/events" });
|
|
||||||
case ThreatLevel.SECURITY_CONCERN:
|
|
||||||
return t("securityConcern", { ns: "views/events" });
|
|
||||||
default:
|
|
||||||
return (
|
|
||||||
THREAT_LEVEL_LABELS[threatLevel as ThreatLevel] ||
|
|
||||||
"Unknown"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})()}
|
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import { baseUrl } from "@/api/baseUrl";
|
|
||||||
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { useCallback, useEffect, useState, useMemo } from "react";
|
import { useCallback, useEffect, useState, useMemo } from "react";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
@ -42,12 +41,9 @@ export default function useCameraLiveMode(
|
|||||||
|
|
||||||
const metadataPromises = streamNames.map(async (streamName) => {
|
const metadataPromises = streamNames.map(async (streamName) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(`/api/go2rtc/streams/${streamName}`, {
|
||||||
`${baseUrl}api/go2rtc/streams/${streamName}`,
|
priority: "low",
|
||||||
{
|
});
|
||||||
priority: "low",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|||||||
@ -17,7 +17,6 @@ export function useHistoryBack({
|
|||||||
}: UseHistoryBackOptions): void {
|
}: UseHistoryBackOptions): void {
|
||||||
const historyPushedRef = React.useRef(false);
|
const historyPushedRef = React.useRef(false);
|
||||||
const closedByBackRef = React.useRef(false);
|
const closedByBackRef = React.useRef(false);
|
||||||
const urlWhenOpenedRef = React.useRef<string | null>(null);
|
|
||||||
|
|
||||||
// Keep onClose in a ref to avoid effect re-runs that cause multiple history pushes
|
// Keep onClose in a ref to avoid effect re-runs that cause multiple history pushes
|
||||||
const onCloseRef = React.useRef(onClose);
|
const onCloseRef = React.useRef(onClose);
|
||||||
@ -31,9 +30,6 @@ export function useHistoryBack({
|
|||||||
if (open) {
|
if (open) {
|
||||||
// Only push history state if we haven't already (prevents duplicates in strict mode)
|
// Only push history state if we haven't already (prevents duplicates in strict mode)
|
||||||
if (!historyPushedRef.current) {
|
if (!historyPushedRef.current) {
|
||||||
// Store the current URL (pathname + search, without hash) before pushing history state
|
|
||||||
urlWhenOpenedRef.current =
|
|
||||||
window.location.pathname + window.location.search;
|
|
||||||
window.history.pushState({ overlayOpen: true }, "");
|
window.history.pushState({ overlayOpen: true }, "");
|
||||||
historyPushedRef.current = true;
|
historyPushedRef.current = true;
|
||||||
}
|
}
|
||||||
@ -41,7 +37,6 @@ export function useHistoryBack({
|
|||||||
const handlePopState = () => {
|
const handlePopState = () => {
|
||||||
closedByBackRef.current = true;
|
closedByBackRef.current = true;
|
||||||
historyPushedRef.current = false;
|
historyPushedRef.current = false;
|
||||||
urlWhenOpenedRef.current = null;
|
|
||||||
onCloseRef.current();
|
onCloseRef.current();
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -53,22 +48,10 @@ export function useHistoryBack({
|
|||||||
} else {
|
} else {
|
||||||
// Overlay is closing - clean up history if we pushed and it wasn't via back button
|
// Overlay is closing - clean up history if we pushed and it wasn't via back button
|
||||||
if (historyPushedRef.current && !closedByBackRef.current) {
|
if (historyPushedRef.current && !closedByBackRef.current) {
|
||||||
const currentUrl = window.location.pathname + window.location.search;
|
window.history.back();
|
||||||
const urlWhenOpened = urlWhenOpenedRef.current;
|
|
||||||
|
|
||||||
// If the URL has changed (e.g., filters were applied via search params),
|
|
||||||
// don't go back as it would undo the filter update.
|
|
||||||
// The history entry we pushed will remain, but that's acceptable compared
|
|
||||||
// to losing the user's filter changes.
|
|
||||||
if (!urlWhenOpened || currentUrl === urlWhenOpened) {
|
|
||||||
// URL hasn't changed, safe to go back and remove our history entry
|
|
||||||
window.history.back();
|
|
||||||
}
|
|
||||||
// If URL changed, we skip history.back() to preserve the filter updates
|
|
||||||
}
|
}
|
||||||
historyPushedRef.current = false;
|
historyPushedRef.current = false;
|
||||||
closedByBackRef.current = false;
|
closedByBackRef.current = false;
|
||||||
urlWhenOpenedRef.current = null;
|
|
||||||
}
|
}
|
||||||
}, [enabled, open]);
|
}, [enabled, open]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -49,7 +49,6 @@ function ConfigEditor() {
|
|||||||
|
|
||||||
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
|
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
|
||||||
const { send: sendRestart } = useRestart();
|
const { send: sendRestart } = useRestart();
|
||||||
const initialValidationRef = useRef(false);
|
|
||||||
|
|
||||||
const onHandleSaveConfig = useCallback(
|
const onHandleSaveConfig = useCallback(
|
||||||
async (save_option: SaveOptions): Promise<void> => {
|
async (save_option: SaveOptions): Promise<void> => {
|
||||||
@ -172,33 +171,6 @@ function ConfigEditor() {
|
|||||||
};
|
};
|
||||||
}, [rawConfig, apiHost, systemTheme, theme, onHandleSaveConfig]);
|
}, [rawConfig, apiHost, systemTheme, theme, onHandleSaveConfig]);
|
||||||
|
|
||||||
// when in safe mode, attempt to validate the existing (invalid) config immediately
|
|
||||||
// so that the user sees the validation errors without needing to press save
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
config?.safe_mode &&
|
|
||||||
rawConfig &&
|
|
||||||
!initialValidationRef.current &&
|
|
||||||
!error
|
|
||||||
) {
|
|
||||||
initialValidationRef.current = true;
|
|
||||||
axios
|
|
||||||
.post(`config/save?save_option=saveonly`, rawConfig, {
|
|
||||||
headers: { "Content-Type": "text/plain" },
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
// if this succeeds while in safe mode, we won't force any UI change
|
|
||||||
})
|
|
||||||
.catch((e: AxiosError<ApiErrorResponse>) => {
|
|
||||||
const errorMessage =
|
|
||||||
e.response?.data?.message ||
|
|
||||||
e.response?.data?.detail ||
|
|
||||||
"Unknown error";
|
|
||||||
setError(errorMessage);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [config?.safe_mode, rawConfig, error]);
|
|
||||||
|
|
||||||
// monitoring state
|
// monitoring state
|
||||||
|
|
||||||
const [hasChanges, setHasChanges] = useState(false);
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
|
|||||||
@ -14,12 +14,12 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
|
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
|
||||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
import { useIsCustomRole } from "@/hooks/use-is-custom-role";
|
||||||
|
|
||||||
function Live() {
|
function Live() {
|
||||||
const { t } = useTranslation(["views/live"]);
|
const { t } = useTranslation(["views/live"]);
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
const isAdmin = useIsAdmin();
|
const isCustomRole = useIsCustomRole();
|
||||||
|
|
||||||
// selection
|
// selection
|
||||||
|
|
||||||
@ -94,7 +94,7 @@ function Live() {
|
|||||||
|
|
||||||
const includesBirdseye = useMemo(() => {
|
const includesBirdseye = useMemo(() => {
|
||||||
// Restricted users should never have access to birdseye
|
// Restricted users should never have access to birdseye
|
||||||
if (!isAdmin) {
|
if (isCustomRole) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -109,7 +109,7 @@ function Live() {
|
|||||||
} else {
|
} else {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, [config, cameraGroup, isAdmin]);
|
}, [config, cameraGroup, isCustomRole]);
|
||||||
|
|
||||||
const cameras = useMemo(() => {
|
const cameras = useMemo(() => {
|
||||||
if (!config) {
|
if (!config) {
|
||||||
|
|||||||
@ -87,13 +87,6 @@ export type ZoomLevel = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export enum ThreatLevel {
|
export enum ThreatLevel {
|
||||||
NORMAL = 0,
|
SUSPICIOUS = 1,
|
||||||
NEEDS_REVIEW = 1,
|
DANGER = 2,
|
||||||
SECURITY_CONCERN = 2,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const THREAT_LEVEL_LABELS: Record<ThreatLevel, string> = {
|
|
||||||
[ThreatLevel.NORMAL]: "Normal",
|
|
||||||
[ThreatLevel.NEEDS_REVIEW]: "Needs review",
|
|
||||||
[ThreatLevel.SECURITY_CONCERN]: "Security concern",
|
|
||||||
};
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import { baseUrl } from "@/api/baseUrl";
|
|
||||||
import { generateFixedHash, isValidId } from "./stringUtil";
|
import { generateFixedHash, isValidId } from "./stringUtil";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -53,12 +52,9 @@ export async function detectReolinkCamera(
|
|||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(`/api/reolink/detect?${params.toString()}`, {
|
||||||
`${baseUrl}api/reolink/detect?${params.toString()}`,
|
method: "GET",
|
||||||
{
|
});
|
||||||
method: "GET",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@ -54,7 +54,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { EmptyCard } from "@/components/card/EmptyCard";
|
import { EmptyCard } from "@/components/card/EmptyCard";
|
||||||
import { BsFillCameraVideoOffFill } from "react-icons/bs";
|
import { BsFillCameraVideoOffFill } from "react-icons/bs";
|
||||||
import { AuthContext } from "@/context/auth-context";
|
import { AuthContext } from "@/context/auth-context";
|
||||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
import { useIsCustomRole } from "@/hooks/use-is-custom-role";
|
||||||
|
|
||||||
type LiveDashboardViewProps = {
|
type LiveDashboardViewProps = {
|
||||||
cameras: CameraConfig[];
|
cameras: CameraConfig[];
|
||||||
@ -661,10 +661,10 @@ export default function LiveDashboardView({
|
|||||||
function NoCameraView() {
|
function NoCameraView() {
|
||||||
const { t } = useTranslation(["views/live"]);
|
const { t } = useTranslation(["views/live"]);
|
||||||
const { auth } = useContext(AuthContext);
|
const { auth } = useContext(AuthContext);
|
||||||
const isAdmin = useIsAdmin();
|
const isCustomRole = useIsCustomRole();
|
||||||
|
|
||||||
// Check if this is a restricted user with no cameras in this group
|
// Check if this is a restricted user with no cameras in this group
|
||||||
const isRestricted = !isAdmin && auth.isAuthenticated;
|
const isRestricted = isCustomRole && auth.isAuthenticated;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex size-full items-center justify-center">
|
<div className="flex size-full items-center justify-center">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user